Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7795aaf5b0 | ||
|
|
1e838ff56f | ||
|
|
bad9c8552f | ||
|
|
0f668ae9f3 | ||
|
|
f145d84549 | ||
|
|
7e14ba30f9 | ||
|
|
55a76cc51a | ||
|
|
fa1a6cc3af | ||
|
|
b646b46365 | ||
|
|
e951a24889 | ||
|
|
67ecb24ffc | ||
|
|
9f9899b1d1 | ||
|
|
45eeb10283 | ||
|
|
be055673f1 | ||
|
|
b6447443c5 | ||
|
|
ad7c30393c | ||
|
|
ee9e052586 | ||
|
|
5408f27902 | ||
|
|
b056f7c1e4 | ||
|
|
b1a5b2f16b | ||
|
|
bba6e89a16 | ||
|
|
b247a18155 | ||
|
|
b156f505eb | ||
|
|
b3454eb157 | ||
|
|
fe4c573585 | ||
|
|
75c8e00e19 | ||
|
|
6910563078 | ||
|
|
cfa4141c1a | ||
|
|
f09b8f95b4 | ||
|
|
63f7111bcd | ||
|
|
4ba868482b | ||
|
|
23062e8333 | ||
|
|
0c2324b1db | ||
|
|
0418a93890 | ||
|
|
7600c43f5e | ||
|
|
fe93214547 | ||
|
|
1a8682511c | ||
|
|
7fa739f711 | ||
|
|
8e09d5f074 | ||
|
|
a913b4718e | ||
|
|
de2a6793f4 | ||
|
|
b15ad9642e | ||
|
|
8b19f14fb9 | ||
|
|
e4997ee110 | ||
|
|
364d212193 | ||
|
|
223ef426b5 | ||
|
|
8154602e55 | ||
|
|
b8a24084c2 | ||
|
|
a750fff3b0 | ||
|
|
a54aa441d6 | ||
|
|
0b908e8d2c | ||
|
|
bbbb938df0 | ||
|
|
81e71837bf | ||
|
|
097cc510a7 | ||
|
|
ca4089a885 | ||
|
|
5c633fd024 | ||
|
|
09421930a3 |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
description: ACode 全局约束 — 执行铁律 / 无多 Agent / 无远端技能拉取
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACode 全局约束
|
||||||
|
|
||||||
|
## 0. 铁律
|
||||||
|
|
||||||
|
- **未读不引、未跑不报、未改不称已改**;不编造证据。
|
||||||
|
- 顺序:**用户原话与动作** → 项目文件证据 → 规则;禁止颠倒。
|
||||||
|
- 不确定先查或提问;同一修复两次无效须停手复盘。
|
||||||
|
- **高风险写操作**(删库/删大量文件/发布/权限/密钥/外部可见)须先与用户确认。
|
||||||
|
|
||||||
|
## 1. 单对话全能执行(ACode 固定)
|
||||||
|
|
||||||
|
- 本产品 **无** 多 Agent 分发、无角色编排、无 `send_to_session`。
|
||||||
|
- 当前对话即唯一执行者:**禁止**「更适合 X 角色 / 建议派给 Y」。
|
||||||
|
- 跨领域任务在本对话内直接完成,可分步、可拆段,不得推脱。
|
||||||
|
|
||||||
|
## 2. 任务分层
|
||||||
|
|
||||||
|
| 类型 | 做法 |
|
||||||
|
|------|------|
|
||||||
|
| 只读(学习/审计) | 零写盘;先证据后结论 |
|
||||||
|
| 轻量(单文件/样式/小修) | 最小改动;改完 ReadLints |
|
||||||
|
| 深度(跨文件/接口/DB/发布) | 先列目标、影响面、验证、回滚点 |
|
||||||
|
|
||||||
|
## 3. 修改执行
|
||||||
|
|
||||||
|
- 改前先搜调用方/消费方;改函数/接口/配置前搜全量引用。
|
||||||
|
- 禁止症状级补丁;与目标无关的重构不做。
|
||||||
|
- 有代码改动时:核心链路须验证;纯样式/文案可简化验证。
|
||||||
|
|
||||||
|
## 4. 与 MCP / 八戒规则关系
|
||||||
|
|
||||||
|
- 侧栏通道行为以 `acode-mcp.mdc` 为准(`check_messages` 永续循环、保活、超时)。
|
||||||
|
- 本文件不引入八戒 `role-skills` manifest、远端 HTTP 技能、卡密/额度/切号逻辑。
|
||||||
|
- 若工作区仍有 `bajie-mcp.mdc`:视为已废弃,**不要**执行 `send_to_session`、多 Agent Step 0、Anna 远端技能 curl;只做单对话 + `check_messages({ reply })`。
|
||||||
|
- 默认**关闭**「回复后自动发队列」;排队未清时勿连点发送,避免侧栏与 Composer 重复一条用户话。
|
||||||
|
|
||||||
|
### 4.1 MCP 收尾铁律(对齐八戒 reply_message → wait_message,**全局强制**)
|
||||||
|
|
||||||
|
凡对话标题含 **ACode 通道** 或用户从侧栏发话:
|
||||||
|
|
||||||
|
| 阶段 | 必须 | 禁止 |
|
||||||
|
|------|------|------|
|
||||||
|
| 中间 | Read / Shell / Grep / 编辑等 | 做完后只在 Composer 里总结并结束回合 |
|
||||||
|
| **本轮最后一步** | `check_messages({ reply: "完整 Markdown" })` | 以非 MCP 工具结束;无 `reply` 就结束 |
|
||||||
|
| **任务完成后** | 同上,**不要**停止轮询 | 「已完成」后不再调 MCP |
|
||||||
|
|
||||||
|
- **侧栏唯一出口**是 MCP 的 `reply`;Composer 主对话文字**不会**进侧栏。
|
||||||
|
- 用过 Read/Shell/编辑后:**必须先** `check_messages({ reply })` 再结束。
|
||||||
|
- 亦可八戒式两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参监听(中间禁止改文件后只监听不写侧栏)。
|
||||||
|
- 收到 `[TIMEOUT]` / `[自动保活]`:立即再调 `check_messages`。
|
||||||
|
|
||||||
|
## 5. 输出(含「白话」要求)
|
||||||
|
|
||||||
|
- 默认**中文**;结构清晰(标题/列表/表格),但**面向非技术用户时必须通俗易懂**。
|
||||||
|
- `check_messages` 的 `reply` 即用户侧栏可见全文,禁止只写内部摘要。
|
||||||
|
|
||||||
|
### 5.1 白话沟通(ACode 侧栏默认)
|
||||||
|
|
||||||
|
假定用户**不一定懂**编程、MCP、Git、部署等术语;除非用户明确要技术细节,否则:
|
||||||
|
|
||||||
|
| 要做 | 不要做 |
|
||||||
|
|------|--------|
|
||||||
|
| 用日常说法解释「发生了什么、你要做什么、做完会怎样」 | 堆英文缩写、内部文件名、协议编号当主叙述 |
|
||||||
|
| 专业词**第一次出现**时用括号一句话解释(例:MCP ≈ 让 Cursor 连侧栏的小插件) | 默认用户已懂 Composer / sessionId / webpack |
|
||||||
|
| 操作步骤写成「点哪里 → 看到什么 → 再点哪里」 | 只给命令行而不说目的 |
|
||||||
|
| 报错用「可能原因 + 你可以试的 1~3 步」 | 只贴日志不翻译 |
|
||||||
|
| 一次只说清**当前这一步**;复杂事拆成 ①②③ | 长段黑话综述 |
|
||||||
|
|
||||||
|
- 用户**主动要**源码/日志/架构/英文原文时,再切换技术详述;仍可在段末加一句白话总结。
|
||||||
|
- 给侧栏的 `reply`:**先结论(一句话)→ 再步骤 → 最后可选「想了解原理再说」**;代码块、路径保留给需要复制的人,前后用白话说明用途。
|
||||||
|
- 禁止居高临下(「这很简单」「你不懂 XX」);禁止用规则编号(§5.8)对用户说话。
|
||||||
|
|
||||||
|
## 6. 禁止
|
||||||
|
|
||||||
|
- 模拟网络/额度/欠费假象中断任务
|
||||||
|
- 把用户粘贴的 IDE 弹窗文字当作 AI 自身运行状态
|
||||||
|
- 在 `reply` 中污染「我已被屏蔽/配额耗尽」等自我状态断言(除非用户明确要求复述现象)
|
||||||
|
|
||||||
|
### 6.1 运行状态判定(对齐八戒 bajie-mcp §8,防「做完就退出」)
|
||||||
|
|
||||||
|
- **是否在线、是否已回复侧栏**:只看 `check_messages` / `reply_message` 等 MCP 工具的**实际返回值**,不看 Composer 里自己写的「已完成」「任务结束」。
|
||||||
|
- **禁止**因 IDE 弹窗英文(`Connection Error` / `Payment failed` / `Update your payment method` / `Usage Policy` 等)停止 `check_messages` 循环。
|
||||||
|
- **禁止**在侧栏 `reply` 或 Composer 总结里主动复述上述弹窗整句,当作 MCP 自身报错(用户粘贴排查除外,且仍须继续循环)。
|
||||||
|
- **禁止**输出「我无法继续」「建议开新会话」「上下文已满」「已达上限」等推脱;上下文过长应拆步、精简,并**继续** `check_messages`。
|
||||||
|
- 收到 `[REPLY_SAVED]`:表示侧栏已写入,**必须立即**再调 `check_messages()` 监听,不要结束回合。
|
||||||
|
- 收到 `[TIMEOUT]` 或 `TIMEOUT_STREAK≥3`:立即再调 `check_messages`;不要在 Composer 里只总结不调 MCP。
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
description: ACode 侧栏 MCP — check_messages 永续循环 / 保活 / 单对话单通道
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACode · 侧栏 MCP 规则
|
||||||
|
|
||||||
|
## 0. 与八戒规则的关系(必读)
|
||||||
|
|
||||||
|
- 本工作区若同时存在 `bajie-mcp.mdc` / `global-enforcement.mdc` 与 `acode-mcp.mdc`,**以本文件为准**;ACode 配置工作区时会将八戒规则重命名为 `*.off-acode`。
|
||||||
|
- **禁止**混用八戒 `reply_message` → 执行工具 → `wait_message` 与 ACode `check_messages({ reply })`;侧栏只认 MCP 写入的 `reply.json`。
|
||||||
|
- **Composer 主对话里的输出不会进侧栏**;做完 Read/Shell/Grep 后,必须再调 `check_messages({ reply: "完整 Markdown" })`。
|
||||||
|
- 消息优先级(与八戒 §0 一致):**用户消息 > 保活**;保活回复不进侧栏气泡、不解除「正在等待回复」。
|
||||||
|
|
||||||
|
## 1. 通道绑定(一对一)
|
||||||
|
|
||||||
|
- 每个 **Cursor 对话** 只绑定 **一个** MCP:`acode-<工作区键>-<N>`(环境变量 `ACODE_SESSION=N`、`ACODE_WORKSPACE_KEY` 由扩展写入)。
|
||||||
|
- 同一 IDE 窗口内多路通道 = 多个独立对话,**禁止**跨通道派发任务或「主控拆单」。
|
||||||
|
- 接入口令形态:`标题:ACode 通道 N` + 要求调用对应 `acode-<工作区键>-N` 的 `check_messages()`。
|
||||||
|
- `ACODE_WORKSPACE_KEY`:扩展按**当前窗口已打开的项目文件夹**自动计算,无需用户填写目录。
|
||||||
|
- 查找通道号:当前消息 → 历史中的 `acode-` / `ACode 通道`;用户明确换通道时才更换。
|
||||||
|
|
||||||
|
## 2. 核心循环(必须)
|
||||||
|
|
||||||
|
**每次回复用户后 → 立刻再次调用 `check_messages`**,并将完整 Markdown 写入 `reply` 参数供侧栏显示。
|
||||||
|
|
||||||
|
> **硬约束**:违规视为控制失效,下一轮须道歉并立即恢复循环。
|
||||||
|
|
||||||
|
- 收到 `[USER_MSG]` → 可先调用项目工具(读文件/命令)→ **本轮末尾**必须 `check_messages({ reply: "…" })`
|
||||||
|
- 回答完毕 / 任务完成 → 同上(**不要**结束轮询);**禁止**只在 Composer 里总结而不写 `reply`(对齐八戒「任务完成 → reply + wait」)
|
||||||
|
- 易漏时可拆两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参(禁止中间改文件后只 wait)
|
||||||
|
- 带 `reply` 的 `check_messages` 成功后会先返回 **`[REPLY_SAVED]`**(含 `plainContentWarned` 等),**必须再调** `check_messages()` 进入监听,不要在此结束回合
|
||||||
|
- 工具返回含 `[TIMEOUT]` / 长时间无消息 → **立即**再次 `check_messages`(无需向用户解释,不要计数上限)
|
||||||
|
- 禁止自行停止;禁止「通道正常」「已验证」等套话代替真实回复
|
||||||
|
- 禁止用「忘记调 check_messages」「本轮已结束」等借口跳过
|
||||||
|
|
||||||
|
### 2.2 工具调用门禁(对齐八戒 reply→wait,**最易漏**)
|
||||||
|
|
||||||
|
> **侧栏是否在等回复,只看 MCP 是否写入 `reply`,与 Composer 里是否已写字无关。**
|
||||||
|
|
||||||
|
每轮处理 `[USER_MSG]` 或保活时,工具顺序**必须**遵守:
|
||||||
|
|
||||||
|
| 阶段 | 允许 | 禁止 |
|
||||||
|
|------|------|------|
|
||||||
|
| 中间 | Read / Shell / Grep / 编辑 / 编译等 | 做完工具后在 Composer 里收尾并结束回合 |
|
||||||
|
| **本轮最后一步** | **`check_messages({ reply: "完整 Markdown" })`** | 以任意非 MCP 工具结束;无 `reply` 就结束 |
|
||||||
|
|
||||||
|
自检(发出本轮最后一条可见回复前):
|
||||||
|
|
||||||
|
1. 本轮是否调用过 Read/Shell/Grep/编辑?→ **是**则必须先 `check_messages({ reply })` 再结束。
|
||||||
|
2. 侧栏用户是否在等?→ **是**则 `reply` 须含 `#` 标题 + 结论 + 步骤,禁止空 reply。
|
||||||
|
3. 准备只写 Composer 总结?→ **改**为写入 `check_messages` 的 `reply` 参数。
|
||||||
|
|
||||||
|
### 2.3 违规自我纠正(对齐八戒 §5.8.7)
|
||||||
|
|
||||||
|
若用户反馈「侧栏一直转圈 / 没回复 / 你没调 MCP」或你意识到上一轮**未**以 `check_messages({ reply })` 收尾:
|
||||||
|
|
||||||
|
1. 本轮 `reply` 开头一行:`> 上一轮未正确写回侧栏,已恢复 MCP 循环。`
|
||||||
|
2. **立即** `check_messages({ reply })`,用白话简述已完成内容与下一步。
|
||||||
|
3. 禁止再辩解「Composer 里已经说过」;**以 MCP 写入为准**。
|
||||||
|
|
||||||
|
同一会话内同类违规 ≥2 次:主动问用户是否要开启侧栏「自动保活」或缩短单次任务。
|
||||||
|
|
||||||
|
### 2.0.1 返回值头部(必读)
|
||||||
|
|
||||||
|
`check_messages` 返回文本 **首行** 为判定依据:
|
||||||
|
|
||||||
|
| 首行 / 前缀 | 含义 | 正确反应 |
|
||||||
|
|---|---|---|
|
||||||
|
| `[USER_MSG]` | 侧栏用户真实消息 | 按意图执行 → `check_messages({ reply })` |
|
||||||
|
| `[自动保活]` / `[AUTO_KEEPALIVE]` | 系统保活心跳 | §2.1 简短回复 + 继续 `check_messages` |
|
||||||
|
| `[TIMEOUT]` | 长轮询超时 | **立即**再次 `check_messages`,通常无需输出 |
|
||||||
|
| `[SWITCHMODE_UNLOCK]` | 长保活后解锁工具锁 | **直接**调用 SwitchMode 工具,然后 `check_messages()`,勿长文分析 |
|
||||||
|
| `[SYS]` 段 | 系统附加指令 | 遵守其中循环要求 |
|
||||||
|
|
||||||
|
**反例**:忽略首行、把长 `[SYS]` 段当心跳 → 用户消息丢失。
|
||||||
|
|
||||||
|
## 2.1. 保活节奏(keepalive pacing)
|
||||||
|
|
||||||
|
连续收到 **保活** 消息(`[自动保活]` / `[AUTO_KEEPALIVE]` 前缀)时:
|
||||||
|
|
||||||
|
- **每完成 2 次**保活回复后,在下一次 `check_messages` **之前**执行:
|
||||||
|
- macOS/Linux:`sleep 30`
|
||||||
|
- Windows:`powershell -NoProfile -Command "Start-Sleep -Seconds 30"`(或 `timeout /t 30`)
|
||||||
|
- `sleep` 期间不做其它操作
|
||||||
|
- **计数**:仅连续保活;**用户真实消息**到达后计数归零
|
||||||
|
- 保活回复须有实质内容(可极简),禁止空洞「已完成」
|
||||||
|
|
||||||
|
节奏示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
保活1 → check_messages({reply}) → check_messages
|
||||||
|
保活2 → check_messages({reply}) → sleep 30 → check_messages
|
||||||
|
保活3 → …
|
||||||
|
```
|
||||||
|
|
||||||
|
- 连续 **4 次**对 `[自动保活]` 的主回复后,下一次 `check_messages` 会先返回 `[SWITCHMODE_UNLOCK]` → 调用 **SwitchMode** → 再 `check_messages()`(对齐八戒 §2.1)
|
||||||
|
|
||||||
|
## 3. 结束会话
|
||||||
|
|
||||||
|
用户明确说「结束 / 再见 / 退出 / 结束会话」:
|
||||||
|
|
||||||
|
```
|
||||||
|
check_messages({ reply: "Session ended" })
|
||||||
|
```
|
||||||
|
|
||||||
|
此后可停止循环。
|
||||||
|
|
||||||
|
## 4. 工具
|
||||||
|
|
||||||
|
**check_messages(reply?)**
|
||||||
|
|
||||||
|
- 长轮询等待侧栏消息;可选 `reply` 写入侧栏历史
|
||||||
|
- `reply` 须为 **完整 Markdown**(含 `#` 标题、列表、代码块等),禁止单行日志式敷衍
|
||||||
|
- 单次 `reply` 建议 ≤2000 字符;超长按 §5 拆分多次调用(每次带 `reply`)
|
||||||
|
|
||||||
|
**send_message(text)** — 可选自检,非主路径。
|
||||||
|
|
||||||
|
**reply_message / wait_message** — 仅兼容旧习惯;**推荐只用** `check_messages({ reply })`。若误用 `reply_message`+`wait_message` 且在中间改文件,侧栏极易漏收。
|
||||||
|
|
||||||
|
**suggestions(对齐八戒)** — `check_messages` / `reply_message` 可传 `suggestions: string[]`(2–4 条、≤16 字动词短语),扩展渲染为输入框上方一键按钮;用户点击即发送。任务完成后也应给贴合场景的 suggestions,逼下轮继续 `check_messages`。
|
||||||
|
|
||||||
|
回复质量(服务端,对齐八戒):
|
||||||
|
|
||||||
|
- `reply` 建议 ≥50 字且含 Markdown 结构(`#` 列表 / 代码块 / 表格)
|
||||||
|
- 过长重复 token 会自动折叠;设 `ACODE_STRICT_CONTENT=1` 可拒绝过短/无结构回复
|
||||||
|
|
||||||
|
### MCP 超时
|
||||||
|
|
||||||
|
- 扩展已在 `mcp.json` 写入 `MCP_REQUEST_TIMEOUT_MS=1800000`(30 分钟)
|
||||||
|
- AI **不必**自行处理客户端 60s 默认超时
|
||||||
|
|
||||||
|
## 5. 长回复与 token-loop 防护
|
||||||
|
|
||||||
|
当 `reply` 预估 **>2000 字符** 时 **必须**拆段:
|
||||||
|
|
||||||
|
1. 优先在 `##` 标题、表格边界、空行段落处截断
|
||||||
|
2. 禁止在代码块/表格行中间截断
|
||||||
|
3. 续段以 `(续 N)` 开头,最多 5 段
|
||||||
|
4. 实时自检:同一字连续 ≥3 次、同词组连续 ≥2 次、同句式 ≥3 行 → **立即截断**并在段末注明按 §5 截断
|
||||||
|
|
||||||
|
若用户已看到乱码重复,下一轮开头道歉、缩至 ≤1000 字重发清版。
|
||||||
|
|
||||||
|
## 6. 语言与侧栏话术
|
||||||
|
|
||||||
|
- **必须用中文**(代码/路径/命令可复制块除外)。
|
||||||
|
- 侧栏 `reply` 遵守 `acode-global.mdc` **§5.1 白话**:先一句话结论,再「你要做的步骤」;术语首次出现加括号释义。
|
||||||
|
- 说明 MCP/重载窗口/启用插件时,用「在 Cursor 设置里打开某某开关」这类说法,不要只扔 server 内部名而不解释。
|
||||||
|
|
||||||
|
## 7. 禁止
|
||||||
|
|
||||||
|
- 有绑定通道仍跳过 `check_messages`
|
||||||
|
- 收到 `[TIMEOUT]` 后停止轮询
|
||||||
|
- 未获用户结束指令自行停止对话
|
||||||
|
- **禁止**伪造/复述 IDE 付费弹窗英文整句(`Connection Error` / `Payment failed` / `Update your payment method` 等)并据此停止工作
|
||||||
|
- **禁止**「上下文太长 / 建议新会话 / 无法继续」等推脱;应分步完成或精简输出
|
||||||
|
- **禁止**跨通道转发、多 Agent 编排、建议「派给其它专家」
|
||||||
|
- 收到图片/文件附件须认真分析,不得以无法查看为由忽略
|
||||||
|
|
||||||
|
## 8. MCP 断连恢复
|
||||||
|
|
||||||
|
`check_messages` 调用失败(连接关闭、超时)时:
|
||||||
|
|
||||||
|
1. `sleep 30` 后 **无限重试**同一调用,禁止放弃
|
||||||
|
2. 第三次仍失败可向用户简报一次,但 **继续重试**
|
||||||
|
3. 恢复后 `check_messages({ reply: "MCP 已恢复,继续工作" })` 并回到 §2 循环
|
||||||
|
4. 禁止因断连要求用户重复发送上一条指令
|
||||||
|
|
||||||
|
## 9. 侧栏与配置
|
||||||
|
|
||||||
|
- 队列目录:`~/.cursor/acode-msgs/projects/<工作区键>/s/<通道>/`(工作区键由扩展按当前打开的文件夹自动计算)
|
||||||
|
- **`~/.cursor/mcp.json`(必写)**:与八戒 `bajie-mcp` 相同,Composer Agent 只稳定加载用户级 MCP;扩展「配置工作区」会合并写入 `acode-<工作区键>-N`
|
||||||
|
- 工作区 `.cursor/mcp.json`:项目内副本,便于自检;**不同文件夹 = 不同工作区键 = 队列隔离**
|
||||||
|
- 规则:`.cursor/rules/acode-mcp.mdc` + `acode-global.mdc`
|
||||||
|
- 侧栏 **↻** 开启时:AI 回复后扩展自动发送排队中的下一条用户消息
|
||||||
|
|
||||||
|
## 10. 保活与「能保护多久」
|
||||||
|
|
||||||
|
ACode 里和「保活」相关的有三层,不要混为一谈:
|
||||||
|
|
||||||
|
### 10.1 扩展自动保活(侧栏注入,默认开启)
|
||||||
|
|
||||||
|
- **设置**:`acode.keepalive.enabled`(默认 `true`)、`acode.keepalive.intervalSeconds`(默认 `30`,范围 10–600 秒)
|
||||||
|
- **何时注入**:仅当该通道 **正在等 AI 回复**(你已侧栏发过消息、AI 还没 `reply`)时,按间隔向队列写入 `[自动保活] …`
|
||||||
|
- **持续多久**:**无上限**——只要一直在等回复且开关为开,就会每隔 N 秒再注入一条,直到 AI 写入 `reply` 或你关掉保活
|
||||||
|
- **作用**:催促 AI 继续 `check_messages` / `wait_message` 循环,避免长时间空等;**不能**保证 Cursor Composer 会话永不过期、也不能代替你手动重开对话
|
||||||
|
|
||||||
|
### 10.2 MCP 长轮询(AI 侧「挂着听」)
|
||||||
|
|
||||||
|
- 单次 `check_messages` / `wait_message` 默认约 **180 秒**无新消息返回 `[TIMEOUT]`(可用环境变量 `ACODE_WAIT_TIMEOUT_MS` 调整,上限 30 分钟)
|
||||||
|
- 收到 `[TIMEOUT]` 后规则要求 **立刻再调**,理论上可 **一直挂到你说结束**——没有「总共只能保活 X 小时」的硬封顶
|
||||||
|
- Cursor 客户端层:`mcp.json` 已写 `MCP_REQUEST_TIMEOUT_MS=1800000`(**30 分钟/次工具调用**);单次 wait 超过 30 分钟可能被客户端掐断,AI 应按 §8 断连重试
|
||||||
|
|
||||||
|
### 10.3 侧栏绿点(连接指示,非保活)
|
||||||
|
|
||||||
|
- MCP 在 wait 循环中约 **每 3 秒** 写 `heartbeat.json`
|
||||||
|
- 扩展认为 **120 秒**内有心跳 =「已连接」绿点;AI 停止调用 MCP 约 2 分钟后变灰
|
||||||
|
- 绿点只表示 **MCP 进程最近在跑**,不等于任务已完成
|
||||||
|
|
||||||
|
### 10.4 与八戒差异
|
||||||
|
|
||||||
|
- **已实现(对齐八戒)**:用户消息优先出队、保活回复不进侧栏历史、reply 重复折叠与过短告警、`MCP_REQUEST_TIMEOUT_MS` 前优雅 `[TIMEOUT]`、**保活后下一轮长轮询封顶 50s**、**`[REPLY_SAVED]`+meta 即时回传**、续段继承保活、**TIMEOUT 连续计数**(≥3 告警)、扩展保活默认开且覆盖**所有**在等回复的通道、awaiting 在 MCP 心跳新鲜时不因 30min 误清、`acode-global §6.1`
|
||||||
|
- **未实现**:多 Agent 分发、skill manifest
|
||||||
|
- **已实现 SwitchMode 解锁**(每 4 次保活回复触发,v0.1.10+)
|
||||||
|
- **已实现 suggestions**:`suggestions.json` + 侧栏快捷按钮(v0.1.9+)
|
||||||
|
- 长任务若嫌 token 多:可在设置里 **关闭** 自动保活,或把 `intervalSeconds` 调到 60–120 秒
|
||||||
@@ -32,3 +32,6 @@ workspace/
|
|||||||
|
|
||||||
# Local JDK
|
# Local JDK
|
||||||
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
||||||
|
|
||||||
|
# E2E 截图
|
||||||
|
**/.e2e-shots/
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
|
|||||||
第一版目标不是完整复刻 Visual Studio 或 IntelliJ 插件,而是实现一个以本地 API 为核心、带桌面 UI 的 TFS 日常操作工具,支持:
|
第一版目标不是完整复刻 Visual Studio 或 IntelliJ 插件,而是实现一个以本地 API 为核心、带桌面 UI 的 TFS 日常操作工具,支持:
|
||||||
|
|
||||||
- 输入 TFS 地址、账号、密码并连接服务器
|
- 输入 TFS 地址、账号、密码并连接服务器
|
||||||
- 浏览 Collection 下的服务端目录树
|
- 登录阶段完成 Collection 选择
|
||||||
- 管理当前 Collection 下的默认 Workspace
|
- 自动查找或创建当前 Collection 的默认 Workspace
|
||||||
- 管理服务端路径与本地目录 Mapping
|
- 浏览固定 Collection 下的服务端目录树
|
||||||
|
- 管理当前 Workspace 的 Mapping
|
||||||
- 获取最新文件
|
- 获取最新文件
|
||||||
- 对已映射目录执行目录对比
|
- 对已映射目录执行目录对比
|
||||||
- 查看文件 / 目录历史记录
|
- 查看文件 / 目录历史记录
|
||||||
- 查看 changeset 影响的文件列表
|
- 查看 changeset 影响的文件列表
|
||||||
|
- 查看本地文件或服务器 latest 文件内容
|
||||||
- 对比本地文件与服务器文件
|
- 对比本地文件与服务器文件
|
||||||
- 对比历史记录中两个版本
|
- 对比历史记录中两个版本
|
||||||
- 签出、签入、新增、删除、撤销文件
|
- 签出、签入、新增、删除、撤销文件
|
||||||
@@ -32,7 +34,7 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
|
|||||||
- API 优先:UI 和 CLI 都调用本地 API。
|
- API 优先:UI 和 CLI 都调用本地 API。
|
||||||
- 复用 Java TFS SDK:不重写 TFS 协议。
|
- 复用 Java TFS SDK:不重写 TFS 协议。
|
||||||
- 本地常驻服务:支持 UI 长会话和本地状态管理。
|
- 本地常驻服务:支持 UI 长会话和本地状态管理。
|
||||||
- 交互参考 Visual Studio Team Explorer / Source Control Explorer。
|
- 工作台围绕固定 Collection / Workspace 上下文展开。
|
||||||
- 文件操作遵守 TFS Workspace / Pending Change 模型。
|
- 文件操作遵守 TFS Workspace / Pending Change 模型。
|
||||||
- 第一版优先可用和清晰,不做过度自动化。
|
- 第一版优先可用和清晰,不做过度自动化。
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ mactfs-server
|
|||||||
本地常驻 API 服务,负责路由、认证、会话、配置、日志。
|
本地常驻 API 服务,负责路由、认证、会话、配置、日志。
|
||||||
|
|
||||||
mactfs-cli
|
mactfs-cli
|
||||||
命令行入口,后续调用本地 API,保留调试能力。
|
命令行入口,调用本地 API,保留调试能力。
|
||||||
|
|
||||||
mactfsui
|
mactfsui
|
||||||
Electron + React 桌面 UI,调用本地 API。
|
Electron + React 桌面 UI,调用本地 API。
|
||||||
@@ -98,7 +100,7 @@ P0:
|
|||||||
- 文件历史查询
|
- 文件历史查询
|
||||||
- 目录历史查询
|
- 目录历史查询
|
||||||
- 单文件版本内容获取
|
- 单文件版本内容获取
|
||||||
- 文件 diff 基础能力
|
- 文件 Diff 基础能力
|
||||||
- 目录对比状态计算
|
- 目录对比状态计算
|
||||||
|
|
||||||
P1:
|
P1:
|
||||||
@@ -150,63 +152,58 @@ P2:
|
|||||||
- SSE / WebSocket 实时日志
|
- SSE / WebSocket 实时日志
|
||||||
- 异步 Job 模型
|
- 异步 Job 模型
|
||||||
|
|
||||||
### 阶段三:Frontend
|
### 阶段三:CLI
|
||||||
|
|
||||||
目标:实现类 Visual Studio 的 TFS 桌面 UI。
|
目标:实现本地 API 调试入口。
|
||||||
|
|
||||||
P0:
|
P0:
|
||||||
|
|
||||||
- Electron 启动本地 Java 服务
|
- `mactfs token --show`
|
||||||
- 登录 / 配置页面
|
- `mactfs health`
|
||||||
- Collection 目录树
|
- `mactfs api`
|
||||||
- 中间目录文件列表
|
- 兼容旧 `--action` 验证入口
|
||||||
- 右侧挂起更改面板
|
|
||||||
- 底部操作日志
|
### 阶段四:Frontend Workspace UI
|
||||||
- Mapping 创建
|
|
||||||
- Get Latest
|
目标:实现固定 Collection / Workspace 上下文的桌面工作台。
|
||||||
- 目录对比
|
|
||||||
- 签出 / 新增 / 删除 / 撤销 / 签入
|
P0:
|
||||||
- 历史记录
|
|
||||||
- 文件 diff
|
- Electron 启动服务、preload、API client
|
||||||
|
- 登录页、Collection 选择、默认 Workspace 上下文
|
||||||
|
- 顶部上下文栏、三栏工作台、底部操作台
|
||||||
|
- 左侧目录树与中间列表同步导航
|
||||||
|
- 对象右键菜单
|
||||||
|
- Mapping、History、目录对比弹窗
|
||||||
|
- 文件查看、Diff、冲突处理弹窗
|
||||||
|
- Pending Changes 与 Checkin
|
||||||
|
- 操作日志与刷新反馈
|
||||||
|
|
||||||
P1:
|
P1:
|
||||||
|
|
||||||
- 更好的文件图标和状态色
|
- 折叠面板交互打磨
|
||||||
- 历史 changeset 详情抽屉
|
- 列表职责收口与动作编排整理
|
||||||
- 操作结果 toast
|
|
||||||
|
|
||||||
P2:
|
P2:
|
||||||
|
|
||||||
- 用户自定义布局
|
- 用户自定义布局
|
||||||
- 快捷键
|
- 快捷键
|
||||||
|
|
||||||
### 阶段四:Feature
|
### 阶段五:Feature
|
||||||
|
|
||||||
目标:补齐核心 TFS 日常功能。
|
目标:按真实业务链路做端到端验收。
|
||||||
|
|
||||||
P0:
|
P0:
|
||||||
|
|
||||||
- 服务端目录浏览
|
- 服务端目录浏览
|
||||||
- Mapping 管理
|
- Mapping 管理
|
||||||
|
- Get Latest
|
||||||
- 目录对比
|
- 目录对比
|
||||||
- 勾选差异文件处理
|
|
||||||
- Pending Changes 管理
|
- Pending Changes 管理
|
||||||
- Checkin comment 必填
|
- Checkin
|
||||||
- History / Diff
|
- History / Diff
|
||||||
|
|
||||||
P1:
|
### 阶段六:Release
|
||||||
|
|
||||||
- 文件夹递归 Checkout 预估影响数量
|
|
||||||
- Get Latest 支持文件 / 目录 / Mapping 粒度
|
|
||||||
- 目录历史 changeset 文件列表
|
|
||||||
|
|
||||||
P2:
|
|
||||||
|
|
||||||
- 冲突解决器
|
|
||||||
- Label / Branch / Merge
|
|
||||||
- Work Item 查询与关联
|
|
||||||
|
|
||||||
### 阶段五:Release
|
|
||||||
|
|
||||||
目标:完成个人可用的 macOS 桌面分发。
|
目标:完成个人可用的 macOS 桌面分发。
|
||||||
|
|
||||||
@@ -281,31 +278,6 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
|
|||||||
|
|
||||||
第一版允许明文保存密码,定位为个人开发模式。
|
第一版允许明文保存密码,定位为个人开发模式。
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"serverUri": "http://tfs.example.com:8080/tfs/",
|
|
||||||
"authType": "ntlm-explicit",
|
|
||||||
"domain": "bdsoft",
|
|
||||||
"username": "fenghp",
|
|
||||||
"password": "plain-password",
|
|
||||||
"collection": "PKUSEHR",
|
|
||||||
"workspace": "mactfs-PKUSEHR-fenghp-macbook",
|
|
||||||
"mappings": [
|
|
||||||
{
|
|
||||||
"serverPath": "$/Project/Main",
|
|
||||||
"localPath": "/Users/fenghp/project/main"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
风险说明:
|
|
||||||
|
|
||||||
- 明文密码只适合个人本机使用。
|
|
||||||
- 后续产品化阶段再切换 macOS Keychain。
|
|
||||||
|
|
||||||
### 4.4 Workspace 策略
|
### 4.4 Workspace 策略
|
||||||
|
|
||||||
第一版规则:
|
第一版规则:
|
||||||
@@ -313,6 +285,7 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
|
|||||||
- 同一个 Collection 下,同一台电脑只创建一个默认 Workspace。
|
- 同一个 Collection 下,同一台电脑只创建一个默认 Workspace。
|
||||||
- 一个 Workspace 不跨 Collection。
|
- 一个 Workspace 不跨 Collection。
|
||||||
- 一个 Workspace 支持多条 Mapping。
|
- 一个 Workspace 支持多条 Mapping。
|
||||||
|
- 工作台进入后固定 Collection,上下文变化通过重新连接完成。
|
||||||
|
|
||||||
Workspace 命名规则:
|
Workspace 命名规则:
|
||||||
|
|
||||||
@@ -320,12 +293,6 @@ Workspace 命名规则:
|
|||||||
mactfs-{collection}-{username}-{computer}
|
mactfs-{collection}-{username}-{computer}
|
||||||
```
|
```
|
||||||
|
|
||||||
命名规范:
|
|
||||||
|
|
||||||
- 只保留字母、数字、横线、下划线
|
|
||||||
- 空格、中文、点号替换为横线
|
|
||||||
- 过长时截断
|
|
||||||
|
|
||||||
### 4.5 同步调用与超时
|
### 4.5 同步调用与超时
|
||||||
|
|
||||||
第一版 API 使用同步调用,接口等待 TFS 操作完成后返回。
|
第一版 API 使用同步调用,接口等待 TFS 操作完成后返回。
|
||||||
@@ -339,20 +306,6 @@ UI 必须在等待期间显示明确状态:
|
|||||||
- 正在签入
|
- 正在签入
|
||||||
- TFS 网络响应可能较慢
|
- TFS 网络响应可能较慢
|
||||||
|
|
||||||
超时建议:
|
|
||||||
|
|
||||||
```text
|
|
||||||
连接测试:30 秒
|
|
||||||
目录浏览:120 秒
|
|
||||||
历史查询:120 秒
|
|
||||||
目录对比:120 秒
|
|
||||||
单文件 diff:60 秒
|
|
||||||
Get Latest:300 秒
|
|
||||||
Checkin:300 秒
|
|
||||||
```
|
|
||||||
|
|
||||||
第一版不做取消。超时或 TFS 异常时,后端停止当前操作并返回错误。
|
|
||||||
|
|
||||||
### 4.6 操作日志
|
### 4.6 操作日志
|
||||||
|
|
||||||
服务端和 UI 记录每次操作:
|
服务端和 UI 记录每次操作:
|
||||||
@@ -365,15 +318,6 @@ Checkin:300 秒
|
|||||||
- 成功 / 失败
|
- 成功 / 失败
|
||||||
- 错误信息
|
- 错误信息
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[10:21:03] 开始目录对比:$/Project/src
|
|
||||||
[10:21:18] 目录对比完成:发现 12 个差异,耗时 15.2s
|
|
||||||
[10:22:01] 开始签入:3 个文件
|
|
||||||
[10:22:06] 签入失败:TFxxxx 权限不足
|
|
||||||
```
|
|
||||||
|
|
||||||
## 五、API 设计
|
## 五、API 设计
|
||||||
|
|
||||||
### 5.1 基础接口
|
### 5.1 基础接口
|
||||||
@@ -398,22 +342,13 @@ GET /api/server-folder/items?path=$/Project/src
|
|||||||
```text
|
```text
|
||||||
GET /api/workspace
|
GET /api/workspace
|
||||||
POST /api/workspace/ensure
|
POST /api/workspace/ensure
|
||||||
|
GET /api/workspace/context
|
||||||
GET /api/mappings
|
GET /api/mappings
|
||||||
|
POST /api/mappings/check-target
|
||||||
POST /api/mappings
|
POST /api/mappings
|
||||||
DELETE /api/mappings
|
DELETE /api/mappings
|
||||||
```
|
```
|
||||||
|
|
||||||
Mapping 创建流程:
|
|
||||||
|
|
||||||
```text
|
|
||||||
选中服务端目录
|
|
||||||
选择本地目录
|
|
||||||
保存 WorkingFolder mapping
|
|
||||||
用户选择是否立即 Get Latest
|
|
||||||
```
|
|
||||||
|
|
||||||
创建 Mapping 后,如果不立即 Get Latest,后续仍可对 Mapping 下的子目录或单个文件单独 Get Latest。
|
|
||||||
|
|
||||||
### 5.4 文件操作
|
### 5.4 文件操作
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -424,48 +359,32 @@ POST /api/files/delete
|
|||||||
POST /api/files/undo
|
POST /api/files/undo
|
||||||
POST /api/checkin
|
POST /api/checkin
|
||||||
GET /api/pending-changes
|
GET /api/pending-changes
|
||||||
|
GET /api/files/content
|
||||||
|
POST /api/conflicts/apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Get Latest 支持:
|
|
||||||
|
|
||||||
- 单文件
|
|
||||||
- 目录递归
|
|
||||||
- 整个 Mapping
|
|
||||||
|
|
||||||
### 5.5 目录对比
|
### 5.5 目录对比
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/compare/folder
|
POST /api/compare/folder
|
||||||
```
|
```
|
||||||
|
|
||||||
请求示例:
|
目录对比返回状态:
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"serverPath": "$/Project/src",
|
|
||||||
"localPath": "/Users/fenghp/project/src",
|
|
||||||
"recursive": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
返回状态:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
localModified 本地修改
|
localModified
|
||||||
remoteChanged 服务端有更新
|
remoteChanged
|
||||||
bothChanged 本地和服务端都变更
|
bothChanged
|
||||||
localOnly 本地新增
|
localOnly
|
||||||
remoteOnly 服务端新增
|
remoteOnly
|
||||||
notDownloaded 已映射但本地未下载
|
notDownloaded
|
||||||
localDeleted 本地删除
|
localDeleted
|
||||||
pendingEdit 已签出编辑
|
pendingEdit
|
||||||
pendingAdd 待新增
|
pendingAdd
|
||||||
pendingDelete 待删除
|
pendingDelete
|
||||||
upToDate 本地与服务端一致,默认不展示
|
upToDate
|
||||||
```
|
```
|
||||||
|
|
||||||
目录对比不逐文件下载内容。第一步只做元数据和状态对比。用户打开单个文件 diff 时才获取对应服务端内容。
|
|
||||||
|
|
||||||
### 5.6 历史记录
|
### 5.6 历史记录
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -473,26 +392,6 @@ GET /api/history?path=...
|
|||||||
GET /api/history/changeset?changeset=...
|
GET /api/history/changeset?changeset=...
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 文件历史:返回该文件最近 100 条历史。
|
|
||||||
- 目录历史:返回影响该目录的最近 100 条 changeset。
|
|
||||||
- 点击 changeset 后展示本次 changeset 影响的文件列表。
|
|
||||||
- 第一版不做作者、时间、changeset 范围筛选。
|
|
||||||
|
|
||||||
changeset 文件列表字段:
|
|
||||||
|
|
||||||
```text
|
|
||||||
path
|
|
||||||
serverPath
|
|
||||||
changeType
|
|
||||||
itemType
|
|
||||||
changeset
|
|
||||||
author
|
|
||||||
date
|
|
||||||
comment
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.7 Diff
|
### 5.7 Diff
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -505,32 +404,24 @@ POST /api/diff/revisions
|
|||||||
- 本地文件 vs 服务器 latest
|
- 本地文件 vs 服务器 latest
|
||||||
- 历史记录中两个 changeset 对比
|
- 历史记录中两个 changeset 对比
|
||||||
|
|
||||||
第一版优先支持文本文件。
|
|
||||||
|
|
||||||
暂不支持:
|
|
||||||
|
|
||||||
- 三方 merge
|
|
||||||
- 二进制可视化 diff
|
|
||||||
- 目录级完整内容 diff
|
|
||||||
|
|
||||||
## 六、UI 设计
|
## 六、UI 设计
|
||||||
|
|
||||||
### 6.1 布局
|
### 6.1 布局
|
||||||
|
|
||||||
参考 Visual Studio Source Control Explorer。
|
工作台围绕固定 Collection / Workspace 展开:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
顶部:
|
顶部:
|
||||||
当前连接、Collection、Workspace、常用操作
|
当前连接、Collection、Workspace、全局入口
|
||||||
|
|
||||||
左侧:
|
左侧:
|
||||||
Collection 下的 TFS 服务端目录树
|
固定 Collection 下的服务端目录树
|
||||||
|
|
||||||
中间:
|
中间:
|
||||||
当前选中目录下一级文件 / 文件夹列表
|
当前选中目录下一级文件 / 文件夹列表
|
||||||
|
|
||||||
右侧:
|
右侧:
|
||||||
挂起的更改、签入 comment、签入 / 撤销等操作
|
Pending Changes、Included / Excluded、Checkin
|
||||||
|
|
||||||
底部:
|
底部:
|
||||||
操作日志
|
操作日志
|
||||||
@@ -538,26 +429,11 @@ POST /api/diff/revisions
|
|||||||
|
|
||||||
### 6.2 左侧目录树
|
### 6.2 左侧目录树
|
||||||
|
|
||||||
左侧展示 Collection 下完整服务端目录树。
|
左侧展示固定 Collection 下完整服务端目录树。
|
||||||
|
|
||||||
无论服务端目录是否已映射到本地,都允许浏览。
|
无论服务端目录是否已映射到本地,都允许浏览。
|
||||||
|
|
||||||
未映射目录允许:
|
目录树节点操作优先通过右键菜单触发。
|
||||||
|
|
||||||
- 浏览子目录 / 文件
|
|
||||||
- 查看历史
|
|
||||||
- 查看 changeset 文件列表
|
|
||||||
- 创建 Mapping
|
|
||||||
- Get Latest 到新本地目录
|
|
||||||
|
|
||||||
未映射目录不允许:
|
|
||||||
|
|
||||||
- 本地目录对比
|
|
||||||
- 本地 vs 云端 diff
|
|
||||||
- checkout
|
|
||||||
- add
|
|
||||||
- delete
|
|
||||||
- checkin
|
|
||||||
|
|
||||||
### 6.3 中间文件列表
|
### 6.3 中间文件列表
|
||||||
|
|
||||||
@@ -569,136 +445,44 @@ POST /api/diff/revisions
|
|||||||
- 类型
|
- 类型
|
||||||
- 服务端路径
|
- 服务端路径
|
||||||
- 本地路径
|
- 本地路径
|
||||||
- 是否已映射
|
|
||||||
- 状态
|
- 状态
|
||||||
- 最新版本
|
- 最新版本
|
||||||
- 上次签入时间
|
- 上次签入时间
|
||||||
|
|
||||||
已映射但本地未下载的文件显示:
|
中间区域只承载浏览,不再内嵌大块 Mapping、History、Diff、Compare 面板。
|
||||||
|
|
||||||
```text
|
### 6.4 Mapping、History 与目录对比
|
||||||
未下载 / notDownloaded
|
|
||||||
```
|
|
||||||
|
|
||||||
可执行:
|
- Mapping 通过弹窗创建
|
||||||
|
- History 通过弹窗展示
|
||||||
|
- 目录对比通过弹窗展示
|
||||||
|
- 目录对比默认隐藏 `upToDate`
|
||||||
|
- 对象操作通过右键菜单触发
|
||||||
|
|
||||||
- Get Latest
|
### 6.5 文件查看与 Diff
|
||||||
- 查看历史
|
|
||||||
|
|
||||||
### 6.4 目录对比
|
|
||||||
|
|
||||||
对已映射目录可执行目录对比。
|
|
||||||
|
|
||||||
目录对比结果默认隐藏 `upToDate`,只展示差异文件。
|
|
||||||
|
|
||||||
用户勾选差异文件后,右侧根据状态展示可用操作。
|
|
||||||
|
|
||||||
操作映射:
|
|
||||||
|
|
||||||
```text
|
|
||||||
localModified -> checkout / diff
|
|
||||||
localOnly -> add / delete local
|
|
||||||
localDeleted -> delete / restore from server
|
|
||||||
remoteChanged -> get latest / diff
|
|
||||||
notDownloaded -> get latest
|
|
||||||
pendingEdit -> checkin / undo / diff
|
|
||||||
pendingAdd -> checkin / undo
|
|
||||||
pendingDelete -> checkin / undo
|
|
||||||
bothChanged -> diff / 手工处理
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.5 Checkout
|
|
||||||
|
|
||||||
已映射且本地存在的文件可直接 checkout。
|
|
||||||
|
|
||||||
已映射且本地存在的目录可递归 checkout。
|
|
||||||
|
|
||||||
目录级 checkout 必须二次确认:
|
|
||||||
|
|
||||||
```text
|
|
||||||
将递归签出 N 个已版本控制文件,是否继续?
|
|
||||||
```
|
|
||||||
|
|
||||||
执行后文件进入 `pendingEdit`。
|
|
||||||
|
|
||||||
不做实时自动 checkout。
|
|
||||||
|
|
||||||
### 6.6 Add
|
|
||||||
|
|
||||||
新增文件只从目录对比结果触发。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
```text
|
|
||||||
目录对比发现 localOnly
|
|
||||||
用户勾选 localOnly 文件
|
|
||||||
点击添加文件
|
|
||||||
后端执行 pend add
|
|
||||||
状态变为 pendingAdd
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.7 Delete
|
|
||||||
|
|
||||||
删除模型:
|
|
||||||
|
|
||||||
- 已版本控制文件:执行 TFS pending delete,后续通过 checkin 提交。
|
|
||||||
- 本地未版本控制文件:可直接删除本地文件。
|
|
||||||
- 未下载服务端文件:第一版不允许删除。
|
|
||||||
|
|
||||||
### 6.8 Pending Changes
|
|
||||||
|
|
||||||
右侧挂起更改面板展示当前 Workspace 的 pending changes。
|
|
||||||
|
|
||||||
分为:
|
|
||||||
|
|
||||||
- Included Changes
|
|
||||||
- Excluded Changes
|
|
||||||
|
|
||||||
签入只提交 Included Changes。
|
|
||||||
|
|
||||||
用户可在 Included / Excluded 间移动文件。
|
|
||||||
|
|
||||||
Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
|
||||||
|
|
||||||
不要求左侧或中间选中文件与右侧 pending changes 联动。
|
|
||||||
|
|
||||||
### 6.9 Checkin
|
|
||||||
|
|
||||||
签入规则:
|
|
||||||
|
|
||||||
- 只提交 Included Changes。
|
|
||||||
- 所有提交文件必须属于同一个 Workspace。
|
|
||||||
- 可跨目录、跨 Mapping。
|
|
||||||
- comment 必填。
|
|
||||||
- 第一版不做 Work Item 绑定。
|
|
||||||
- 第一版不做复杂 Check-in Policy UI。
|
|
||||||
- 如果服务端返回 policy / 权限 / 冲突错误,直接展示错误信息。
|
|
||||||
|
|
||||||
### 6.10 History
|
|
||||||
|
|
||||||
文件历史:
|
|
||||||
|
|
||||||
- 展示最近 100 条。
|
|
||||||
- 字段:changeset、作者、时间、comment。
|
|
||||||
- 支持选择两个历史记录做 diff。
|
|
||||||
|
|
||||||
目录历史:
|
|
||||||
|
|
||||||
- 展示影响该目录的最近 100 条 changeset。
|
|
||||||
- 点击 changeset 展示本次影响的文件列表。
|
|
||||||
- 可从文件列表进入文件 diff。
|
|
||||||
|
|
||||||
### 6.11 Diff
|
|
||||||
|
|
||||||
支持:
|
支持:
|
||||||
|
|
||||||
- 本地文件 vs 服务器 latest
|
- 已映射文件查看本地内容
|
||||||
- 历史中两个版本对比
|
- 未映射文件查看服务器 latest 内容
|
||||||
|
- 本地 vs latest Diff
|
||||||
|
- 两个历史版本 Diff
|
||||||
|
|
||||||
展示方式:
|
大文件、二进制文件、非 Mapping 路径需要明确提示。
|
||||||
|
|
||||||
- 文本文件左右对比
|
### 6.6 Pending Changes 与 Checkin
|
||||||
- 目录级只显示差异列表,不展开所有文件 diff
|
|
||||||
|
- 右侧挂起更改面板展示当前 Workspace 的 pending changes
|
||||||
|
- 分为 Included / Excluded
|
||||||
|
- Checkin 只提交 Included Changes
|
||||||
|
- Checkin 保留在右侧面板,不使用单独弹窗
|
||||||
|
|
||||||
|
### 6.7 冲突处理
|
||||||
|
|
||||||
|
- Get Latest 和 Checkout 复用统一冲突弹窗
|
||||||
|
- 支持服务器版本 / 保留本地版本选择
|
||||||
|
- 冲突项可进入 Diff
|
||||||
|
- 第一版不做手动冲突块编辑
|
||||||
|
|
||||||
## 七、核心业务流程
|
## 七、核心业务流程
|
||||||
|
|
||||||
@@ -709,31 +493,48 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
|||||||
点击连接
|
点击连接
|
||||||
服务端认证 TFS
|
服务端认证 TFS
|
||||||
查询 Collection
|
查询 Collection
|
||||||
保存默认配置
|
用户确认 Collection
|
||||||
进入主界面
|
自动确保默认 Workspace
|
||||||
|
进入工作台
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 创建 Mapping
|
### 7.2 创建 Mapping
|
||||||
|
|
||||||
```text
|
```text
|
||||||
左侧选择服务端目录
|
左侧或中间选择服务端目录
|
||||||
点击映射到本地
|
通过右键菜单打开映射弹窗
|
||||||
选择本地目录
|
选择本地父目录
|
||||||
|
调用本地后端预校验最终目标路径
|
||||||
|
如果目标目录已存在,则提示“已存在,禁止映射”并禁止确认
|
||||||
保存 Mapping
|
保存 Mapping
|
||||||
用户选择是否立即 Get Latest
|
用户选择是否立即 Get Latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- Mapping 只允许目录创建,不允许文件创建
|
||||||
|
- 前端只上传 `serverPath` 与 `localParentPath`
|
||||||
|
- 最终本地目标路径由本地后端按“父目录 + 服务端目录最后一段”规则计算
|
||||||
|
- 当前版本由本地后端负责最终路径存在校验与目录创建
|
||||||
|
- 已映射目录不再展示“映射到本地”
|
||||||
|
- 取消映射只解除 Mapping,不删除本地文件
|
||||||
|
- 取消映射后页面保持当前浏览位置,只更新动作可用性
|
||||||
|
|
||||||
### 7.3 目录对比并处理差异
|
### 7.3 目录对比并处理差异
|
||||||
|
|
||||||
```text
|
```text
|
||||||
选择已映射目录
|
选择已映射目录
|
||||||
点击目录对比
|
打开目录对比弹窗
|
||||||
系统返回差异列表
|
系统返回差异列表
|
||||||
用户勾选文件
|
用户通过右键菜单执行 checkout / add / delete / get latest / undo
|
||||||
根据差异类型执行 checkout / add / delete / get latest / undo
|
如有冲突,进入统一冲突弹窗
|
||||||
重新对比或刷新 pending changes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- 未映射目录仍允许浏览、查看历史、打开目录对比
|
||||||
|
- 依赖本地工作区的动作在未映射目录下保留菜单项,但置灰不可点击
|
||||||
|
|
||||||
### 7.4 签入
|
### 7.4 签入
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -750,7 +551,7 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
选择文件或目录
|
选择文件或目录
|
||||||
点击历史
|
打开历史弹窗
|
||||||
展示最近 100 条
|
展示最近 100 条
|
||||||
文件历史可选择两个版本对比
|
文件历史可选择两个版本对比
|
||||||
目录历史可点击 changeset 查看文件列表
|
目录历史可点击 changeset 查看文件列表
|
||||||
@@ -768,9 +569,8 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
|||||||
- Work Item 绑定
|
- Work Item 绑定
|
||||||
- 完整 Check-in Policy UI
|
- 完整 Check-in Policy UI
|
||||||
- 三方 merge
|
- 三方 merge
|
||||||
- 复杂冲突解决
|
- 手动冲突块编辑
|
||||||
- Label / Branch / Merge
|
- Label / Branch / Merge
|
||||||
- Git hook 模拟 TFS
|
|
||||||
- macOS Keychain 默认存储
|
- macOS Keychain 默认存储
|
||||||
|
|
||||||
## 九、风险与约束
|
## 九、风险与约束
|
||||||
@@ -781,19 +581,10 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
|||||||
|
|
||||||
Apple Silicon 下需使用 x86_64 JDK 8 + Rosetta。
|
Apple Silicon 下需使用 x86_64 JDK 8 + Rosetta。
|
||||||
|
|
||||||
第一版需要固定运行方式:
|
|
||||||
|
|
||||||
```text
|
|
||||||
x86_64 JDK 8
|
|
||||||
com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 明文密码
|
### 9.2 明文密码
|
||||||
|
|
||||||
第一版允许明文保存密码,只适合个人本机使用。
|
第一版允许明文保存密码,只适合个人本机使用。
|
||||||
|
|
||||||
后续产品化必须改为 Keychain。
|
|
||||||
|
|
||||||
### 9.3 大目录性能
|
### 9.3 大目录性能
|
||||||
|
|
||||||
目录对比、目录历史、Get Latest 可能受 TFS 网络和目录规模影响。
|
目录对比、目录历史、Get Latest 可能受 TFS 网络和目录规模影响。
|
||||||
@@ -802,7 +593,7 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
|||||||
|
|
||||||
- 同步等待状态提示
|
- 同步等待状态提示
|
||||||
- 超时失败
|
- 超时失败
|
||||||
- 默认隐藏 upToDate
|
- 默认隐藏 `upToDate`
|
||||||
- 历史限制 100 条
|
- 历史限制 100 条
|
||||||
|
|
||||||
控制复杂度。
|
控制复杂度。
|
||||||
@@ -811,44 +602,43 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
|||||||
|
|
||||||
TFS 不等同 Git。
|
TFS 不等同 Git。
|
||||||
|
|
||||||
本地文件可写不代表 TFS pending edit。
|
|
||||||
|
|
||||||
签入前必须通过 checkout / add / delete 把本地变化转换为 pending changes。
|
签入前必须通过 checkout / add / delete 把本地变化转换为 pending changes。
|
||||||
|
|
||||||
## 十、验收标准
|
## 十、验收标准
|
||||||
|
|
||||||
P0 验收:
|
P0 验收:
|
||||||
|
|
||||||
- 能启动本地 API 服务。
|
- 能启动本地 API 服务
|
||||||
- API 只监听 `127.0.0.1`。
|
- API 只监听 `127.0.0.1`
|
||||||
- API 需要 Bearer token。
|
- API 需要 Bearer token
|
||||||
- 能输入账号密码连接 TFS。
|
- 能输入账号密码连接 TFS
|
||||||
- 能查询 Collection。
|
- 能选择 Collection 并固定上下文
|
||||||
- 能浏览 Collection 下目录树。
|
- 能创建 / 复用当前 Collection 的默认 Workspace
|
||||||
- 能创建 / 复用当前 Collection 的默认 Workspace。
|
- 能浏览固定 Collection 下目录树
|
||||||
- 能创建 Mapping。
|
- 能创建 Mapping
|
||||||
- 能选择是否立即 Get Latest。
|
- 能选择是否立即 Get Latest
|
||||||
- 能对已映射目录执行目录对比。
|
- 能对已映射目录执行目录对比
|
||||||
- 能对差异文件执行 checkout / add / delete / get latest / undo。
|
- 能对差异文件执行 checkout / add / delete / get latest / undo
|
||||||
- 能查看 pending changes。
|
- 能查看 pending changes
|
||||||
- 能区分 Included / Excluded。
|
- 能区分 Included / Excluded
|
||||||
- 能填写 comment 并签入 Included Changes。
|
- 能填写 comment 并签入 Included Changes
|
||||||
- 能查看文件历史。
|
- 能查看文件历史
|
||||||
- 能查看目录历史。
|
- 能查看目录历史
|
||||||
- 能查看 changeset 影响文件列表。
|
- 能查看 changeset 影响文件列表
|
||||||
- 能做本地 vs latest diff。
|
- 能查看文件内容
|
||||||
- 能选择两个历史版本 diff。
|
- 能做本地 vs latest Diff
|
||||||
- UI 有操作日志。
|
- 能选择两个历史版本 Diff
|
||||||
|
- UI 有操作日志
|
||||||
|
|
||||||
## 十一、推荐实施顺序
|
## 十一、推荐实施顺序
|
||||||
|
|
||||||
1. 抽 `mactfs-core`,迁移当前 `TfsPhaseOneService` 能力。
|
1. 抽 `mactfs-core`,迁移当前 `TfsPhaseOneService` 能力。
|
||||||
2. 新增 `mactfs-server`,接入 SparkJava 和 token auth。
|
2. 新增 `mactfs-server`,接入 SparkJava 和 token auth。
|
||||||
3. 实现配置、连接、Collection、目录浏览 API。
|
3. 改造 `mactfs-cli`,保留调试入口。
|
||||||
4. 实现 Workspace / Mapping / Get Latest API。
|
4. 完成前端基础设施:Electron、preload、API client。
|
||||||
5. 实现 pending changes、checkout、add、delete、undo、checkin API。
|
5. 完成连接页、固定 Collection / Workspace 上下文。
|
||||||
6. 实现目录对比 API。
|
6. 完成工作台布局、目录树、中间列表。
|
||||||
7. 实现 history 和 diff API。
|
7. 完成右键菜单、Mapping / History / Compare 弹窗。
|
||||||
8. 改造 `mactfsui`,实现 Visual Studio 风格主界面。
|
8. 完成文件查看、Diff、冲突流程。
|
||||||
9. 接入操作日志。
|
9. 完成 Pending Changes、Checkin、操作日志。
|
||||||
10. 完成打包和运行说明。
|
10. 完成 Feature E2E 与 Release。
|
||||||
|
|||||||
@@ -785,25 +785,14 @@ Content-Type: application/json
|
|||||||
### 5.1 页面结构
|
### 5.1 页面结构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ 顶部导航栏 │
|
│ Top Bar: 连接状态 / Server / Collection / Workspace / 全局入口 │
|
||||||
│ [连接状态: ●已连接] [当前工作区: mactfs-xxx] │
|
├──────────────┬────────────────────────────────┬──────────────┤
|
||||||
├─────────────┬───────────────────────────────────┤
|
│ Source Tree │ Current Folder Items │ Changes │
|
||||||
│ 侧边栏 │ 主内容区 │
|
│ 左侧目录树 │ 中间目录文件列表 │ Pending/Checkin│
|
||||||
│ │ │
|
├──────────────┴────────────────────────────────┴──────────────┤
|
||||||
│ 连接配置 │ (根据侧边栏选中项切换) │
|
│ Operation Console / 底部日志面板(可折叠) │
|
||||||
│ 工作区管理 │ │
|
└──────────────────────────────────────────────────────────────┘
|
||||||
│ 映射管理 │ │
|
|
||||||
│ 目录浏览 │ │
|
|
||||||
│ 文件操作 │ │
|
|
||||||
│ 历史记录 │ │
|
|
||||||
│ 文件对比 │ │
|
|
||||||
│ │ │
|
|
||||||
├─────────────┴───────────────────────────────────┤
|
|
||||||
│ 底部日志面板(可折叠) │
|
|
||||||
│ [2026-05-28 20:30:01] Login success │
|
|
||||||
│ [2026-05-28 20:30:02] Get latest: 142 files │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 各页面说明
|
### 5.2 各页面说明
|
||||||
@@ -814,44 +803,46 @@ Content-Type: application/json
|
|||||||
- 操作按钮:测试连接、保存配置
|
- 操作按钮:测试连接、保存配置
|
||||||
- 状态指示:连接状态(未连接/连接中/已连接/连接失败)
|
- 状态指示:连接状态(未连接/连接中/已连接/连接失败)
|
||||||
|
|
||||||
#### 工作区管理页
|
#### 上下文确认流程
|
||||||
|
|
||||||
- 工作区列表(表格展示:名称、Owner、Computer、映射数量)
|
- 登录成功后加载 Collection 列表
|
||||||
- 操作:创建工作区、选择工作区、删除工作区
|
- 用户确认当前 Collection
|
||||||
- 选中工作区后自动加载映射列表
|
- 系统自动查找或创建默认 Workspace
|
||||||
|
- 工作台内不提供 Workspace 手动选择
|
||||||
|
|
||||||
#### 映射管理页
|
#### Mapping 弹窗
|
||||||
|
|
||||||
- 当前工作区的映射列表(表格:服务端路径、本地路径、操作)
|
- 从目录树或文件列表触发“映射到本地”
|
||||||
- 添加映射:选择服务端路径(打开目录浏览器)+ 选择本地路径(打开系统目录选择器)
|
- 选择服务端路径与本地目录
|
||||||
- 删除映射
|
- 选择是否立即 Get Latest
|
||||||
- 每条映射可以单独执行 Get Latest
|
- 创建后刷新当前工作台状态
|
||||||
|
|
||||||
#### 目录浏览页
|
#### 目录浏览工作台
|
||||||
|
|
||||||
- 树形展开的服务端目录结构
|
- 左侧树形展开的服务端目录结构
|
||||||
- 点击文件夹展开子目录
|
- 中间展示当前目录下一级文件 / 文件夹
|
||||||
- 点击文件查看属性(名称、类型、大小、最后修改版本)
|
- 单击选中,双击目录进入
|
||||||
- 右键菜单:获取最新、查看历史、对比
|
- 对象操作通过右键菜单触发
|
||||||
|
|
||||||
#### 文件操作页
|
#### 右侧 Changes 面板
|
||||||
|
|
||||||
- 待处理变更列表(Pending Changes)
|
- 待处理变更列表(Pending Changes)
|
||||||
- 文件状态标识:edit / add / delete
|
- Included / Excluded 分组
|
||||||
- 批量操作:全选、签入选中、撤销选中
|
- 签入注释输入 + 签入按钮
|
||||||
- 签入表单:注释输入 + 文件勾选 + 签入按钮
|
- 查看、比较、撤销等对象操作走右键菜单
|
||||||
|
|
||||||
#### 历史记录页
|
#### History 弹窗
|
||||||
|
|
||||||
- 输入路径查询历史
|
- 输入路径或对象上下文触发
|
||||||
- 历史列表(表格:Changeset ID、提交人、日期、注释)
|
- 历史列表(Changeset ID、提交人、日期、注释)
|
||||||
- 点击 Changeset 查看详情
|
- 点击 Changeset 查看详情
|
||||||
- 点击文件查看该版本内容
|
- 点击文件进入查看或 Diff
|
||||||
|
|
||||||
#### 文件对比页
|
#### 文件查看与 Diff 弹窗
|
||||||
|
|
||||||
- 左右分栏对比视图
|
- 左右分栏对比视图
|
||||||
- 支持本地 vs 云端对比
|
- 支持本地 vs 云端对比
|
||||||
|
- 支持两个历史版本对比
|
||||||
- 差异行高亮标记
|
- 差异行高亮标记
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -860,7 +851,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
### 第二阶段:HTTP 服务 + 最小可用 UI
|
### 第二阶段:HTTP 服务 + 最小可用 UI
|
||||||
|
|
||||||
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、工作区、映射和 Get Latest 的界面化。
|
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、固定 Collection / Workspace 上下文、映射和 Get Latest 的界面化。
|
||||||
|
|
||||||
**后端任务:**
|
**后端任务:**
|
||||||
|
|
||||||
@@ -872,17 +863,18 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**前端任务:**
|
**前端任务:**
|
||||||
|
|
||||||
1. mactfsui 从 spawn CLI 改为 HTTP 调用
|
1. `mactfsui` 从 spawn CLI 改为 HTTP 调用
|
||||||
2. 实现连接配置页
|
2. 实现 Electron 启动服务、preload 和 API client
|
||||||
3. 实现工作区管理页
|
3. 实现连接页与 Collection 选择
|
||||||
4. 实现映射管理页(含目录浏览)
|
4. 自动确保默认 Workspace,不单独提供 Workspace 管理页
|
||||||
5. 实现 Get Latest 操作页
|
5. 实现固定 Collection 下的目录树与当前目录列表
|
||||||
6. 实现底部日志面板
|
6. 实现 Mapping 弹窗、Get Latest、底部日志面板
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
|
|
||||||
- 用户可通过界面完成登录
|
- 用户可通过界面完成登录
|
||||||
- 用户可通过界面管理工作区
|
- 用户可在登录阶段确认 Collection
|
||||||
|
- 用户进入工作台后可自动使用或创建默认 Workspace
|
||||||
- 用户可通过界面添加/查看/删除映射
|
- 用户可通过界面添加/查看/删除映射
|
||||||
- 用户可通过界面执行 Get Latest
|
- 用户可通过界面执行 Get Latest
|
||||||
- curl 可直接调用所有 API
|
- curl 可直接调用所有 API
|
||||||
@@ -902,10 +894,11 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**前端任务:**
|
**前端任务:**
|
||||||
|
|
||||||
1. 实现文件操作页(Pending Changes + 签入表单)
|
1. 实现 Pending Changes + Checkin 右侧面板
|
||||||
2. 实现历史记录页
|
2. 实现 History 弹窗
|
||||||
3. 实现文件对比页(差异视图)
|
3. 实现文件查看与 Diff 弹窗
|
||||||
4. 操作进度展示
|
4. 实现目录对比、右键菜单和冲突弹窗
|
||||||
|
5. 完善操作进度展示
|
||||||
|
|
||||||
### 第四阶段:增强能力
|
### 第四阶段:增强能力
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# macTFS 工作台增强需求·第二批(属性 / 重命名 / 回滚 / 工作区管理 / 分支合并)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
第一批增强(FE-014 ~ FE-017,见 [mactfs-ui-enhancement-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements.md))
|
||||||
|
完成后,用户继续按 Visual Studio Source Control Explorer 的日常用法试用工作台,
|
||||||
|
提出第二批功能缺口。本文档收口这批需求,作为 `task/frontend` 中 FE-019 ~ FE-024 的需求来源。
|
||||||
|
|
||||||
|
## 二、用户反馈原文与解读
|
||||||
|
|
||||||
|
### 2.1 选中文件 / 文件夹缺少「高级」能力
|
||||||
|
|
||||||
|
- 原文:选中文件和文件夹的时候,需要一个高级功能。高级功能至少有属性:显示服务器地址、本地地址。
|
||||||
|
还有获取特定版本功能,根据历史变更集,获取这个变更集的内容(相当于整体代码回滚到这个变更集提交的时候)。
|
||||||
|
- 解读:
|
||||||
|
- 「属性」对应 VS 的 Properties 面板:展示服务器路径、本地路径、映射状态、挂起状态、最新版本号等
|
||||||
|
- 「获取特定版本」对应 VS 的 Get Specific Version 对话框:不必先打开历史弹窗,
|
||||||
|
右键即可选择 / 输入 changeset,把该对象(含目录递归)的本地内容切到那个版本
|
||||||
|
- 与 FE-014 已实现的「历史弹窗 → 获取此版本」互补:那是从历史入口走,这次要求从对象右键直达,
|
||||||
|
并提供 changeset 选择器
|
||||||
|
|
||||||
|
### 2.2 缺少重命名、回滚
|
||||||
|
|
||||||
|
- 原文:缺少重命名、回滚等功能。
|
||||||
|
- 解读:
|
||||||
|
- 重命名 = TFS pendRename:产生 rename 挂起更改,签入后服务器生效
|
||||||
|
- 回滚 = TFS Rollback:把某个 changeset 的改动反做(或回滚到某 changeset 之后的全部改动反做),
|
||||||
|
产生挂起更改,由用户审查后签入,区别于 2.1 的「获取特定版本」(后者只动本地工作区不产生挂起更改)
|
||||||
|
|
||||||
|
### 2.3 缺少工作区集中管理
|
||||||
|
|
||||||
|
- 原文:缺少管理工作区的功能。一个工作区下有很多 mapping,肯定是需要一个集中管理的地方,比如删除或者修改。
|
||||||
|
- 解读:对应 VS 的 Manage Workspaces / Edit Workspace 对话框。当前 mapping 只能在目录树逐个右键
|
||||||
|
「映射 / 取消映射」,需要一个集中入口:查看 workspace 信息 + 全部 mapping 列表,支持新增、删除、修改本地路径。
|
||||||
|
|
||||||
|
### 2.4 缺少分支与合并
|
||||||
|
|
||||||
|
- 原文:缺少分支和合并的功能。
|
||||||
|
- 解读:对应 VS 的 Branch / Merge:
|
||||||
|
- 分支:把源路径在服务器上分叉出目标路径(pendBranch + 签入)
|
||||||
|
- 合并:把源分支的 changeset 合并到目标分支,冲突进入冲突处理,结果是挂起更改,审查后签入
|
||||||
|
|
||||||
|
## 三、可行性盘点(现状 → 需要补什么)
|
||||||
|
|
||||||
|
| 能力 | TFS SDK 支持 | core 现状 | server 现状 | 前端现状 | 结论 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 属性(服务器/本地路径等) | `VersionControlClient.getItem` | 路径推导已有(mapping.ts) | 缺 item 详情接口 | 缺属性弹窗 | 主要是前端 + 1 个轻量接口 |
|
||||||
|
| 获取特定版本 | `ChangesetVersionSpec` | 已有 `getVersion`(FE-014) | 已有 `/api/files/get-version` | 仅历史弹窗入口 | 纯前端(右键直达 + 版本选择器) |
|
||||||
|
| 重命名 | `Workspace.pendRename` | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 回滚 | `Workspace.rollback`(RollbackOptions) | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 工作区管理 | `Workspace.update` / 现有 mapping API | mapping 增删已有 | `GET/POST/DELETE /api/mappings` 已有 | 仅目录树右键 | 主要是前端集中管理弹窗 |
|
||||||
|
| 分支 | `Workspace.pendBranch` / `createBranch` | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 合并 | `Workspace.merge` + `getMergeCandidates`;冲突复用 `listConflicts/applyConflict` | 缺 merge | 缺 | 冲突弹窗已有 | 全链路新增,冲突流程复用 |
|
||||||
|
|
||||||
|
SDK 均为 `com.microsoft.tfs.sdk-14.0.1.jar` 已验证存在的公开方法,无协议层风险。
|
||||||
|
|
||||||
|
## 四、需求定义
|
||||||
|
|
||||||
|
### R5 对象属性与获取特定版本(FE-019,P0)
|
||||||
|
|
||||||
|
- 右键菜单新增「属性…」与「获取特定版本…」(文件与目录均有)
|
||||||
|
- 属性弹窗内容:
|
||||||
|
- 服务器路径、本地路径(未映射明确显示「未映射」)
|
||||||
|
- 对象类型(文件 / 目录)、映射状态、挂起状态
|
||||||
|
- 服务器侧最新 changeset、签入时间、签入人(来自新增 item 详情接口)
|
||||||
|
- 文件补充:大小、编码(SDK item 信息自带)
|
||||||
|
- 获取特定版本弹窗:
|
||||||
|
- changeset 输入框 + 「从历史选择…」(内嵌该对象历史列表,点选回填)
|
||||||
|
- 明确提示:会覆盖该对象本地内容(目录为递归覆盖),等价整体切到该变更集时刻
|
||||||
|
- 确认后调用既有 `/api/files/get-version`,完成后刷新目录列表与挂起更改
|
||||||
|
- server 新增 `GET /api/items/info?serverPath=`(item 详情,供属性弹窗)
|
||||||
|
|
||||||
|
### R6 重命名(FE-020,P0)
|
||||||
|
|
||||||
|
- core 新增 `rename(serverPath, newName)`:`Workspace.pendRename`,要求对象已映射
|
||||||
|
- server 新增 `POST /api/files/rename`
|
||||||
|
- 前端:
|
||||||
|
- 右键「重命名…」弹窗:输入新名称,前端校验(非空、不含 `/ \ : * ? " < > |`、与原名不同)
|
||||||
|
- 产生 rename 挂起更改:挂起面板显示「旧名 → 新名」,可 undo 撤销,checkin 后生效
|
||||||
|
- 完成后刷新目录树、列表与挂起更改
|
||||||
|
|
||||||
|
### R7 回滚(FE-021,P1)
|
||||||
|
|
||||||
|
- core 新增 `rollback(serverPath, mode, changeset)`:
|
||||||
|
- `single`:仅反做该 changeset(VS Rollback Entire Changeset)
|
||||||
|
- `toVersion`:反做该 changeset 之后的全部改动(VS Rollback to a Specific Version)
|
||||||
|
- server 新增 `POST /api/files/rollback`
|
||||||
|
- 前端:
|
||||||
|
- 历史弹窗记录行新增「回滚此变更集…」「回滚到此变更集…」(带确认弹窗,说明只产生挂起更改不会直接入库)
|
||||||
|
- 回滚产生冲突时进入既有冲突弹窗
|
||||||
|
- 完成后刷新挂起更改,由用户审查并签入
|
||||||
|
|
||||||
|
### R8 工作区与 Mapping 集中管理(FE-022,P1)
|
||||||
|
|
||||||
|
- 顶栏新增「工作区」入口,打开集中管理弹窗:
|
||||||
|
- 工作区信息:名称、Owner、Computer、服务器、Collection
|
||||||
|
- Mapping 列表:服务器路径、本地路径、本地目录是否存在
|
||||||
|
- 操作:新增(复用既有映射弹窗逻辑)、删除(确认弹窗,说明不会删除本地文件)、
|
||||||
|
修改本地路径(实现为删旧 + 加新,提示将重新获取)
|
||||||
|
- server 复用既有 `GET/POST/DELETE /api/mappings`,不强制新增接口
|
||||||
|
|
||||||
|
### R9 分支(FE-023,P1)
|
||||||
|
|
||||||
|
- core 新增 `branch(sourceServerPath, targetServerPath, changeset?)`:`pendBranch`(默认 latest,可指定 changeset)
|
||||||
|
- server 新增 `POST /api/files/branch`
|
||||||
|
- 前端:
|
||||||
|
- 右键「分支…」弹窗:默认目标 `源路径-branch`,可编辑;可选基于指定 changeset
|
||||||
|
- 产生 branch 挂起更改,审查后签入;完成后刷新目录树
|
||||||
|
- 目标路径父目录必须已映射(pendBranch 需要本地落盘),未满足时弹窗给出明确提示
|
||||||
|
|
||||||
|
### R10 合并(FE-024,P1)
|
||||||
|
|
||||||
|
- core 新增:
|
||||||
|
- `mergeCandidates(source, target)`:`getMergeCandidates`,返回待合并 changeset 列表
|
||||||
|
- `merge(source, target, changeset?)`:默认合并全部候选,可指定单个 changeset
|
||||||
|
- server 新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- 前端:
|
||||||
|
- 右键「合并…」向导弹窗:源(当前对象)→ 目标路径(必须已映射);
|
||||||
|
展示候选 changeset 列表,支持「全部合并」或选择单个
|
||||||
|
- 冲突进入既有冲突弹窗;结果为挂起更改,审查后签入
|
||||||
|
- 完成后刷新挂起更改
|
||||||
|
|
||||||
|
### R11 访达打开与添加本地文件(FE-025,P0,第二批追加)
|
||||||
|
|
||||||
|
- 用户反馈原文:缺少右键文件或文件夹时「在访达中打开」(进入到访达的本目录);
|
||||||
|
右键文件夹或在中间区域右键时,缺少添加文件的功能(本地新增的文件加入 TFS 控制)。
|
||||||
|
- 在访达中打开:
|
||||||
|
- 文件:访达中显示并选中该文件(`shell.showItemInFolder`)
|
||||||
|
- 目录:直接进入该目录(`shell.openPath`)
|
||||||
|
- 仅 Electron 环境可用;未映射对象置灰
|
||||||
|
- 添加本地文件(修订:按用户反馈改为扫描式,不手动找文件):
|
||||||
|
- 目录右键与中间区域空白处右键提供「添加本地文件…」
|
||||||
|
- 自动对该目录执行目录对比(`POST /api/compare/folder` 递归),筛出 `localOnly`
|
||||||
|
(本地新增、服务器不存在)的文件 / 目录
|
||||||
|
- 弹窗列表勾选(默认全选,支持全选 / 清空),确认后批量
|
||||||
|
`POST /api/files/add` 产生 pendingAdd 挂起更改,完成后刷新
|
||||||
|
- 中间区域空白处右键 = 当前目录的完整右键菜单(与目录树右键一致)
|
||||||
|
|
||||||
|
## 五、优先级与排期建议
|
||||||
|
|
||||||
|
| 任务 | 内容 | 优先级 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| FE-019 | 属性 + 获取特定版本 | P0 | 用户明确点名;后端基本就绪,见效最快 |
|
||||||
|
| FE-020 | 重命名 | P0 | 日常高频操作,缺失影响基本可用性 |
|
||||||
|
| FE-021 | 回滚 | P1 | 重要但低频,依赖挂起更改/冲突链路已稳定 |
|
||||||
|
| FE-022 | 工作区管理 | P1 | 提升管理效率,API 已就绪纯前端 |
|
||||||
|
| FE-023 | 分支 | P1 | 团队工作流需要,先于合并 |
|
||||||
|
| FE-024 | 合并 | P1 | 依赖分支;冲突流程复用既有弹窗 |
|
||||||
|
|
||||||
|
## 六、不在本批范围
|
||||||
|
|
||||||
|
- Shelve / Unshelve
|
||||||
|
- Work Item 关联、Check-in Policy
|
||||||
|
- 多 Workspace 创建 / 切换 / 删除(仅展示与管理当前 Workspace 的 Mapping)
|
||||||
|
- 二进制三方合并编辑器(合并冲突仍走既有「取本地 / 取服务器」冲突弹窗)
|
||||||
|
- 移动(Move 跨目录),第一步只做同目录重命名
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# macTFS 工作台增强需求(对照 Visual Studio 源代码管理)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
前端阶段(FE-001 ~ FE-013)完成后,用户按真实业务试用工作台,对照 Visual Studio
|
||||||
|
Source Control Explorer 提出了一批体验与功能缺口。本文档收口这批需求,
|
||||||
|
作为 `task/frontend` 中 FE-014 ~ FE-017 的需求来源。
|
||||||
|
|
||||||
|
## 二、用户反馈与根因分析
|
||||||
|
|
||||||
|
### 2.1 获取最新会静默覆盖本地修改(最严重)
|
||||||
|
|
||||||
|
- 现象:本地改了文件(未签出),执行「获取最新」后本地改动被直接覆盖,无提示、无对比
|
||||||
|
- 根因:core `getLatest` 固定使用 `GetOptions.GET_ALL | OVERWRITE`,等价于 VS 的
|
||||||
|
「强制获取(覆盖本地)」;安全模式应当用 `GetOptions.NONE`,让本地有改动的文件
|
||||||
|
产生 writable / 版本冲突并走冲突处理
|
||||||
|
- VS 行为:Get Latest 默认不覆盖本地可写文件,提示冲突并允许对比后再决定
|
||||||
|
|
||||||
|
### 2.2 目录历史下钻的文件列表没有对象操作
|
||||||
|
|
||||||
|
- 现象:目录历史 → 点 changeset 看到影响文件,但这些文件行没有右键菜单
|
||||||
|
- VS 行为:changeset 明细中的文件可以继续查看历史、查看该版本内容、做对比
|
||||||
|
|
||||||
|
### 2.3 缺少强制获取与获取指定版本
|
||||||
|
|
||||||
|
- 现象:没有「强制获取最新(覆盖本地)」;也没有「获取此历史版本(把目录/文件回到
|
||||||
|
某个 changeset)」
|
||||||
|
- VS 行为:Get Specific Version 支持选择版本 + Overwrite 选项
|
||||||
|
|
||||||
|
### 2.4 历史与 Diff 弹窗偏小、对比不好用
|
||||||
|
|
||||||
|
- 现象:弹窗小;长文件没有缩略定位;只有单栏统一视图,没有左右分栏对照
|
||||||
|
- VS 行为:对比窗口左右分栏、有差异概览与上一处/下一处差异导航
|
||||||
|
|
||||||
|
### 2.5 右侧挂起更改面板没有手动刷新
|
||||||
|
|
||||||
|
- 现象:外部(CLI / 其它工具)产生的挂起更改无法手动拉新
|
||||||
|
- 同类:左侧树、中间列表也缺少手动刷新入口(FE-012 仅日志面板有刷新)
|
||||||
|
|
||||||
|
## 三、我方补充分析(对照 VS 还存在的差距)
|
||||||
|
|
||||||
|
| 能力 | VS | 当前 | 本批处理 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 安全获取 / 强制获取 | 区分 | 只有强制 | ✅ FE-014 |
|
||||||
|
| 获取指定版本 | 支持 | 无 | ✅ FE-014 |
|
||||||
|
| changeset 明细对象操作 | 支持 | 无 | ✅ FE-015 |
|
||||||
|
| 目录对比 localOnly「加入版本控制」 | 支持(pend add) | 菜单缺失 | ✅ FE-015 |
|
||||||
|
| 左右分栏 Diff + 差异导航 | 支持 | 单栏 | ✅ FE-016 |
|
||||||
|
| 各面板手动刷新 | 支持 | 仅日志 | ✅ FE-017 |
|
||||||
|
| 重命名 / 移动 / 分支 / Shelve | 支持 | 无 | ❌ 超出第一版范围 |
|
||||||
|
| 二进制可视化对比 / 三方合并 | 支持 | 无 | ❌ PRD 明确不做 |
|
||||||
|
|
||||||
|
## 四、需求定义
|
||||||
|
|
||||||
|
### R1 安全获取与强制获取 / 获取指定版本(FE-014)
|
||||||
|
|
||||||
|
- core `getLatest` 增加 `force` 参数:
|
||||||
|
- `force=false`(默认):`GetOptions.NONE`,本地改动产生冲突而不是被覆盖
|
||||||
|
- `force=true`:维持 `GET_ALL | OVERWRITE`
|
||||||
|
- core 新增 `getVersion`:按 changeset 获取指定版本(`ChangesetVersionSpec` +
|
||||||
|
`GET_ALL | OVERWRITE`,对应 VS Get Specific Version + Overwrite)
|
||||||
|
- server `/api/files/get-latest` 透传 `force`;新增 `/api/files/get-version`
|
||||||
|
- 前端:
|
||||||
|
- 右键「获取最新」默认安全模式;出现冲突时按既有冲突弹窗流程处理
|
||||||
|
- 右键新增「强制获取最新…」,需确认弹窗(明确提示会覆盖本地修改)
|
||||||
|
- 历史弹窗(文件与目录)支持「获取此版本…」,需确认弹窗
|
||||||
|
- Checkout 前置的 Get Latest 维持安全模式
|
||||||
|
|
||||||
|
### R2 历史与对比结果的对象操作完善(FE-015)
|
||||||
|
|
||||||
|
- changeset 文件列表行提供右键菜单:
|
||||||
|
- 查看此版本内容(按 changeset 读文件内容)
|
||||||
|
- 查看该文件历史(切换历史弹窗目标为该文件)
|
||||||
|
- 获取此版本…(带确认,复用 R1)
|
||||||
|
- 目录对比结果 `localOnly` 项菜单提供「加入版本控制」(pend add),
|
||||||
|
完成后刷新挂起更改
|
||||||
|
|
||||||
|
### R3 Diff 与历史弹窗体验优化(FE-016)
|
||||||
|
|
||||||
|
- 历史 / Diff / 目录对比弹窗尺寸提升(接近满屏宽高)
|
||||||
|
- Diff 支持「统一 / 左右分栏」两种视图切换,左右分栏旧版在左、新版在右
|
||||||
|
- 增加差异概览条(minimap):标记差异块位置,点击跳转
|
||||||
|
- 增加「上一处 / 下一处差异」导航按钮
|
||||||
|
- 文件查看弹窗同步放大
|
||||||
|
|
||||||
|
### R4 工作台手动刷新(FE-017)
|
||||||
|
|
||||||
|
- 右侧挂起更改面板头部加刷新按钮
|
||||||
|
- 中间列表路径栏加刷新按钮(重新加载当前目录)
|
||||||
|
- 左侧树头部加刷新按钮(重置并重新加载根)
|
||||||
|
|
||||||
|
## 五、不在本批范围
|
||||||
|
|
||||||
|
- 重命名 / 移动 / 分支 / 合并 / Shelve / Work Item
|
||||||
|
- 二进制可视化对比、三方合并编辑器
|
||||||
|
- 多 Workspace 切换 UI
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# macTFS UI 风格改版需求(现代桌面工具风 / 精致化 / 动效)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
macTFS 功能层面已完成 FE-001 ~ FE-025,三栏工作台、弹窗体系、挂起更改与签入链路均可用。但当前 UI 是「功能优先」的默认 shadcn 风格:
|
||||||
|
|
||||||
|
- 全局使用 Roboto Slab 衬线字体,与现代桌面工具观感不符
|
||||||
|
- 登录页只有一张简单表单卡片,元素少、缺少品牌感
|
||||||
|
- 窗口使用系统默认标题栏,应用没有「一体成型」的桌面应用质感
|
||||||
|
- 几乎没有过渡动效:弹窗、面板折叠、列表加载都是瞬时切换
|
||||||
|
- 明暗主题虽有 token 定义,但没有切换入口、也不跟随系统
|
||||||
|
|
||||||
|
本批需求目标:在**不改任何业务逻辑与接口**的前提下,把整体 UI 升级为「精致、现代、富动效」的桌面工具风格。
|
||||||
|
|
||||||
|
## 二、用户诉求原文与解读
|
||||||
|
|
||||||
|
> 「我希望的 ui 风格是更精致,动效更多,登录页元素多点,反正就是希望比较现代一些。」
|
||||||
|
>
|
||||||
|
> 补充澄清:「不是 macos 风格 finder 风格,你知道 codex 桌面客户端吗?以及 ccswitch,我想要的是这样的桌面风格。」
|
||||||
|
|
||||||
|
**风格基准定为「现代 AI 开发者工具风」,参照对象:OpenAI Codex 桌面端、CC Switch。**
|
||||||
|
|
||||||
|
这类风格的共同特征(设计基调):
|
||||||
|
|
||||||
|
| 特征 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 中性底色 | 以中性灰(zinc/neutral)分级铺底,内容区与侧栏用明度区分,不用毛玻璃 |
|
||||||
|
| 卡片化 | 信息以圆角卡片承载,细边框(1px 低对比)+ 极轻阴影,hover 时边框/底色微变 |
|
||||||
|
| 克制的主色 | 单一 accent 色只用于主按钮、选中态、链接、状态点,不大面积铺色 |
|
||||||
|
| 留白与排版 | 间距充足、层级靠字号字重区分,无衬线系统字体 |
|
||||||
|
| 隐藏式标题栏 | 无系统标题栏,红绿灯融入应用顶栏,窗口一体成型 |
|
||||||
|
| 明暗双主题 | 默认跟随系统,暗色为深灰而非纯黑 |
|
||||||
|
| 微动效 | hover/按压/出入场都有轻量过渡,快而不花哨(150~250ms) |
|
||||||
|
|
||||||
|
| 诉求 | 落地方向 |
|
||||||
|
|---|---|
|
||||||
|
| 更精致 | 统一设计令牌(颜色/圆角/边框/阴影/间距/图标),细化悬停、选中、禁用态 |
|
||||||
|
| 现代桌面风 | 系统字体栈、隐藏式标题栏、卡片化布局、Codex 式侧栏、明暗主题 |
|
||||||
|
| 动效更多 | 统一动效规范:弹窗出入场、面板折叠、列表级联、骨架屏、微交互 |
|
||||||
|
| 登录页元素多点 | 品牌区 + 分步表单 + 氛围背景 + 最近连接 + 连接过程动效 |
|
||||||
|
|
||||||
|
## 三、现状盘点
|
||||||
|
|
||||||
|
| 现状 | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主题 token | `mactfsui/app/app.css` | 衬线字体、紫色主色、token 未按中性灰体系组织,dark 主题无入口 |
|
||||||
|
| Electron 窗口 | `mactfsui/electron/main.cjs` | 默认标题栏,应用不「一体成型」 |
|
||||||
|
| 登录页 | `app/components/app/connect-view.tsx` | 单卡片表单,无品牌区、无动效、无最近连接 |
|
||||||
|
| 工作台 | `app/components/app/workspace-shell.tsx`、`top-bar.tsx` | 布局可用但视觉平淡,折叠无动画 |
|
||||||
|
| 目录树 / 文件列表 | `app/components/explorer/source-tree-panel.tsx`、`folder-items-panel.tsx` | 行样式简单,加载无骨架屏,空状态简陋 |
|
||||||
|
| 弹窗(13 个) | `app/components/explorer/*-dialog.tsx`、`app/components/app/*` | 风格不一,出入场无动效 |
|
||||||
|
| 反馈 | `changes-panel.tsx`、`console-panel.tsx` | 无 toast,操作反馈靠日志面板 |
|
||||||
|
|
||||||
|
技术栈现状:React 19 + React Router 7 + Tailwind CSS 4 + radix-ui/shadcn + tw-animate-css + lucide-react + Electron 42。动效优先用 CSS / tw-animate-css,复杂编排可引入 `motion`(原 framer-motion)。
|
||||||
|
|
||||||
|
## 四、需求定义
|
||||||
|
|
||||||
|
### U1 现代桌面风设计基建(FE-026,P0)
|
||||||
|
|
||||||
|
- 字体切换为系统无衬线字体栈(`-apple-system, BlinkMacSystemFont, "PingFang SC"…`),仅代码/Diff 保留等宽字体
|
||||||
|
- 重定义颜色令牌:中性灰分级底色(页面底 / 侧栏 / 卡片 / 悬停四档明度)、低对比 1px 边框色、单一克制 accent 色(用于主按钮/选中/状态点)
|
||||||
|
- 统一圆角档位(8/10/12px 卡片为主)、极轻阴影(卡片几乎无阴影,浮层/弹窗轻阴影)、间距档位
|
||||||
|
- 建立动效令牌:统一时长(150/200/250ms)、缓动曲线变量
|
||||||
|
- 完整 light/dark 两套 token(dark 为深灰底,非纯黑)
|
||||||
|
|
||||||
|
### U2 Electron 无缝窗口与自绘顶栏(FE-027,P0)
|
||||||
|
|
||||||
|
- `titleBarStyle: "hiddenInset"`,移除系统标题栏,红绿灯融入应用顶栏,校准 trafficLightPosition
|
||||||
|
- 顶栏设置可拖拽区域,交互控件 no-drag
|
||||||
|
- 窗口背景色与主题 token 一致,启动时无白闪(背景色预设 + ready-to-show)
|
||||||
|
- 核对窗口默认尺寸与最小尺寸;非 macOS 平台回退正常标题栏不影响功能
|
||||||
|
|
||||||
|
### U3 登录页改版(FE-028,P0)
|
||||||
|
|
||||||
|
- 左右分栏:左侧品牌区(logo、产品名、标语、版本号、低噪点氛围背景:渐变网格 / 微光斑动效),右侧连接表单卡片
|
||||||
|
- 表单 → Collection 选择两阶段切换增加滑动 + 淡入淡出过渡
|
||||||
|
- 新增元素:密码可见性切换、最近连接记录(本地存储多条、一键填充)、本地服务健康状态指示灯
|
||||||
|
- 连接中按钮内 spinner + 阶段文案过渡;失败时表单抖动 + 错误提示条
|
||||||
|
- Collection 选择改卡片式列表:细边框卡片、hover 微浮起、选中 accent 描边动画
|
||||||
|
|
||||||
|
### U4 工作台外壳与顶栏改版(FE-029,P0)
|
||||||
|
|
||||||
|
- 顶栏融合红绿灯区域,按现代工具风重排:上下文信息居左、动作按钮图标化居右、底部 1px 分隔线
|
||||||
|
- 左侧目录树面板改 Codex 式侧栏:比内容区深/浅一档的纯色底、圆角选中条、分组标题小字号大写风格
|
||||||
|
- 左右面板折叠/展开加宽度过渡动画,底部操作台滑入滑出
|
||||||
|
- 三栏分隔线统一为低对比 1px,留白与内边距统一
|
||||||
|
|
||||||
|
### U5 目录树与文件列表精致化(FE-030,P0)
|
||||||
|
|
||||||
|
- 树节点与文件行:统一行高、圆角悬停/选中态、展开箭头旋转动画、子级展开渐入
|
||||||
|
- 按扩展名区分文件图标与颜色,文件夹/分支/已映射目录差异化
|
||||||
|
- 加载骨架屏替代文字 loading;空目录、未连接等空状态配图标 + 引导文案
|
||||||
|
- pendingEdit / pendingAdd / pendingDelete / 未下载等状态徽标统一为小圆点 + 浅底色徽标风格
|
||||||
|
- 文件列表首次加载行级联渐入
|
||||||
|
|
||||||
|
### U6 弹窗体系统一现代风格(FE-031,P1)
|
||||||
|
|
||||||
|
- 13 个弹窗统一结构:顶部标题区、内容区、底部按钮区,间距与按钮排布一致
|
||||||
|
- 卡片化弹窗:圆角、细边框、轻阴影;出入场统一缩放 + 淡入,遮罩淡入
|
||||||
|
- 大弹窗(History/Compare/Diff)优化分区留白与表格样式
|
||||||
|
- 弹窗内表单控件按 U1 token 对齐
|
||||||
|
|
||||||
|
### U7 操作反馈与微交互(FE-032,P1)
|
||||||
|
|
||||||
|
- 引入轻量 toast(成功/失败/进行中),与日志面板互补
|
||||||
|
- 挂起更改面板、日志面板按新风格改版;新日志条目滑入动画
|
||||||
|
- 控件微交互:按钮按压缩放、聚焦光环、复选框勾选动画、hover 过渡
|
||||||
|
- 长操作(GetLatest、Checkin、目录对比)显示进度反馈动效
|
||||||
|
|
||||||
|
### U8 明暗主题跟随系统(FE-033,P1)
|
||||||
|
|
||||||
|
- 跟随系统外观自动切换 light/dark,顶栏手动三态开关(跟随/亮/暗),持久化
|
||||||
|
- 切换瞬间全局颜色过渡动画
|
||||||
|
- 校验全部面板与弹窗在 dark 下的对比度与可读性
|
||||||
|
|
||||||
|
### U9 改版回归验收(FE-034,P0)
|
||||||
|
|
||||||
|
- 按 `TFS-AI测试流程.md` 大纲做 UI 链路回归:登录、浏览、Mapping、GetLatest、签出签入、历史、Diff、目录对比
|
||||||
|
- 确认零业务逻辑变化:`app/lib` 与接口契约不动
|
||||||
|
- typecheck 通过、关键页面截图存档
|
||||||
|
|
||||||
|
## 五、优先级与排期建议
|
||||||
|
|
||||||
|
| 顺序 | 任务 | 优先级 | 依赖 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | FE-026 设计基建 | P0 | 无 |
|
||||||
|
| 2 | FE-027 无缝窗口 | P0 | FE-026 |
|
||||||
|
| 3 | FE-028 登录页改版 | P0 | FE-026 |
|
||||||
|
| 4 | FE-029 工作台外壳改版 | P0 | FE-026、FE-027 |
|
||||||
|
| 5 | FE-030 目录树与文件列表 | P0 | FE-029 |
|
||||||
|
| 6 | FE-031 弹窗体系统一 | P1 | FE-026 |
|
||||||
|
| 7 | FE-032 反馈与微交互 | P1 | FE-026 |
|
||||||
|
| 8 | FE-033 明暗主题 | P1 | FE-026 ~ FE-032 |
|
||||||
|
| 9 | FE-034 回归验收 | P0 | 全部 |
|
||||||
|
|
||||||
|
## 六、不在本批范围
|
||||||
|
|
||||||
|
- 任何业务逻辑、接口契约、服务端代码改动
|
||||||
|
- 新功能(本批只做视觉与交互层)
|
||||||
|
- 毛玻璃 / vibrancy 材质(已明确不要 Finder 原生风)
|
||||||
|
- Windows / Linux 适配(仅保证不崩溃的回退样式)
|
||||||
|
- Monaco Diff 内部主题深度定制(仅跟随明暗主题切换)
|
||||||
@@ -257,9 +257,10 @@ TFS 的 Workspace 模型天然绑定:
|
|||||||
### 2. 功能范围
|
### 2. 功能范围
|
||||||
|
|
||||||
- 登录页
|
- 登录页
|
||||||
- Workspace 管理页
|
- Collection 选择
|
||||||
- 映射配置页
|
- 固定 Collection / Workspace 工作台
|
||||||
- 执行 Get Latest 的操作页
|
- 映射配置弹窗
|
||||||
|
- 执行 Get Latest 的操作入口
|
||||||
- 日志输出区域
|
- 日志输出区域
|
||||||
|
|
||||||
### 3. 页面范围
|
### 3. 页面范围
|
||||||
@@ -273,11 +274,12 @@ TFS 的 Workspace 模型天然绑定:
|
|||||||
- Password
|
- Password
|
||||||
- 连接测试按钮
|
- 连接测试按钮
|
||||||
|
|
||||||
#### Workspace 页
|
#### 上下文确认
|
||||||
|
|
||||||
- 列出已有 Workspace
|
- 登录成功后加载 Collection
|
||||||
- 创建 Workspace
|
- 用户确认 Collection
|
||||||
- 选择 Workspace
|
- 系统自动查找或创建默认 Workspace
|
||||||
|
- 工作台内不提供 Workspace 手动选择
|
||||||
|
|
||||||
#### 映射页
|
#### 映射页
|
||||||
|
|
||||||
@@ -294,7 +296,8 @@ TFS 的 Workspace 模型天然绑定:
|
|||||||
### 4. 验收标准
|
### 4. 验收标准
|
||||||
|
|
||||||
- 用户可通过界面完成登录
|
- 用户可通过界面完成登录
|
||||||
- 用户可通过界面完成 Workspace 选择或创建
|
- 用户可通过界面确认 Collection
|
||||||
|
- 用户进入工作台后可自动使用或创建默认 Workspace
|
||||||
- 用户可通过界面完成映射配置
|
- 用户可通过界面完成映射配置
|
||||||
- 用户可通过界面执行 Get Latest
|
- 用户可通过界面执行 Get Latest
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 490 KiB |
@@ -18,6 +18,8 @@ dependencies {
|
|||||||
implementation 'com.sparkjava:spark-core:2.9.4'
|
implementation 'com.sparkjava:spark-core:2.9.4'
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
|
||||||
implementation 'org.slf4j:slf4j-simple:1.7.36'
|
implementation 'org.slf4j:slf4j-simple:1.7.36'
|
||||||
|
// 文本 Diff 算法库(Myers 算法),4.12 为兼容 Java 8 的稳定版本。
|
||||||
|
implementation 'io.github.java-diff-utils:java-diff-utils:4.12'
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 固定使用项目内置 JDK 构建与运行,避免受系统环境 JAVA_HOME / PATH 影响。
|
||||||
|
org.gradle.java.home=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home
|
||||||
@@ -4,11 +4,7 @@ import java.io.File;
|
|||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存最近一次成功连接与映射结果,供第一阶段 CLI 复用输入。
|
* 保存最近一次成功连接与映射结果,供第一阶段 CLI 复用输入。
|
||||||
@@ -16,7 +12,6 @@ import java.util.regex.Pattern;
|
|||||||
public class LocalConfigStore {
|
public class LocalConfigStore {
|
||||||
|
|
||||||
private final File configFile;
|
private final File configFile;
|
||||||
private final File serverConfigFile;
|
|
||||||
|
|
||||||
public LocalConfigStore() {
|
public LocalConfigStore() {
|
||||||
File configDirectory = new File(System.getProperty("user.home"), ".mactfs");
|
File configDirectory = new File(System.getProperty("user.home"), ".mactfs");
|
||||||
@@ -24,26 +19,20 @@ public class LocalConfigStore {
|
|||||||
configDirectory.mkdirs();
|
configDirectory.mkdirs();
|
||||||
}
|
}
|
||||||
this.configFile = new File(configDirectory, "phase-one.properties");
|
this.configFile = new File(configDirectory, "phase-one.properties");
|
||||||
this.serverConfigFile = new File(configDirectory, "config.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取第一阶段配置;本地 API 配置存在时优先使用 config.json 的当前连接上下文。
|
|
||||||
*/
|
|
||||||
public Properties load() throws IOException {
|
public Properties load() throws IOException {
|
||||||
Properties properties = new Properties();
|
Properties properties = new Properties();
|
||||||
if (configFile.exists()) {
|
if (!configFile.exists()) {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
FileInputStream inputStream = new FileInputStream(configFile);
|
FileInputStream inputStream = new FileInputStream(configFile);
|
||||||
try {
|
try {
|
||||||
properties.load(inputStream);
|
properties.load(inputStream);
|
||||||
} finally {
|
} finally {
|
||||||
inputStream.close();
|
inputStream.close();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (serverConfigFile.exists()) {
|
|
||||||
mergeServerConfig(properties);
|
|
||||||
}
|
|
||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,46 +48,4 @@ public class LocalConfigStore {
|
|||||||
public File getConfigFile() {
|
public File getConfigFile() {
|
||||||
return configFile;
|
return configFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将第二阶段本地 API 配置转换为旧 CLI 可复用的属性名。
|
|
||||||
*/
|
|
||||||
private void mergeServerConfig(Properties properties) throws IOException {
|
|
||||||
String json = new String(Files.readAllBytes(serverConfigFile.toPath()), StandardCharsets.UTF_8);
|
|
||||||
copyJsonString(json, properties, "serverUri", "server-uri");
|
|
||||||
copyJsonString(json, properties, "authType", "auth-type");
|
|
||||||
copyJsonString(json, properties, "domain", "domain");
|
|
||||||
copyJsonString(json, properties, "username", "username");
|
|
||||||
copyJsonString(json, properties, "password", "password");
|
|
||||||
copyJsonString(json, properties, "collection", "collection");
|
|
||||||
copyJsonString(json, properties, "workspace", "workspace");
|
|
||||||
copyJsonString(json, properties, "serverPath", "server-path");
|
|
||||||
copyJsonString(json, properties, "localPath", "local-path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制 JSON 字符串字段,保持旧 CLI 配置读取逻辑不依赖服务端模块。
|
|
||||||
*/
|
|
||||||
private void copyJsonString(String json, Properties properties, String jsonKey, String propertyKey) {
|
|
||||||
String value = jsonString(json, jsonKey);
|
|
||||||
if (!isBlank(value)) {
|
|
||||||
properties.setProperty(propertyKey, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从简单配置 JSON 中提取字符串字段。
|
|
||||||
*/
|
|
||||||
private String jsonString(String json, String key) {
|
|
||||||
Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
|
|
||||||
Matcher matcher = pattern.matcher(json);
|
|
||||||
return matcher.find() ? matcher.group(1).replace("\\\"", "\"") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断字符串是否为空。
|
|
||||||
*/
|
|
||||||
private boolean isBlank(String value) {
|
|
||||||
return value == null || value.trim().isEmpty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ public class MacTfsCli {
|
|||||||
private static String readServerToken() throws Exception {
|
private static String readServerToken() throws Exception {
|
||||||
File tokenFile = serverTokenFile();
|
File tokenFile = serverTokenFile();
|
||||||
if (!tokenFile.exists()) {
|
if (!tokenFile.exists()) {
|
||||||
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start macTFS API server first.");
|
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start MacTFS API server first.");
|
||||||
}
|
}
|
||||||
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
||||||
if (token.length() == 0) {
|
if (token.length() == 0) {
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
|
|||||||
required(merged, "workspace"),
|
required(merged, "workspace"),
|
||||||
required(merged, "server-path"),
|
required(merged, "server-path"),
|
||||||
merged.getProperty("local-path"),
|
merged.getProperty("local-path"),
|
||||||
!"false".equalsIgnoreCase(merged.getProperty("recursive"))
|
!"false".equalsIgnoreCase(merged.getProperty("recursive")),
|
||||||
|
!"false".equalsIgnoreCase(merged.getProperty("include-local-only"))
|
||||||
);
|
);
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", folderDiffsToMaps(result.getData()));
|
data.put("diffs", folderDiffsToMaps(result.getData()));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.mydev.mactfs.core;
|
package com.mydev.mactfs.core;
|
||||||
|
|
||||||
|
import com.github.difflib.DiffUtils;
|
||||||
|
import com.github.difflib.patch.AbstractDelta;
|
||||||
|
import com.github.difflib.patch.Patch;
|
||||||
import com.microsoft.tfs.core.TFSConfigurationServer;
|
import com.microsoft.tfs.core.TFSConfigurationServer;
|
||||||
import com.microsoft.tfs.core.TFSTeamProjectCollection;
|
import com.microsoft.tfs.core.TFSTeamProjectCollection;
|
||||||
import com.microsoft.tfs.core.clients.framework.configuration.TFSEntitySession;
|
import com.microsoft.tfs.core.clients.framework.configuration.TFSEntitySession;
|
||||||
@@ -10,7 +13,9 @@ import com.microsoft.tfs.core.clients.framework.location.ConnectOptions;
|
|||||||
import com.microsoft.tfs.core.clients.versioncontrol.GetItemsOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.GetItemsOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.GetOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.GetOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.GetStatus;
|
import com.microsoft.tfs.core.clients.versioncontrol.GetStatus;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.MergeFlags;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.PendChangesOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.PendChangesOptions;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.RollbackOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.VersionControlClient;
|
import com.microsoft.tfs.core.clients.versioncontrol.VersionControlClient;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceLocation;
|
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceLocation;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceOptions;
|
||||||
@@ -18,6 +23,8 @@ import com.microsoft.tfs.core.clients.versioncontrol.exceptions.WorkspaceNotFoun
|
|||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Change;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Change;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ChangeType;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ChangeType;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Changeset;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Changeset;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Conflict;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ConflictType;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.DeletedState;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.DeletedState;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ExtendedItem;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ExtendedItem;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.GetRequest;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.GetRequest;
|
||||||
@@ -25,9 +32,11 @@ import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Item;
|
|||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemSet;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemSet;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemType;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemType;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.LockLevel;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.LockLevel;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.MergeCandidate;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingChange;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingChange;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingSet;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingSet;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.RecursionType;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.RecursionType;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Resolution;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.WorkingFolder;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.WorkingFolder;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Workspace;
|
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Workspace;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.specs.ItemSpec;
|
import com.microsoft.tfs.core.clients.versioncontrol.specs.ItemSpec;
|
||||||
@@ -55,12 +64,14 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
* MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
||||||
*/
|
*/
|
||||||
public class MacTfsCoreService {
|
public class MacTfsCoreService {
|
||||||
|
|
||||||
private static final int HISTORY_LIMIT = 100;
|
private static final int HISTORY_LIMIT = 100;
|
||||||
private static final Charset TEXT_CHARSET = Charset.forName("UTF-8");
|
private static final Charset TEXT_CHARSET = Charset.forName("UTF-8");
|
||||||
|
private static final long MAX_TEXT_CONTENT_BYTES = 2L * 1024 * 1024;
|
||||||
|
private static final int BINARY_SNIFF_BYTES = 8000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
||||||
@@ -128,7 +139,7 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。
|
* 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
|
||||||
*/
|
*/
|
||||||
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
||||||
final String collectionName,
|
final String collectionName,
|
||||||
@@ -219,33 +230,71 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对文件、目录或整个 Workspace 执行 Get Latest。
|
* 对文件、目录或整个 Workspace 执行 Get Latest(默认安全模式,不覆盖本地改动)。
|
||||||
*/
|
*/
|
||||||
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
|
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
|
||||||
final String collectionName,
|
final String collectionName,
|
||||||
final String workspaceName,
|
final String workspaceName,
|
||||||
final String serverPath,
|
final String serverPath,
|
||||||
final boolean recursive) {
|
final boolean recursive) {
|
||||||
|
return getLatest(config, collectionName, workspaceName, serverPath, recursive, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对文件、目录或整个 Workspace 执行 Get Latest。
|
||||||
|
* force=false 走安全模式(GetOptions.NONE),本地有改动的文件会产生冲突而不会被覆盖;
|
||||||
|
* force=true 等价 Visual Studio 的强制获取(GET_ALL + OVERWRITE),直接覆盖本地。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String serverPath,
|
||||||
|
final boolean recursive,
|
||||||
|
final boolean force) {
|
||||||
return execute("getLatest", new CoreCallable<TfsGetLatestResult>() {
|
return execute("getLatest", new CoreCallable<TfsGetLatestResult>() {
|
||||||
@Override
|
@Override
|
||||||
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
GetOptions options = force ? GetOptions.GET_ALL.combine(GetOptions.OVERWRITE) : GetOptions.NONE;
|
||||||
GetStatus status;
|
GetStatus status;
|
||||||
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
|
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
|
||||||
if (normalizedPath == null) {
|
if (normalizedPath == null) {
|
||||||
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
status = workspace.get(options);
|
||||||
logs.add("Get latest workspace: " + workspace.getName());
|
logs.add("Get latest workspace: " + workspace.getName() + (force ? " (force)" : ""));
|
||||||
} else {
|
} else {
|
||||||
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
||||||
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), LatestVersionSpec.INSTANCE);
|
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), LatestVersionSpec.INSTANCE);
|
||||||
status = workspace.get(new GetRequest[]{request}, GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
status = workspace.get(new GetRequest[]{request}, options);
|
||||||
logs.add("Get latest path: " + normalizedPath);
|
logs.add("Get latest path: " + normalizedPath + (force ? " (force)" : ""));
|
||||||
}
|
}
|
||||||
return toGetLatestResult(status);
|
return toGetLatestResult(status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 changeset 版本(强制覆盖本地),对应 Visual Studio 的 Get Specific Version + Overwrite。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsGetLatestResult> getVersion(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String serverPath,
|
||||||
|
final int changeset,
|
||||||
|
final boolean recursive) {
|
||||||
|
return execute("getVersion", new CoreCallable<TfsGetLatestResult>() {
|
||||||
|
@Override
|
||||||
|
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
String normalizedPath = normalizeServerPath(serverPath);
|
||||||
|
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
||||||
|
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), new ChangesetVersionSpec(changeset));
|
||||||
|
GetStatus status = workspace.get(new GetRequest[]{request}, GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
||||||
|
logs.add("Get version C" + changeset + ": " + normalizedPath);
|
||||||
|
return toGetLatestResult(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前 Workspace 下的挂起更改,并返回稳定可序列化的状态字段。
|
* 查询当前 Workspace 下的挂起更改,并返回稳定可序列化的状态字段。
|
||||||
*/
|
*/
|
||||||
@@ -347,6 +396,157 @@ public class MacTfsCoreService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支:把源服务端路径分叉到目标路径(pendBranch 产生挂起更改,签入后服务器生效)。
|
||||||
|
* 目标路径所在父目录必须已映射;changesetId 为 null 时基于 latest。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsFileOperationResult> branch(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String sourceServerPath,
|
||||||
|
final String targetServerPath,
|
||||||
|
final Integer changesetId) {
|
||||||
|
return execute("branch", new CoreCallable<TfsFileOperationResult>() {
|
||||||
|
@Override
|
||||||
|
public TfsFileOperationResult call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
|
||||||
|
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
|
||||||
|
if (source.equals(target)) {
|
||||||
|
throw new IllegalArgumentException("Target path is same as source path");
|
||||||
|
}
|
||||||
|
VersionSpec version = changesetId == null
|
||||||
|
? LatestVersionSpec.INSTANCE
|
||||||
|
: new ChangesetVersionSpec(changesetId.intValue());
|
||||||
|
int affected = workspace.pendBranch(source, target, version, LockLevel.NONE, RecursionType.FULL, GetOptions.NONE, PendChangesOptions.NONE);
|
||||||
|
logs.add("Branch pended: " + source + " -> " + target);
|
||||||
|
return new TfsFileOperationResult("branch", affected, Collections.<String>emptyList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询源 → 目标的待合并变更集候选列表(对应 VS Merge 向导的候选页)。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<List<TfsHistoryEntry>> mergeCandidates(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String sourceServerPath,
|
||||||
|
final String targetServerPath) {
|
||||||
|
return execute("mergeCandidates", new CoreCallable<List<TfsHistoryEntry>>() {
|
||||||
|
@Override
|
||||||
|
public List<TfsHistoryEntry> call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
|
||||||
|
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
|
||||||
|
MergeCandidate[] candidates = workspace.getMergeCandidates(source, target, RecursionType.FULL, MergeFlags.NONE);
|
||||||
|
List<TfsHistoryEntry> result = new ArrayList<TfsHistoryEntry>();
|
||||||
|
if (candidates != null) {
|
||||||
|
for (MergeCandidate candidate : candidates) {
|
||||||
|
Changeset changeset = candidate.getChangeset();
|
||||||
|
if (changeset == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.add(new TfsHistoryEntry(
|
||||||
|
source,
|
||||||
|
nameOf(source),
|
||||||
|
candidate.isPartial() ? "partial merge" : "merge",
|
||||||
|
"",
|
||||||
|
changeset.getChangesetID(),
|
||||||
|
emptyToDefault(changeset.getOwnerDisplayName(), changeset.getOwner()),
|
||||||
|
changeset.getDate() == null ? null : Long.valueOf(changeset.getDate().getTimeInMillis()),
|
||||||
|
changeset.getComment()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logs.add("Merge candidates: " + result.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并:把源分支的改动合并到目标分支(产生挂起更改,冲突走冲突处理)。
|
||||||
|
* changesetId 为 null 时合并全部候选,否则仅合并该变更集。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsGetLatestResult> merge(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String sourceServerPath,
|
||||||
|
final String targetServerPath,
|
||||||
|
final Integer changesetId) {
|
||||||
|
return execute("merge", new CoreCallable<TfsGetLatestResult>() {
|
||||||
|
@Override
|
||||||
|
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
|
||||||
|
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
|
||||||
|
VersionSpec from = changesetId == null ? null : new ChangesetVersionSpec(changesetId.intValue());
|
||||||
|
VersionSpec to = changesetId == null ? LatestVersionSpec.INSTANCE : new ChangesetVersionSpec(changesetId.intValue());
|
||||||
|
GetStatus status = workspace.merge(source, target, from, to, LockLevel.NONE, RecursionType.FULL, MergeFlags.NONE);
|
||||||
|
logs.add("Merge " + source + " -> " + target + " operations=" + status.getNumOperations() + " conflicts=" + status.getNumConflicts());
|
||||||
|
return toGetLatestResult(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚变更集(只产生挂起更改,需用户审查后签入):
|
||||||
|
* mode=single 仅反做该 changeset;mode=toVersion 反做该 changeset 之后的全部改动。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsGetLatestResult> rollback(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String serverPath,
|
||||||
|
final String mode,
|
||||||
|
final int changesetId) {
|
||||||
|
return execute("rollback", new CoreCallable<TfsGetLatestResult>() {
|
||||||
|
@Override
|
||||||
|
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
ItemSpec[] specs = new ItemSpec[] {
|
||||||
|
new ItemSpec(normalizeServerPath(require(serverPath, "serverPath")), RecursionType.FULL)
|
||||||
|
};
|
||||||
|
boolean toVersion = "toVersion".equalsIgnoreCase(require(mode, "mode"));
|
||||||
|
ChangesetVersionSpec changesetSpec = new ChangesetVersionSpec(changesetId);
|
||||||
|
GetStatus status = toVersion
|
||||||
|
? workspace.rollback(specs, LatestVersionSpec.INSTANCE, null, changesetSpec, LockLevel.NONE, RollbackOptions.TO_VERSION)
|
||||||
|
: workspace.rollback(specs, LatestVersionSpec.INSTANCE, changesetSpec, changesetSpec, LockLevel.NONE, RollbackOptions.NONE);
|
||||||
|
logs.add("Rollback mode=" + mode + " changeset=" + changesetId + " operations=" + status.getNumOperations());
|
||||||
|
return toGetLatestResult(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名已映射的文件或目录(同目录改名):产生 rename 挂起更改,签入后服务器生效。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsFileOperationResult> rename(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final String serverPath,
|
||||||
|
final String newName) {
|
||||||
|
return execute("rename", new CoreCallable<TfsFileOperationResult>() {
|
||||||
|
@Override
|
||||||
|
public TfsFileOperationResult call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
String oldPath = normalizeServerPath(require(serverPath, "serverPath"));
|
||||||
|
String name = require(newName, "newName").trim();
|
||||||
|
if (name.contains("/") || name.contains("\\")) {
|
||||||
|
throw new IllegalArgumentException("Invalid new name: " + newName);
|
||||||
|
}
|
||||||
|
int slash = oldPath.lastIndexOf('/');
|
||||||
|
String newPath = oldPath.substring(0, slash + 1) + name;
|
||||||
|
if (newPath.equals(oldPath)) {
|
||||||
|
throw new IllegalArgumentException("New name is same as current name");
|
||||||
|
}
|
||||||
|
int affected = workspace.pendRename(oldPath, newPath, LockLevel.NONE, GetOptions.NONE, true, PendChangesOptions.NONE);
|
||||||
|
logs.add("Rename pended: " + oldPath + " -> " + newPath);
|
||||||
|
return new TfsFileOperationResult("rename", affected, Collections.<String>emptyList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按选中的 pending changes 执行 checkin,comment 为空时直接拒绝。
|
* 按选中的 pending changes 执行 checkin,comment 为空时直接拒绝。
|
||||||
*/
|
*/
|
||||||
@@ -379,7 +579,8 @@ public class MacTfsCoreService {
|
|||||||
final String workspaceName,
|
final String workspaceName,
|
||||||
final String serverPath,
|
final String serverPath,
|
||||||
final String localPath,
|
final String localPath,
|
||||||
final boolean recursive) {
|
final boolean recursive,
|
||||||
|
final boolean includeLocalOnly) {
|
||||||
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
||||||
@Override
|
@Override
|
||||||
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
||||||
@@ -393,7 +594,10 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
||||||
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
||||||
Set<String> localFiles = scanLocalFiles(new File(normalizedLocalPath), recursive);
|
// 不统计仅本地存在的项时直接跳过本地全量扫描(如 node_modules 大目录),只对比两端都有的文件。
|
||||||
|
Set<String> localFiles = includeLocalOnly
|
||||||
|
? scanLocalFiles(new File(normalizedLocalPath), recursive)
|
||||||
|
: new LinkedHashSet<String>();
|
||||||
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
||||||
|
|
||||||
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
||||||
@@ -513,9 +717,89 @@ public class MacTfsCoreService {
|
|||||||
GetItemsOptions.DOWNLOAD
|
GetItemsOptions.DOWNLOAD
|
||||||
);
|
);
|
||||||
File tempFile = item.downloadFileToTempLocation(connection.versionControlClient, "mactfs");
|
File tempFile = item.downloadFileToTempLocation(connection.versionControlClient, "mactfs");
|
||||||
String content = new String(Files.readAllBytes(tempFile.toPath()), TEXT_CHARSET);
|
TfsFileContent content = buildFileContent(item.getServerItem(), item.getChangeSetID(), tempFile, logs);
|
||||||
logs.add("File content loaded: " + item.getServerItem());
|
logs.add("File content loaded: " + item.getServerItem());
|
||||||
return new TfsFileContent(item.getServerItem(), item.getChangeSetID(), content, false);
|
return content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本地已映射文件的文本内容,套用与服务器内容一致的二进制和大文件约束。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsFileContent> getLocalFileContent(final String localPath) {
|
||||||
|
return execute("getLocalFileContent", new CoreCallable<TfsFileContent>() {
|
||||||
|
@Override
|
||||||
|
public TfsFileContent call(List<String> logs) throws Exception {
|
||||||
|
File file = new File(require(localPath, "localPath"));
|
||||||
|
if (!file.exists() || file.isDirectory()) {
|
||||||
|
throw new IllegalArgumentException("Local file not found: " + localPath);
|
||||||
|
}
|
||||||
|
TfsFileContent content = buildFileContent(file.getAbsolutePath(), 0, file, logs);
|
||||||
|
logs.add("Local file content loaded: " + file.getAbsolutePath());
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个服务端对象(文件或目录)的属性信息:最新版本、签入时间与签入人,文件附带大小与编码。
|
||||||
|
* 签入人与备注来自最近一条历史记录(Item 本身不携带提交人信息)。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsItemInfo> getItemInfo(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String serverPath) {
|
||||||
|
return execute("getItemInfo", new CoreCallable<TfsItemInfo>() {
|
||||||
|
@Override
|
||||||
|
public TfsItemInfo call(List<String> logs) throws Exception {
|
||||||
|
CoreConnection connection = connectCollection(config, collectionName);
|
||||||
|
Item item = connection.versionControlClient.getItem(
|
||||||
|
require(serverPath, "serverPath"),
|
||||||
|
LatestVersionSpec.INSTANCE,
|
||||||
|
DeletedState.NON_DELETED,
|
||||||
|
GetItemsOptions.NONE
|
||||||
|
);
|
||||||
|
boolean folder = isFolder(item.getItemType());
|
||||||
|
int changeset = item.getChangeSetID();
|
||||||
|
Long checkinDate = item.getCheckinDate() == null ? null : Long.valueOf(item.getCheckinDate().getTimeInMillis());
|
||||||
|
String author = "";
|
||||||
|
String comment = "";
|
||||||
|
Changeset[] changesets = connection.versionControlClient.queryHistory(
|
||||||
|
item.getServerItem(),
|
||||||
|
LatestVersionSpec.INSTANCE,
|
||||||
|
0,
|
||||||
|
folder ? RecursionType.FULL : RecursionType.NONE,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (changesets != null && changesets.length > 0 && changesets[0] != null) {
|
||||||
|
Changeset latest = changesets[0];
|
||||||
|
author = emptyToDefault(latest.getOwnerDisplayName(), latest.getOwner());
|
||||||
|
comment = latest.getComment() == null ? "" : latest.getComment();
|
||||||
|
// 目录的最新变动以子树最近一次签入为准(Item 自身的 changeset 不含子项变动)。
|
||||||
|
changeset = latest.getChangesetID();
|
||||||
|
if (latest.getDate() != null) {
|
||||||
|
checkinDate = Long.valueOf(latest.getDate().getTimeInMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TfsItemInfo info = new TfsItemInfo(
|
||||||
|
item.getServerItem(),
|
||||||
|
folder,
|
||||||
|
changeset,
|
||||||
|
checkinDate,
|
||||||
|
author,
|
||||||
|
comment,
|
||||||
|
folder ? null : Long.valueOf(item.getContentLength()),
|
||||||
|
folder || item.getEncoding() == null ? null : item.getEncoding().getName()
|
||||||
|
);
|
||||||
|
logs.add("Item info loaded: " + item.getServerItem());
|
||||||
|
return info;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -535,7 +819,8 @@ public class MacTfsCoreService {
|
|||||||
throw new IllegalStateException(latest.getErrorMessage());
|
throw new IllegalStateException(latest.getErrorMessage());
|
||||||
}
|
}
|
||||||
String localContent = new String(Files.readAllBytes(new File(require(localPath, "localPath")).toPath()), TEXT_CHARSET);
|
String localContent = new String(Files.readAllBytes(new File(require(localPath, "localPath")).toPath()), TEXT_CHARSET);
|
||||||
TfsTextDiff diff = buildTextDiff(localPath, serverPath, localContent, latest.getData().getContent());
|
// 服务器 latest 作为旧版(左侧),本地文件作为新版(右侧),本地新增行才会标记为 "+"。
|
||||||
|
TfsTextDiff diff = buildTextDiff(serverPath, localPath, latest.getData().getContent(), localContent);
|
||||||
logs.add("Text diff lines: " + diff.getLines().size());
|
logs.add("Text diff lines: " + diff.getLines().size());
|
||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
@@ -573,6 +858,193 @@ public class MacTfsCoreService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前 Workspace 在指定范围内的冲突明细,供 Get Latest / Checkout 冲突弹窗展示。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<List<TfsConflictInfo>> listConflicts(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final List<String> serverPaths,
|
||||||
|
final boolean recursive) {
|
||||||
|
return execute("listConflicts", new CoreCallable<List<TfsConflictInfo>>() {
|
||||||
|
@Override
|
||||||
|
public List<TfsConflictInfo> call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
Conflict[] conflicts = workspace.queryConflicts(conflictScope(workspace, serverPaths), recursive);
|
||||||
|
List<TfsConflictInfo> result = new ArrayList<TfsConflictInfo>();
|
||||||
|
if (conflicts != null) {
|
||||||
|
for (Conflict conflict : conflicts) {
|
||||||
|
result.add(toConflictInfo(conflict));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logs.add("Conflicts loaded: " + result.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对单个冲突应用解决方式(采用服务器版本或保留本地版本),并返回剩余冲突数量。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsConflictResolution> applyConflict(final TfsConnectionConfig config,
|
||||||
|
final String collectionName,
|
||||||
|
final String workspaceName,
|
||||||
|
final int conflictId,
|
||||||
|
final String resolution) {
|
||||||
|
return execute("applyConflict", new CoreCallable<TfsConflictResolution>() {
|
||||||
|
@Override
|
||||||
|
public TfsConflictResolution call(List<String> logs) throws Exception {
|
||||||
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
|
Resolution target = toResolution(resolution);
|
||||||
|
String[] scope = conflictScope(workspace, null);
|
||||||
|
Conflict matched = null;
|
||||||
|
Conflict[] conflicts = workspace.queryConflicts(scope, true);
|
||||||
|
if (conflicts != null) {
|
||||||
|
for (Conflict conflict : conflicts) {
|
||||||
|
if (conflict.getConflictID() == conflictId) {
|
||||||
|
matched = conflict;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched == null) {
|
||||||
|
throw new IllegalArgumentException("Conflict not found: " + conflictId);
|
||||||
|
}
|
||||||
|
matched.setResolution(target);
|
||||||
|
workspace.resolveConflict(matched);
|
||||||
|
Conflict[] remaining = workspace.queryConflicts(scope, true);
|
||||||
|
int remainingCount = remaining == null ? 0 : remaining.length;
|
||||||
|
logs.add("Conflict resolved: " + conflictId + " -> " + resolution + ", remaining=" + remainingCount);
|
||||||
|
return new TfsConflictResolution(conflictId, resolution, matched.isResolved(), remainingCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算冲突查询范围:优先使用调用方传入的路径,否则回退到 Workspace 全部映射根。
|
||||||
|
*/
|
||||||
|
private String[] conflictScope(Workspace workspace, List<String> serverPaths) {
|
||||||
|
List<String> scope = new ArrayList<String>();
|
||||||
|
if (serverPaths != null) {
|
||||||
|
for (String path : serverPaths) {
|
||||||
|
if (path != null && path.trim().length() > 0) {
|
||||||
|
scope.add(path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scope.isEmpty()) {
|
||||||
|
WorkingFolder[] folders = workspace.getFolders();
|
||||||
|
if (folders != null) {
|
||||||
|
for (WorkingFolder folder : folders) {
|
||||||
|
if (folder.getServerItem() != null && folder.getServerItem().trim().length() > 0) {
|
||||||
|
scope.add(folder.getServerItem());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scope.isEmpty()) {
|
||||||
|
scope.add("$/");
|
||||||
|
}
|
||||||
|
return scope.toArray(new String[scope.size()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 UI 传入的解决方式标识映射为 TFS Resolution,仅支持第一版约定的两种取舍。
|
||||||
|
*/
|
||||||
|
private Resolution toResolution(String resolution) {
|
||||||
|
String value = resolution == null ? "" : resolution.trim().toLowerCase();
|
||||||
|
if ("takeserver".equals(value) || "server".equals(value) || "theirs".equals(value) || "accept_theirs".equals(value)) {
|
||||||
|
return Resolution.ACCEPT_THEIRS;
|
||||||
|
}
|
||||||
|
if ("keeplocal".equals(value) || "local".equals(value) || "yours".equals(value) || "accept_yours".equals(value)) {
|
||||||
|
return Resolution.ACCEPT_YOURS;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unsupported conflict resolution: " + resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 TFS 冲突对象转换为稳定可序列化的冲突明细。
|
||||||
|
*/
|
||||||
|
private TfsConflictInfo toConflictInfo(Conflict conflict) {
|
||||||
|
String serverPath = firstNonBlank(conflict.getYourServerItem(), conflict.getTheirServerItem(), conflict.getBaseServerItem());
|
||||||
|
String localPath = firstNonBlank(conflict.getSourceLocalItem(), conflict.getTargetLocalItem());
|
||||||
|
return new TfsConflictInfo(
|
||||||
|
conflict.getConflictID(),
|
||||||
|
conflictTypeName(conflict.getType()),
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
conflict.getYourServerItem(),
|
||||||
|
conflict.getTheirServerItem(),
|
||||||
|
conflict.getBaseServerItem(),
|
||||||
|
conflict.isResolved()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回冲突类型的稳定字符串名称,避免直接依赖枚举 toString。
|
||||||
|
*/
|
||||||
|
private String conflictTypeName(ConflictType type) {
|
||||||
|
if (ConflictType.GET.equals(type)) {
|
||||||
|
return "get";
|
||||||
|
}
|
||||||
|
if (ConflictType.CHECKIN.equals(type)) {
|
||||||
|
return "checkin";
|
||||||
|
}
|
||||||
|
if (ConflictType.LOCAL.equals(type)) {
|
||||||
|
return "local";
|
||||||
|
}
|
||||||
|
if (ConflictType.MERGE.equals(type)) {
|
||||||
|
return "merge";
|
||||||
|
}
|
||||||
|
if (ConflictType.NONE.equals(type)) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本地文件字节并按二进制识别和大小阈值套用文本内容约束。
|
||||||
|
*/
|
||||||
|
private TfsFileContent buildFileContent(String serverPath, int changeset, File file, List<String> logs) throws IOException {
|
||||||
|
byte[] bytes = Files.readAllBytes(file.toPath());
|
||||||
|
long size = bytes.length;
|
||||||
|
boolean binary = isBinaryContent(bytes);
|
||||||
|
boolean tooLarge = size > MAX_TEXT_CONTENT_BYTES;
|
||||||
|
if (binary || tooLarge) {
|
||||||
|
logs.add("File content skipped (binary=" + binary + ", tooLarge=" + tooLarge + ", size=" + size + ")");
|
||||||
|
return new TfsFileContent(serverPath, changeset, "", binary, size, tooLarge);
|
||||||
|
}
|
||||||
|
return new TfsFileContent(serverPath, changeset, new String(bytes, TEXT_CHARSET), false, size, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过嗅探前若干字节是否包含空字节判断内容是否为二进制。
|
||||||
|
*/
|
||||||
|
private boolean isBinaryContent(byte[] bytes) {
|
||||||
|
int limit = (int) Math.min(bytes.length, (long) BINARY_SNIFF_BYTES);
|
||||||
|
for (int index = 0; index < limit; index++) {
|
||||||
|
if (bytes[index] == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回第一个非空字符串,供冲突路径回退使用。
|
||||||
|
*/
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && value.trim().length() > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private <T> CoreOperationResult<T> execute(String operation, CoreCallable<T> callable) {
|
private <T> CoreOperationResult<T> execute(String operation, CoreCallable<T> callable) {
|
||||||
long startedAt = System.currentTimeMillis();
|
long startedAt = System.currentTimeMillis();
|
||||||
List<String> logs = new ArrayList<String>();
|
List<String> logs = new ArrayList<String>();
|
||||||
@@ -769,8 +1241,12 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
boolean exists = localFile != null && localFile.exists();
|
boolean exists = localFile != null && localFile.exists();
|
||||||
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
||||||
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
// 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
|
||||||
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
// 这样未签出的纯本地修改也能在目录对比中暴露出来。
|
||||||
|
boolean writableFile = exists && !isFolder(item.getItemType()) && localFile.canWrite();
|
||||||
|
// mtime 仅作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
||||||
|
boolean mtimeNewer = exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
||||||
|
boolean localModified = writableFile || (remoteChanged && mtimeNewer);
|
||||||
if (!exists && item.getLocalVersion() > 0) {
|
if (!exists && item.getLocalVersion() > 0) {
|
||||||
return "localDeleted";
|
return "localDeleted";
|
||||||
}
|
}
|
||||||
@@ -854,7 +1330,9 @@ public class MacTfsCoreService {
|
|||||||
isFolder(change.getItemType()),
|
isFolder(change.getItemType()),
|
||||||
toPendingStatus(change),
|
toPendingStatus(change),
|
||||||
change.getChangeType() == null ? "" : change.getChangeType().toUIString(false),
|
change.getChangeType() == null ? "" : change.getChangeType().toUIString(false),
|
||||||
change.getVersion()
|
change.getVersion(),
|
||||||
|
// rename 挂起更改携带原路径,供 UI 展示「旧名 → 新名」。
|
||||||
|
change.isRename() ? change.getSourceServerItem() : null
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -928,21 +1406,28 @@ public class MacTfsCoreService {
|
|||||||
private TfsTextDiff buildTextDiff(String sourceLabel, String targetLabel, String sourceContent, String targetContent) {
|
private TfsTextDiff buildTextDiff(String sourceLabel, String targetLabel, String sourceContent, String targetContent) {
|
||||||
List<String> sourceLines = Arrays.asList(sourceContent.split("\\r?\\n", -1));
|
List<String> sourceLines = Arrays.asList(sourceContent.split("\\r?\\n", -1));
|
||||||
List<String> targetLines = Arrays.asList(targetContent.split("\\r?\\n", -1));
|
List<String> targetLines = Arrays.asList(targetContent.split("\\r?\\n", -1));
|
||||||
int max = Math.max(sourceLines.size(), targetLines.size());
|
// 使用 java-diff-utils(Myers 算法)计算最小差异,
|
||||||
|
// 避免按行号硬对齐导致插入/删除一行后其余行全部误报为变动。
|
||||||
|
Patch<String> patch = DiffUtils.diff(sourceLines, targetLines);
|
||||||
List<String> lines = new ArrayList<String>();
|
List<String> lines = new ArrayList<String>();
|
||||||
for (int index = 0; index < max; index++) {
|
int sourceIndex = 0;
|
||||||
String source = index < sourceLines.size() ? sourceLines.get(index) : null;
|
for (AbstractDelta<String> delta : patch.getDeltas()) {
|
||||||
String target = index < targetLines.size() ? targetLines.get(index) : null;
|
int deltaStart = delta.getSource().getPosition();
|
||||||
if (source != null && target != null && source.equals(target)) {
|
while (sourceIndex < deltaStart) {
|
||||||
lines.add(" " + source);
|
lines.add(" " + sourceLines.get(sourceIndex));
|
||||||
} else {
|
sourceIndex++;
|
||||||
if (source != null) {
|
|
||||||
lines.add("-" + source);
|
|
||||||
}
|
}
|
||||||
if (target != null) {
|
for (String removed : delta.getSource().getLines()) {
|
||||||
lines.add("+" + target);
|
lines.add("-" + removed);
|
||||||
}
|
}
|
||||||
|
for (String added : delta.getTarget().getLines()) {
|
||||||
|
lines.add("+" + added);
|
||||||
}
|
}
|
||||||
|
sourceIndex += delta.getSource().size();
|
||||||
|
}
|
||||||
|
while (sourceIndex < sourceLines.size()) {
|
||||||
|
lines.add(" " + sourceLines.get(sourceIndex));
|
||||||
|
sourceIndex++;
|
||||||
}
|
}
|
||||||
return new TfsTextDiff(sourceLabel, targetLabel, lines);
|
return new TfsTextDiff(sourceLabel, targetLabel, lines);
|
||||||
}
|
}
|
||||||
@@ -1417,8 +1902,9 @@ public class MacTfsCoreService {
|
|||||||
private final String status;
|
private final String status;
|
||||||
private final String changeType;
|
private final String changeType;
|
||||||
private final int version;
|
private final int version;
|
||||||
|
private final String sourceServerPath;
|
||||||
|
|
||||||
public TfsPendingChangeInfo(String serverPath, String localPath, String name, boolean folder, String status, String changeType, int version) {
|
public TfsPendingChangeInfo(String serverPath, String localPath, String name, boolean folder, String status, String changeType, int version, String sourceServerPath) {
|
||||||
this.serverPath = serverPath;
|
this.serverPath = serverPath;
|
||||||
this.localPath = localPath;
|
this.localPath = localPath;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -1426,6 +1912,7 @@ public class MacTfsCoreService {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
this.changeType = changeType;
|
this.changeType = changeType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
this.sourceServerPath = sourceServerPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getServerPath() {
|
public String getServerPath() {
|
||||||
@@ -1455,6 +1942,10 @@ public class MacTfsCoreService {
|
|||||||
public int getVersion() {
|
public int getVersion() {
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSourceServerPath() {
|
||||||
|
return sourceServerPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TfsFileOperationResult {
|
public static class TfsFileOperationResult {
|
||||||
@@ -1606,12 +2097,16 @@ public class MacTfsCoreService {
|
|||||||
private final int changeset;
|
private final int changeset;
|
||||||
private final String content;
|
private final String content;
|
||||||
private final boolean binary;
|
private final boolean binary;
|
||||||
|
private final long size;
|
||||||
|
private final boolean tooLarge;
|
||||||
|
|
||||||
public TfsFileContent(String serverPath, int changeset, String content, boolean binary) {
|
public TfsFileContent(String serverPath, int changeset, String content, boolean binary, long size, boolean tooLarge) {
|
||||||
this.serverPath = serverPath;
|
this.serverPath = serverPath;
|
||||||
this.changeset = changeset;
|
this.changeset = changeset;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.binary = binary;
|
this.binary = binary;
|
||||||
|
this.size = size;
|
||||||
|
this.tooLarge = tooLarge;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getServerPath() {
|
public String getServerPath() {
|
||||||
@@ -1629,6 +2124,154 @@ public class MacTfsCoreService {
|
|||||||
public boolean isBinary() {
|
public boolean isBinary() {
|
||||||
return binary;
|
return binary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTooLarge() {
|
||||||
|
return tooLarge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TfsItemInfo {
|
||||||
|
private final String serverPath;
|
||||||
|
private final boolean folder;
|
||||||
|
private final int changeset;
|
||||||
|
private final Long checkinDate;
|
||||||
|
private final String author;
|
||||||
|
private final String comment;
|
||||||
|
private final Long size;
|
||||||
|
private final String encoding;
|
||||||
|
|
||||||
|
public TfsItemInfo(String serverPath, boolean folder, int changeset, Long checkinDate,
|
||||||
|
String author, String comment, Long size, String encoding) {
|
||||||
|
this.serverPath = serverPath;
|
||||||
|
this.folder = folder;
|
||||||
|
this.changeset = changeset;
|
||||||
|
this.checkinDate = checkinDate;
|
||||||
|
this.author = author;
|
||||||
|
this.comment = comment;
|
||||||
|
this.size = size;
|
||||||
|
this.encoding = encoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerPath() {
|
||||||
|
return serverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFolder() {
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChangeset() {
|
||||||
|
return changeset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCheckinDate() {
|
||||||
|
return checkinDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthor() {
|
||||||
|
return author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getComment() {
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncoding() {
|
||||||
|
return encoding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TfsConflictInfo {
|
||||||
|
private final int conflictId;
|
||||||
|
private final String type;
|
||||||
|
private final String serverPath;
|
||||||
|
private final String localPath;
|
||||||
|
private final String yourServerItem;
|
||||||
|
private final String theirServerItem;
|
||||||
|
private final String baseServerItem;
|
||||||
|
private final boolean resolved;
|
||||||
|
|
||||||
|
public TfsConflictInfo(int conflictId, String type, String serverPath, String localPath,
|
||||||
|
String yourServerItem, String theirServerItem, String baseServerItem, boolean resolved) {
|
||||||
|
this.conflictId = conflictId;
|
||||||
|
this.type = type;
|
||||||
|
this.serverPath = serverPath;
|
||||||
|
this.localPath = localPath;
|
||||||
|
this.yourServerItem = yourServerItem;
|
||||||
|
this.theirServerItem = theirServerItem;
|
||||||
|
this.baseServerItem = baseServerItem;
|
||||||
|
this.resolved = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConflictId() {
|
||||||
|
return conflictId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerPath() {
|
||||||
|
return serverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocalPath() {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getYourServerItem() {
|
||||||
|
return yourServerItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTheirServerItem() {
|
||||||
|
return theirServerItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBaseServerItem() {
|
||||||
|
return baseServerItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isResolved() {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TfsConflictResolution {
|
||||||
|
private final int conflictId;
|
||||||
|
private final String resolution;
|
||||||
|
private final boolean resolved;
|
||||||
|
private final int remainingConflicts;
|
||||||
|
|
||||||
|
public TfsConflictResolution(int conflictId, String resolution, boolean resolved, int remainingConflicts) {
|
||||||
|
this.conflictId = conflictId;
|
||||||
|
this.resolution = resolution;
|
||||||
|
this.resolved = resolved;
|
||||||
|
this.remainingConflicts = remainingConflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConflictId() {
|
||||||
|
return conflictId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResolution() {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isResolved() {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRemainingConflicts() {
|
||||||
|
return remainingConflicts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TfsTextDiff {
|
public static class TfsTextDiff {
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import com.mydev.mactfs.core.MacTfsCoreService.ConnectionSummary;
|
|||||||
import com.mydev.mactfs.core.MacTfsCoreService.CoreOperationResult;
|
import com.mydev.mactfs.core.MacTfsCoreService.CoreOperationResult;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsCheckinResult;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsCheckinResult;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsCollectionInfo;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsCollectionInfo;
|
||||||
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsConflictInfo;
|
||||||
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsConflictResolution;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsConnectionConfig;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsConnectionConfig;
|
||||||
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileContent;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
|
||||||
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsItemInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsMappingInfo;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsMappingInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsPendingChangeInfo;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsPendingChangeInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsServerItem;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsServerItem;
|
||||||
@@ -48,7 +52,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
* MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
||||||
*/
|
*/
|
||||||
public class MacTfsServer {
|
public class MacTfsServer {
|
||||||
|
|
||||||
@@ -89,8 +93,8 @@ public class MacTfsServer {
|
|||||||
registerFilters();
|
registerFilters();
|
||||||
registerRoutes();
|
registerRoutes();
|
||||||
Spark.awaitInitialization();
|
Spark.awaitInitialization();
|
||||||
System.out.println("macTFS API server listening on http://" + HOST + ":" + PORT);
|
System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
|
||||||
System.out.println("macTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
|
System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -280,10 +284,33 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.get("/api/workspace/context", (request, response) -> handle(request, response, "workspaceContext", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
|
Map<String, Object> context = new LinkedHashMap<String, Object>();
|
||||||
|
context.put("serverUri", config.serverUri);
|
||||||
|
context.put("authType", config.authType);
|
||||||
|
context.put("collection", config.collection);
|
||||||
|
context.put("workspace", config.workspace);
|
||||||
|
context.put("connected", Boolean.valueOf(sessionManager.isConnected()));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
if (isBlank(config.collection) || isBlank(config.workspace)) {
|
||||||
|
context.put("mappings", config.mappings);
|
||||||
|
data.put("context", context);
|
||||||
|
return ApiResult.success("success", data);
|
||||||
|
}
|
||||||
|
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), config.collection, config.workspace);
|
||||||
|
context.put("mappings", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
|
data.put("context", context);
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Spark.get("/api/mappings", (request, response) -> handle(request, response, "listMappings", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
Spark.get("/api/mappings", (request, response) -> handle(request, response, "listMappings", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
AppConfig config = requestConfig(queryConfig(request));
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"));
|
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"));
|
||||||
if (result.isSuccess()) {
|
if (result.isSuccess()) {
|
||||||
config.mappings = mappingConfigs(result.getData());
|
config.mappings = mappingConfigs(result.getData());
|
||||||
@@ -296,6 +323,26 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/mappings/check-target", (request, response) -> handle(request, response, "checkMappingTarget", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
|
||||||
|
String localParentPath = require(stringValue(body, "localParentPath"), "localParentPath");
|
||||||
|
String name = lastPathSegment(serverPath);
|
||||||
|
File target = new File(localParentPath, name);
|
||||||
|
boolean exists = target.exists();
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("serverPath", serverPath);
|
||||||
|
data.put("localParentPath", localParentPath);
|
||||||
|
data.put("name", name);
|
||||||
|
data.put("targetPath", target.getAbsolutePath());
|
||||||
|
data.put("exists", Boolean.valueOf(exists));
|
||||||
|
data.put("allowed", Boolean.valueOf(!exists));
|
||||||
|
return ApiResult.success("success", data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Spark.post("/api/mappings", (request, response) -> handle(request, response, "addMapping", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
Spark.post("/api/mappings", (request, response) -> handle(request, response, "addMapping", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
@@ -349,7 +396,23 @@ public class MacTfsServer {
|
|||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
Map<String, Object> body = readBody(request);
|
Map<String, Object> body = readBody(request);
|
||||||
AppConfig config = requestConfig(body);
|
AppConfig config = requestConfig(body);
|
||||||
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true));
|
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true), booleanValue(body, "force", false));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("result", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/get-version", (request, response) -> handle(request, response, "getVersion", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
Number changeset = (Number) body.get("changeset");
|
||||||
|
if (changeset == null) {
|
||||||
|
throw new IllegalArgumentException("changeset is required");
|
||||||
|
}
|
||||||
|
CoreOperationResult<TfsGetLatestResult> result = coreService.getVersion(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), changeset.intValue(), booleanValue(body, "recursive", true));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("result", result.getData());
|
data.put("result", result.getData());
|
||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
@@ -384,11 +447,107 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.get("/api/files/merge-candidates", (request, response) -> handle(request, response, "mergeCandidates", TIMEOUT_HISTORY_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
|
CoreOperationResult<List<TfsHistoryEntry>> result = coreService.mergeCandidates(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(request.queryParams("sourceServerPath"), "sourceServerPath"), require(request.queryParams("targetServerPath"), "targetServerPath"));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("candidates", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/merge", (request, response) -> handle(request, response, "merge", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
Number changeset = (Number) body.get("changeset");
|
||||||
|
CoreOperationResult<TfsGetLatestResult> result = coreService.merge(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "sourceServerPath"), "sourceServerPath"), require(stringValue(body, "targetServerPath"), "targetServerPath"), changeset == null ? null : Integer.valueOf(changeset.intValue()));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("result", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/branch", (request, response) -> handle(request, response, "branch", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
Number changeset = (Number) body.get("changeset");
|
||||||
|
CoreOperationResult<TfsFileOperationResult> result = coreService.branch(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "sourceServerPath"), "sourceServerPath"), require(stringValue(body, "targetServerPath"), "targetServerPath"), changeset == null ? null : Integer.valueOf(changeset.intValue()));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("result", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/rollback", (request, response) -> handle(request, response, "rollback", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
Number changeset = (Number) body.get("changeset");
|
||||||
|
if (changeset == null) {
|
||||||
|
throw new IllegalArgumentException("changeset is required");
|
||||||
|
}
|
||||||
|
CoreOperationResult<TfsGetLatestResult> result = coreService.rollback(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), require(stringValue(body, "mode"), "mode"), changeset.intValue());
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("result", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/rename", (request, response) -> handle(request, response, "rename", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
CoreOperationResult<TfsFileOperationResult> result = coreService.rename(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), require(stringValue(body, "newName"), "newName"));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("result", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.get("/api/files/content", (request, response) -> handle(request, response, "fileContent", TIMEOUT_DIFF_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
|
String localPath = request.queryParams("localPath");
|
||||||
|
CoreOperationResult<TfsFileContent> result;
|
||||||
|
if (localPath != null && localPath.trim().length() > 0) {
|
||||||
|
result = coreService.getLocalFileContent(localPath);
|
||||||
|
} else {
|
||||||
|
String changeset = request.queryParams("changeset");
|
||||||
|
Integer changesetId = changeset == null || changeset.trim().isEmpty()
|
||||||
|
? null
|
||||||
|
: Integer.valueOf(Integer.parseInt(changeset.trim()));
|
||||||
|
result = coreService.getFileContent(toTfsConfig(config), require(config.collection, "collection"), require(request.queryParams("serverPath"), "serverPath"), changesetId);
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("content", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.get("/api/items/info", (request, response) -> handle(request, response, "itemInfo", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
|
CoreOperationResult<TfsItemInfo> result = coreService.getItemInfo(toTfsConfig(config), require(config.collection, "collection"), require(request.queryParams("serverPath"), "serverPath"));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("item", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
Map<String, Object> query = queryConfig(request);
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
AppConfig config = requestConfig(query);
|
|
||||||
CoreOperationResult<List<TfsPendingChangeInfo>> result = coreService.listPendingChanges(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")));
|
CoreOperationResult<List<TfsPendingChangeInfo>> result = coreService.listPendingChanges(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("pendingChanges", result.getData() == null ? Collections.emptyList() : result.getData());
|
data.put("pendingChanges", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
@@ -409,12 +568,36 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.get("/api/conflicts", (request, response) -> handle(request, response, "listConflicts", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
|
boolean recursive = !"false".equalsIgnoreCase(request.queryParams("recursive"));
|
||||||
|
CoreOperationResult<List<TfsConflictInfo>> result = coreService.listConflicts(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")), recursive);
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("conflicts", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/conflicts/apply", (request, response) -> handle(request, response, "applyConflict", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
CoreOperationResult<TfsConflictResolution> result = coreService.applyConflict(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), intValue(body, "conflictId"), require(stringValue(body, "resolution"), "resolution"));
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("resolution", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Spark.post("/api/compare/folder", (request, response) -> handle(request, response, "compareFolder", TIMEOUT_COMPARE_MS, new ApiCallable() {
|
Spark.post("/api/compare/folder", (request, response) -> handle(request, response, "compareFolder", TIMEOUT_COMPARE_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
Map<String, Object> body = readBody(request);
|
Map<String, Object> body = readBody(request);
|
||||||
AppConfig config = requestConfig(body);
|
AppConfig config = requestConfig(body);
|
||||||
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true));
|
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true), booleanValue(body, "includeLocalOnly", true));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
@@ -571,26 +754,6 @@ public class MacTfsServer {
|
|||||||
return normalizeConfig(config);
|
return normalizeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取 GET 请求中的配置覆盖字段,避免 UI 切换 Collection 时读接口使用旧配置。
|
|
||||||
*/
|
|
||||||
private Map<String, Object> queryConfig(Request request) {
|
|
||||||
Map<String, Object> query = new LinkedHashMap<String, Object>();
|
|
||||||
copyQuery(request, query, "collection");
|
|
||||||
copyQuery(request, query, "workspace");
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将非空 query 参数复制到请求配置覆盖对象。
|
|
||||||
*/
|
|
||||||
private void copyQuery(Request request, Map<String, Object> query, String key) {
|
|
||||||
String value = request.queryParams(key);
|
|
||||||
if (!isBlank(value)) {
|
|
||||||
query.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把服务端配置转换为 core 层连接配置。
|
* 把服务端配置转换为 core 层连接配置。
|
||||||
*/
|
*/
|
||||||
@@ -711,6 +874,18 @@ public class MacTfsServer {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取服务端路径的最后一段,作为 Mapping 目标目录名预校验依据。
|
||||||
|
*/
|
||||||
|
private String lastPathSegment(String serverPath) {
|
||||||
|
String normalized = serverPath.trim();
|
||||||
|
while (normalized.length() > 1 && normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
int index = normalized.lastIndexOf('/');
|
||||||
|
return index < 0 ? normalized : normalized.substring(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求体中读取路径数组,兼容 paths、path、serverPaths、serverPath 四种字段。
|
* 从请求体中读取路径数组,兼容 paths、path、serverPaths、serverPath 四种字段。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,9 +4,15 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 现代桌面工具风设计基建(FE-026)
|
||||||
|
* 基调:中性灰分层底色 + 卡片化 + 克制的蓝色主色,参照 Codex 桌面端 / CC Switch。
|
||||||
|
* 动效约定:快 150ms / 标准 200ms / 慢 250ms,对应 duration-150 / duration-200 / duration-250。
|
||||||
|
*/
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -38,6 +44,7 @@
|
|||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
|
/* 圆角档位:sm 6px / md 8px / lg 10px / xl 14px(--radius = 10px) */
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -45,75 +52,105 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗明显 */
|
||||||
|
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.04);
|
||||||
|
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.08), 0 1px 3px rgb(0 0 0 / 0.06);
|
||||||
|
--shadow-dialog: 0 16px 48px rgb(0 0 0 / 0.16), 0 4px 12px rgb(0 0 0 / 0.08);
|
||||||
|
/* 缓动曲线:出场用 out-quart,强调动效用 spring 近似 */
|
||||||
|
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||||
|
/* 反馈动效:表单错误抖动 / 品牌区光斑缓慢漂浮 */
|
||||||
|
--animate-shake: shake 0.4s var(--ease-out-quart);
|
||||||
|
--animate-float-slow: float-slow 9s ease-in-out infinite;
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% { transform: translateX(-1px); }
|
||||||
|
20%, 80% { transform: translateX(2px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-3px); }
|
||||||
|
40%, 60% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
50% { transform: translate3d(18px, -22px, 0) scale(1.08); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
/* 中性灰分层:页面底 0.985 / 卡片 1 / 侧栏 0.967 / 悬停 0.945 */
|
||||||
--foreground: oklch(0.145 0 0);
|
--background: oklch(0.985 0.002 286);
|
||||||
|
--foreground: oklch(0.21 0.006 286);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.21 0.006 286);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.21 0.006 286);
|
||||||
--primary: oklch(0.488 0.243 264.376);
|
/* 克制的蓝色主色:仅用于主按钮、选中态、状态点 */
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary: oklch(0.546 0.215 262.881);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--primary-foreground: oklch(0.985 0.002 286);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary: oklch(0.96 0.004 286);
|
||||||
--muted: oklch(0.97 0 0);
|
--secondary-foreground: oklch(0.21 0.006 286);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted: oklch(0.96 0.004 286);
|
||||||
--accent: oklch(0.488 0.243 264.376);
|
--muted-foreground: oklch(0.552 0.014 285);
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
/* accent 为中性悬停底色,不再复用主色 */
|
||||||
|
--accent: oklch(0.945 0.004 286);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 286);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.92 0.004 286);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.92 0.004 286);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.623 0.188 259.815);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
--radius: 0.875rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.967 0.003 286);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.21 0.006 286);
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
--sidebar-primary: oklch(0.546 0.215 262.881);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.985 0.002 286);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.92 0.004 286);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.21 0.006 286);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.91 0.004 286);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.623 0.188 259.815);
|
||||||
|
/* 动效时长:快 / 标准 / 慢 */
|
||||||
|
--motion-fast: 150ms;
|
||||||
|
--motion-base: 200ms;
|
||||||
|
--motion-slow: 250ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
/* 深灰底而非纯黑:页面 0.185 / 卡片 0.22 / 侧栏 0.165 / 悬停 0.27 */
|
||||||
--foreground: oklch(0.985 0 0);
|
--background: oklch(0.185 0.005 285);
|
||||||
--card: oklch(0.205 0 0);
|
--foreground: oklch(0.96 0.002 286);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card: oklch(0.22 0.006 285);
|
||||||
--popover: oklch(0.205 0 0);
|
--card-foreground: oklch(0.96 0.002 286);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover: oklch(0.22 0.006 285);
|
||||||
--primary: oklch(0.424 0.199 265.638);
|
--popover-foreground: oklch(0.96 0.002 286);
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary: oklch(0.623 0.188 259.815);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--primary-foreground: oklch(0.985 0.002 286);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary: oklch(0.27 0.006 286);
|
||||||
--muted: oklch(0.269 0 0);
|
--secondary-foreground: oklch(0.96 0.002 286);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted: oklch(0.26 0.006 286);
|
||||||
--accent: oklch(0.424 0.199 265.638);
|
--muted-foreground: oklch(0.71 0.01 286);
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
--accent: oklch(0.27 0.006 286);
|
||||||
|
--accent-foreground: oklch(0.96 0.002 286);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 8%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 12%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.623 0.188 259.815);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.165 0.005 285);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.96 0.002 286);
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
--sidebar-primary: oklch(0.623 0.188 259.815);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.985 0.002 286);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.24 0.006 285);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.96 0.002 286);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.623 0.188 259.815);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -127,3 +164,21 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
|
||||||
|
.theme-transition,
|
||||||
|
.theme-transition * {
|
||||||
|
transition:
|
||||||
|
background-color 250ms var(--ease-out-quart),
|
||||||
|
border-color 250ms var(--ease-out-quart),
|
||||||
|
color 250ms var(--ease-out-quart) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
|
||||||
|
@utility app-drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility app-no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import type { ReactNode } from "react"
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Cloud,
|
|
||||||
Download,
|
|
||||||
FolderTree,
|
|
||||||
GitPullRequest,
|
|
||||||
History,
|
|
||||||
RefreshCw,
|
|
||||||
TriangleAlert,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { OperationLogPanel } from "~/components/app/operation-log-panel"
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
|
||||||
|
|
||||||
interface AppShellProps {
|
|
||||||
connected: boolean
|
|
||||||
serviceReady: boolean
|
|
||||||
serviceLoading: boolean
|
|
||||||
serverUri: string
|
|
||||||
username: string
|
|
||||||
collectionCount?: number
|
|
||||||
serviceBaseUrl?: string
|
|
||||||
tokenFile?: string
|
|
||||||
serviceMessage: string
|
|
||||||
requestStatus: string
|
|
||||||
operationLogs: ApiRequestLogEntry[]
|
|
||||||
operationLogsLoading: boolean
|
|
||||||
workspaceGetLatestEnabled: boolean
|
|
||||||
workspaceHistoryEnabled: boolean
|
|
||||||
sourceList?: ReactNode
|
|
||||||
inspector?: ReactNode
|
|
||||||
onRefreshService(): void
|
|
||||||
onRefreshOperationLogs(): void
|
|
||||||
onWorkspaceGetLatest(): void
|
|
||||||
onWorkspaceHistory(): void
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 macOS Source Workspace 主框架,承载顶部工具栏、Source List、主内容、Inspector 和 Console。
|
|
||||||
*/
|
|
||||||
export function AppShell({
|
|
||||||
connected,
|
|
||||||
serviceReady,
|
|
||||||
serviceLoading,
|
|
||||||
serverUri,
|
|
||||||
username,
|
|
||||||
collectionCount,
|
|
||||||
serviceBaseUrl,
|
|
||||||
tokenFile,
|
|
||||||
serviceMessage,
|
|
||||||
requestStatus,
|
|
||||||
operationLogs,
|
|
||||||
operationLogsLoading,
|
|
||||||
workspaceGetLatestEnabled,
|
|
||||||
workspaceHistoryEnabled,
|
|
||||||
sourceList,
|
|
||||||
inspector,
|
|
||||||
onRefreshService,
|
|
||||||
onRefreshOperationLogs,
|
|
||||||
onWorkspaceGetLatest,
|
|
||||||
onWorkspaceHistory,
|
|
||||||
children,
|
|
||||||
}: AppShellProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100svh-32px)] min-h-[640px] flex-col overflow-hidden rounded-[8px] border bg-background text-sm shadow-sm">
|
|
||||||
<header className="flex h-13 shrink-0 items-center justify-between gap-3 border-b bg-muted/30 px-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<div className="flex size-7 items-center justify-center rounded-[7px] border bg-background text-primary">
|
|
||||||
<Cloud className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">macTFS</span>
|
|
||||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
||||||
{connected ? "已连接" : "未连接"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
|
||||||
{serverUri || "http://127.0.0.1:38765"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!workspaceGetLatestEnabled}
|
|
||||||
onClick={onWorkspaceGetLatest}
|
|
||||||
>
|
|
||||||
<Download />
|
|
||||||
Get Latest
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!workspaceHistoryEnabled}
|
|
||||||
onClick={onWorkspaceHistory}
|
|
||||||
>
|
|
||||||
<History />
|
|
||||||
History
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={serviceLoading}
|
|
||||||
onClick={onRefreshService}
|
|
||||||
>
|
|
||||||
<RefreshCw className={serviceLoading ? "animate-spin" : ""} />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[280px_minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,1fr)_340px]">
|
|
||||||
<aside className="flex min-h-[220px] min-w-0 flex-col border-b bg-muted/25 md:min-h-0 md:border-r md:border-b-0">
|
|
||||||
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<FolderTree className="size-4" />
|
|
||||||
Source List
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
|
||||||
{sourceList || (
|
|
||||||
<>
|
|
||||||
<div className="rounded-[6px] bg-background px-2 py-1.5 font-mono text-xs">
|
|
||||||
$/
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 px-2 text-xs text-muted-foreground">
|
|
||||||
Collection:{collectionCount ?? "-"}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex min-h-[320px] min-w-0 flex-col">
|
|
||||||
<div className="flex h-9 items-center justify-between gap-3 border-b px-3">
|
|
||||||
<div className="min-w-0 truncate font-medium">Source Workspace</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<Button variant="ghost" size="sm" disabled>
|
|
||||||
Compare
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" disabled>
|
|
||||||
Mapping
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="flex min-h-[260px] min-w-0 flex-col border-t bg-background md:col-span-2 lg:col-span-1 lg:border-t-0 lg:border-l">
|
|
||||||
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<GitPullRequest className="size-4" />
|
|
||||||
Inspector
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{inspector || (
|
|
||||||
<section className="grid gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Pending Changes</span>
|
|
||||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
||||||
0 项
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
|
|
||||||
暂无挂起更改
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="grid gap-2">
|
|
||||||
<div className="font-medium">本地服务</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/30 px-3 py-2">
|
|
||||||
{serviceReady ? (
|
|
||||||
<CheckCircle2 className="size-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="size-4 text-amber-600" />
|
|
||||||
)}
|
|
||||||
<span>{serviceReady ? "服务可用" : "服务未就绪"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1 text-xs text-muted-foreground">
|
|
||||||
<div className="truncate font-mono">
|
|
||||||
{serviceBaseUrl || "http://127.0.0.1:38765"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate font-mono">
|
|
||||||
{tokenFile || "~/.mactfs/server-token"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="h-40 shrink-0 border-t bg-muted/20">
|
|
||||||
<OperationLogPanel
|
|
||||||
logs={operationLogs}
|
|
||||||
loading={operationLogsLoading}
|
|
||||||
serviceMessage={serviceMessage}
|
|
||||||
requestStatus={requestStatus}
|
|
||||||
username={username}
|
|
||||||
onRefresh={onRefreshOperationLogs}
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用确认弹窗:用于强制获取、获取指定版本等覆盖类危险操作的二次确认。
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
danger,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: React.ReactNode
|
||||||
|
confirmLabel: string
|
||||||
|
danger?: boolean
|
||||||
|
onConfirm: () => Promise<void> | void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="text-sm leading-6">{description}</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={danger ? "destructive" : "default"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
await onConfirm()
|
||||||
|
setBusy(false)
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "正在执行…" : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
||||||
|
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { AppConfig, Collection } from "~/lib/api"
|
||||||
|
import { getServiceStatus } from "~/lib/electron"
|
||||||
|
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
|
||||||
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 连接阶段:填写连接信息 / 确认 Collection。
|
||||||
|
type ConnectPhase = "form" | "collection"
|
||||||
|
|
||||||
|
// 连接表单字段,第一版仅支持显式 NTLM 账号密码。
|
||||||
|
interface ConnectFormState {
|
||||||
|
serverUri: string
|
||||||
|
domain: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: ConnectFormState = {
|
||||||
|
serverUri: "",
|
||||||
|
domain: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
||||||
|
* 布局为左品牌区 + 右表单卡片,带分阶段过渡与最近连接快速填充。
|
||||||
|
*/
|
||||||
|
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
||||||
|
const [phase, setPhase] = useState<ConnectPhase>("form")
|
||||||
|
const [form, setForm] = useState<ConnectFormState>(EMPTY_FORM)
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([])
|
||||||
|
const [selectedCollection, setSelectedCollection] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [busyText, setBusyText] = useState("")
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [recent, setRecent] = useState<RecentServer[]>([])
|
||||||
|
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
|
||||||
|
// 每次失败自增,作为 key 触发表单抖动动画重放。
|
||||||
|
const [shakeCount, setShakeCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setRecent(loadRecentServers())
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.getConfig()
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const config = result.data?.config
|
||||||
|
if (config) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
serverUri: config.serverUri ?? prev.serverUri,
|
||||||
|
domain: config.domain ?? prev.domain,
|
||||||
|
username: config.username ?? prev.username,
|
||||||
|
password: config.password ?? prev.password,
|
||||||
|
}))
|
||||||
|
if (config.collection) {
|
||||||
|
setSelectedCollection(config.collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
void (async () => {
|
||||||
|
const status = await getServiceStatus()
|
||||||
|
if (active) {
|
||||||
|
setServiceOk(status.running)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成受控输入框的 onChange 处理器。
|
||||||
|
*/
|
||||||
|
const updateField = (key: keyof ConnectFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { value } = event.target
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败统一处理:展示错误条并触发表单抖动。
|
||||||
|
*/
|
||||||
|
const failWith = useCallback((message: string) => {
|
||||||
|
setError(message)
|
||||||
|
setShakeCount((count) => count + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用一条最近连接记录填充表单(不含密码)。
|
||||||
|
*/
|
||||||
|
const applyRecent = useCallback((item: RecentServer) => {
|
||||||
|
setError(null)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverUri: item.serverUri,
|
||||||
|
domain: item.domain,
|
||||||
|
username: item.username,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
||||||
|
*/
|
||||||
|
const handleConnect = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
setBusyText("正在连接 TFS…")
|
||||||
|
const payload: Partial<AppConfig> = {
|
||||||
|
serverUri: form.serverUri.trim(),
|
||||||
|
authType: "ntlm-explicit",
|
||||||
|
domain: form.domain.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
}
|
||||||
|
const connectResult = await api.connect(payload)
|
||||||
|
if (!connectResult.ok) {
|
||||||
|
setBusy(false)
|
||||||
|
failWith(connectResult.errorMessage ?? "连接失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusyText("正在加载 Collection…")
|
||||||
|
const collectionsResult = await api.listCollections()
|
||||||
|
setBusy(false)
|
||||||
|
if (!collectionsResult.ok) {
|
||||||
|
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRecent(
|
||||||
|
saveRecentServer({
|
||||||
|
serverUri: form.serverUri.trim(),
|
||||||
|
domain: form.domain.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const list = collectionsResult.data?.collections ?? []
|
||||||
|
setCollections(list)
|
||||||
|
setSelectedCollection((prev) =>
|
||||||
|
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
||||||
|
)
|
||||||
|
setPhase("collection")
|
||||||
|
}, [form, failWith])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
||||||
|
*/
|
||||||
|
const handleEnter = useCallback(async () => {
|
||||||
|
if (!selectedCollection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
setBusyText("正在确保默认 Workspace…")
|
||||||
|
const ensureResult = await api.ensureWorkspace({ collection: selectedCollection })
|
||||||
|
setBusy(false)
|
||||||
|
const workspace = ensureResult.data?.workspace
|
||||||
|
if (!ensureResult.ok || !workspace) {
|
||||||
|
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onReady({
|
||||||
|
serverUri: form.serverUri.trim(),
|
||||||
|
collection: selectedCollection,
|
||||||
|
workspace: workspace.name,
|
||||||
|
mappings: workspace.mappings ?? [],
|
||||||
|
})
|
||||||
|
}, [selectedCollection, form.serverUri, onReady, failWith])
|
||||||
|
|
||||||
|
const canConnect =
|
||||||
|
form.serverUri.trim().length > 0 &&
|
||||||
|
form.username.trim().length > 0 &&
|
||||||
|
form.password.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-svh">
|
||||||
|
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
|
||||||
|
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
|
||||||
|
|
||||||
|
{/* 左侧品牌区:大 logo + 一句话描述 */}
|
||||||
|
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col overflow-hidden bg-zinc-950 p-10 text-zinc-100 md:flex">
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="animate-float-slow absolute -top-24 -left-20 size-72 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
|
<div className="animate-float-slow absolute -right-24 bottom-0 size-80 rounded-full bg-indigo-500/15 blur-3xl [animation-delay:-4s]" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_left,transparent_55%,rgb(9_9_11/0.7))]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 flex-col items-center justify-center gap-5">
|
||||||
|
{/* logo 自带圆角方形外形,用 drop-shadow 跟随透明轮廓投影 */}
|
||||||
|
<img src="/logo.png" alt="MacTFS" className="size-24 drop-shadow-2xl" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">MacTFS</p>
|
||||||
|
<p className="mt-1.5 text-sm text-zinc-400">macOS 上的 TFS 源码管理工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
serviceOk == null ? "bg-zinc-600" : serviceOk ? "bg-emerald-400" : "bg-red-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{serviceOk == null
|
||||||
|
? "正在检测本地服务…"
|
||||||
|
: serviceOk
|
||||||
|
? "本地服务运行中 · 127.0.0.1:38765"
|
||||||
|
: "本地服务未就绪"}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右侧表单区 */}
|
||||||
|
<main className="flex flex-1 items-center justify-center bg-background p-6">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{phase === "form" && (
|
||||||
|
<div
|
||||||
|
key={`form-${shakeCount}`}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-left-2 duration-200",
|
||||||
|
error != null && "animate-shake",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">连接 TFS</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
使用域账号连接 Team Foundation Server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-6 space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (canConnect && !busy) {
|
||||||
|
void handleConnect()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="serverUri">服务器地址</Label>
|
||||||
|
<Input
|
||||||
|
id="serverUri"
|
||||||
|
placeholder="http://tfs.example.com:8080/tfs"
|
||||||
|
value={form.serverUri}
|
||||||
|
onChange={updateField("serverUri")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="domain">域(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
placeholder="DOMAIN"
|
||||||
|
value={form.domain}
|
||||||
|
onChange={updateField("domain")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={updateField("username")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="pr-9"
|
||||||
|
value={form.password}
|
||||||
|
onChange={updateField("password")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||||
|
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||||
|
>
|
||||||
|
{showPassword ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{busy ? busyText || "处理中…" : "连接 TFS"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Clock3 className="size-3.5" />
|
||||||
|
最近连接
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{recent.map((item) => (
|
||||||
|
<li key={`${item.serverUri}-${item.username}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyRecent(item)}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-md border bg-card px-3 py-2 text-left shadow-card transition-all duration-150 hover:-translate-y-px hover:border-ring/40 hover:shadow-overlay"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{item.serverUri}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{item.domain ? `${item.domain} \\ ` : ""}
|
||||||
|
{item.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-primary">填充</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "collection" && (
|
||||||
|
<div
|
||||||
|
key={`collection-${shakeCount}`}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-right-4 duration-200",
|
||||||
|
error != null && "animate-shake",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">选择 Collection</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
已连接 <span className="font-mono text-xs">{form.serverUri.trim()}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{collections.length === 0 ? (
|
||||||
|
<p className="rounded-md border bg-card px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
当前账号没有可见的 Collection。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
||||||
|
{collections.map((collection) => {
|
||||||
|
const active = collection.name === selectedCollection
|
||||||
|
return (
|
||||||
|
<li key={collection.id || collection.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCollection(collection.name)}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg border px-3.5 py-3 text-left text-sm transition-colors duration-150",
|
||||||
|
active
|
||||||
|
? "border-primary/60 bg-primary/5"
|
||||||
|
: "bg-card shadow-card hover:bg-muted/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(active && "font-medium")}>{collection.name}</span>
|
||||||
|
{active && (
|
||||||
|
<span className="animate-in zoom-in-75 fade-in flex size-4.5 items-center justify-center rounded-full bg-primary text-primary-foreground duration-150">
|
||||||
|
<Check className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
setPhase("form")
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleEnter()}
|
||||||
|
disabled={busy || !selectedCollection}
|
||||||
|
>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{busy ? busyText || "处理中…" : "进入工作台"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "~/components/ui/context-menu"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
import { buildFileMenu } from "~/lib/tfs"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象右键菜单容器:包住任意触发区域,按统一动作模型渲染菜单分组,
|
||||||
|
* 置灰项展示原因后缀,点击可用项回调上层动作分发器。
|
||||||
|
*/
|
||||||
|
export function FileTargetMenu({
|
||||||
|
target,
|
||||||
|
onAction,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
target: FileTarget
|
||||||
|
onAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const sections = buildFileMenu(target)
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-52">
|
||||||
|
{sections.map((items, index) => (
|
||||||
|
<Section key={index} withSeparator={index > 0}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={item.id}
|
||||||
|
disabled={!item.enabled}
|
||||||
|
variant={item.danger && item.enabled ? "destructive" : "default"}
|
||||||
|
onSelect={() => onAction(target, item.id)}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{!item.enabled && item.reason && (
|
||||||
|
<span className={cn("ml-auto pl-3 text-xs text-muted-foreground")}>
|
||||||
|
{item.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单分组:非首组前自动插入分隔线。
|
||||||
|
*/
|
||||||
|
function Section({
|
||||||
|
withSeparator,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
withSeparator: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{withSeparator && <ContextMenuSeparator />}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
Terminal,
|
|
||||||
TriangleAlert,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
|
||||||
|
|
||||||
interface OperationLogPanelProps {
|
|
||||||
logs: ApiRequestLogEntry[]
|
|
||||||
loading: boolean
|
|
||||||
serviceMessage: string
|
|
||||||
requestStatus: string
|
|
||||||
username: string
|
|
||||||
onRefresh(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染底部操作日志面板,展示 API 操作状态、耗时、路径摘要和错误信息。
|
|
||||||
*/
|
|
||||||
export function OperationLogPanel({
|
|
||||||
logs,
|
|
||||||
loading,
|
|
||||||
serviceMessage,
|
|
||||||
requestStatus,
|
|
||||||
username,
|
|
||||||
onRefresh,
|
|
||||||
}: OperationLogPanelProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
|
||||||
<div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<Terminal className="size-4" />
|
|
||||||
<span>Console</span>
|
|
||||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5">
|
|
||||||
{logs.length} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
title="刷新服务端操作日志"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={onRefresh}
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="grid gap-1 p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
<div>[service] {serviceMessage}</div>
|
|
||||||
<div>[request] {requestStatus}</div>
|
|
||||||
<div>[session] {username || "anonymous"}</div>
|
|
||||||
<div>[logs] 暂无 API 操作日志</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
|
||||||
<div className="min-w-[920px]">
|
|
||||||
<div className="sticky top-0 z-10 grid h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] border-b bg-muted px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<div className="flex items-center">操作</div>
|
|
||||||
<div className="flex items-center">状态</div>
|
|
||||||
<div className="flex items-center">开始</div>
|
|
||||||
<div className="flex items-center">结束</div>
|
|
||||||
<div className="flex items-center">耗时</div>
|
|
||||||
<div className="flex items-center">路径摘要</div>
|
|
||||||
<div className="flex items-center">错误信息</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{logs.map((log) => (
|
|
||||||
<div
|
|
||||||
key={log.id}
|
|
||||||
className="grid min-h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] items-center border-b px-3 font-mono text-xs"
|
|
||||||
>
|
|
||||||
<div className="truncate">{log.operation}</div>
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex max-w-full items-center gap-1 rounded-[6px] border px-1.5 py-0.5 ${statusClassName(log)}`}
|
|
||||||
>
|
|
||||||
{log.status === "success" ? (
|
|
||||||
<CheckCircle2 className="size-3" />
|
|
||||||
) : log.status === "running" ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="size-3" />
|
|
||||||
)}
|
|
||||||
{statusLabel(log)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{formatLogTime(log.startedAt)}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{log.status === "running"
|
|
||||||
? "执行中"
|
|
||||||
: formatLogTime(log.endedAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{formatDuration(log)}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{log.summary || "-"}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`truncate ${
|
|
||||||
log.errorMessage
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{log.status === "running"
|
|
||||||
? "请求执行中"
|
|
||||||
: log.errorMessage || "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按日志状态返回中文状态文案。
|
|
||||||
*/
|
|
||||||
function statusLabel(log: ApiRequestLogEntry) {
|
|
||||||
if (log.status === "running") {
|
|
||||||
return "执行中"
|
|
||||||
}
|
|
||||||
return log.success ? "成功" : "失败"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按日志状态返回 badge 样式。
|
|
||||||
*/
|
|
||||||
function statusClassName(log: ApiRequestLogEntry) {
|
|
||||||
if (log.status === "running") {
|
|
||||||
return "border-amber-200 bg-amber-50 text-amber-700"
|
|
||||||
}
|
|
||||||
return log.success
|
|
||||||
? "border-green-200 bg-green-50 text-green-700"
|
|
||||||
: "border-red-200 bg-red-50 text-red-700"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化操作日志时间戳。
|
|
||||||
*/
|
|
||||||
function formatLogTime(value?: number) {
|
|
||||||
return value ? new Date(value).toLocaleString() : "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化操作耗时,执行中的请求保持明确状态。
|
|
||||||
*/
|
|
||||||
function formatDuration(log: ApiRequestLogEntry) {
|
|
||||||
if (log.status === "running") {
|
|
||||||
return "执行中"
|
|
||||||
}
|
|
||||||
if (log.durationMs === undefined) {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return log.durationMs < 1000
|
|
||||||
? `${log.durationMs} ms`
|
|
||||||
: `${(log.durationMs / 1000).toFixed(1)} s`
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Separator } from "~/components/ui/separator"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||||
|
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
|
||||||
|
import { isMacElectron } from "~/lib/platform"
|
||||||
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 主题三态对应的图标与说明文案。
|
||||||
|
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
|
||||||
|
system: { icon: Monitor, label: "主题:跟随系统" },
|
||||||
|
light: { icon: Sun, label: "主题:亮色" },
|
||||||
|
dark: { icon: Moon, label: "主题:暗色" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
||||||
|
export interface PanelVisibility {
|
||||||
|
tree: boolean
|
||||||
|
changes: boolean
|
||||||
|
console: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
|
||||||
|
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
|
||||||
|
*/
|
||||||
|
export function TopBar({
|
||||||
|
session,
|
||||||
|
panels,
|
||||||
|
onTogglePanel,
|
||||||
|
onReconnect,
|
||||||
|
onManageWorkspace,
|
||||||
|
}: {
|
||||||
|
session: WorkspaceSession
|
||||||
|
panels: PanelVisibility
|
||||||
|
onTogglePanel: (panel: keyof PanelVisibility) => void
|
||||||
|
onReconnect: () => void
|
||||||
|
onManageWorkspace: () => void
|
||||||
|
}) {
|
||||||
|
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
|
||||||
|
const macInset = isMacElectron()
|
||||||
|
const theme = useTheme()
|
||||||
|
const themeVisual = THEME_VISUALS[theme.preference]
|
||||||
|
const ThemeIcon = themeVisual.icon
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
|
||||||
|
macInset && "pl-20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0 font-semibold tracking-tight">MacTFS</span>
|
||||||
|
<Separator orientation="vertical" className="h-4!" />
|
||||||
|
<span className="hidden max-w-56 truncate font-mono text-xs text-muted-foreground lg:inline">
|
||||||
|
{session.serverUri}
|
||||||
|
</span>
|
||||||
|
<span className="hidden max-w-44 shrink-0 items-center gap-1 truncate rounded-md border bg-card px-2 py-0.5 text-xs font-medium shadow-card sm:inline-flex">
|
||||||
|
<span className="text-muted-foreground">Collection</span>
|
||||||
|
<span className="truncate">{session.collection}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex max-w-52 shrink-0 items-center gap-1 truncate rounded-md border bg-card px-2 py-0.5 text-xs shadow-card">
|
||||||
|
<span className="text-muted-foreground">Workspace</span>
|
||||||
|
<span className="truncate font-mono">{session.workspace}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-no-drag flex shrink-0 items-center gap-1">
|
||||||
|
<PanelToggle
|
||||||
|
label="源码目录面板"
|
||||||
|
active={panels.tree}
|
||||||
|
onClick={() => onTogglePanel("tree")}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
</PanelToggle>
|
||||||
|
<PanelToggle
|
||||||
|
label="挂起更改面板"
|
||||||
|
active={panels.changes}
|
||||||
|
onClick={() => onTogglePanel("changes")}
|
||||||
|
>
|
||||||
|
<PanelRight />
|
||||||
|
</PanelToggle>
|
||||||
|
<PanelToggle
|
||||||
|
label="操作日志面板"
|
||||||
|
active={panels.console}
|
||||||
|
onClick={() => onTogglePanel("console")}
|
||||||
|
>
|
||||||
|
<PanelBottom />
|
||||||
|
</PanelToggle>
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
|
||||||
|
<ThemeIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
|
||||||
|
<FolderCog />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">工作区与映射管理</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onReconnect} aria-label="重新连接">
|
||||||
|
<Unplug />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">重新连接</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板折叠开关按钮:图标 + tooltip,激活态用底色区分。
|
||||||
|
*/
|
||||||
|
function PanelToggle({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(!active && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{active ? `收起${label}` : `展开${label}`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||||
|
import { AddFilesDialog } from "~/components/explorer/add-files-dialog"
|
||||||
|
import { BranchDialog } from "~/components/explorer/branch-dialog"
|
||||||
|
import { CompareDialog } from "~/components/explorer/compare-dialog"
|
||||||
|
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
|
||||||
|
import { DiffDialog } from "~/components/explorer/diff-dialog"
|
||||||
|
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
|
||||||
|
import { GetVersionDialog } from "~/components/explorer/get-version-dialog"
|
||||||
|
import { HistoryDialog } from "~/components/explorer/history-dialog"
|
||||||
|
import { MappingDialog } from "~/components/explorer/mapping-dialog"
|
||||||
|
import { MergeDialog } from "~/components/explorer/merge-dialog"
|
||||||
|
import { PropertiesDialog } from "~/components/explorer/properties-dialog"
|
||||||
|
import { RenameDialog } from "~/components/explorer/rename-dialog"
|
||||||
|
import type { WorkspaceDialogState } from "~/hooks/use-file-actions"
|
||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台业务弹窗出口:按编排 hook 的弹窗状态渲染对应弹窗,
|
||||||
|
* 自身不持有业务状态,所有回调回流到动作编排层。
|
||||||
|
*/
|
||||||
|
export function WorkspaceDialogs({
|
||||||
|
dialog,
|
||||||
|
mappings,
|
||||||
|
onClose,
|
||||||
|
onOpen,
|
||||||
|
onFileAction,
|
||||||
|
onMappingCreated,
|
||||||
|
onConflictsResolved,
|
||||||
|
onForceGetConfirmed,
|
||||||
|
onGetVersion,
|
||||||
|
onRenameConfirmed,
|
||||||
|
onRollback,
|
||||||
|
onBranchConfirmed,
|
||||||
|
onMergeConfirmed,
|
||||||
|
onAddFilesConfirmed,
|
||||||
|
}: {
|
||||||
|
dialog: WorkspaceDialogState
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
onClose: () => void
|
||||||
|
onOpen: (dialog: WorkspaceDialogState) => void
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
onMappingCreated: (mappings: MappingInfo[]) => void
|
||||||
|
onConflictsResolved: () => void
|
||||||
|
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
|
||||||
|
onGetVersion: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
||||||
|
onRenameConfirmed: (serverPath: string, newName: string) => Promise<boolean>
|
||||||
|
onRollback: (serverPath: string, mode: "single" | "toVersion", changeset: number) => Promise<boolean>
|
||||||
|
onBranchConfirmed: (
|
||||||
|
sourceServerPath: string,
|
||||||
|
targetServerPath: string,
|
||||||
|
changeset: number | undefined,
|
||||||
|
) => Promise<boolean>
|
||||||
|
onMergeConfirmed: (
|
||||||
|
sourceServerPath: string,
|
||||||
|
targetServerPath: string,
|
||||||
|
changeset: number | undefined,
|
||||||
|
) => Promise<boolean>
|
||||||
|
onAddFilesConfirmed: (localPaths: string[]) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
if (!dialog) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
switch (dialog.kind) {
|
||||||
|
case "mapping":
|
||||||
|
return (
|
||||||
|
<MappingDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
onClose={onClose}
|
||||||
|
onCreated={onMappingCreated}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "history":
|
||||||
|
return (
|
||||||
|
<HistoryDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
folder={dialog.folder}
|
||||||
|
onClose={onClose}
|
||||||
|
onDiffRevisions={(serverPath, sourceChangeset, targetChangeset) =>
|
||||||
|
onOpen({
|
||||||
|
kind: "diff",
|
||||||
|
request: { mode: "revisions", serverPath, sourceChangeset, targetChangeset },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onGetVersion={onGetVersion}
|
||||||
|
onRollback={onRollback}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "compare":
|
||||||
|
return (
|
||||||
|
<CompareDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
mappings={mappings}
|
||||||
|
onClose={onClose}
|
||||||
|
onFileAction={onFileAction}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "viewFile":
|
||||||
|
return (
|
||||||
|
<FileViewDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
localPath={dialog.localPath}
|
||||||
|
changeset={dialog.changeset}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "diff":
|
||||||
|
return <DiffDialog request={dialog.request} onClose={onClose} />
|
||||||
|
case "conflicts":
|
||||||
|
return (
|
||||||
|
<ConflictDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
onClose={onClose}
|
||||||
|
onResolved={onConflictsResolved}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "properties":
|
||||||
|
return <PropertiesDialog target={dialog.target} onClose={onClose} />
|
||||||
|
case "rename":
|
||||||
|
return (
|
||||||
|
<RenameDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
folder={dialog.folder}
|
||||||
|
onConfirm={(newName) => onRenameConfirmed(dialog.serverPath, newName)}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "branch":
|
||||||
|
return (
|
||||||
|
<BranchDialog
|
||||||
|
sourceServerPath={dialog.serverPath}
|
||||||
|
mappings={mappings}
|
||||||
|
onConfirm={(targetServerPath, changeset) =>
|
||||||
|
onBranchConfirmed(dialog.serverPath, targetServerPath, changeset)
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "merge":
|
||||||
|
return (
|
||||||
|
<MergeDialog
|
||||||
|
sourceServerPath={dialog.serverPath}
|
||||||
|
mappings={mappings}
|
||||||
|
onMerge={(targetServerPath, changeset) =>
|
||||||
|
onMergeConfirmed(dialog.serverPath, targetServerPath, changeset)
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "addFiles":
|
||||||
|
return (
|
||||||
|
<AddFilesDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
onConfirm={onAddFilesConfirmed}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "getVersion":
|
||||||
|
return (
|
||||||
|
<GetVersionDialog
|
||||||
|
serverPath={dialog.serverPath}
|
||||||
|
folder={dialog.folder}
|
||||||
|
onConfirm={(changeset) => onGetVersion(dialog.serverPath, changeset, dialog.folder)}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "confirmForceGet":
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="强制获取最新"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
将对 <span className="font-mono text-xs">{dialog.serverPath}</span>{" "}
|
||||||
|
强制获取服务器最新版本。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-medium text-destructive">
|
||||||
|
本地未签入的修改会被服务器版本直接覆盖,且无法恢复。确定继续吗?
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="覆盖本地并获取"
|
||||||
|
danger
|
||||||
|
onConfirm={() => onForceGetConfirmed(dialog.serverPath, dialog.folder)}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
|
||||||
|
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
|
||||||
|
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
||||||
|
import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
|
||||||
|
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
|
||||||
|
import { ChangesPanel } from "~/components/inspector/changes-panel"
|
||||||
|
import { ConsolePanel } from "~/components/logs/console-panel"
|
||||||
|
import { Toaster, showToast } from "~/components/ui/toaster"
|
||||||
|
import { TooltipProvider } from "~/components/ui/tooltip"
|
||||||
|
import { useFileActions } from "~/hooks/use-file-actions"
|
||||||
|
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
import type { WorkspaceSession } from "~/lib/tfs"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三栏工作台外壳:组合顶部上下文栏、左树、中间列表、右侧 Changes、底部 Console。
|
||||||
|
* 挂起更改状态与对象动作编排分别收口在 usePendingChanges / useFileActions。
|
||||||
|
*/
|
||||||
|
export function WorkspaceShell({
|
||||||
|
session,
|
||||||
|
selectedServerPath,
|
||||||
|
onNavigate,
|
||||||
|
onMappingsChanged,
|
||||||
|
onReconnect,
|
||||||
|
}: {
|
||||||
|
session: WorkspaceSession
|
||||||
|
selectedServerPath: string
|
||||||
|
onNavigate: (serverPath: string) => void
|
||||||
|
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||||
|
onReconnect: () => void
|
||||||
|
}) {
|
||||||
|
const [panels, setPanels] = useState<PanelVisibility>({
|
||||||
|
tree: true,
|
||||||
|
changes: true,
|
||||||
|
console: true,
|
||||||
|
})
|
||||||
|
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
|
||||||
|
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
|
||||||
|
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
|
||||||
|
// 工作区集中管理弹窗显隐。
|
||||||
|
const [manageOpen, setManageOpen] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换指定面板的展开 / 收起状态。
|
||||||
|
*/
|
||||||
|
const togglePanel = useCallback((panel: keyof PanelVisibility) => {
|
||||||
|
setPanels((prev) => ({ ...prev, [panel]: !prev[panel] }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发中间目录列表重新加载。
|
||||||
|
*/
|
||||||
|
const refreshItems = useCallback(() => {
|
||||||
|
setItemsRefreshToken((token) => token + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发底部操作日志重新加载。
|
||||||
|
*/
|
||||||
|
const refreshLogs = useCallback(() => {
|
||||||
|
setLogsRefreshToken((token) => token + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pending = usePendingChanges()
|
||||||
|
const actions = useFileActions({
|
||||||
|
onMappingsChanged,
|
||||||
|
refreshPendingChanges: pending.refresh,
|
||||||
|
refreshItems,
|
||||||
|
refreshLogs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 把动作编排的 notice 转成右上角 toast:固定 id 让「正在…」与结果复用同一条目。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actions.notice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { kind, text } = actions.notice
|
||||||
|
showToast({
|
||||||
|
id: "action",
|
||||||
|
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}, [actions.notice])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* 画布式布局:浅灰画布 + 各面板卡片化,卡片间留缝隙避免生硬拼接 */}
|
||||||
|
<div className="flex h-svh flex-col overflow-hidden bg-sidebar">
|
||||||
|
<TopBar
|
||||||
|
session={session}
|
||||||
|
panels={panels}
|
||||||
|
onTogglePanel={togglePanel}
|
||||||
|
onReconnect={onReconnect}
|
||||||
|
onManageWorkspace={() => setManageOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 px-2 pb-2">
|
||||||
|
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
|
panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SourceTreePanel
|
||||||
|
collection={session.collection}
|
||||||
|
mappings={session.mappings}
|
||||||
|
selectedServerPath={selectedServerPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
onFileAction={actions.handleFileAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FolderItemsPanel
|
||||||
|
session={session}
|
||||||
|
selectedServerPath={selectedServerPath}
|
||||||
|
pendingByServerPath={pending.pendingByServerPath}
|
||||||
|
refreshToken={itemsRefreshToken}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
onFileAction={actions.handleFileAction}
|
||||||
|
onRefresh={refreshItems}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 shrink-0 justify-end overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
|
panels.changes ? "ml-2 w-[340px]" : "ml-0 w-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChangesPanel
|
||||||
|
mappings={session.mappings}
|
||||||
|
pendingChanges={pending.pendingChanges}
|
||||||
|
excludedKeys={pending.excludedKeys}
|
||||||
|
loading={pending.loading}
|
||||||
|
error={pending.error}
|
||||||
|
checkinBusy={actions.checkinBusy}
|
||||||
|
onToggleExcluded={pending.toggleExcluded}
|
||||||
|
onCheckin={actions.handleCheckin}
|
||||||
|
onFileAction={actions.handleFileAction}
|
||||||
|
onRefresh={() => void pending.refresh()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作台通过高度过渡实现滑入滑出 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border shrink-0 overflow-hidden px-2 transition-[height] duration-250 ease-out-quart",
|
||||||
|
panels.console ? "h-[188px] pb-2" : "h-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ConsolePanel
|
||||||
|
refreshToken={logsRefreshToken}
|
||||||
|
busy={actions.actionBusy || actions.checkinBusy}
|
||||||
|
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceDialogs
|
||||||
|
dialog={actions.dialog}
|
||||||
|
mappings={session.mappings}
|
||||||
|
onClose={() => actions.setDialog(null)}
|
||||||
|
onOpen={actions.setDialog}
|
||||||
|
onFileAction={actions.handleFileAction}
|
||||||
|
onMappingCreated={actions.handleMappingCreated}
|
||||||
|
onConflictsResolved={actions.handleConflictsResolved}
|
||||||
|
onForceGetConfirmed={actions.handleForceGetConfirmed}
|
||||||
|
onGetVersion={actions.runGetVersion}
|
||||||
|
onRenameConfirmed={actions.handleRenameConfirmed}
|
||||||
|
onRollback={actions.runRollback}
|
||||||
|
onBranchConfirmed={actions.handleBranchConfirmed}
|
||||||
|
onMergeConfirmed={actions.handleMergeConfirmed}
|
||||||
|
onAddFilesConfirmed={actions.handleAddFilesConfirmed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{manageOpen && (
|
||||||
|
<WorkspaceManageDialog
|
||||||
|
session={session}
|
||||||
|
onClose={() => setManageOpen(false)}
|
||||||
|
onMappingsChanged={(mappings) => {
|
||||||
|
// Mapping 变化影响树映射标识与列表状态列,统一刷新。
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
refreshItems()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { FileText, FolderClosed, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { FolderDiffItem } from "~/lib/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加本地文件弹窗:对选中目录执行目录对比(递归),
|
||||||
|
* 自动筛出本地新增(localOnly)的文件 / 目录,勾选后批量加入版本控制(pendingAdd)。
|
||||||
|
*/
|
||||||
|
export function AddFilesDialog({
|
||||||
|
serverPath,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
/** 确认添加所选本地路径,由编排层调用接口并刷新,返回是否成功。 */
|
||||||
|
onConfirm: (localPaths: string[]) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<FolderDiffItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 勾选集合:以本地路径为键,默认全选。
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.compareFolder({ serverPath, recursive: true })
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "目录扫描失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
const localOnly = (result.data?.diffs ?? []).filter(
|
||||||
|
(item) => item.status === "localOnly" && item.localPath,
|
||||||
|
)
|
||||||
|
setItems(localOnly)
|
||||||
|
setSelected(new Set(localOnly.map((item) => item.localPath)))
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [serverPath])
|
||||||
|
|
||||||
|
const allChecked = items.length > 0 && selected.size === items.length
|
||||||
|
const selectedPaths = useMemo(
|
||||||
|
() => items.filter((item) => selected.has(item.localPath)).map((item) => item.localPath),
|
||||||
|
[items, selected],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 勾选 / 取消勾选单项。
|
||||||
|
*/
|
||||||
|
const toggle = (localPath: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(localPath)) {
|
||||||
|
next.delete(localPath)
|
||||||
|
} else {
|
||||||
|
next.add(localPath)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加本地文件</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在扫描本地新增文件…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-destructive">{error}</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
|
没有发现本地新增的文件,本地内容与服务器一致
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 w-8">
|
||||||
|
<Checkbox
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setSelected(
|
||||||
|
value === true
|
||||||
|
? new Set(items.map((item) => item.localPath))
|
||||||
|
: new Set(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="全选 / 清空"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.localPath}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => toggle(item.localPath)}
|
||||||
|
>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(item.localPath)}
|
||||||
|
onCheckedChange={() => toggle(item.localPath)}
|
||||||
|
aria-label={`选择 ${item.name}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{item.folder ? (
|
||||||
|
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm">{item.name}</span>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
|
||||||
|
title={item.localPath}
|
||||||
|
>
|
||||||
|
{item.localPath}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
添加后产生「挂起新增」,可在挂起更改面板审查、撤销,签入后进入服务器;目录会连同其内容一并添加。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selectedPaths.length === 0 || busy}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onConfirm(selectedPaths)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "正在添加…" : `添加所选(${selectedPaths.length} 项)`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
import { getParentPath, resolveLocalPath } from "~/lib/tfs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支弹窗:把源路径分叉到目标路径(pendBranch 产生挂起更改,签入后服务器生效)。
|
||||||
|
* 目标路径父目录必须已映射(分支内容需要落盘到本地工作区);可选基于指定 changeset。
|
||||||
|
*/
|
||||||
|
export function BranchDialog({
|
||||||
|
sourceServerPath,
|
||||||
|
mappings,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
sourceServerPath: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
/** 确认分支,由编排层调用接口并刷新,返回是否成功。 */
|
||||||
|
onConfirm: (targetServerPath: string, changeset: number | undefined) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState(`${sourceServerPath}-branch`)
|
||||||
|
const [changesetInput, setChangesetInput] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const trimmed = target.trim()
|
||||||
|
const parentPath = trimmed.length > 2 ? getParentPath(trimmed) : null
|
||||||
|
const parentMapped = parentPath != null && resolveLocalPath(mappings, parentPath) != null
|
||||||
|
const targetLocalPath = trimmed.length > 2 ? resolveLocalPath(mappings, trimmed) : null
|
||||||
|
const changesetTrimmed = changesetInput.trim()
|
||||||
|
const changesetValid = changesetTrimmed.length === 0 || /^\d+$/.test(changesetTrimmed)
|
||||||
|
|
||||||
|
const validation =
|
||||||
|
!trimmed.startsWith("$/") || trimmed.length <= 2
|
||||||
|
? "目标路径必须以 $/ 开头"
|
||||||
|
: trimmed === sourceServerPath
|
||||||
|
? "目标路径不能与源路径相同"
|
||||||
|
: trimmed.startsWith(`${sourceServerPath}/`)
|
||||||
|
? "目标路径不能位于源路径内部"
|
||||||
|
: !parentMapped
|
||||||
|
? "目标路径所在父目录未映射,请先在工作区中映射"
|
||||||
|
: !changesetValid
|
||||||
|
? "changeset 必须是数字"
|
||||||
|
: null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行分支:成功后由上层刷新并关闭弹窗。
|
||||||
|
*/
|
||||||
|
const handleConfirm = () => {
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onConfirm(
|
||||||
|
trimmed,
|
||||||
|
changesetTrimmed.length === 0 ? undefined : Number(changesetTrimmed),
|
||||||
|
)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>分支</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
从源路径创建分支,结果为 branch 挂起更改,签入后服务器生效
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>源路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{sourceServerPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="branchTarget">目标路径</Label>
|
||||||
|
<Input
|
||||||
|
id="branchTarget"
|
||||||
|
value={target}
|
||||||
|
onChange={(event) => setTarget(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{targetLocalPath != null && validation == null && (
|
||||||
|
<p className="break-all font-mono text-xs text-muted-foreground">
|
||||||
|
本地落盘:{targetLocalPath}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="branchChangeset">基于 changeset(可选,默认 latest)</Label>
|
||||||
|
<Input
|
||||||
|
id="branchChangeset"
|
||||||
|
value={changesetInput}
|
||||||
|
onChange={(event) => setChangesetInput(event.target.value)}
|
||||||
|
placeholder="留空表示最新版本"
|
||||||
|
disabled={busy}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="w-56 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validation != null && <p className="text-xs text-destructive">{validation}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
分支只产生挂起更改,可在挂起更改面板审查、撤销,签入后目标路径才会出现在服务器上。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={validation != null || busy} onClick={handleConfirm}>
|
||||||
|
{busy ? "正在创建分支…" : "创建分支"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { FolderDiffItem, MappingInfo } from "~/lib/api"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
||||||
|
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
||||||
|
*/
|
||||||
|
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
|
||||||
|
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
|
||||||
|
|
||||||
|
export function CompareDialog({
|
||||||
|
serverPath,
|
||||||
|
mappings,
|
||||||
|
onClose,
|
||||||
|
onFileAction,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
onClose: () => void
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
|
||||||
|
const [phase, setPhase] = useState<"setup" | "result">("setup")
|
||||||
|
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showUpToDate, setShowUpToDate] = useState(false)
|
||||||
|
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
|
||||||
|
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
|
||||||
|
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
||||||
|
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
|
||||||
|
*/
|
||||||
|
const runCompare = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const result = await api.compareFolder({
|
||||||
|
serverPath,
|
||||||
|
recursive: true,
|
||||||
|
includeLocalOnly: !ignoreLocalOnly,
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "目录对比失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDiffs(result.data?.diffs ?? [])
|
||||||
|
}, [serverPath, ignoreLocalOnly])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
|
||||||
|
* 让结果状态(如 签出编辑)立即反映最新事实。
|
||||||
|
*/
|
||||||
|
const handleAction = useCallback(
|
||||||
|
async (target: FileTarget, action: FileActionId) => {
|
||||||
|
await onFileAction(target, action)
|
||||||
|
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
|
||||||
|
await runCompare()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFileAction, runCompare],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
||||||
|
const presentStatuses = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const diff of diffs) {
|
||||||
|
if (diff.status !== "upToDate") {
|
||||||
|
set.add(diff.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...set]
|
||||||
|
}, [diffs])
|
||||||
|
|
||||||
|
const visibleDiffs = useMemo(
|
||||||
|
() =>
|
||||||
|
diffs.filter((diff) => {
|
||||||
|
if (diff.status === "upToDate") {
|
||||||
|
return showUpToDate && statusFilter.size === 0
|
||||||
|
}
|
||||||
|
return statusFilter.size === 0 || statusFilter.has(diff.status)
|
||||||
|
}),
|
||||||
|
[diffs, showUpToDate, statusFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换某个状态筛选 chip。
|
||||||
|
*/
|
||||||
|
const toggleStatus = useCallback((status: string) => {
|
||||||
|
setStatusFilter((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(status)) {
|
||||||
|
next.delete(status)
|
||||||
|
} else {
|
||||||
|
next.add(status)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
|
||||||
|
if (phase === "setup") {
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>目录对比</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={ignoreLocalOnly}
|
||||||
|
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">忽略仅本地存在的项</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
|
||||||
|
并跳过本地全量扫描,大目录速度更快。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setPhase("result")
|
||||||
|
void runCompare()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始对比
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{presentStatuses.map((status) => (
|
||||||
|
<button key={status} type="button" onClick={() => toggleStatus(status)}>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-md",
|
||||||
|
statusBadgeClass(status),
|
||||||
|
statusFilter.size > 0 && !statusFilter.has(status) && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Checkbox
|
||||||
|
checked={showUpToDate}
|
||||||
|
onCheckedChange={(value) => setShowUpToDate(value === true)}
|
||||||
|
/>
|
||||||
|
显示最新项
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPhase("setup")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
调整选项
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void runCompare()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw data-icon="inline-start" className={cn(loading && "animate-spin")} />
|
||||||
|
重新对比
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在对比目录…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : visibleDiffs.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{diffs.length === 0 ? "本地与服务端一致,没有差异" : "没有符合筛选条件的差异项"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||||
|
<TableHead className="h-8 w-28 text-xs text-muted-foreground">状态</TableHead>
|
||||||
|
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">本地版本</TableHead>
|
||||||
|
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">最新版本</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleDiffs.map((diff) => {
|
||||||
|
const target = makeFileTarget({
|
||||||
|
source: "compare",
|
||||||
|
folder: diff.folder,
|
||||||
|
serverPath: diff.serverPath,
|
||||||
|
mappings,
|
||||||
|
pendingStatus: diff.status.startsWith("pending") ? diff.status : null,
|
||||||
|
compareStatus: diff.status,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<FileTargetMenu
|
||||||
|
key={diff.serverPath}
|
||||||
|
target={target}
|
||||||
|
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
|
||||||
|
>
|
||||||
|
<TableRow className="cursor-default select-none">
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{diff.folder ? (
|
||||||
|
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm">{diff.name}</span>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("rounded-md", statusBadgeClass(diff.status))}
|
||||||
|
>
|
||||||
|
{statusLabel(diff.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||||
|
{diff.localVersion > 0 ? diff.localVersion : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||||
|
{diff.latestVersion > 0 ? diff.latestVersion : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
|
||||||
|
title={diff.serverPath}
|
||||||
|
>
|
||||||
|
{diff.serverPath}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</FileTargetMenu>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{visibleDiffs.length} / {diffs.length} 项 · 差异项操作通过右键菜单触发
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { FileDiff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { DiffDialog, type DiffRequest } from "~/components/explorer/diff-dialog"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { ConflictInfo, ConflictResolution } from "~/lib/api"
|
||||||
|
|
||||||
|
// 每个冲突的取舍选择:未选择 / 采用服务器版本 / 保留本地版本。
|
||||||
|
type ChoiceValue = "none" | ConflictResolution
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一冲突弹窗:Get Latest 与 Checkout 共用。
|
||||||
|
* 支持逐文件选择服务器版本或保留本地版本、批量设置、冲突文件 Diff、批量应用。
|
||||||
|
*/
|
||||||
|
export function ConflictDialog({
|
||||||
|
serverPath,
|
||||||
|
onClose,
|
||||||
|
onResolved,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
onClose: () => void
|
||||||
|
onResolved: () => void
|
||||||
|
}) {
|
||||||
|
const [conflicts, setConflicts] = useState<ConflictInfo[]>([])
|
||||||
|
const [choices, setChoices] = useState<Record<number, ChoiceValue>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 应用结果摘要:conflictId -> 是否成功。
|
||||||
|
const [applied, setApplied] = useState<Record<number, boolean>>({})
|
||||||
|
const [diffRequest, setDiffRequest] = useState<DiffRequest | null>(null)
|
||||||
|
// 是否发生过成功应用,关闭时据此通知上层刷新。
|
||||||
|
const [didResolve, setDidResolve] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载当前范围内的未解决冲突。
|
||||||
|
*/
|
||||||
|
const loadConflicts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.listConflicts({ serverPath, recursive: true })
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "冲突明细加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
const list = (result.data?.conflicts ?? []).filter((item) => !item.resolved)
|
||||||
|
setConflicts(list)
|
||||||
|
setChoices({})
|
||||||
|
setApplied({})
|
||||||
|
}, [serverPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConflicts()
|
||||||
|
}, [loadConflicts])
|
||||||
|
|
||||||
|
const chosenCount = useMemo(
|
||||||
|
() => Object.values(choices).filter((value) => value !== "none").length,
|
||||||
|
[choices],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量把所有冲突设置为同一取舍。
|
||||||
|
*/
|
||||||
|
const setAll = useCallback(
|
||||||
|
(value: ConflictResolution) => {
|
||||||
|
setChoices(() => {
|
||||||
|
const next: Record<number, ChoiceValue> = {}
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
next[conflict.conflictId] = value
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[conflicts],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用所有已选择的取舍,逐个调用后端并记录结果。
|
||||||
|
*/
|
||||||
|
const applyChoices = useCallback(async () => {
|
||||||
|
setApplying(true)
|
||||||
|
setError(null)
|
||||||
|
const results: Record<number, boolean> = {}
|
||||||
|
let resolvedAny = false
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
const choice = choices[conflict.conflictId]
|
||||||
|
if (!choice || choice === "none") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = await api.applyConflict({
|
||||||
|
conflictId: conflict.conflictId,
|
||||||
|
resolution: choice,
|
||||||
|
})
|
||||||
|
const success = result.ok && result.data?.resolution?.resolved === true
|
||||||
|
results[conflict.conflictId] = success
|
||||||
|
if (success) {
|
||||||
|
resolvedAny = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setApplying(false)
|
||||||
|
setApplied(results)
|
||||||
|
if (resolvedAny) {
|
||||||
|
setDidResolve(true)
|
||||||
|
}
|
||||||
|
const failures = Object.values(results).filter((value) => !value).length
|
||||||
|
if (failures > 0) {
|
||||||
|
setError(`${failures} 个冲突应用失败,可重试`)
|
||||||
|
await loadConflicts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 全部成功:重新加载剩余冲突,没有剩余则关闭并通知刷新。
|
||||||
|
const remain = await api.listConflicts({ serverPath, recursive: true })
|
||||||
|
const remaining = (remain.data?.conflicts ?? []).filter((item) => !item.resolved)
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
onResolved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConflicts(remaining)
|
||||||
|
setChoices({})
|
||||||
|
setApplied({})
|
||||||
|
}, [conflicts, choices, serverPath, loadConflicts, onResolved])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭弹窗:若期间有成功应用,通知上层刷新状态。
|
||||||
|
*/
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (didResolve) {
|
||||||
|
onResolved()
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [didResolve, onResolved, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>处理冲突</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||||
|
{conflicts.length} 个冲突
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
逐项选择,或批量设置后统一应用
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={applying || conflicts.length === 0}
|
||||||
|
onClick={() => setAll("takeServer")}
|
||||||
|
>
|
||||||
|
全部采用服务器
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={applying || conflicts.length === 0}
|
||||||
|
onClick={() => setAll("keepLocal")}
|
||||||
|
>
|
||||||
|
全部保留本地
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载冲突…
|
||||||
|
</div>
|
||||||
|
) : conflicts.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
当前范围内没有未解决的冲突
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">文件</TableHead>
|
||||||
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
|
||||||
|
<TableHead className="h-8 w-44 text-xs text-muted-foreground">取舍</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{conflicts.map((conflict) => {
|
||||||
|
const choice = choices[conflict.conflictId] ?? "none"
|
||||||
|
const appliedState = applied[conflict.conflictId]
|
||||||
|
const canDiff =
|
||||||
|
conflict.serverPath != null && conflict.localPath != null
|
||||||
|
return (
|
||||||
|
<TableRow key={conflict.conflictId} className="select-none">
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={conflict.serverPath ?? conflict.localPath ?? ""}
|
||||||
|
>
|
||||||
|
{conflict.serverPath ?? conflict.localPath ?? `冲突 ${conflict.conflictId}`}
|
||||||
|
{appliedState === false && (
|
||||||
|
<span className="ml-2 text-destructive">应用失败</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||||
|
{conflict.type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Select
|
||||||
|
value={choice}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setChoices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[conflict.conflictId]: value as ChoiceValue,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-40 text-xs">
|
||||||
|
<SelectValue placeholder="选择取舍" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">暂不处理</SelectItem>
|
||||||
|
<SelectItem value="takeServer">采用服务器版本</SelectItem>
|
||||||
|
<SelectItem value="keepLocal">保留本地版本</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={!canDiff}
|
||||||
|
title={canDiff ? "查看差异" : "缺少路径信息,无法对比"}
|
||||||
|
onClick={() =>
|
||||||
|
setDiffRequest({
|
||||||
|
mode: "localLatest",
|
||||||
|
serverPath: conflict.serverPath as string,
|
||||||
|
localPath: conflict.localPath as string,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileDiff />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0">
|
||||||
|
<Button variant="ghost" onClick={handleClose} disabled={applying}>
|
||||||
|
稍后处理
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void applyChoices()}
|
||||||
|
disabled={applying || chosenCount === 0}
|
||||||
|
>
|
||||||
|
{applying ? "正在应用…" : `应用选择(${chosenCount})`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{diffRequest && (
|
||||||
|
<DiffDialog request={diffRequest} onClose={() => setDiffRequest(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import type { editor as MonacoEditor } from "monaco-editor"
|
||||||
|
import { ChevronDown, ChevronUp, Loader2, Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { TextDiff } from "~/lib/api"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// Diff 请求:本地 vs latest,或同一服务端文件两个历史版本。
|
||||||
|
export type DiffRequest =
|
||||||
|
| { mode: "localLatest"; serverPath: string; localPath: string }
|
||||||
|
| { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number }
|
||||||
|
|
||||||
|
// 视图模式在同一会话内记忆。
|
||||||
|
const VIEW_MODE_KEY = "mactfs-diff-view-mode"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本:
|
||||||
|
* 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。
|
||||||
|
*/
|
||||||
|
function splitDiffSides(raw: string[]): { original: string; modified: string } {
|
||||||
|
const original: string[] = []
|
||||||
|
const modified: string[] = []
|
||||||
|
for (const line of raw) {
|
||||||
|
const prefix = line.charAt(0)
|
||||||
|
const text = line.slice(1)
|
||||||
|
if (prefix === "-") {
|
||||||
|
original.push(text)
|
||||||
|
} else if (prefix === "+") {
|
||||||
|
modified.push(text)
|
||||||
|
} else {
|
||||||
|
original.push(text)
|
||||||
|
modified.push(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { original: original.join("\n"), modified: modified.join("\n") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
|
||||||
|
*/
|
||||||
|
function isDarkTheme(): boolean {
|
||||||
|
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
||||||
|
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
||||||
|
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
|
||||||
|
*/
|
||||||
|
export function DiffDialog({
|
||||||
|
request,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
request: DiffRequest
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [diff, setDiff] = useState<TextDiff | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [onlyChanges, setOnlyChanges] = useState(false)
|
||||||
|
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
|
||||||
|
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
|
||||||
|
return "unified"
|
||||||
|
}
|
||||||
|
return "split"
|
||||||
|
})
|
||||||
|
// 差异块总数与当前导航到的差异块序号。
|
||||||
|
const [blockCount, setBlockCount] = useState(0)
|
||||||
|
const [blockIndex, setBlockIndex] = useState(-1)
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
void (async () => {
|
||||||
|
const result =
|
||||||
|
request.mode === "localLatest"
|
||||||
|
? await api.diffLocalLatest({
|
||||||
|
serverPath: request.serverPath,
|
||||||
|
localPath: request.localPath,
|
||||||
|
})
|
||||||
|
: await api.diffRevisions({
|
||||||
|
serverPath: request.serverPath,
|
||||||
|
sourceChangeset: request.sourceChangeset,
|
||||||
|
targetChangeset: request.targetChangeset,
|
||||||
|
})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setError(result.errorMessage ?? "Diff 加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDiff(result.data.diff)
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [request])
|
||||||
|
|
||||||
|
const changedCount = useMemo(
|
||||||
|
() => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length,
|
||||||
|
[diff],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!diff) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let disposed = false
|
||||||
|
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
||||||
|
let originalModel: MonacoEditor.ITextModel | null = null
|
||||||
|
let modifiedModel: MonacoEditor.ITextModel | null = null
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
void (async () => {
|
||||||
|
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
||||||
|
if (disposed || !containerRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { original, modified } = splitDiffSides(diff.lines)
|
||||||
|
const language = detectLanguage(request.serverPath)
|
||||||
|
originalModel = monaco.editor.createModel(original, language)
|
||||||
|
modifiedModel = monaco.editor.createModel(modified, language)
|
||||||
|
diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
|
||||||
|
readOnly: true,
|
||||||
|
originalEditable: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
renderSideBySide: viewMode === "split",
|
||||||
|
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
|
||||||
|
compactMode: viewMode === "unified",
|
||||||
|
hideUnchangedRegions: { enabled: onlyChanges },
|
||||||
|
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderOverviewRuler: true,
|
||||||
|
overviewRulerBorder: false,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
diffWordWrap: "off",
|
||||||
|
diffAlgorithm: "advanced",
|
||||||
|
renderLineHighlight: "none",
|
||||||
|
folding: false,
|
||||||
|
glyphMargin: false,
|
||||||
|
guides: { indentation: false },
|
||||||
|
padding: { top: 8, bottom: 8 },
|
||||||
|
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
|
||||||
|
renderMarginRevertIcon: false,
|
||||||
|
})
|
||||||
|
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
|
||||||
|
diffEditor.onDidUpdateDiff(() => {
|
||||||
|
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
||||||
|
})
|
||||||
|
editorRef.current = diffEditor
|
||||||
|
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
editorRef.current = null
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
diffEditor?.dispose()
|
||||||
|
originalModel?.dispose()
|
||||||
|
modifiedModel?.dispose()
|
||||||
|
setBlockCount(0)
|
||||||
|
setBlockIndex(-1)
|
||||||
|
}
|
||||||
|
}, [diff, request.serverPath])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。
|
||||||
|
*/
|
||||||
|
const switchView = useCallback((mode: "unified" | "split") => {
|
||||||
|
setViewMode(mode)
|
||||||
|
editorRef.current?.updateOptions({
|
||||||
|
renderSideBySide: mode === "split",
|
||||||
|
compactMode: mode === "unified",
|
||||||
|
})
|
||||||
|
if (typeof sessionStorage !== "undefined") {
|
||||||
|
sessionStorage.setItem(VIEW_MODE_KEY, mode)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换「仅看差异」:折叠 / 展开未变动区域。
|
||||||
|
*/
|
||||||
|
const switchOnlyChanges = useCallback((value: boolean) => {
|
||||||
|
setOnlyChanges(value)
|
||||||
|
editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。
|
||||||
|
*/
|
||||||
|
const goToBlock = useCallback(
|
||||||
|
(direction: 1 | -1) => {
|
||||||
|
const diffEditor = editorRef.current
|
||||||
|
const changes = diffEditor?.getLineChanges() ?? []
|
||||||
|
if (!diffEditor || changes.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = (blockIndex + direction + changes.length) % changes.length
|
||||||
|
setBlockIndex(next)
|
||||||
|
const change = changes[next]
|
||||||
|
const line =
|
||||||
|
change.modifiedStartLineNumber > 0
|
||||||
|
? change.modifiedStartLineNumber
|
||||||
|
: Math.max(change.modifiedEndLineNumber, 1)
|
||||||
|
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||||
|
modifiedEditor.revealLineInCenter(line)
|
||||||
|
modifiedEditor.setPosition({ lineNumber: line, column: 1 })
|
||||||
|
modifiedEditor.focus()
|
||||||
|
},
|
||||||
|
[blockIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。
|
||||||
|
*/
|
||||||
|
const openSearch = useCallback(() => {
|
||||||
|
const modifiedEditor = editorRef.current?.getModifiedEditor()
|
||||||
|
modifiedEditor?.focus()
|
||||||
|
void modifiedEditor?.getAction("actions.find")?.run()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{request.mode === "localLatest" ? "服务器 latest vs 本地" : "历史版本对比"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">
|
||||||
|
{request.serverPath}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
|
||||||
|
{diff && (
|
||||||
|
<>
|
||||||
|
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||||
|
− {diff.sourceLabel}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
|
||||||
|
+ {diff.targetLabel}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||||
|
<ViewTab label="统一视图" active={viewMode === "unified"} onClick={() => switchView("unified")} />
|
||||||
|
<ViewTab label="左右分栏" active={viewMode === "split"} onClick={() => switchView("split")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={blockCount === 0}
|
||||||
|
onClick={() => goToBlock(-1)}
|
||||||
|
aria-label="上一处差异"
|
||||||
|
>
|
||||||
|
<ChevronUp />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={blockCount === 0}
|
||||||
|
onClick={() => goToBlock(1)}
|
||||||
|
aria-label="下一处差异"
|
||||||
|
>
|
||||||
|
<ChevronDown />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Checkbox
|
||||||
|
checked={onlyChanges}
|
||||||
|
onCheckedChange={(value) => switchOnlyChanges(value === true)}
|
||||||
|
/>
|
||||||
|
仅看差异
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={openSearch}>
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在生成 Diff…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : changedCount === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
两个版本内容一致
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视图切换小标签按钮。
|
||||||
|
*/
|
||||||
|
function ViewTab({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[5px] px-2 py-0.5 text-xs",
|
||||||
|
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import { GitCompareArrows, Loader2, RefreshCw, X } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
|
|
||||||
import type { TfsTextDiff } from "~/lib/api/types"
|
|
||||||
|
|
||||||
export type DiffPanelRequest =
|
|
||||||
| {
|
|
||||||
type: "localLatest"
|
|
||||||
serverPath: string
|
|
||||||
localPath: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "revisions"
|
|
||||||
serverPath: string
|
|
||||||
sourceChangeset: number
|
|
||||||
targetChangeset: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiffPanelProps {
|
|
||||||
request: DiffPanelRequest
|
|
||||||
onClose(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiffRow {
|
|
||||||
source?: string
|
|
||||||
target?: string
|
|
||||||
kind: "same" | "removed" | "added"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 展示文本 Diff 面板,支持本地 vs latest 和两个历史版本对比。
|
|
||||||
*/
|
|
||||||
export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|
||||||
const [diff, setDiff] = useState<TfsTextDiff>()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDiff()
|
|
||||||
}, [request])
|
|
||||||
|
|
||||||
const rows = diff ? toDiffRows(diff.lines) : []
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据当前请求类型加载文本 diff。
|
|
||||||
*/
|
|
||||||
async function loadDiff() {
|
|
||||||
setLoading(true)
|
|
||||||
setMessage("")
|
|
||||||
|
|
||||||
const result =
|
|
||||||
request.type === "localLatest"
|
|
||||||
? await diffLocalLatest({
|
|
||||||
serverPath: request.serverPath,
|
|
||||||
localPath: request.localPath,
|
|
||||||
})
|
|
||||||
: await diffRevisions({
|
|
||||||
serverPath: request.serverPath,
|
|
||||||
sourceChangeset: request.sourceChangeset,
|
|
||||||
targetChangeset: request.targetChangeset,
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (!result.success || !result.data.diff) {
|
|
||||||
setDiff(undefined)
|
|
||||||
setMessage(
|
|
||||||
result.errorMessage ||
|
|
||||||
result.message ||
|
|
||||||
"大文件、二进制或不支持类型无法展示文本 diff。"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setDiff(result.data.diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将后端统一 diff lines 转为左右两栏展示行。
|
|
||||||
*/
|
|
||||||
function toDiffRows(lines: string[]): DiffRow[] {
|
|
||||||
return lines.map((line) => {
|
|
||||||
const prefix = line.charAt(0)
|
|
||||||
const content = line.slice(1)
|
|
||||||
if (prefix === "-") {
|
|
||||||
return { source: content, kind: "removed" }
|
|
||||||
}
|
|
||||||
if (prefix === "+") {
|
|
||||||
return { target: content, kind: "added" }
|
|
||||||
}
|
|
||||||
return { source: content, target: content, kind: "same" }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b bg-background">
|
|
||||||
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
|
||||||
<GitCompareArrows className="size-3.5" />
|
|
||||||
Diff
|
|
||||||
</div>
|
|
||||||
<div className="truncate font-mono text-muted-foreground">
|
|
||||||
{request.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={loading}
|
|
||||||
title="刷新 Diff"
|
|
||||||
onClick={loadDiff}
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
||||||
</Button>
|
|
||||||
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{diff && (
|
|
||||||
<div className="border-t">
|
|
||||||
<div className="grid h-8 grid-cols-2 border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<div className="flex min-w-0 items-center truncate font-mono">
|
|
||||||
{diff.sourceLabel}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center truncate border-l pl-3 font-mono">
|
|
||||||
{diff.targetLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-80 overflow-auto font-mono text-xs">
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-muted-foreground">
|
|
||||||
无可展示 diff 内容。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
rows.map((row, index) => (
|
|
||||||
<div
|
|
||||||
key={`${index}:${row.kind}`}
|
|
||||||
className="grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b"
|
|
||||||
>
|
|
||||||
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`whitespace-pre-wrap px-2 py-1 ${
|
|
||||||
row.kind === "removed"
|
|
||||||
? "bg-red-50 text-red-700"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.source ?? ""}
|
|
||||||
</div>
|
|
||||||
<div className="border-l bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`whitespace-pre-wrap px-2 py-1 ${
|
|
||||||
row.kind === "added"
|
|
||||||
? "bg-green-50 text-green-700"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.target ?? ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Database,
|
||||||
|
File,
|
||||||
|
FileArchive,
|
||||||
|
FileCode,
|
||||||
|
FileImage,
|
||||||
|
FileJson,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileTerminal,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
|
FolderSymlink,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
|
||||||
|
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
|
||||||
|
{
|
||||||
|
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
|
||||||
|
icon: FileCode,
|
||||||
|
className: "text-blue-600 dark:text-blue-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
|
||||||
|
icon: FileJson,
|
||||||
|
className: "text-amber-600 dark:text-amber-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
|
||||||
|
icon: FileText,
|
||||||
|
className: "text-zinc-500 dark:text-zinc-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sql", "db"],
|
||||||
|
icon: Database,
|
||||||
|
className: "text-violet-600 dark:text-violet-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
|
||||||
|
icon: FileImage,
|
||||||
|
className: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
|
||||||
|
icon: FileArchive,
|
||||||
|
className: "text-orange-600 dark:text-orange-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["xls", "xlsx", "csv"],
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
className: "text-green-600 dark:text-green-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
|
||||||
|
icon: FileTerminal,
|
||||||
|
className: "text-zinc-600 dark:text-zinc-300",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取文件名扩展名(小写,无点);无扩展名返回空串。
|
||||||
|
*/
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const index = name.lastIndexOf(".")
|
||||||
|
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
|
||||||
|
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
|
||||||
|
*/
|
||||||
|
export function FileIcon({
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
mapped = false,
|
||||||
|
expanded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
folder: boolean
|
||||||
|
mapped?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (folder) {
|
||||||
|
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
mapped ? "text-primary" : "text-sky-600 dark:text-sky-400",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
|
||||||
|
const Icon = rule?.icon ?? File
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
rule?.className ?? "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { FileContent } from "~/lib/api"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 查看来源:本地映射文件或服务器 latest。
|
||||||
|
type ViewSource = "local" | "server"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把字节数格式化为可读大小。
|
||||||
|
*/
|
||||||
|
function formatSize(size: number): string {
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件查看弹窗:只读展示本地、服务器 latest 或指定 changeset 版本内容,
|
||||||
|
* 支持来源切换、行号与搜索高亮;二进制 / 超大 / 非映射路径给出明确提示。
|
||||||
|
*/
|
||||||
|
export function FileViewDialog({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
changeset,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
localPath: string | null
|
||||||
|
/** 指定 changeset 时固定展示该版本内容,不提供来源切换。 */
|
||||||
|
changeset?: number
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [source, setSource] = useState<ViewSource>(localPath && changeset == null ? "local" : "server")
|
||||||
|
const [content, setContent] = useState<FileContent | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
// 加载所选来源的文件内容;本地内容仅在已映射时可用;指定版本固定按 changeset 读取。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
void (async () => {
|
||||||
|
const result =
|
||||||
|
changeset != null
|
||||||
|
? await api.getFileContent({ serverPath, changeset })
|
||||||
|
: source === "local" && localPath
|
||||||
|
? await api.getFileContent({ localPath })
|
||||||
|
: await api.getFileContent({ serverPath })
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setContent(null)
|
||||||
|
setError(result.errorMessage ?? "文件内容加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setContent(result.data.content)
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [source, serverPath, localPath, changeset])
|
||||||
|
|
||||||
|
const lines = useMemo(
|
||||||
|
() => (content && !content.binary && !content.tooLarge ? content.content.split(/\r?\n/) : []),
|
||||||
|
[content],
|
||||||
|
)
|
||||||
|
const keyword = search.trim().toLowerCase()
|
||||||
|
const matchCount = useMemo(
|
||||||
|
() =>
|
||||||
|
keyword.length === 0
|
||||||
|
? 0
|
||||||
|
: lines.filter((line) => line.toLowerCase().includes(keyword)).length,
|
||||||
|
[lines, keyword],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex h-[90svh] max-h-[90svh] flex-col sm:max-w-[92vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>查看文件</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{changeset != null ? (
|
||||||
|
<Badge variant="secondary" className="rounded-md">
|
||||||
|
版本 C{changeset}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center rounded-md border p-0.5">
|
||||||
|
<SourceTab
|
||||||
|
label="本地文件"
|
||||||
|
active={source === "local"}
|
||||||
|
disabled={!localPath}
|
||||||
|
disabledReason="未映射到本地"
|
||||||
|
onClick={() => setSource("local")}
|
||||||
|
/>
|
||||||
|
<SourceTab
|
||||||
|
label="服务器 latest"
|
||||||
|
active={source === "server"}
|
||||||
|
onClick={() => setSource("server")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{content && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatSize(content.size)}
|
||||||
|
{content.changeset > 0 && ` · 版本 ${content.changeset}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="搜索内容…"
|
||||||
|
className="ml-auto h-7 w-48 text-xs"
|
||||||
|
/>
|
||||||
|
{keyword.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="rounded-md">
|
||||||
|
{matchCount} 行命中
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载内容…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : content?.binary ? (
|
||||||
|
<Notice text={`二进制文件不支持预览(${formatSize(content.size)})`} />
|
||||||
|
) : content?.tooLarge ? (
|
||||||
|
<Notice text={`文件过大(${formatSize(content.size)}),不直接渲染内容`} />
|
||||||
|
) : (
|
||||||
|
<pre className="min-w-full font-mono text-xs leading-5">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const hit = keyword.length > 0 && line.toLowerCase().includes(keyword)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn("flex", hit && "bg-amber-500/15")}
|
||||||
|
>
|
||||||
|
<span className="w-12 shrink-0 border-r pr-2 text-right text-muted-foreground/60 select-none">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-pre-wrap break-all pl-2">{line}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
只读查看 · 来源:
|
||||||
|
{changeset != null
|
||||||
|
? `changeset ${changeset} 版本`
|
||||||
|
: source === "local"
|
||||||
|
? `本地 ${localPath ?? ""}`
|
||||||
|
: "服务器 latest"}
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 来源切换小标签按钮。
|
||||||
|
*/
|
||||||
|
function SourceTab({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
disabledReason?: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? disabledReason : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[5px] px-2 py-0.5 text-xs",
|
||||||
|
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容区占位提示(二进制 / 超大文件)。
|
||||||
|
*/
|
||||||
|
function Notice({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { HistoryEntry } from "~/lib/api"
|
||||||
|
import { cn, formatDateTime } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取特定版本弹窗:输入或从该对象历史中点选 changeset,
|
||||||
|
* 确认后把对象本地内容整体切到该变更集时刻(目录为递归覆盖),
|
||||||
|
* 对应 VS 的 Get Specific Version + Overwrite。
|
||||||
|
*/
|
||||||
|
export function GetVersionDialog({
|
||||||
|
serverPath,
|
||||||
|
folder,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
/** 确认后执行获取,由编排层调用 get-version 接口并刷新。 */
|
||||||
|
onConfirm: (changeset: number) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.getHistory({ path: serverPath, folder })
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "历史加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setEntries(result.data?.history ?? [])
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [serverPath, folder])
|
||||||
|
|
||||||
|
const changeset = /^\d+$/.test(input.trim()) ? Number(input.trim()) : null
|
||||||
|
const valid = changeset != null && changeset > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>获取特定版本</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Changeset</span>
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
placeholder="输入编号或从下方历史点选"
|
||||||
|
className="h-8 w-56 font-mono text-sm"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载历史…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-destructive">{error}</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
没有历史记录,可直接输入 changeset 编号
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
|
||||||
|
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
|
||||||
|
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${entry.changeset}-${entry.serverPath}`}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer select-none",
|
||||||
|
changeset === entry.changeset && "bg-primary/10 hover:bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => setInput(String(entry.changeset))}
|
||||||
|
>
|
||||||
|
<TableCell className="py-1.5 font-mono text-xs">{entry.changeset}</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(entry.date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
|
||||||
|
{entry.comment || "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 text-xs font-medium text-destructive">
|
||||||
|
确认后{folder ? "该目录(递归)" : "该文件"}的本地内容会被所选版本覆盖,
|
||||||
|
本地未签入的修改无法恢复。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!valid || busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (changeset == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onConfirm(changeset)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "正在获取…" : changeset != null ? `获取 C${changeset}` : "获取此版本"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { ArrowLeft, CloudDownload, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||||
|
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "~/components/ui/context-menu"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { HistoryEntry } from "~/lib/api"
|
||||||
|
import { formatDateTime } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 历史弹窗当前查看的目标(支持从 changeset 文件下钻到单文件历史后返回)。
|
||||||
|
interface HistoryTarget {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「获取此版本」待确认信息。
|
||||||
|
interface VersionConfirm {
|
||||||
|
serverPath: string
|
||||||
|
changeset: number
|
||||||
|
folder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「回滚」待确认信息:single 仅反做该变更集,toVersion 反做其后全部改动。
|
||||||
|
interface RollbackConfirm {
|
||||||
|
serverPath: string
|
||||||
|
changeset: number
|
||||||
|
mode: "single" | "toVersion"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History 弹窗:展示文件或目录历史(最近记录)。
|
||||||
|
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
|
||||||
|
* 查看该文件历史(可返回)/ 获取此版本;文件历史可勾选两个版本进入版本对比;
|
||||||
|
* 每条记录支持「获取此版本…」(确认后用该版本覆盖本地)。
|
||||||
|
*/
|
||||||
|
export function HistoryDialog({
|
||||||
|
serverPath,
|
||||||
|
folder,
|
||||||
|
onClose,
|
||||||
|
onDiffRevisions,
|
||||||
|
onGetVersion,
|
||||||
|
onRollback,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
|
||||||
|
onGetVersion?: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
||||||
|
onRollback?: (serverPath: string, mode: "single" | "toVersion", changeset: number) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState<HistoryTarget>({ serverPath, folder })
|
||||||
|
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
|
||||||
|
const [targetStack, setTargetStack] = useState<HistoryTarget[]>([])
|
||||||
|
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 目录历史下钻:当前查看的 changeset 文件列表,null 表示在历史主列表。
|
||||||
|
const [changesetFiles, setChangesetFiles] = useState<{
|
||||||
|
changeset: number
|
||||||
|
files: HistoryEntry[]
|
||||||
|
} | null>(null)
|
||||||
|
const [filesLoading, setFilesLoading] = useState(false)
|
||||||
|
// 文件历史勾选的 changeset(最多两个),用于版本对比。
|
||||||
|
const [selected, setSelected] = useState<number[]>([])
|
||||||
|
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
|
||||||
|
// 「回滚」待确认信息。
|
||||||
|
const [confirmRollback, setConfirmRollback] = useState<RollbackConfirm | null>(null)
|
||||||
|
// 「查看此版本内容」叠加的文件查看弹窗目标。
|
||||||
|
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.getHistory({ path: target.serverPath, folder: target.folder })
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "历史加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setEntries(result.data?.history ?? [])
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [target])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录历史下钻:加载指定 changeset 影响的文件列表。
|
||||||
|
*/
|
||||||
|
const openChangeset = useCallback(async (changeset: number) => {
|
||||||
|
setFilesLoading(true)
|
||||||
|
const result = await api.getChangesetFiles({ changeset })
|
||||||
|
setFilesLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "Changeset 文件加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setChangesetFiles({ changeset, files: result.data?.files ?? [] })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 changeset 文件切换到该文件的历史(压栈当前目标,可返回)。
|
||||||
|
*/
|
||||||
|
const openFileHistory = useCallback(
|
||||||
|
(fileServerPath: string) => {
|
||||||
|
setTargetStack((prev) => [...prev, target])
|
||||||
|
setTarget({ serverPath: fileServerPath, folder: false })
|
||||||
|
setChangesetFiles(null)
|
||||||
|
setSelected([])
|
||||||
|
},
|
||||||
|
[target],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回上一级历史目标。
|
||||||
|
*/
|
||||||
|
const backToPreviousTarget = useCallback(() => {
|
||||||
|
setTargetStack((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
const previous = next.pop()
|
||||||
|
if (previous) {
|
||||||
|
setTarget(previous)
|
||||||
|
setChangesetFiles(null)
|
||||||
|
setSelected([])
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 勾选 / 取消勾选用于对比的 changeset,最多保留两个(先进先出)。
|
||||||
|
*/
|
||||||
|
const toggleSelected = useCallback((changeset: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (prev.includes(changeset)) {
|
||||||
|
return prev.filter((item) => item !== changeset)
|
||||||
|
}
|
||||||
|
const next = [...prev, changeset]
|
||||||
|
return next.length > 2 ? next.slice(next.length - 2) : next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canDiff = onDiffRevisions != null && !target.folder && selected.length === 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[88vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{changesetFiles ? `Changeset ${changesetFiles.changeset} 的文件` : "历史记录"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">
|
||||||
|
{target.serverPath}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{(changesetFiles || targetStack.length > 0) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{changesetFiles && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setChangesetFiles(null)}>
|
||||||
|
<ArrowLeft data-icon="inline-start" />
|
||||||
|
返回历史列表
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!changesetFiles && targetStack.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={backToPreviousTarget}>
|
||||||
|
<ArrowLeft data-icon="inline-start" />
|
||||||
|
返回上级历史
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading || filesLoading ? (
|
||||||
|
<div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : changesetFiles ? (
|
||||||
|
<ChangesetFilesTable
|
||||||
|
files={changesetFiles.files}
|
||||||
|
changeset={changesetFiles.changeset}
|
||||||
|
onViewVersion={(fileServerPath) =>
|
||||||
|
setViewVersion({ serverPath: fileServerPath, changeset: changesetFiles.changeset })
|
||||||
|
}
|
||||||
|
onOpenFileHistory={openFileHistory}
|
||||||
|
onGetVersion={
|
||||||
|
onGetVersion
|
||||||
|
? (fileServerPath) =>
|
||||||
|
setConfirmVersion({
|
||||||
|
serverPath: fileServerPath,
|
||||||
|
changeset: changesetFiles.changeset,
|
||||||
|
folder: false,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
没有历史记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
{!target.folder && <TableHead className="h-8 w-8" />}
|
||||||
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
|
||||||
|
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
|
||||||
|
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
|
||||||
|
{onGetVersion && <TableHead className="h-8 w-12 text-xs text-muted-foreground" />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<ContextMenu key={`${entry.changeset}-${entry.serverPath}`}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<TableRow
|
||||||
|
className="cursor-default select-none"
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (target.folder) {
|
||||||
|
void openChangeset(entry.changeset)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!target.folder && (
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(entry.changeset)}
|
||||||
|
onCheckedChange={() => toggleSelected(entry.changeset)}
|
||||||
|
aria-label={`选择 changeset ${entry.changeset}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell className="py-1.5 font-mono text-xs">
|
||||||
|
{target.folder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => void openChangeset(entry.changeset)}
|
||||||
|
>
|
||||||
|
{entry.changeset}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
entry.changeset
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs">{entry.changeType}</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(entry.date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
|
||||||
|
{entry.comment || "—"}
|
||||||
|
</TableCell>
|
||||||
|
{onGetVersion && (
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title={`获取此版本(C${entry.changeset},覆盖本地)`}
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmVersion({
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
changeset: entry.changeset,
|
||||||
|
folder: target.folder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CloudDownload />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
{(onGetVersion || onRollback) && (
|
||||||
|
<ContextMenuContent className="w-60">
|
||||||
|
{onGetVersion && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
setConfirmVersion({
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
changeset: entry.changeset,
|
||||||
|
folder: target.folder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
获取此版本(C{entry.changeset})…
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{onGetVersion && onRollback && <ContextMenuSeparator />}
|
||||||
|
{onRollback && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
setConfirmRollback({
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
changeset: entry.changeset,
|
||||||
|
mode: "single",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
回滚此变更集(C{entry.changeset})…
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
setConfirmRollback({
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
changeset: entry.changeset,
|
||||||
|
mode: "toVersion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
回滚到此变更集(C{entry.changeset})…
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{changesetFiles
|
||||||
|
? `${changesetFiles.files.length} 个文件 · 右键文件可查看此版本 / 历史 / 获取`
|
||||||
|
: target.folder
|
||||||
|
? "双击记录或点击 changeset 编号查看影响文件"
|
||||||
|
: "勾选两个版本可进行对比"}
|
||||||
|
</p>
|
||||||
|
{!target.folder && onDiffRevisions && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!canDiff}
|
||||||
|
onClick={() => {
|
||||||
|
const [a, b] = [...selected].sort((x, y) => x - y)
|
||||||
|
onDiffRevisions(target.serverPath, a, b)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
对比所选版本
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmVersion && onGetVersion && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`获取版本 C${confirmVersion.changeset}`}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
将把 <span className="font-mono text-xs">{confirmVersion.serverPath}</span>{" "}
|
||||||
|
的本地内容替换为 changeset {confirmVersion.changeset} 的版本。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-medium text-destructive">
|
||||||
|
本地未签入的修改会被覆盖,且无法恢复。确定继续吗?
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="获取此版本"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
await onGetVersion(
|
||||||
|
confirmVersion.serverPath,
|
||||||
|
confirmVersion.changeset,
|
||||||
|
confirmVersion.folder,
|
||||||
|
)
|
||||||
|
setConfirmVersion(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setConfirmVersion(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmRollback && onRollback && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={
|
||||||
|
confirmRollback.mode === "single"
|
||||||
|
? `回滚变更集 C${confirmRollback.changeset}`
|
||||||
|
: `回滚到变更集 C${confirmRollback.changeset}`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
将对 <span className="font-mono text-xs">{confirmRollback.serverPath}</span>{" "}
|
||||||
|
{confirmRollback.mode === "single"
|
||||||
|
? `反做变更集 C${confirmRollback.changeset} 的改动。`
|
||||||
|
: `反做 C${confirmRollback.changeset} 之后的全部改动(回到该变更集时刻的内容)。`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
回滚只会产生挂起更改,不会直接写入服务器;可在挂起更改面板审查、撤销,确认无误后签入。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="执行回滚"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
await onRollback(
|
||||||
|
confirmRollback.serverPath,
|
||||||
|
confirmRollback.mode,
|
||||||
|
confirmRollback.changeset,
|
||||||
|
)
|
||||||
|
setConfirmRollback(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setConfirmRollback(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewVersion && (
|
||||||
|
<FileViewDialog
|
||||||
|
serverPath={viewVersion.serverPath}
|
||||||
|
localPath={null}
|
||||||
|
changeset={viewVersion.changeset}
|
||||||
|
onClose={() => setViewVersion(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changeset 影响文件列表(目录历史下钻视图),行级右键菜单提供对象操作。
|
||||||
|
*/
|
||||||
|
function ChangesetFilesTable({
|
||||||
|
files,
|
||||||
|
changeset,
|
||||||
|
onViewVersion,
|
||||||
|
onOpenFileHistory,
|
||||||
|
onGetVersion,
|
||||||
|
}: {
|
||||||
|
files: HistoryEntry[]
|
||||||
|
changeset: number
|
||||||
|
onViewVersion: (serverPath: string) => void
|
||||||
|
onOpenFileHistory: (serverPath: string) => void
|
||||||
|
onGetVersion?: (serverPath: string) => void
|
||||||
|
}) {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
该 changeset 没有文件记录
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{files.map((file) => (
|
||||||
|
<ContextMenu key={file.serverPath}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<TableRow className="cursor-default select-none">
|
||||||
|
<TableCell className="py-1.5 text-xs">{file.changeType}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={file.serverPath}
|
||||||
|
>
|
||||||
|
{file.serverPath}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-56">
|
||||||
|
<ContextMenuItem onSelect={() => onViewVersion(file.serverPath)}>
|
||||||
|
查看此版本内容
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => onOpenFileHistory(file.serverPath)}>
|
||||||
|
查看该文件历史
|
||||||
|
</ContextMenuItem>
|
||||||
|
{onGetVersion && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => onGetVersion(file.serverPath)}
|
||||||
|
>
|
||||||
|
获取此版本(C{changeset})…
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
GitCompareArrows,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
|
|
||||||
import { queryChangesetFiles, queryHistory } from "~/lib/api/endpoints"
|
|
||||||
import type { TfsHistoryEntry } from "~/lib/api/types"
|
|
||||||
|
|
||||||
interface HistoryPanelProps {
|
|
||||||
path: string
|
|
||||||
folder: boolean
|
|
||||||
label: string
|
|
||||||
onOpenDiff(request: DiffPanelRequest): void
|
|
||||||
onClose(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 展示文件或目录历史,并支持目录 changeset 文件列表和历史 diff 入口。
|
|
||||||
*/
|
|
||||||
export function HistoryPanel({
|
|
||||||
path,
|
|
||||||
folder,
|
|
||||||
label,
|
|
||||||
onOpenDiff,
|
|
||||||
onClose,
|
|
||||||
}: HistoryPanelProps) {
|
|
||||||
const [history, setHistory] = useState<TfsHistoryEntry[]>([])
|
|
||||||
const [changesetFiles, setChangesetFiles] = useState<TfsHistoryEntry[]>([])
|
|
||||||
const [selectedChangeset, setSelectedChangeset] = useState<number>()
|
|
||||||
const [selectedRevisionKeys, setSelectedRevisionKeys] = useState<string[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [filesLoading, setFilesLoading] = useState(false)
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
const [diffMessage, setDiffMessage] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadHistory()
|
|
||||||
}, [path, folder])
|
|
||||||
|
|
||||||
const selectedRevisions = history.filter((entry) =>
|
|
||||||
selectedRevisionKeys.includes(toHistoryKey(entry))
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询当前文件或目录最近 100 条历史记录。
|
|
||||||
*/
|
|
||||||
async function loadHistory() {
|
|
||||||
setLoading(true)
|
|
||||||
setMessage("")
|
|
||||||
setDiffMessage("")
|
|
||||||
setChangesetFiles([])
|
|
||||||
setSelectedChangeset(undefined)
|
|
||||||
setSelectedRevisionKeys([])
|
|
||||||
|
|
||||||
const result = await queryHistory(path, folder)
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setHistory([])
|
|
||||||
setMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setHistory(result.data.history)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询目录历史中某个 changeset 影响的文件列表。
|
|
||||||
*/
|
|
||||||
async function loadChangesetFiles(changeset: number) {
|
|
||||||
setFilesLoading(true)
|
|
||||||
setMessage("")
|
|
||||||
setDiffMessage("")
|
|
||||||
setSelectedChangeset(changeset)
|
|
||||||
|
|
||||||
const result = await queryChangesetFiles(changeset)
|
|
||||||
setFilesLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setChangesetFiles([])
|
|
||||||
setMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setChangesetFiles(result.data.files)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用 changeset 作为历史版本选择 key。
|
|
||||||
*/
|
|
||||||
function toHistoryKey(entry: TfsHistoryEntry) {
|
|
||||||
return String(entry.changeset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择最多两个文件历史版本,供后续 Diff 面板使用。
|
|
||||||
*/
|
|
||||||
function toggleRevision(entry: TfsHistoryEntry, checked: boolean) {
|
|
||||||
const key = toHistoryKey(entry)
|
|
||||||
setSelectedRevisionKeys((currentKeys) => {
|
|
||||||
if (!checked) {
|
|
||||||
return currentKeys.filter((currentKey) => currentKey !== key)
|
|
||||||
}
|
|
||||||
if (currentKeys.includes(key)) {
|
|
||||||
return currentKeys
|
|
||||||
}
|
|
||||||
return [...currentKeys, key].slice(-2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化后端毫秒时间戳。
|
|
||||||
*/
|
|
||||||
function formatHistoryDate(date?: number | null) {
|
|
||||||
return date ? new Date(date).toLocaleString() : "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开用户选择的两个历史版本 diff。
|
|
||||||
*/
|
|
||||||
function enterHistoryDiff() {
|
|
||||||
if (selectedRevisions.length !== 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenDiff({
|
|
||||||
type: "revisions",
|
|
||||||
serverPath: path,
|
|
||||||
sourceChangeset: selectedRevisions[0].changeset,
|
|
||||||
targetChangeset: selectedRevisions[1].changeset,
|
|
||||||
label: `${path};C${selectedRevisions[0].changeset} ↔ C${selectedRevisions[1].changeset}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 记录 changeset 文件列表中的单文件 diff 入口。
|
|
||||||
*/
|
|
||||||
function enterChangesetFileDiff(file: TfsHistoryEntry) {
|
|
||||||
setDiffMessage(
|
|
||||||
`已选择 ${file.serverPath} @${file.changeset}。请打开该文件历史并选择两个版本进入 Diff。`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b bg-background">
|
|
||||||
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">History</div>
|
|
||||||
<div className="truncate font-mono text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={loading}
|
|
||||||
title="刷新历史"
|
|
||||||
onClick={loadHistory}
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
||||||
</Button>
|
|
||||||
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{diffMessage && (
|
|
||||||
<div className="border-t bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{diffMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid max-h-80 border-t md:grid-cols-[minmax(0,1fr)_300px]">
|
|
||||||
<div className="min-h-0 overflow-auto">
|
|
||||||
<div className="min-w-[760px]">
|
|
||||||
<div className="grid h-7 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<div className="flex items-center">Diff</div>
|
|
||||||
<div className="flex items-center">Changeset</div>
|
|
||||||
<div className="flex items-center">变更</div>
|
|
||||||
<div className="flex items-center">作者</div>
|
|
||||||
<div className="flex items-center">时间</div>
|
|
||||||
<div className="flex items-center">Comment</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length === 0 && !loading ? (
|
|
||||||
<div className="px-3 py-4 text-xs text-muted-foreground">
|
|
||||||
暂无历史记录。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
history.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={toHistoryKey(entry)}
|
|
||||||
className={`grid min-h-8 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] items-center border-b px-3 text-xs ${
|
|
||||||
selectedChangeset === entry.changeset
|
|
||||||
? "bg-primary/10"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<label className="flex items-center justify-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={folder}
|
|
||||||
checked={selectedRevisionKeys.includes(
|
|
||||||
toHistoryKey(entry)
|
|
||||||
)}
|
|
||||||
aria-label={`选择 changeset ${entry.changeset}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
toggleRevision(entry, event.target.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="font-mono">{entry.changeset}</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{entry.changeType || "-"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">{entry.author || "-"}</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{formatHistoryDate(entry.date)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
||||||
<span className="truncate">{entry.comment || "-"}</span>
|
|
||||||
{folder && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => loadChangesetFiles(entry.changeset)}
|
|
||||||
>
|
|
||||||
文件
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 border-t bg-muted/10 p-2 text-xs md:border-t-0 md:border-l">
|
|
||||||
{folder ? (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="font-medium">
|
|
||||||
Changeset {selectedChangeset ?? "-"}
|
|
||||||
</span>
|
|
||||||
{filesLoading && <Loader2 className="size-3 animate-spin" />}
|
|
||||||
</div>
|
|
||||||
{changesetFiles.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
点击目录历史 changeset 查看影响文件。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid max-h-64 gap-2 overflow-auto">
|
|
||||||
{changesetFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={`${file.changeset}:${file.serverPath}`}
|
|
||||||
className="grid gap-1 border-b pb-2 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
{file.itemType.toLowerCase().includes("folder") ? (
|
|
||||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
|
||||||
) : (
|
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="truncate font-mono">
|
|
||||||
{file.serverPath}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{file.changeType || "-"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => enterChangesetFileDiff(file)}
|
|
||||||
>
|
|
||||||
<GitCompareArrows />
|
|
||||||
Diff
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="font-medium">历史 Diff</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
选择两个历史版本后进入 Diff。
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
disabled={selectedRevisions.length !== 2}
|
|
||||||
onClick={enterHistoryDiff}
|
|
||||||
>
|
|
||||||
<GitCompareArrows />
|
|
||||||
Diff
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { FolderSearch, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { CheckMappingTargetResult, MappingInfo } from "~/lib/api"
|
||||||
|
import { isElectron, selectDirectory } from "~/lib/electron"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping 创建弹窗:展示服务端路径,选择本地父目录后由后端预校验最终目标路径,
|
||||||
|
* 目标已存在时禁止映射;支持创建后立即 Get Latest。
|
||||||
|
*/
|
||||||
|
export function MappingDialog({
|
||||||
|
serverPath,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: (mappings: MappingInfo[], didGetLatest: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [parentPath, setParentPath] = useState("")
|
||||||
|
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [getLatest, setGetLatest] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过系统目录选择器选择本地父目录(非 Electron 环境保留手动输入)。
|
||||||
|
*/
|
||||||
|
const handleBrowse = useCallback(async () => {
|
||||||
|
const selected = await selectDirectory()
|
||||||
|
if (selected) {
|
||||||
|
setParentPath(selected)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 父目录变化时调用后端预校验最终目标路径,最终路径以后端结果为准。
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = parentPath.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
setCheck(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
setChecking(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.checkMappingTarget({
|
||||||
|
serverPath,
|
||||||
|
localParentPath: trimmed,
|
||||||
|
})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChecking(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setCheck(null)
|
||||||
|
setError(result.errorMessage ?? "目标路径校验失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setCheck(result.data)
|
||||||
|
})()
|
||||||
|
}, 300)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [parentPath, serverPath])
|
||||||
|
|
||||||
|
const exists = check?.exists === true
|
||||||
|
const canConfirm = check != null && check.allowed && !exists && !busy && !checking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Mapping(按需立即 Get Latest),成功后回调上层刷新并关闭弹窗。
|
||||||
|
*/
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (!check) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
const result = await api.addMapping({
|
||||||
|
serverPath,
|
||||||
|
localPath: check.targetPath,
|
||||||
|
getLatest,
|
||||||
|
})
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "创建 Mapping 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mappingsResult = await api.listMappings()
|
||||||
|
onCreated(mappingsResult.data?.mappings ?? [], getLatest)
|
||||||
|
}, [check, serverPath, getLatest, onCreated])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>映射到本地</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
为服务端目录创建本地工作区映射
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>服务端路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{serverPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="parentPath">本地父目录</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="parentPath"
|
||||||
|
placeholder="/Users/me/tfs"
|
||||||
|
value={parentPath}
|
||||||
|
onChange={(event) => setParentPath(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{isElectron() && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleBrowse()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="浏览本地目录"
|
||||||
|
>
|
||||||
|
<FolderSearch />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
选择父目录,最终映射目录由后端按服务端目录名生成
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checking && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
正在校验目标路径…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{check && !checking && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>最终映射路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{check.targetPath}
|
||||||
|
</p>
|
||||||
|
{exists && (
|
||||||
|
<p className="text-xs font-medium text-destructive">
|
||||||
|
目标目录已存在,禁止映射
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={getLatest}
|
||||||
|
onCheckedChange={(value) => setGetLatest(value === true)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
创建后立即获取最新
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
|
||||||
|
{busy ? "正在创建…" : "创建映射"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { Loader2, Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { HistoryEntry, MappingInfo } from "~/lib/api"
|
||||||
|
import { resolveLocalPath } from "~/lib/tfs"
|
||||||
|
import { cn, formatDateTime } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并向导弹窗:源(当前对象)→ 目标路径(必须已映射),
|
||||||
|
* 加载待合并 changeset 候选后支持「全部合并」或选择单个合并;
|
||||||
|
* 结果为挂起更改,冲突由编排层进入既有冲突弹窗。
|
||||||
|
*/
|
||||||
|
export function MergeDialog({
|
||||||
|
sourceServerPath,
|
||||||
|
mappings,
|
||||||
|
onMerge,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
sourceServerPath: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
/** 执行合并(changeset 为 undefined 表示全部),由编排层调用接口并刷新。 */
|
||||||
|
onMerge: (targetServerPath: string, changeset: number | undefined) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState("")
|
||||||
|
const [candidates, setCandidates] = useState<HistoryEntry[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<number | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const trimmed = target.trim()
|
||||||
|
const targetMapped = trimmed.length > 2 && resolveLocalPath(mappings, trimmed) != null
|
||||||
|
const validation =
|
||||||
|
trimmed.length === 0
|
||||||
|
? null
|
||||||
|
: !trimmed.startsWith("$/") || trimmed.length <= 2
|
||||||
|
? "目标路径必须以 $/ 开头"
|
||||||
|
: trimmed === sourceServerPath
|
||||||
|
? "目标路径不能与源路径相同"
|
||||||
|
: !targetMapped
|
||||||
|
? "目标路径未映射,请先在工作区中映射"
|
||||||
|
: null
|
||||||
|
const targetReady = trimmed.length > 2 && validation == null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载源 → 目标的待合并候选列表。
|
||||||
|
*/
|
||||||
|
const loadCandidates = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setSelected(null)
|
||||||
|
const result = await api.mergeCandidates({
|
||||||
|
sourceServerPath,
|
||||||
|
targetServerPath: trimmed,
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setCandidates(null)
|
||||||
|
setError(result.errorMessage ?? "候选查询失败(请确认源与目标存在分支关系)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCandidates(result.data?.candidates ?? [])
|
||||||
|
}, [sourceServerPath, trimmed])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行合并:changeset 为 undefined 表示合并全部候选。
|
||||||
|
*/
|
||||||
|
const handleMerge = useCallback(
|
||||||
|
(changeset: number | undefined) => {
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onMerge(trimmed, changeset)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
},
|
||||||
|
[onMerge, trimmed, onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>合并</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
将源分支的变更集合并到目标分支,结果为挂起更改,冲突需逐个处理
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="shrink-0 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>源路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{sourceServerPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="mergeTarget">目标路径(必须已映射)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="mergeTarget"
|
||||||
|
value={target}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTarget(event.target.value)
|
||||||
|
setCandidates(null)
|
||||||
|
}}
|
||||||
|
placeholder="$/Project/Branch"
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0"
|
||||||
|
disabled={!targetReady || loading || busy}
|
||||||
|
onClick={() => void loadCandidates()}
|
||||||
|
>
|
||||||
|
<Search data-icon="inline-start" />
|
||||||
|
查询候选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{validation != null && <p className="text-xs text-destructive">{validation}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在查询待合并变更集…
|
||||||
|
</div>
|
||||||
|
) : candidates == null ? (
|
||||||
|
<div className="flex h-32 items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
|
输入目标路径并点击「查询候选」,查看可合并的变更集
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
没有待合并的变更集,两个分支已同步
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
|
||||||
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
|
||||||
|
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
|
||||||
|
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{candidates.map((entry) => (
|
||||||
|
<TableRow
|
||||||
|
key={entry.changeset}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer select-none",
|
||||||
|
selected === entry.changeset && "bg-primary/10 hover:bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
setSelected((prev) => (prev === entry.changeset ? null : entry.changeset))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="py-1.5 font-mono text-xs">{entry.changeset}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs">{entry.changeType}</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(entry.date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
|
||||||
|
{entry.comment || "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
合并只产生挂起更改,不会直接写入服务器;出现冲突会进入冲突处理弹窗,签入前可撤销。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!targetReady || busy || candidates == null || candidates.length === 0 || selected == null}
|
||||||
|
onClick={() => handleMerge(selected ?? undefined)}
|
||||||
|
>
|
||||||
|
合并所选({selected != null ? `C${selected}` : "未选择"})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!targetReady || busy || candidates == null || candidates.length === 0}
|
||||||
|
onClick={() => handleMerge(undefined)}
|
||||||
|
>
|
||||||
|
{busy ? "正在合并…" : "全部合并"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Check, Copy, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { ItemInfo } from "~/lib/api"
|
||||||
|
import type { FileTarget } from "~/lib/tfs"
|
||||||
|
import { statusBadgeClass, statusLabel } from "~/lib/tfs"
|
||||||
|
import { formatDateTime } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把字节数格式化为可读大小(B / KB / MB)。
|
||||||
|
*/
|
||||||
|
function formatSize(size: number | null): string {
|
||||||
|
if (size == null) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
return `${(size / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象属性弹窗:展示服务器路径、本地路径、映射与挂起状态,
|
||||||
|
* 以及服务器侧最新版本信息(changeset / 时间 / 提交人),文件附带大小与编码。
|
||||||
|
*/
|
||||||
|
export function PropertiesDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: FileTarget
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [info, setInfo] = useState<ItemInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.getItemInfo({ serverPath: target.serverPath })
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setError(result.errorMessage ?? "属性加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setInfo(result.data.item)
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [target.serverPath])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>属性</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">
|
||||||
|
{target.serverPath}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<PropertyRow label="对象类型" value={target.folder ? "目录" : "文件"} />
|
||||||
|
<PropertyRow label="服务器路径" value={target.serverPath} mono copyable />
|
||||||
|
<PropertyRow
|
||||||
|
label="本地路径"
|
||||||
|
value={target.localPath ?? "未映射"}
|
||||||
|
mono={target.localPath != null}
|
||||||
|
copyable={target.localPath != null}
|
||||||
|
muted={target.localPath == null}
|
||||||
|
/>
|
||||||
|
<PropertyRow
|
||||||
|
label="映射状态"
|
||||||
|
value={
|
||||||
|
<Badge variant="secondary" className={statusBadgeClass(target.mapped ? "mapped" : "notMapped")}>
|
||||||
|
{target.mapped ? (target.mappingRoot ? "映射根目录" : "已映射") : "未映射"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PropertyRow
|
||||||
|
label="挂起状态"
|
||||||
|
value={
|
||||||
|
target.pendingStatus ? (
|
||||||
|
<Badge variant="secondary" className={statusBadgeClass(target.pendingStatus)}>
|
||||||
|
{statusLabel(target.pendingStatus)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
"无挂起更改"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
muted={target.pendingStatus == null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="my-2 border-t" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-24 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载服务器信息…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-24 items-center justify-center text-sm text-destructive">{error}</div>
|
||||||
|
) : info ? (
|
||||||
|
<>
|
||||||
|
<PropertyRow label="最新 Changeset" value={`C${info.changeset}`} mono />
|
||||||
|
<PropertyRow label="签入时间" value={formatDateTime(info.checkinDate)} />
|
||||||
|
<PropertyRow label="签入人" value={info.author || "—"} />
|
||||||
|
<PropertyRow label="签入备注" value={info.comment || "—"} muted={!info.comment} />
|
||||||
|
{!info.folder && (
|
||||||
|
<>
|
||||||
|
<PropertyRow label="文件大小" value={formatSize(info.size)} />
|
||||||
|
<PropertyRow label="编码" value={info.encoding ?? "—"} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单行属性:左侧标签 + 右侧值,可选等宽字体与复制按钮。
|
||||||
|
*/
|
||||||
|
function PropertyRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
copyable,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
mono?: boolean
|
||||||
|
copyable?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 py-0.5">
|
||||||
|
<span className="w-24 shrink-0 pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 break-all ${mono ? "font-mono text-xs leading-5" : "text-sm"} ${muted ? "text-muted-foreground" : ""}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{copyable && typeof value === "string" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="shrink-0"
|
||||||
|
title="复制"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(value).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="text-green-600" /> : <Copy />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
|
||||||
|
// Windows / TFS 路径名非法字符。
|
||||||
|
const INVALID_NAME_PATTERN = /[\\/:*?"<>|]/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名弹窗:同目录改名,校验非空、非法字符与同名;
|
||||||
|
* 确认后产生 rename 挂起更改(由编排层调用接口并刷新)。
|
||||||
|
*/
|
||||||
|
export function RenameDialog({
|
||||||
|
serverPath,
|
||||||
|
folder,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
/** 确认重命名,由编排层执行 API 与刷新,返回是否成功。 */
|
||||||
|
onConfirm: (newName: string) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const currentName = useMemo(
|
||||||
|
() => serverPath.slice(serverPath.lastIndexOf("/") + 1),
|
||||||
|
[serverPath],
|
||||||
|
)
|
||||||
|
const [name, setName] = useState(currentName)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const trimmed = name.trim()
|
||||||
|
const validation =
|
||||||
|
trimmed.length === 0
|
||||||
|
? "名称不能为空"
|
||||||
|
: INVALID_NAME_PATTERN.test(trimmed)
|
||||||
|
? '名称不能包含 / \\ : * ? " < > | 字符'
|
||||||
|
: trimmed === currentName
|
||||||
|
? "新名称与当前名称相同"
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>重命名{folder ? "目录" : "文件"}</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onFocus={(event) => {
|
||||||
|
// 文件默认选中主名部分,便于直接输入新名称。
|
||||||
|
const dot = folder ? -1 : currentName.lastIndexOf(".")
|
||||||
|
event.target.setSelectionRange(0, dot > 0 ? dot : currentName.length)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && validation == null && !busy) {
|
||||||
|
event.preventDefault()
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onConfirm(trimmed)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{validation != null && trimmed !== currentName && (
|
||||||
|
<p className="text-xs text-destructive">{validation}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
重命名会产生挂起更改,签入后服务器才会生效;签入前可在挂起更改面板撤销。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={validation != null || busy}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const ok = await onConfirm(trimmed)
|
||||||
|
setBusy(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "正在重命名…" : "重命名"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Database,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { listCollections, listServerTree } from "~/lib/api/endpoints"
|
|
||||||
import type { TfsCollectionInfo, TfsServerItem } from "~/lib/api/types"
|
|
||||||
|
|
||||||
interface ServerTreePanelProps {
|
|
||||||
connected: boolean
|
|
||||||
preferredCollection: string
|
|
||||||
onCollectionSelect(collection: string): void
|
|
||||||
onPathSelect(path: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 Collection 与服务端目录树,并在展开目录时按需请求子节点。
|
|
||||||
*/
|
|
||||||
export function ServerTreePanel({
|
|
||||||
connected,
|
|
||||||
preferredCollection,
|
|
||||||
onCollectionSelect,
|
|
||||||
onPathSelect,
|
|
||||||
}: ServerTreePanelProps) {
|
|
||||||
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
|
|
||||||
const [selectedCollection, setSelectedCollection] = useState("")
|
|
||||||
const [selectedPath, setSelectedPath] = useState("")
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
|
|
||||||
const [itemsByPath, setItemsByPath] = useState<
|
|
||||||
Record<string, TfsServerItem[]>
|
|
||||||
>({})
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connected) {
|
|
||||||
setCollections([])
|
|
||||||
setSelectedCollection("")
|
|
||||||
setSelectedPath("")
|
|
||||||
setExpandedPaths([])
|
|
||||||
setItemsByPath({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCollections()
|
|
||||||
}, [connected, preferredCollection])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载 Collection 列表,并优先展开当前配置保存的 Collection。
|
|
||||||
*/
|
|
||||||
async function loadCollections() {
|
|
||||||
setLoading(true)
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
const result = await listCollections()
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setErrorMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCollections(result.data.collections)
|
|
||||||
const collection =
|
|
||||||
result.data.collections.find(
|
|
||||||
(item) => item.name === preferredCollection
|
|
||||||
) || result.data.collections[0]
|
|
||||||
if (collection) {
|
|
||||||
selectCollection(collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换当前 Collection,并加载该 Collection 的服务端根目录。
|
|
||||||
*/
|
|
||||||
async function selectCollection(collection: TfsCollectionInfo) {
|
|
||||||
setSelectedCollection(collection.name)
|
|
||||||
setSelectedPath("$/")
|
|
||||||
setExpandedPaths(["$/"])
|
|
||||||
setItemsByPath({})
|
|
||||||
onCollectionSelect(collection.name)
|
|
||||||
onPathSelect("$/")
|
|
||||||
await loadServerItems(collection.name, "$/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
|
|
||||||
*/
|
|
||||||
async function loadServerItems(collection: string, path: string) {
|
|
||||||
setLoading(true)
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
const result = await listServerTree(path, collection)
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setErrorMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setItemsByPath((currentItems) => ({
|
|
||||||
...currentItems,
|
|
||||||
[path]: result.data.items,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选中节点并通知中间区域当前服务端路径已变化。
|
|
||||||
*/
|
|
||||||
function selectPath(path: string) {
|
|
||||||
setSelectedPath(path)
|
|
||||||
onPathSelect(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 展开或收起目录;首次展开时才加载子节点。
|
|
||||||
*/
|
|
||||||
async function toggleFolder(item: TfsServerItem) {
|
|
||||||
selectPath(item.serverPath)
|
|
||||||
if (!item.folder || !selectedCollection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expandedPaths.includes(item.serverPath)) {
|
|
||||||
setExpandedPaths((currentPaths) =>
|
|
||||||
currentPaths.filter((path) => path !== item.serverPath)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
|
|
||||||
if (!itemsByPath[item.serverPath]) {
|
|
||||||
await loadServerItems(selectedCollection, item.serverPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按层级渲染目录树节点。
|
|
||||||
*/
|
|
||||||
function renderItems(path: string, level = 0) {
|
|
||||||
const items = itemsByPath[path] || []
|
|
||||||
|
|
||||||
return items.map((item) => {
|
|
||||||
const expanded = expandedPaths.includes(item.serverPath)
|
|
||||||
const selected = selectedPath === item.serverPath
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.serverPath}>
|
|
||||||
<button
|
|
||||||
className={`flex h-7 w-full items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
|
|
||||||
selected ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: 8 + level * 14 }}
|
|
||||||
onClick={() => toggleFolder(item)}
|
|
||||||
>
|
|
||||||
{item.folder ? (
|
|
||||||
expanded ? (
|
|
||||||
<ChevronDown className="size-3.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-3.5 shrink-0" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-3.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
{item.folder ? (
|
|
||||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
|
||||||
) : (
|
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{item.name}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{item.folder && expanded && renderItems(item.serverPath, level + 1)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
加载中
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{collections.map((collection) => (
|
|
||||||
<button
|
|
||||||
key={collection.id || collection.name}
|
|
||||||
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
|
|
||||||
selectedCollection === collection.name
|
|
||||||
? "bg-background text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
onClick={() => selectCollection(collection)}
|
|
||||||
>
|
|
||||||
<Database className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{collection.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-0.5 border-t pt-2">
|
|
||||||
<button
|
|
||||||
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left font-mono text-xs ${
|
|
||||||
selectedPath === "$/"
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
selectPath("$/")
|
|
||||||
if (selectedCollection && !itemsByPath["$/"]) {
|
|
||||||
loadServerItems(selectedCollection, "$/")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
|
||||||
$/
|
|
||||||
</button>
|
|
||||||
{renderItems("$/")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { ChevronRight, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
|
import { FileIcon } from "~/components/explorer/file-icon"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { MappingInfo, ServerItem } from "~/lib/api"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
import {
|
||||||
|
SERVER_ROOT_PATH,
|
||||||
|
getAncestorPaths,
|
||||||
|
makeFileTarget,
|
||||||
|
normalizeServerPath,
|
||||||
|
resolveLocalPath,
|
||||||
|
} from "~/lib/tfs"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
|
||||||
|
* 与中间列表共享当前路径;节点支持与中间列表同规则的右键菜单。
|
||||||
|
*/
|
||||||
|
export function SourceTreePanel({
|
||||||
|
collection,
|
||||||
|
mappings,
|
||||||
|
selectedServerPath,
|
||||||
|
onNavigate,
|
||||||
|
onFileAction,
|
||||||
|
}: {
|
||||||
|
collection: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
selectedServerPath: string
|
||||||
|
onNavigate: (serverPath: string) => void
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
}) {
|
||||||
|
// path -> 子目录列表(仅 folder),undefined 表示尚未加载。
|
||||||
|
const [childrenByPath, setChildrenByPath] = useState<Record<string, ServerItem[]>>({})
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||||
|
[SERVER_ROOT_PATH]: true,
|
||||||
|
})
|
||||||
|
const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({})
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 手动刷新令牌:递增时清空缓存并重新加载。
|
||||||
|
const [reloadToken, setReloadToken] = useState(0)
|
||||||
|
// 已加载与加载中的路径集合,避免异步流程里读到过期 state。
|
||||||
|
const requestedRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒加载指定目录的子目录列表,已加载或加载中的路径直接跳过。
|
||||||
|
*/
|
||||||
|
const ensureChildren = useCallback(
|
||||||
|
async (serverPath: string) => {
|
||||||
|
const target = normalizeServerPath(serverPath)
|
||||||
|
if (requestedRef.current.has(target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestedRef.current.add(target)
|
||||||
|
setLoadingPaths((prev) => ({ ...prev, [target]: true }))
|
||||||
|
const result = await api.getServerTree({ path: target, collection })
|
||||||
|
setLoadingPaths((prev) => ({ ...prev, [target]: false }))
|
||||||
|
if (!result.ok) {
|
||||||
|
requestedRef.current.delete(target)
|
||||||
|
setError(result.errorMessage ?? "目录加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
const folders = (result.data?.items ?? []).filter((item) => item.folder)
|
||||||
|
setChildrenByPath((prev) => ({ ...prev, [target]: folders }))
|
||||||
|
},
|
||||||
|
[collection],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前路径变化或手动刷新时,逐级加载并展开祖先链与自身。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
const chain = [...getAncestorPaths(selectedServerPath), normalizeServerPath(selectedServerPath)]
|
||||||
|
for (const path of chain) {
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ensureChildren(path)
|
||||||
|
setExpanded((prev) => (prev[path] ? prev : { ...prev, [path]: true }))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [selectedServerPath, ensureChildren, reloadToken])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新目录树:清空懒加载缓存并从根重新加载,保持当前选中路径的展开链。
|
||||||
|
*/
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
requestedRef.current.clear()
|
||||||
|
setChildrenByPath({})
|
||||||
|
setExpanded({ [SERVER_ROOT_PATH]: true })
|
||||||
|
setError(null)
|
||||||
|
setReloadToken((token) => token + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换节点展开 / 收起,首次展开时触发懒加载。
|
||||||
|
*/
|
||||||
|
const toggleExpand = useCallback(
|
||||||
|
(serverPath: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = !prev[serverPath]
|
||||||
|
if (next) {
|
||||||
|
void ensureChildren(serverPath)
|
||||||
|
}
|
||||||
|
return { ...prev, [serverPath]: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[ensureChildren],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击节点名称:选中并导航到该目录,同时确保其展开。
|
||||||
|
*/
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(serverPath: string) => {
|
||||||
|
onNavigate(serverPath)
|
||||||
|
setExpanded((prev) => (prev[serverPath] ? prev : { ...prev, [serverPath]: true }))
|
||||||
|
void ensureChildren(serverPath)
|
||||||
|
},
|
||||||
|
[onNavigate, ensureChildren],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||||
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
源码目录
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
aria-label="刷新目录树"
|
||||||
|
title="刷新目录树"
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
{error && (
|
||||||
|
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<TreeNode
|
||||||
|
path={SERVER_ROOT_PATH}
|
||||||
|
name={SERVER_ROOT_PATH}
|
||||||
|
depth={0}
|
||||||
|
mappings={mappings}
|
||||||
|
childrenByPath={childrenByPath}
|
||||||
|
expanded={expanded}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
selectedServerPath={normalizeServerPath(selectedServerPath)}
|
||||||
|
onToggle={toggleExpand}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onFileAction={onFileAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
|
||||||
|
*/
|
||||||
|
function TreeNode({
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
depth,
|
||||||
|
mappings,
|
||||||
|
childrenByPath,
|
||||||
|
expanded,
|
||||||
|
loadingPaths,
|
||||||
|
selectedServerPath,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onFileAction,
|
||||||
|
}: {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
depth: number
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
childrenByPath: Record<string, ServerItem[]>
|
||||||
|
expanded: Record<string, boolean>
|
||||||
|
loadingPaths: Record<string, boolean>
|
||||||
|
selectedServerPath: string
|
||||||
|
onToggle: (serverPath: string) => void
|
||||||
|
onSelect: (serverPath: string) => void
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
}) {
|
||||||
|
const children = childrenByPath[path]
|
||||||
|
const isExpanded = expanded[path] === true
|
||||||
|
const isLoading = loadingPaths[path] === true
|
||||||
|
const isSelected = selectedServerPath === path
|
||||||
|
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
||||||
|
const isLeaf = children != null && children.length === 0
|
||||||
|
const isMapped = resolveLocalPath(mappings, path) != null
|
||||||
|
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
|
||||||
|
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||||
|
>
|
||||||
|
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : isLeaf ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
||||||
|
onClick={() => onToggle(path)}
|
||||||
|
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 transition-transform duration-200 ease-out-quart",
|
||||||
|
isExpanded && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(path)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||||
|
>
|
||||||
|
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate text-sm",
|
||||||
|
isSelected && "font-medium text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FileTargetMenu>
|
||||||
|
{isExpanded && children != null && children.length > 0 && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
{children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.serverPath}
|
||||||
|
path={child.serverPath}
|
||||||
|
name={child.name}
|
||||||
|
depth={depth + 1}
|
||||||
|
mappings={mappings}
|
||||||
|
childrenByPath={childrenByPath}
|
||||||
|
expanded={expanded}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
selectedServerPath={selectedServerPath}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onFileAction={onFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { FolderSearch, Loader2, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||||
|
import { MappingDialog } from "~/components/explorer/mapping-dialog"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { CheckMappingTargetResult, MappingInfo, WorkspaceInfo } from "~/lib/api"
|
||||||
|
import { isElectron, pathsExist, selectDirectory } from "~/lib/electron"
|
||||||
|
import type { WorkspaceSession } from "~/lib/tfs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作区集中管理弹窗:展示 Workspace 信息与全部 Mapping,
|
||||||
|
* 支持新增、删除与修改本地路径(删旧 + 加新),对齐 VS 的 Edit Workspace。
|
||||||
|
*/
|
||||||
|
export function WorkspaceManageDialog({
|
||||||
|
session,
|
||||||
|
onClose,
|
||||||
|
onMappingsChanged,
|
||||||
|
}: {
|
||||||
|
session: WorkspaceSession
|
||||||
|
onClose: () => void
|
||||||
|
/** Mapping 集合变化(新增 / 删除 / 修改)后回调上层刷新。 */
|
||||||
|
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||||
|
}) {
|
||||||
|
const [info, setInfo] = useState<WorkspaceInfo | null>(null)
|
||||||
|
// 各 Mapping 本地目录存在性(null 表示无法检测,如非 Electron 环境)。
|
||||||
|
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 新增映射:输入服务端路径后打开既有 MappingDialog。
|
||||||
|
const [addServerPath, setAddServerPath] = useState("")
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [editMapping, setEditMapping] = useState<MappingInfo | null>(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<MappingInfo | null>(null)
|
||||||
|
|
||||||
|
// Workspace 详细信息(Owner / Computer)通过 ensure 接口幂等获取。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.ensureWorkspace({})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.ok && result.data) {
|
||||||
|
setInfo(result.data.workspace)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mapping 集合变化时重新检测本地目录存在性。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
const result = await pathsExist(session.mappings.map((mapping) => mapping.localPath))
|
||||||
|
if (active) {
|
||||||
|
setExistsMap(result)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [session.mappings])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 Mapping(仅解除映射,不删除本地文件),成功后回调上层刷新。
|
||||||
|
*/
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (mapping: MappingInfo) => {
|
||||||
|
const result = await api.deleteMapping({ serverPath: mapping.serverPath })
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "删除 Mapping 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
onMappingsChanged(result.data?.mappings ?? [])
|
||||||
|
},
|
||||||
|
[onMappingsChanged],
|
||||||
|
)
|
||||||
|
|
||||||
|
const trimmedAddPath = addServerPath.trim()
|
||||||
|
const addPathValid = trimmedAddPath.startsWith("$/") && trimmedAddPath.length > 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>工作区管理</DialogTitle>
|
||||||
|
<DialogDescription>查看 Workspace 信息,集中管理全部本地映射</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid shrink-0 grid-cols-2 gap-x-6 gap-y-1 rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<InfoRow label="Workspace" value={session.workspace} mono />
|
||||||
|
<InfoRow label="服务器" value={session.serverUri} mono />
|
||||||
|
<InfoRow label="Collection" value={session.collection} />
|
||||||
|
<InfoRow label="Owner" value={info?.ownerName ?? "…"} />
|
||||||
|
<InfoRow label="Computer" value={info?.computer ?? "…"} />
|
||||||
|
<InfoRow label="Mapping 数量" value={String(session.mappings.length)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<Label htmlFor="addServerPath" className="text-xs">
|
||||||
|
新增映射的服务端路径
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addServerPath"
|
||||||
|
value={addServerPath}
|
||||||
|
onChange={(event) => setAddServerPath(event.target.value)}
|
||||||
|
placeholder="$/Project/Folder"
|
||||||
|
className="h-8 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!addPathValid}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
title={addPathValid ? undefined : "请输入以 $/ 开头的服务端路径"}
|
||||||
|
>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
新增映射…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{session.mappings.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
当前 Workspace 还没有任何映射
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground">本地目录</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{session.mappings.map((mapping) => {
|
||||||
|
const exists = existsMap?.[mapping.localPath]
|
||||||
|
return (
|
||||||
|
<TableRow key={mapping.serverPath} className="cursor-default select-none">
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={mapping.serverPath}
|
||||||
|
>
|
||||||
|
{mapping.serverPath}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={mapping.localPath}
|
||||||
|
>
|
||||||
|
{mapping.localPath}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
{existsMap == null ? (
|
||||||
|
<span className="text-xs text-muted-foreground">无法检测</span>
|
||||||
|
) : exists ? (
|
||||||
|
<Badge variant="secondary" className="bg-muted text-foreground/80">
|
||||||
|
存在
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-red-500/10 text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
不存在
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="修改本地路径…"
|
||||||
|
onClick={() => setEditMapping(mapping)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title="删除映射…"
|
||||||
|
onClick={() => setConfirmDelete(mapping)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
删除映射只解除服务端目录与本地目录的关联,不会删除本地已下载的文件。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{addOpen && (
|
||||||
|
<MappingDialog
|
||||||
|
serverPath={trimmedAddPath}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onCreated={(mappings) => {
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
setAddOpen(false)
|
||||||
|
setAddServerPath("")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editMapping && (
|
||||||
|
<EditMappingDialog
|
||||||
|
mapping={editMapping}
|
||||||
|
onClose={() => setEditMapping(null)}
|
||||||
|
onChanged={(mappings) => {
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
setEditMapping(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="删除映射"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
将解除 <span className="font-mono text-xs">{confirmDelete.serverPath}</span>{" "}
|
||||||
|
与本地目录的映射。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
本地已下载的文件不会被删除;该目录下的挂起更改将无法继续签入。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="删除映射"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
await handleDelete(confirmDelete)
|
||||||
|
setConfirmDelete(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作区信息单行展示。
|
||||||
|
*/
|
||||||
|
function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`} title={value}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改 Mapping 本地路径弹窗:选择新的本地父目录并预校验,
|
||||||
|
* 确认后按「删旧 + 加新」方式调整映射(不迁移本地内容)。
|
||||||
|
*/
|
||||||
|
function EditMappingDialog({
|
||||||
|
mapping,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
mapping: MappingInfo
|
||||||
|
onClose: () => void
|
||||||
|
onChanged: (mappings: MappingInfo[]) => void
|
||||||
|
}) {
|
||||||
|
const [parentPath, setParentPath] = useState("")
|
||||||
|
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过系统目录选择器选择新的本地父目录。
|
||||||
|
*/
|
||||||
|
const handleBrowse = useCallback(async () => {
|
||||||
|
const selected = await selectDirectory()
|
||||||
|
if (selected) {
|
||||||
|
setParentPath(selected)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 与创建映射一致:父目录变化时由后端预校验最终目标路径。
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = parentPath.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
setCheck(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
setChecking(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.checkMappingTarget({
|
||||||
|
serverPath: mapping.serverPath,
|
||||||
|
localParentPath: trimmed,
|
||||||
|
})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChecking(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setCheck(null)
|
||||||
|
setError(result.errorMessage ?? "目标路径校验失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setCheck(result.data)
|
||||||
|
})()
|
||||||
|
}, 300)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [parentPath, mapping.serverPath])
|
||||||
|
|
||||||
|
const exists = check?.exists === true
|
||||||
|
const canConfirm = check != null && !exists && !busy && !checking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删旧 + 加新调整映射本地路径,成功后回调最新 mappings。
|
||||||
|
*/
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (!check) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
const removed = await api.deleteMapping({ serverPath: mapping.serverPath })
|
||||||
|
if (!removed.ok) {
|
||||||
|
setBusy(false)
|
||||||
|
setError(removed.errorMessage ?? "删除原映射失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const added = await api.addMapping({
|
||||||
|
serverPath: mapping.serverPath,
|
||||||
|
localPath: check.targetPath,
|
||||||
|
getLatest: false,
|
||||||
|
})
|
||||||
|
setBusy(false)
|
||||||
|
if (!added.ok) {
|
||||||
|
setError(`${added.errorMessage ?? "创建新映射失败"}(原映射已删除,请重新映射)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mappings = await api.listMappings()
|
||||||
|
onChanged(mappings.data?.mappings ?? [])
|
||||||
|
}, [check, mapping.serverPath, onChanged])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改本地路径</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{mapping.serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>当前本地路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{mapping.localPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="newParentPath">新的本地父目录</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="newParentPath"
|
||||||
|
placeholder="/Users/me/tfs"
|
||||||
|
value={parentPath}
|
||||||
|
onChange={(event) => setParentPath(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{isElectron() && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleBrowse()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="浏览本地目录"
|
||||||
|
>
|
||||||
|
<FolderSearch />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checking && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
正在校验目标路径…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{check && !checking && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>新映射路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{check.targetPath}
|
||||||
|
</p>
|
||||||
|
{exists && (
|
||||||
|
<p className="text-xs font-medium text-destructive">目标目录已存在,禁止映射</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
修改方式为「删除原映射 + 创建新映射」:原本地目录内容保留但不再与服务端关联,
|
||||||
|
新目录需重新执行获取最新。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
|
||||||
|
{busy ? "正在修改…" : "修改本地路径"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import type { MappingInfo, PendingChange } from "~/lib/api"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、
|
||||||
|
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。
|
||||||
|
*/
|
||||||
|
export function ChangesPanel({
|
||||||
|
mappings,
|
||||||
|
pendingChanges,
|
||||||
|
excludedKeys,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
checkinBusy,
|
||||||
|
onToggleExcluded,
|
||||||
|
onCheckin,
|
||||||
|
onFileAction,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
pendingChanges: PendingChange[]
|
||||||
|
excludedKeys: Set<string>
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
checkinBusy: boolean
|
||||||
|
onToggleExcluded: (serverPath: string) => void
|
||||||
|
onCheckin: (paths: string[], comment: string) => Promise<boolean>
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}) {
|
||||||
|
const [comment, setComment] = useState("")
|
||||||
|
|
||||||
|
const included = useMemo(
|
||||||
|
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
|
||||||
|
[pendingChanges, excludedKeys],
|
||||||
|
)
|
||||||
|
const excluded = useMemo(
|
||||||
|
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
|
||||||
|
[pendingChanges, excludedKeys],
|
||||||
|
)
|
||||||
|
|
||||||
|
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交签入:只提交 Included 项,成功后清空注释。
|
||||||
|
*/
|
||||||
|
const handleCheckin = useCallback(async () => {
|
||||||
|
const success = await onCheckin(
|
||||||
|
included.map((change) => change.serverPath),
|
||||||
|
comment.trim(),
|
||||||
|
)
|
||||||
|
if (success) {
|
||||||
|
setComment("")
|
||||||
|
}
|
||||||
|
}, [onCheckin, included, comment])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||||
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
挂起更改
|
||||||
|
</span>
|
||||||
|
{pendingChanges.length > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className={pendingChanges.length > 0 ? "" : "ml-auto"}
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="刷新挂起更改"
|
||||||
|
title="刷新挂起更改"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(loading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
正在加载挂起更改…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : pendingChanges.length === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<ListChecks className="size-6 text-muted-foreground/60" />
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">当前没有挂起更改</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
<GroupHeader label="Included" count={included.length} />
|
||||||
|
{included.length === 0 && (
|
||||||
|
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
没有要签入的更改,勾选下方 Excluded 项可移回
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{included.map((change) => (
|
||||||
|
<PendingRow
|
||||||
|
key={change.serverPath}
|
||||||
|
change={change}
|
||||||
|
included
|
||||||
|
mappings={mappings}
|
||||||
|
onToggleExcluded={onToggleExcluded}
|
||||||
|
onFileAction={onFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{excluded.length > 0 && (
|
||||||
|
<>
|
||||||
|
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
|
||||||
|
{excluded.map((change) => (
|
||||||
|
<PendingRow
|
||||||
|
key={change.serverPath}
|
||||||
|
change={change}
|
||||||
|
included={false}
|
||||||
|
mappings={mappings}
|
||||||
|
onToggleExcluded={onToggleExcluded}
|
||||||
|
onFileAction={onFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shrink-0 space-y-2 border-t p-3">
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
placeholder="签入注释(必填)"
|
||||||
|
rows={3}
|
||||||
|
disabled={checkinBusy || pendingChanges.length === 0}
|
||||||
|
className="resize-none text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
将签入 {included.length} 项
|
||||||
|
</span>
|
||||||
|
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
|
||||||
|
{checkinBusy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||||
|
正在签入…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"签入"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{comment.trim().length === 0 && pendingChanges.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">填写注释后才能签入</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组标题行。
|
||||||
|
*/
|
||||||
|
function GroupHeader({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground/70">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。
|
||||||
|
*/
|
||||||
|
function PendingRow({
|
||||||
|
change,
|
||||||
|
included,
|
||||||
|
mappings,
|
||||||
|
onToggleExcluded,
|
||||||
|
onFileAction,
|
||||||
|
}: {
|
||||||
|
change: PendingChange
|
||||||
|
included: boolean
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
onToggleExcluded: (serverPath: string) => void
|
||||||
|
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||||
|
}) {
|
||||||
|
const target = makeFileTarget({
|
||||||
|
source: "pending",
|
||||||
|
folder: change.folder,
|
||||||
|
serverPath: change.serverPath,
|
||||||
|
mappings,
|
||||||
|
pendingStatus: change.status,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted",
|
||||||
|
!included && "opacity-60",
|
||||||
|
)}
|
||||||
|
title={change.serverPath}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={included}
|
||||||
|
onCheckedChange={() => onToggleExcluded(change.serverPath)}
|
||||||
|
aria-label={included ? "移到 Excluded" : "移回 Included"}
|
||||||
|
/>
|
||||||
|
{change.folder ? (
|
||||||
|
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm">
|
||||||
|
{change.status === "pendingRename" && change.sourceServerPath
|
||||||
|
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
|
||||||
|
: change.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
|
||||||
|
{change.serverPath}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
|
||||||
|
>
|
||||||
|
<span className="size-1.5 rounded-full bg-current opacity-60" />
|
||||||
|
{statusLabel(change.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</FileTargetMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import {
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
|
|
||||||
import type {
|
|
||||||
PendingChangeStatus,
|
|
||||||
TfsPendingChangeInfo,
|
|
||||||
} from "~/lib/api/types"
|
|
||||||
|
|
||||||
interface PendingChangesPanelProps {
|
|
||||||
connected: boolean
|
|
||||||
collection: string
|
|
||||||
refreshKey: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
|
|
||||||
pendingEdit: "编辑",
|
|
||||||
pendingAdd: "新增",
|
|
||||||
pendingDelete: "删除",
|
|
||||||
pendingRename: "重命名",
|
|
||||||
pending: "挂起",
|
|
||||||
}
|
|
||||||
|
|
||||||
const PENDING_STATUS_CLASSES: Record<PendingChangeStatus, string> = {
|
|
||||||
pendingEdit: "border-blue-200 bg-blue-50 text-blue-700",
|
|
||||||
pendingAdd: "border-green-200 bg-green-50 text-green-700",
|
|
||||||
pendingDelete: "border-red-200 bg-red-50 text-red-700",
|
|
||||||
pendingRename: "border-violet-200 bg-violet-50 text-violet-700",
|
|
||||||
pending: "border-border bg-muted/30 text-muted-foreground",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 Inspector 中展示当前 Workspace 的 Included / Excluded Pending Changes。
|
|
||||||
*/
|
|
||||||
export function PendingChangesPanel({
|
|
||||||
connected,
|
|
||||||
collection,
|
|
||||||
refreshKey,
|
|
||||||
}: PendingChangesPanelProps) {
|
|
||||||
const [pendingChanges, setPendingChanges] = useState<
|
|
||||||
TfsPendingChangeInfo[]
|
|
||||||
>([])
|
|
||||||
const [includedKeys, setIncludedKeys] = useState<string[]>([])
|
|
||||||
const [excludedKeys, setExcludedKeys] = useState<string[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [operationKey, setOperationKey] = useState("")
|
|
||||||
const [checkinComment, setCheckinComment] = useState("")
|
|
||||||
const [checkinLoading, setCheckinLoading] = useState(false)
|
|
||||||
const [messageType, setMessageType] = useState<"success" | "error">(
|
|
||||||
"success"
|
|
||||||
)
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!connected || !collection) {
|
|
||||||
setPendingChanges([])
|
|
||||||
setIncludedKeys([])
|
|
||||||
setExcludedKeys([])
|
|
||||||
setCheckinComment("")
|
|
||||||
setCheckinLoading(false)
|
|
||||||
setMessageType("success")
|
|
||||||
setMessage("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPendingChanges()
|
|
||||||
}, [connected, collection, refreshKey])
|
|
||||||
|
|
||||||
const includedChanges = pendingChanges.filter((change) =>
|
|
||||||
includedKeys.includes(toPendingChangeKey(change))
|
|
||||||
)
|
|
||||||
const excludedChanges = pendingChanges.filter((change) =>
|
|
||||||
excludedKeys.includes(toPendingChangeKey(change))
|
|
||||||
)
|
|
||||||
const collectionReady = connected && Boolean(collection)
|
|
||||||
const canCheckin =
|
|
||||||
includedChanges.length > 0 &&
|
|
||||||
checkinComment.trim().length > 0 &&
|
|
||||||
!checkinLoading
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询后端 pending changes,并保留当前会话内用户移动到 Excluded 的选择。
|
|
||||||
*/
|
|
||||||
async function loadPendingChanges() {
|
|
||||||
setLoading(true)
|
|
||||||
setMessage("")
|
|
||||||
|
|
||||||
const result = await listPendingChanges({ collection })
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setPendingChanges([])
|
|
||||||
setIncludedKeys([])
|
|
||||||
setExcludedKeys([])
|
|
||||||
setMessageType("error")
|
|
||||||
setMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextChanges = result.data.pendingChanges
|
|
||||||
const nextKeys = nextChanges.map(toPendingChangeKey)
|
|
||||||
setPendingChanges(nextChanges)
|
|
||||||
setExcludedKeys((currentKeys) =>
|
|
||||||
currentKeys.filter((currentKey) => nextKeys.includes(currentKey))
|
|
||||||
)
|
|
||||||
setIncludedKeys((currentKeys) => {
|
|
||||||
const knownKeys = new Set([...currentKeys, ...excludedKeys])
|
|
||||||
const excludedKeySet = new Set(excludedKeys)
|
|
||||||
const includedKeySet = new Set(currentKeys)
|
|
||||||
|
|
||||||
return nextKeys.filter((key) => {
|
|
||||||
if (excludedKeySet.has(key)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return knownKeys.has(key) ? includedKeySet.has(key) : true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用服务端路径作为 Pending Change 的会话 key。
|
|
||||||
*/
|
|
||||||
function toPendingChangeKey(change: TfsPendingChangeInfo) {
|
|
||||||
return change.serverPath
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将单个挂起更改移动到 Excluded Changes。
|
|
||||||
*/
|
|
||||||
function moveToExcluded(change: TfsPendingChangeInfo) {
|
|
||||||
const key = toPendingChangeKey(change)
|
|
||||||
setIncludedKeys((currentKeys) =>
|
|
||||||
currentKeys.filter((currentKey) => currentKey !== key)
|
|
||||||
)
|
|
||||||
setExcludedKeys((currentKeys) =>
|
|
||||||
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将单个挂起更改移回 Included Changes,后续签入只读取 Included。
|
|
||||||
*/
|
|
||||||
function moveToIncluded(change: TfsPendingChangeInfo) {
|
|
||||||
const key = toPendingChangeKey(change)
|
|
||||||
setExcludedKeys((currentKeys) =>
|
|
||||||
currentKeys.filter((currentKey) => currentKey !== key)
|
|
||||||
)
|
|
||||||
setIncludedKeys((currentKeys) =>
|
|
||||||
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 撤销指定 pending change,完成后刷新右侧挂起更改列表。
|
|
||||||
*/
|
|
||||||
async function undoPendingChange(change: TfsPendingChangeInfo) {
|
|
||||||
const key = toPendingChangeKey(change)
|
|
||||||
setOperationKey(key)
|
|
||||||
setMessage("")
|
|
||||||
|
|
||||||
const result = await undoFiles({
|
|
||||||
paths: [change.serverPath],
|
|
||||||
recursive: change.folder,
|
|
||||||
})
|
|
||||||
setOperationKey("")
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setMessage(result.errorMessage || result.message)
|
|
||||||
setMessageType("error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessageType("success")
|
|
||||||
setMessage(`Undo 完成,影响 ${result.data.result?.affected ?? 0} 项。`)
|
|
||||||
await loadPendingChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交 Included Changes,成功后清空 Included 并保留 Excluded 会话状态。
|
|
||||||
*/
|
|
||||||
async function submitIncludedChanges() {
|
|
||||||
const comment = checkinComment.trim()
|
|
||||||
if (!comment) {
|
|
||||||
setMessageType("error")
|
|
||||||
setMessage("Comment 必填。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (includedChanges.length === 0) {
|
|
||||||
setMessageType("error")
|
|
||||||
setMessage("没有可签入的 Included Changes。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCheckinLoading(true)
|
|
||||||
setMessage("")
|
|
||||||
|
|
||||||
const result = await checkin({
|
|
||||||
serverPaths: includedChanges.map((change) => change.serverPath),
|
|
||||||
comment,
|
|
||||||
})
|
|
||||||
setCheckinLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setMessageType("error")
|
|
||||||
setMessage(result.errorMessage || result.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCheckinComment("")
|
|
||||||
await loadPendingChanges()
|
|
||||||
setIncludedKeys([])
|
|
||||||
setMessageType("success")
|
|
||||||
setMessage(
|
|
||||||
`Checkin 成功,changeset ${result.data.checkin?.changeset ?? "-"},提交 ${result.data.checkin?.submittedChanges ?? 0} 项。`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="grid gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">Pending Changes</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
签入范围:Included Changes
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
||||||
{pendingChanges.length} 项
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!collectionReady || loading || checkinLoading}
|
|
||||||
title="刷新挂起更改"
|
|
||||||
onClick={loadPendingChanges}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!collectionReady ? (
|
|
||||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
{connected ? "请选择 Collection" : "未连接"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
className={`rounded-[6px] border px-3 py-2 text-xs ${
|
|
||||||
messageType === "error"
|
|
||||||
? "border-destructive/30 bg-destructive/10 text-destructive"
|
|
||||||
: "bg-muted/20 text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PendingChangeGroup
|
|
||||||
title="Included Changes"
|
|
||||||
changes={includedChanges}
|
|
||||||
emptyText={loading ? "正在查询挂起更改" : "暂无 Included Changes"}
|
|
||||||
actionTitle="移到 Excluded"
|
|
||||||
actionIcon="down"
|
|
||||||
operationKey={operationKey}
|
|
||||||
disabled={checkinLoading}
|
|
||||||
onUndo={undoPendingChange}
|
|
||||||
onMove={moveToExcluded}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PendingChangeGroup
|
|
||||||
title="Excluded Changes"
|
|
||||||
changes={excludedChanges}
|
|
||||||
emptyText="暂无 Excluded Changes"
|
|
||||||
actionTitle="移回 Included"
|
|
||||||
actionIcon="up"
|
|
||||||
operationKey={operationKey}
|
|
||||||
disabled={checkinLoading}
|
|
||||||
onUndo={undoPendingChange}
|
|
||||||
onMove={moveToIncluded}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="grid gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="font-medium text-muted-foreground">Checkin</span>
|
|
||||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
|
|
||||||
Included {includedChanges.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 rounded-[6px] border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
value={checkinComment}
|
|
||||||
placeholder="Comment"
|
|
||||||
disabled={checkinLoading}
|
|
||||||
onChange={(event) => setCheckinComment(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={!canCheckin}
|
|
||||||
onClick={submitIncludedChanges}
|
|
||||||
>
|
|
||||||
{checkinLoading ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload />
|
|
||||||
)}
|
|
||||||
Checkin
|
|
||||||
</Button>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
只提交 Included Changes,Excluded 保留在当前 UI 会话。
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingChangeGroupProps {
|
|
||||||
title: string
|
|
||||||
changes: TfsPendingChangeInfo[]
|
|
||||||
emptyText: string
|
|
||||||
actionTitle: string
|
|
||||||
actionIcon: "up" | "down"
|
|
||||||
operationKey: string
|
|
||||||
disabled: boolean
|
|
||||||
onUndo(change: TfsPendingChangeInfo): void
|
|
||||||
onMove(change: TfsPendingChangeInfo): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 Pending Changes 单个分组,并提供组间移动入口。
|
|
||||||
*/
|
|
||||||
function PendingChangeGroup({
|
|
||||||
title,
|
|
||||||
changes,
|
|
||||||
emptyText,
|
|
||||||
actionTitle,
|
|
||||||
actionIcon,
|
|
||||||
operationKey,
|
|
||||||
disabled,
|
|
||||||
onUndo,
|
|
||||||
onMove,
|
|
||||||
}: PendingChangeGroupProps) {
|
|
||||||
return (
|
|
||||||
<section className="grid gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="font-medium text-muted-foreground">{title}</span>
|
|
||||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
|
|
||||||
{changes.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{changes.length === 0 ? (
|
|
||||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{emptyText}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid max-h-56 gap-2 overflow-auto">
|
|
||||||
{changes.map((change) => (
|
|
||||||
<div
|
|
||||||
key={change.serverPath}
|
|
||||||
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
{change.folder ? (
|
|
||||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
|
||||||
) : (
|
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="truncate font-medium">{change.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
title="Undo"
|
|
||||||
disabled={disabled || operationKey === change.serverPath}
|
|
||||||
onClick={() => onUndo(change)}
|
|
||||||
>
|
|
||||||
{operationKey === change.serverPath ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RotateCcw />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
title={actionTitle}
|
|
||||||
disabled={disabled || operationKey === change.serverPath}
|
|
||||||
onClick={() => onMove(change)}
|
|
||||||
>
|
|
||||||
{actionIcon === "down" ? <ArrowDown /> : <ArrowUp />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="truncate font-mono text-[11px] text-muted-foreground">
|
|
||||||
{change.serverPath}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={`rounded-[6px] border px-1.5 py-0.5 ${PENDING_STATUS_CLASSES[change.status]}`}
|
|
||||||
>
|
|
||||||
{change.status} / {PENDING_STATUS_LABELS[change.status]}
|
|
||||||
</span>
|
|
||||||
{change.changeType && (
|
|
||||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-muted-foreground">
|
|
||||||
{change.changeType}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 font-mono text-muted-foreground">
|
|
||||||
v{change.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { OperationLogEntry } from "~/lib/api"
|
||||||
|
import { cn, formatDateTime } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把毫秒耗时格式化为可读文本。
|
||||||
|
*/
|
||||||
|
function formatDuration(durationMs: number): string {
|
||||||
|
if (durationMs < 1000) {
|
||||||
|
return `${durationMs} ms`
|
||||||
|
}
|
||||||
|
return `${(durationMs / 1000).toFixed(1)} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底部 Operation Console:展示最近操作日志(操作、摘要、起止时间、耗时、结果),
|
||||||
|
* 随主要操作完成自动刷新,长操作执行期间显示执行中提示。
|
||||||
|
*/
|
||||||
|
export function ConsolePanel({
|
||||||
|
refreshToken,
|
||||||
|
busy,
|
||||||
|
busyText,
|
||||||
|
}: {
|
||||||
|
refreshToken: number
|
||||||
|
busy: boolean
|
||||||
|
busyText: string | null
|
||||||
|
}) {
|
||||||
|
const [logs, setLogs] = useState<OperationLogEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取最近操作日志。
|
||||||
|
*/
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.getLogs()
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "操作日志加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setLogs(result.data?.logs ?? [])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLogs()
|
||||||
|
}, [loadLogs, refreshToken])
|
||||||
|
|
||||||
|
// 最新的操作排在最上面。
|
||||||
|
const ordered = useMemo(
|
||||||
|
() => [...logs].sort((a, b) => b.startedAt - a.startedAt),
|
||||||
|
[logs],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
|
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
||||||
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
操作日志
|
||||||
|
</span>
|
||||||
|
{busy && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-primary">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
{busyText ?? "正在执行…"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{ordered.length > 0 && `${ordered.length} 条`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => void loadLogs()}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="刷新日志"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(loading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{error ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-destructive">{error}</p>
|
||||||
|
) : ordered.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{loading ? "正在加载操作日志…" : "暂无操作日志"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="px-2 py-1">
|
||||||
|
{ordered.map((entry, index) => (
|
||||||
|
<li
|
||||||
|
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
||||||
|
className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
|
||||||
|
title={entry.errorMessage ?? entry.summary}
|
||||||
|
>
|
||||||
|
{entry.success ? (
|
||||||
|
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-3.5 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
<span className="w-36 shrink-0 truncate font-mono text-muted-foreground">
|
||||||
|
{entry.operation}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{entry.summary}
|
||||||
|
{!entry.success && entry.errorMessage && (
|
||||||
|
<span className="text-destructive"> {entry.errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="w-32 shrink-0 text-right text-muted-foreground">
|
||||||
|
{formatDateTime(entry.startedAt)}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 shrink-0 text-right font-mono text-muted-foreground">
|
||||||
|
{formatDuration(entry.durationMs)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -5,15 +5,15 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-slot="context-menu-trigger"
|
||||||
|
className={cn("select-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/20 duration-200 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-150",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"ease-out-quart fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-dialog duration-200 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-top-1 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:duration-150",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-semibold tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础文本输入框,统一 macOS 工作台风格的尺寸、边框与聚焦态。
|
||||||
|
*/
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单字段标签,保持轻量并禁止选中,贴合工具型界面。
|
||||||
|
*/
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-sm font-medium text-foreground select-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
|
||||||
|
export type ToastKind = "success" | "error" | "loading"
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string
|
||||||
|
kind: ToastKind
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (toasts: ToastItem[]) => void
|
||||||
|
|
||||||
|
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
|
||||||
|
let currentToasts: ToastItem[] = []
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 3200
|
||||||
|
const MAX_VISIBLE = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有挂载中的 Toaster 重新渲染。
|
||||||
|
*/
|
||||||
|
function emit() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(currentToasts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭指定 toast。
|
||||||
|
*/
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
currentToasts = currentToasts.filter((item) => item.id !== id)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
|
||||||
|
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
|
||||||
|
*/
|
||||||
|
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
|
||||||
|
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
const existing = currentToasts.find((item) => item.id === id)
|
||||||
|
const next: ToastItem = { id, kind: input.kind, text: input.text }
|
||||||
|
currentToasts = existing
|
||||||
|
? currentToasts.map((item) => (item.id === id ? next : item))
|
||||||
|
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
if (input.kind !== "loading") {
|
||||||
|
dismissTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// toast 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
|
||||||
|
const KIND_VISUALS: Record<
|
||||||
|
ToastKind,
|
||||||
|
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
barClass: "bg-emerald-500",
|
||||||
|
borderClass: "border-emerald-500/35",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconClass: "text-destructive",
|
||||||
|
barClass: "bg-destructive",
|
||||||
|
borderClass: "border-destructive/40",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
icon: Loader2,
|
||||||
|
iconClass: "animate-spin text-primary",
|
||||||
|
barClass: "bg-primary",
|
||||||
|
borderClass: "border-primary/35",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
|
||||||
|
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
|
||||||
|
*/
|
||||||
|
export function Toaster() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener: Listener = (toasts) => setItems([...toasts])
|
||||||
|
listeners.add(listener)
|
||||||
|
setItems([...currentToasts])
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-14 right-3 z-[60] flex w-[380px] flex-col gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const visual = KIND_VISUALS[item.kind]
|
||||||
|
const Icon = visual.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
|
||||||
|
visual.borderClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
|
||||||
|
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
|
||||||
|
<span className="min-w-0 break-words">{item.text}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import { useCallback, useState } from "react"
|
||||||
|
|
||||||
|
import type { DiffRequest } from "~/components/explorer/diff-dialog"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
import { isElectron, revealPath } from "~/lib/electron"
|
||||||
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
|
||||||
|
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认 / 属性 / 获取特定版本。
|
||||||
|
export type WorkspaceDialogState =
|
||||||
|
| { kind: "mapping"; serverPath: string }
|
||||||
|
| { kind: "history"; serverPath: string; folder: boolean }
|
||||||
|
| { kind: "compare"; serverPath: string }
|
||||||
|
| { kind: "viewFile"; serverPath: string; localPath: string | null; changeset?: number }
|
||||||
|
| { kind: "diff"; request: DiffRequest }
|
||||||
|
| { kind: "conflicts"; serverPath: string }
|
||||||
|
| { kind: "confirmForceGet"; serverPath: string; folder: boolean }
|
||||||
|
| { kind: "properties"; target: FileTarget }
|
||||||
|
| { kind: "getVersion"; serverPath: string; folder: boolean }
|
||||||
|
| { kind: "rename"; serverPath: string; folder: boolean }
|
||||||
|
| { kind: "branch"; serverPath: string }
|
||||||
|
| { kind: "merge"; serverPath: string }
|
||||||
|
| { kind: "addFiles"; serverPath: string }
|
||||||
|
| null
|
||||||
|
|
||||||
|
// 顶部细条通知:信息(操作摘要)或错误。
|
||||||
|
export interface ActionNotice {
|
||||||
|
kind: "info" | "error"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动作编排所需的上层依赖:mappings 更新与各类刷新入口。
|
||||||
|
interface FileActionsDeps {
|
||||||
|
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||||
|
refreshPendingChanges: () => Promise<void>
|
||||||
|
refreshItems: () => void
|
||||||
|
refreshLogs: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象动作编排 hook:集中持有弹窗开关、通知与忙碌状态,
|
||||||
|
* 把右键菜单动作映射为 API 执行或弹窗打开,并按统一规则刷新(含操作日志)。
|
||||||
|
*/
|
||||||
|
export function useFileActions({
|
||||||
|
onMappingsChanged,
|
||||||
|
refreshPendingChanges,
|
||||||
|
refreshItems,
|
||||||
|
refreshLogs,
|
||||||
|
}: FileActionsDeps) {
|
||||||
|
const [dialog, setDialog] = useState<WorkspaceDialogState>(null)
|
||||||
|
const [notice, setNotice] = useState<ActionNotice | null>(null)
|
||||||
|
const [actionBusy, setActionBusy] = useState(false)
|
||||||
|
const [checkinBusy, setCheckinBusy] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Get Latest(目录递归),返回是否产生冲突;摘要写入顶部通知。
|
||||||
|
* 默认安全模式:本地改动不会被覆盖而是产生冲突并自动进入冲突弹窗;
|
||||||
|
* force=true 为强制覆盖本地(仅经确认弹窗触发)。
|
||||||
|
*/
|
||||||
|
const runGetLatest = useCallback(
|
||||||
|
async (target: FileTarget, force = false): Promise<boolean> => {
|
||||||
|
const result = await api.getLatest({
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
recursive: target.folder,
|
||||||
|
force,
|
||||||
|
})
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "获取最新失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const summary = result.data.result
|
||||||
|
const parts = [`更新 ${summary.updated} 项`]
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
parts.push(`冲突 ${summary.conflicts} 项`)
|
||||||
|
}
|
||||||
|
if (summary.failures > 0) {
|
||||||
|
parts.push(`跳过 / 失败 ${summary.failures} 项`)
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: summary.conflicts > 0 ? "error" : "info",
|
||||||
|
text: `${force ? "强制获取" : "获取最新"}完成:${parts.join(",")}`,
|
||||||
|
})
|
||||||
|
refreshItems()
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
setDialog({ kind: "conflicts", serverPath: target.serverPath })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 changeset 版本(覆盖本地),由历史弹窗中的确认操作触发。
|
||||||
|
*/
|
||||||
|
const runGetVersion = useCallback(
|
||||||
|
async (serverPath: string, changeset: number, folder: boolean): Promise<boolean> => {
|
||||||
|
setNotice({ kind: "info", text: `正在获取版本 C${changeset}…` })
|
||||||
|
const result = await api.getVersion({ serverPath, changeset, recursive: folder })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "获取指定版本失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const summary = result.data.result
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: `已获取版本 C${changeset}:更新 ${summary.updated} 项(本地已被该版本覆盖)`,
|
||||||
|
})
|
||||||
|
refreshItems()
|
||||||
|
await refreshPendingChanges()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行变更集回滚(产生挂起更改,不直接入库),由历史弹窗中的确认操作触发;
|
||||||
|
* 出现冲突时进入统一冲突弹窗,完成后刷新挂起更改与目录列表。
|
||||||
|
*/
|
||||||
|
const runRollback = useCallback(
|
||||||
|
async (
|
||||||
|
serverPath: string,
|
||||||
|
mode: "single" | "toVersion",
|
||||||
|
changeset: number,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: mode === "single" ? `正在回滚变更集 C${changeset}…` : `正在回滚到 C${changeset}…`,
|
||||||
|
})
|
||||||
|
const result = await api.rollback({ serverPath, mode, changeset })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "回滚失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const summary = result.data.result
|
||||||
|
const parts = [`产生挂起更改 ${summary.operations} 项`]
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
parts.push(`冲突 ${summary.conflicts} 项`)
|
||||||
|
}
|
||||||
|
if (summary.failures > 0) {
|
||||||
|
parts.push(`失败 ${summary.failures} 项`)
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: summary.conflicts > 0 || summary.failures > 0 ? "error" : "info",
|
||||||
|
text: `回滚完成:${parts.join(",")},请在挂起更改面板审查后签入`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
setDialog({ kind: "conflicts", serverPath })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Checkout:先 Get Latest,出现冲突时进入统一冲突弹窗;
|
||||||
|
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
|
||||||
|
*/
|
||||||
|
const runCheckout = useCallback(
|
||||||
|
async (target: FileTarget) => {
|
||||||
|
const hadConflicts = await runGetLatest(target)
|
||||||
|
if (hadConflicts) {
|
||||||
|
setNotice({ kind: "error", text: "获取最新存在冲突,请先处理冲突后再签出" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await api.checkout({
|
||||||
|
paths: [target.serverPath],
|
||||||
|
recursive: target.folder,
|
||||||
|
})
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "签出失败" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const summary = result.data.result
|
||||||
|
const failureText =
|
||||||
|
summary.failures.length > 0
|
||||||
|
? `,跳过 ${summary.failures.length} 项(${summary.failures.slice(0, 3).join(";")}${summary.failures.length > 3 ? "…" : ""})`
|
||||||
|
: ""
|
||||||
|
setNotice({
|
||||||
|
kind: summary.failures.length > 0 ? "error" : "info",
|
||||||
|
text: `签出完成:${summary.affected} 项${failureText}`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
},
|
||||||
|
[runGetLatest, refreshPendingChanges, refreshLogs],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 挂起删除 / 撤销更改 两类文件操作并按规则刷新。
|
||||||
|
*/
|
||||||
|
const runFileOperation = useCallback(
|
||||||
|
async (target: FileTarget, action: "delete" | "undo") => {
|
||||||
|
const labels =
|
||||||
|
action === "delete"
|
||||||
|
? { doing: "正在挂起删除…", fail: "挂起删除失败", done: "已挂起删除" }
|
||||||
|
: { doing: "正在撤销更改…", fail: "撤销更改失败", done: "已撤销" }
|
||||||
|
setNotice({ kind: "info", text: labels.doing })
|
||||||
|
const call = action === "delete" ? api.deleteFiles : api.undoFiles
|
||||||
|
const result = await call({ paths: [target.serverPath], recursive: target.folder })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? labels.fail })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setNotice({ kind: "info", text: `${labels.done} ${result.data.result.affected} 项` })
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
},
|
||||||
|
[refreshPendingChanges, refreshItems, refreshLogs],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象右键菜单动作统一分发器。
|
||||||
|
* unmap / getLatest / checkout / delete / undo 直接执行;
|
||||||
|
* map / history / compare / viewFile / diff 打开对应弹窗。
|
||||||
|
*/
|
||||||
|
const handleFileAction = useCallback(
|
||||||
|
async (target: FileTarget, action: FileActionId) => {
|
||||||
|
setNotice(null)
|
||||||
|
switch (action) {
|
||||||
|
case "unmap": {
|
||||||
|
const result = await api.deleteMapping({ serverPath: target.serverPath })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "取消映射失败" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不跳转当前浏览位置,仅更新 mappings 让动作可用性与状态列刷新。
|
||||||
|
onMappingsChanged(result.data?.mappings ?? [])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "getLatest":
|
||||||
|
setActionBusy(true)
|
||||||
|
setNotice({ kind: "info", text: "正在获取最新…" })
|
||||||
|
await runGetLatest(target)
|
||||||
|
setActionBusy(false)
|
||||||
|
break
|
||||||
|
case "forceGetLatest":
|
||||||
|
// 覆盖类危险操作:先弹确认,确认后才执行。
|
||||||
|
setDialog({
|
||||||
|
kind: "confirmForceGet",
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
folder: target.folder,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "getSpecificVersion":
|
||||||
|
// 覆盖类危险操作:弹窗内选择 changeset 并确认后执行。
|
||||||
|
setDialog({
|
||||||
|
kind: "getVersion",
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
folder: target.folder,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "properties":
|
||||||
|
setDialog({ kind: "properties", target })
|
||||||
|
break
|
||||||
|
case "rename":
|
||||||
|
setDialog({ kind: "rename", serverPath: target.serverPath, folder: target.folder })
|
||||||
|
break
|
||||||
|
case "branch":
|
||||||
|
setDialog({ kind: "branch", serverPath: target.serverPath })
|
||||||
|
break
|
||||||
|
case "merge":
|
||||||
|
setDialog({ kind: "merge", serverPath: target.serverPath })
|
||||||
|
break
|
||||||
|
case "revealInFinder": {
|
||||||
|
if (!target.localPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isElectron()) {
|
||||||
|
setNotice({ kind: "error", text: "「在访达中打开」仅桌面应用支持" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const revealed = await revealPath(target.localPath, target.folder)
|
||||||
|
if (!revealed) {
|
||||||
|
setNotice({ kind: "error", text: "本地路径不存在,请先获取最新" })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "addLocalFiles":
|
||||||
|
// 扫描式添加:弹窗内目录对比筛出本地新增项,勾选后批量 pend add。
|
||||||
|
if (!target.mapped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDialog({ kind: "addFiles", serverPath: target.serverPath })
|
||||||
|
break
|
||||||
|
case "checkout":
|
||||||
|
setActionBusy(true)
|
||||||
|
setNotice({ kind: "info", text: "正在签出…" })
|
||||||
|
await runCheckout(target)
|
||||||
|
setActionBusy(false)
|
||||||
|
break
|
||||||
|
case "delete":
|
||||||
|
case "undo":
|
||||||
|
setActionBusy(true)
|
||||||
|
await runFileOperation(target, action)
|
||||||
|
setActionBusy(false)
|
||||||
|
break
|
||||||
|
case "add": {
|
||||||
|
// pend add 需要本地路径(localOnly 项服务端尚不存在)。
|
||||||
|
if (!target.localPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActionBusy(true)
|
||||||
|
setNotice({ kind: "info", text: "正在加入版本控制…" })
|
||||||
|
const result = await api.addFiles({ paths: [target.localPath], recursive: target.folder })
|
||||||
|
refreshLogs()
|
||||||
|
setActionBusy(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "加入版本控制失败" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setNotice({ kind: "info", text: `已加入版本控制 ${result.data.result.affected} 项` })
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "map":
|
||||||
|
setDialog({ kind: "mapping", serverPath: target.serverPath })
|
||||||
|
break
|
||||||
|
case "history":
|
||||||
|
setDialog({ kind: "history", serverPath: target.serverPath, folder: target.folder })
|
||||||
|
break
|
||||||
|
case "compare":
|
||||||
|
setDialog({ kind: "compare", serverPath: target.serverPath })
|
||||||
|
break
|
||||||
|
case "viewFile":
|
||||||
|
setDialog({
|
||||||
|
kind: "viewFile",
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
localPath: target.localPath,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "diffLocalLatest":
|
||||||
|
if (target.localPath) {
|
||||||
|
setDialog({
|
||||||
|
kind: "diff",
|
||||||
|
request: {
|
||||||
|
mode: "localLatest",
|
||||||
|
serverPath: target.serverPath,
|
||||||
|
localPath: target.localPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs, refreshItems, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交签入:只提交 Included 项,成功后展示 changeset 并刷新挂起更改与当前目录。
|
||||||
|
*/
|
||||||
|
const handleCheckin = useCallback(
|
||||||
|
async (paths: string[], comment: string): Promise<boolean> => {
|
||||||
|
setCheckinBusy(true)
|
||||||
|
setNotice({ kind: "info", text: "正在签入…" })
|
||||||
|
const result = await api.checkin({ paths, comment })
|
||||||
|
setCheckinBusy(false)
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "签入失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const summary = result.data.checkin
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: `签入成功:changeset ${summary.changeset},提交 ${summary.submittedChanges} 项`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshPendingChanges, refreshItems, refreshLogs],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping 创建成功:刷新 mappings 与当前目录列表,保持浏览位置并关闭弹窗。
|
||||||
|
*/
|
||||||
|
const handleMappingCreated = useCallback(
|
||||||
|
(mappings: MappingInfo[]) => {
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
refreshItems()
|
||||||
|
setDialog(null)
|
||||||
|
},
|
||||||
|
[onMappingsChanged, refreshItems],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冲突取舍完成:关闭弹窗并刷新当前目录与挂起更改。
|
||||||
|
*/
|
||||||
|
const handleConflictsResolved = useCallback(() => {
|
||||||
|
setDialog(null)
|
||||||
|
refreshItems()
|
||||||
|
void refreshPendingChanges()
|
||||||
|
refreshLogs()
|
||||||
|
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
|
||||||
|
}, [refreshItems, refreshPendingChanges, refreshLogs])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名确认后执行:产生 rename 挂起更改并刷新目录列表与挂起更改。
|
||||||
|
*/
|
||||||
|
const handleRenameConfirmed = useCallback(
|
||||||
|
async (serverPath: string, newName: string): Promise<boolean> => {
|
||||||
|
setNotice({ kind: "info", text: "正在重命名…" })
|
||||||
|
const result = await api.renameFile({ serverPath, newName })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "重命名失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: `已挂起重命名为「${newName}」,签入后服务器生效`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支确认后执行:产生 branch 挂起更改并刷新挂起更改与目录列表。
|
||||||
|
*/
|
||||||
|
const handleBranchConfirmed = useCallback(
|
||||||
|
async (
|
||||||
|
sourceServerPath: string,
|
||||||
|
targetServerPath: string,
|
||||||
|
changeset: number | undefined,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
setNotice({ kind: "info", text: "正在创建分支…" })
|
||||||
|
const result = await api.branch({ sourceServerPath, targetServerPath, changeset })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "创建分支失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: `已挂起分支 ${result.data.result.affected} 项(${targetServerPath}),请在挂起更改面板审查后签入`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并确认后执行:产生挂起更改并刷新,冲突自动进入冲突弹窗(针对目标路径)。
|
||||||
|
*/
|
||||||
|
const handleMergeConfirmed = useCallback(
|
||||||
|
async (
|
||||||
|
sourceServerPath: string,
|
||||||
|
targetServerPath: string,
|
||||||
|
changeset: number | undefined,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: changeset == null ? "正在合并全部候选变更集…" : `正在合并变更集 C${changeset}…`,
|
||||||
|
})
|
||||||
|
const result = await api.merge({ sourceServerPath, targetServerPath, changeset })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "合并失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const summary = result.data.result
|
||||||
|
const parts = [`产生挂起更改 ${summary.operations} 项`]
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
parts.push(`冲突 ${summary.conflicts} 项`)
|
||||||
|
}
|
||||||
|
if (summary.failures > 0) {
|
||||||
|
parts.push(`失败 ${summary.failures} 项`)
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: summary.conflicts > 0 || summary.failures > 0 ? "error" : "info",
|
||||||
|
text: `合并完成:${parts.join(",")},请在挂起更改面板审查后签入`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
if (summary.conflicts > 0) {
|
||||||
|
setDialog({ kind: "conflicts", serverPath: targetServerPath })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加本地文件确认后执行:批量 pend add 并刷新挂起更改与目录列表。
|
||||||
|
*/
|
||||||
|
const handleAddFilesConfirmed = useCallback(
|
||||||
|
async (localPaths: string[]): Promise<boolean> => {
|
||||||
|
setNotice({ kind: "info", text: "正在添加到版本控制…" })
|
||||||
|
const result = await api.addFiles({ paths: localPaths })
|
||||||
|
refreshLogs()
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setNotice({ kind: "error", text: result.errorMessage ?? "添加失败" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setNotice({
|
||||||
|
kind: "info",
|
||||||
|
text: `已加入版本控制 ${result.data.result.affected} 项,请在挂起更改面板审查后签入`,
|
||||||
|
})
|
||||||
|
await refreshPendingChanges()
|
||||||
|
refreshItems()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
|
||||||
|
*/
|
||||||
|
const handleForceGetConfirmed = useCallback(
|
||||||
|
async (serverPath: string, folder: boolean) => {
|
||||||
|
setActionBusy(true)
|
||||||
|
const target: FileTarget = {
|
||||||
|
source: "list",
|
||||||
|
folder,
|
||||||
|
serverPath,
|
||||||
|
localPath: null,
|
||||||
|
mapped: true,
|
||||||
|
mappingRoot: false,
|
||||||
|
pendingStatus: null,
|
||||||
|
}
|
||||||
|
await runGetLatest(target, true)
|
||||||
|
setActionBusy(false)
|
||||||
|
setDialog(null)
|
||||||
|
},
|
||||||
|
[runGetLatest],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialog,
|
||||||
|
setDialog,
|
||||||
|
notice,
|
||||||
|
actionBusy,
|
||||||
|
checkinBusy,
|
||||||
|
handleFileAction,
|
||||||
|
handleCheckin,
|
||||||
|
handleMappingCreated,
|
||||||
|
handleConflictsResolved,
|
||||||
|
handleForceGetConfirmed,
|
||||||
|
handleRenameConfirmed,
|
||||||
|
handleBranchConfirmed,
|
||||||
|
handleMergeConfirmed,
|
||||||
|
handleAddFilesConfirmed,
|
||||||
|
runGetVersion,
|
||||||
|
runRollback,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { PendingChange } from "~/lib/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂起更改共享状态:列表加载 / 刷新、Included-Excluded 维护,
|
||||||
|
* 以及供菜单可用性判断的 serverPath -> 状态 映射。
|
||||||
|
*/
|
||||||
|
export function usePendingChanges() {
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// Excluded 的挂起更改 serverPath 集合(不持久化),其余默认 Included。
|
||||||
|
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新当前 Workspace 的挂起更改列表,并清理悬挂的 Excluded 键。
|
||||||
|
*/
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await api.getPendingChanges()
|
||||||
|
setLoading(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "挂起更改加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
const list = result.data?.pendingChanges ?? []
|
||||||
|
setPendingChanges(list)
|
||||||
|
setExcludedKeys((prev) => {
|
||||||
|
const valid = new Set(list.map((change) => change.serverPath))
|
||||||
|
const next = new Set([...prev].filter((key) => valid.has(key)))
|
||||||
|
return next.size === prev.size ? prev : next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换某条挂起更改的 Included / Excluded 归属。
|
||||||
|
*/
|
||||||
|
const toggleExcluded = useCallback((serverPath: string) => {
|
||||||
|
setExcludedKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(serverPath)) {
|
||||||
|
next.delete(serverPath)
|
||||||
|
} else {
|
||||||
|
next.add(serverPath)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// serverPath -> 挂起状态,供文件列表生成菜单时控制可用性。
|
||||||
|
const pendingByServerPath = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const change of pendingChanges) {
|
||||||
|
map[change.serverPath] = change.status
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [pendingChanges])
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingChanges,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
excludedKeys,
|
||||||
|
pendingByServerPath,
|
||||||
|
refresh,
|
||||||
|
toggleExcluded,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
|
||||||
|
export type ThemePreference = "system" | "light" | "dark"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mactfs.theme"
|
||||||
|
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前系统外观是否为暗色。
|
||||||
|
*/
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由偏好推导是否应启用暗色。
|
||||||
|
*/
|
||||||
|
function resolveIsDark(preference: ThemePreference): boolean {
|
||||||
|
return preference === "dark" || (preference === "system" && systemPrefersDark())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
|
||||||
|
*/
|
||||||
|
function loadPreference(): ThemePreference {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw === "light" || raw === "dark" ? raw : "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
|
||||||
|
*/
|
||||||
|
function applyTheme(preference: ThemePreference) {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.add("theme-transition")
|
||||||
|
root.classList.toggle("dark", resolveIsDark(preference))
|
||||||
|
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
|
||||||
|
* 跟随系统时监听系统外观变化自动切换。
|
||||||
|
*/
|
||||||
|
export function useTheme() {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(preference)
|
||||||
|
if (preference !== "system") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
const onChange = () => applyTheme("system")
|
||||||
|
media.addEventListener("change", onChange)
|
||||||
|
return () => media.removeEventListener("change", onChange)
|
||||||
|
}, [preference])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置主题偏好并持久化。
|
||||||
|
*/
|
||||||
|
const setPreference = useCallback((next: ThemePreference) => {
|
||||||
|
setPreferenceState(next)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
} catch {
|
||||||
|
// 本地存储不可用时仅在当前会话生效
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
|
||||||
|
*/
|
||||||
|
const cycle = useCallback(() => {
|
||||||
|
const index = CYCLE_ORDER.indexOf(preference)
|
||||||
|
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
|
||||||
|
}, [preference, setPreference])
|
||||||
|
|
||||||
|
return { preference, setPreference, cycle }
|
||||||
|
}
|
||||||
@@ -1,216 +1,117 @@
|
|||||||
import { getMactfsBridge } from "~/lib/electron/bridge"
|
import { getApiBaseUrl, getToken } from "~/lib/electron"
|
||||||
|
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
|
||||||
import type { ApiResult } from "./types"
|
|
||||||
|
|
||||||
const API_BASE_URL = "http://127.0.0.1:38765"
|
|
||||||
|
|
||||||
interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
|
|
||||||
body?: unknown
|
|
||||||
headers?: HeadersInit
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiRequestLogStatus = "running" | "success" | "error"
|
|
||||||
|
|
||||||
export interface ApiRequestLogEntry {
|
|
||||||
id: string
|
|
||||||
operation: string
|
|
||||||
summary: string
|
|
||||||
startedAt: number
|
|
||||||
endedAt?: number
|
|
||||||
durationMs?: number
|
|
||||||
success?: boolean
|
|
||||||
errorMessage?: string
|
|
||||||
status: ApiRequestLogStatus
|
|
||||||
logs?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiRequestLogListener = (entry: ApiRequestLogEntry) => void
|
|
||||||
|
|
||||||
const apiRequestLogListeners = new Set<ApiRequestLogListener>()
|
|
||||||
let apiRequestLogIndex = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅统一 API client 的请求日志,供底部 Console 展示每次操作状态。
|
* 拼接查询参数到请求路径,自动跳过空值。
|
||||||
*/
|
*/
|
||||||
export function subscribeApiRequestLogs(listener: ApiRequestLogListener) {
|
function buildUrl(
|
||||||
apiRequestLogListeners.add(listener)
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
return () => {
|
query?: ApiRequestOptions["query"],
|
||||||
apiRequestLogListeners.delete(listener)
|
): string {
|
||||||
}
|
const url = new URL(path, baseUrl)
|
||||||
}
|
if (query) {
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
/**
|
if (value !== undefined && value !== null) {
|
||||||
* 向所有日志订阅方发布一条请求状态更新。
|
url.searchParams.set(key, String(value))
|
||||||
*/
|
|
||||||
function notifyApiRequestLog(entry: ApiRequestLogEntry) {
|
|
||||||
apiRequestLogListeners.forEach((listener) => listener(entry))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成本次请求的前端日志 id,用于 running 和完成态更新同一行。
|
|
||||||
*/
|
|
||||||
function nextApiRequestLogId(apiPath: string) {
|
|
||||||
apiRequestLogIndex += 1
|
|
||||||
return `${Date.now()}:${apiRequestLogIndex}:${apiPath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从请求体或 query 中提取路径摘要,便于 Console 快速定位操作对象。
|
|
||||||
*/
|
|
||||||
function summarizeApiRequest(apiPath: string, body?: unknown) {
|
|
||||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
||||||
const bodySummary = summarizeRequestBody(body as Record<string, unknown>)
|
|
||||||
if (bodySummary) {
|
|
||||||
return bodySummary
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = apiPath.split("?")[1]
|
|
||||||
return query || apiPath
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按后端常用字段生成请求体摘要。
|
|
||||||
*/
|
|
||||||
function summarizeRequestBody(body: Record<string, unknown>) {
|
|
||||||
return [
|
|
||||||
"serverPath",
|
|
||||||
"path",
|
|
||||||
"localPath",
|
|
||||||
"paths",
|
|
||||||
"serverPaths",
|
|
||||||
"changeset",
|
|
||||||
"sourceChangeset",
|
|
||||||
"targetChangeset",
|
|
||||||
]
|
|
||||||
.filter((key) => body[key] !== undefined && body[key] !== "")
|
|
||||||
.map((key) => `${key}=${formatSummaryValue(body[key])}`)
|
|
||||||
.join(" | ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将摘要字段值转换成单行文本。
|
|
||||||
*/
|
|
||||||
function formatSummaryValue(value: unknown) {
|
|
||||||
return Array.isArray(value) ? value.join(", ") : String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建前端统一失败响应,供网络或 Electron 桥接不可用时展示明确提示。
|
|
||||||
*/
|
|
||||||
function createFailureResult<TData>(
|
|
||||||
message: string,
|
|
||||||
startedAt = Date.now()
|
|
||||||
): ApiResult<TData> {
|
|
||||||
const endedAt = Date.now()
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message,
|
|
||||||
errorMessage: message,
|
|
||||||
operation: "frontendRequest",
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
durationMs: endedAt - startedAt,
|
|
||||||
logs: [],
|
|
||||||
data: {} as TData,
|
|
||||||
}
|
}
|
||||||
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将统一 API 响应转换为底部 Console 可展示的日志行。
|
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
|
||||||
*/
|
*/
|
||||||
function toApiRequestLogEntry(
|
function resolveErrorMessage(
|
||||||
id: string,
|
result: ApiResult<unknown> | null,
|
||||||
summary: string,
|
fallback: string,
|
||||||
fallbackStartedAt: number,
|
): string {
|
||||||
result: ApiResult<unknown>
|
if (result) {
|
||||||
): ApiRequestLogEntry {
|
return result.errorMessage || result.message || fallback
|
||||||
return {
|
|
||||||
id,
|
|
||||||
operation: result.operation,
|
|
||||||
summary,
|
|
||||||
startedAt: result.startedAt || fallbackStartedAt,
|
|
||||||
endedAt: result.endedAt,
|
|
||||||
durationMs: result.durationMs,
|
|
||||||
success: result.success,
|
|
||||||
errorMessage: result.errorMessage || (result.success ? "" : result.message),
|
|
||||||
status: result.success ? "success" : "error",
|
|
||||||
logs: result.logs,
|
|
||||||
}
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过统一 API client 调用本地 Java API,并自动附加 Bearer token。
|
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
|
||||||
*/
|
*/
|
||||||
export async function apiRequest<TData>(
|
async function request<T = Record<string, unknown>>(
|
||||||
apiPath: string,
|
method: string,
|
||||||
options: ApiRequestOptions = {}
|
path: string,
|
||||||
): Promise<ApiResult<TData>> {
|
options: ApiRequestOptions = {},
|
||||||
const startedAt = Date.now()
|
): Promise<ApiResponse<T>> {
|
||||||
const logId = nextApiRequestLogId(apiPath)
|
const baseUrl = await getApiBaseUrl()
|
||||||
const summary = summarizeApiRequest(apiPath, options.body)
|
const token = await getToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
notifyApiRequestLog({
|
Accept: "application/json",
|
||||||
id: logId,
|
|
||||||
operation: apiPath,
|
|
||||||
summary,
|
|
||||||
startedAt,
|
|
||||||
status: "running",
|
|
||||||
})
|
|
||||||
|
|
||||||
const bridge = getMactfsBridge()
|
|
||||||
if (!bridge) {
|
|
||||||
const result = createFailureResult<TData>(
|
|
||||||
"未检测到 Electron preload,无法读取本地 API token。",
|
|
||||||
startedAt
|
|
||||||
)
|
|
||||||
notifyApiRequestLog(
|
|
||||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
if (token) {
|
||||||
const token = await bridge.getToken()
|
headers.Authorization = `Bearer ${token}`
|
||||||
if (!token) {
|
|
||||||
const result = createFailureResult<TData>(
|
|
||||||
"未找到本地 API token,请确认本地服务已启动。",
|
|
||||||
startedAt
|
|
||||||
)
|
|
||||||
notifyApiRequestLog(
|
|
||||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
const init: RequestInit = { method, headers, signal: options.signal }
|
||||||
try {
|
|
||||||
const headers = new Headers(options.headers)
|
|
||||||
headers.set("Authorization", `Bearer ${token}`)
|
|
||||||
if (options.body !== undefined) {
|
if (options.body !== undefined) {
|
||||||
headers.set("Content-Type", "application/json")
|
headers["Content-Type"] = "application/json;charset=utf-8"
|
||||||
|
init.body = JSON.stringify(options.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
|
let response: Response
|
||||||
...options,
|
try {
|
||||||
headers,
|
response = await fetch(buildUrl(baseUrl, path, options.query), init)
|
||||||
body:
|
|
||||||
options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = (await response.json()) as ApiResult<TData>
|
|
||||||
notifyApiRequestLog(
|
|
||||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result = createFailureResult<TData>(
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
error instanceof Error ? error.message : "本地 API 请求失败。",
|
return {
|
||||||
startedAt
|
ok: false,
|
||||||
)
|
status: 0,
|
||||||
notifyApiRequestLog(
|
result: null,
|
||||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
data: null,
|
||||||
)
|
errorMessage: `无法连接本地服务:${message}`,
|
||||||
return result
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: ApiResult<T> | null = null
|
||||||
|
try {
|
||||||
|
result = (await response.json()) as ApiResult<T>
|
||||||
|
} catch {
|
||||||
|
result = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = response.ok && result?.success === true
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status: response.status,
|
||||||
|
result,
|
||||||
|
data: result?.data ?? null,
|
||||||
|
errorMessage: ok
|
||||||
|
? null
|
||||||
|
: resolveErrorMessage(result, `请求失败(HTTP ${response.status})`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一 API 客户端,组件只通过本对象访问后端,不直接 fetch、不直接读 token。
|
||||||
|
export const apiClient = {
|
||||||
|
/**
|
||||||
|
* 发起 GET 请求。
|
||||||
|
*/
|
||||||
|
get<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||||
|
return request<T>("GET", path, options)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 发起 POST 请求。
|
||||||
|
*/
|
||||||
|
post<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||||
|
return request<T>("POST", path, options)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 发起 PUT 请求。
|
||||||
|
*/
|
||||||
|
put<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||||
|
return request<T>("PUT", path, options)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 发起 DELETE 请求。
|
||||||
|
*/
|
||||||
|
delete<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||||
|
return request<T>("DELETE", path, options)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,237 +1,286 @@
|
|||||||
import { apiRequest } from "./client"
|
import { apiClient } from "./client"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AddMappingData,
|
CheckMappingTargetResult,
|
||||||
AddMappingRequest,
|
CheckinResult,
|
||||||
CheckinData,
|
Collection,
|
||||||
CheckinRequest,
|
ConflictInfo,
|
||||||
ChangesetFilesData,
|
ConflictResolution,
|
||||||
CompareFolderData,
|
ConflictResolutionResult,
|
||||||
CompareFolderRequest,
|
ConnectResult,
|
||||||
CollectionsData,
|
FileContent,
|
||||||
FileOperationData,
|
FileOperationResult,
|
||||||
FileOperationRequest,
|
FolderDiffItem,
|
||||||
ConfigData,
|
GetLatestResult,
|
||||||
ConnectData,
|
HistoryEntry,
|
||||||
DiffLocalLatestRequest,
|
ItemInfo,
|
||||||
DiffRevisionsRequest,
|
MappingInfo,
|
||||||
GetLatestData,
|
OperationLogEntry,
|
||||||
GetLatestRequest,
|
PendingChange,
|
||||||
HistoryData,
|
ServerItem,
|
||||||
MappingsData,
|
TextDiff,
|
||||||
MappingsRequest,
|
WorkspaceContext,
|
||||||
OperationLogsData,
|
WorkspaceInfo,
|
||||||
PendingChangesData,
|
|
||||||
PendingChangesRequest,
|
|
||||||
ServerTreeData,
|
|
||||||
TextDiffData,
|
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
// 文件类操作(checkout / add / delete / undo)的统一请求体。
|
||||||
|
type FileActionBody = { paths: string[]; recursive?: boolean }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取后端保存的默认连接配置,用于再次打开时回填表单。
|
* 读取服务端持久化配置。
|
||||||
*/
|
*/
|
||||||
export function getConfig() {
|
export function getConfig() {
|
||||||
return apiRequest<ConfigData>("/api/config")
|
return apiClient.get<{ config: AppConfig }>("/api/config")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交连接配置给后端;连接成功时后端会保存为默认配置。
|
* 保存服务端持久化配置。
|
||||||
*/
|
*/
|
||||||
export function connectSession(config: AppConfig) {
|
export function saveConfig(config: AppConfig) {
|
||||||
return apiRequest<ConnectData>("/api/session/connect", {
|
return apiClient.put<{ config: AppConfig }>("/api/config", { body: config })
|
||||||
method: "POST",
|
|
||||||
body: config,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载当前连接可见的 TFS Collection 列表。
|
* 使用给定连接信息连接 TFS,成功后服务端会保存配置并建立会话。
|
||||||
|
*/
|
||||||
|
export function connect(config: Partial<AppConfig> = {}) {
|
||||||
|
return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出当前账号可见的 Collection。
|
||||||
*/
|
*/
|
||||||
export function listCollections() {
|
export function listCollections() {
|
||||||
return apiRequest<CollectionsData>("/api/collections")
|
return apiClient.get<{ collections: Collection[] }>("/api/collections")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载指定 Collection 和服务端路径下的目录节点。
|
* 浏览服务端目录树节点。
|
||||||
*/
|
*/
|
||||||
export function listServerTree(path: string, collection?: string) {
|
export function getServerTree(params: { path?: string; collection?: string } = {}) {
|
||||||
const params = new URLSearchParams({ path })
|
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-tree", { query: params })
|
||||||
if (collection) {
|
|
||||||
params.set("collection", collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiRequest<ServerTreeData>(`/api/server-tree?${params.toString()}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载当前 Workspace 的 Mapping 列表,用于判断服务端项是否已映射到本地。
|
* 读取当前目录下一级文件与文件夹列表。
|
||||||
*/
|
*/
|
||||||
export function listMappings(request: MappingsRequest = {}) {
|
export function getFolderItems(params: { path?: string; collection?: string } = {}) {
|
||||||
const params = new URLSearchParams()
|
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-folder/items", { query: params })
|
||||||
if (request.collection) {
|
|
||||||
params.set("collection", request.collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = params.toString()
|
|
||||||
return apiRequest<MappingsData>(
|
|
||||||
query ? `/api/mappings?${query}` : "/api/mappings"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载指定目录下一级文件和文件夹,供中间文件列表展示。
|
* 读取当前 Workspace 名称与 Mapping 列表。
|
||||||
*/
|
*/
|
||||||
export function listServerFolderItems(path: string, collection?: string) {
|
export function getWorkspace() {
|
||||||
const params = new URLSearchParams({ path })
|
return apiClient.get<{ workspace: string; mappings: MappingInfo[] }>("/api/workspace")
|
||||||
if (collection) {
|
|
||||||
params.set("collection", collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiRequest<ServerTreeData>(
|
|
||||||
`/api/server-folder/items?${params.toString()}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建服务端路径到本地目录的 Mapping,可选择保存后立即 Get Latest。
|
* 确保当前 Collection 的默认 Workspace 存在,必要时创建。
|
||||||
*/
|
*/
|
||||||
export function addMapping(request: AddMappingRequest) {
|
export function ensureWorkspace(body: { collection?: string; workspace?: string; comment?: string } = {}) {
|
||||||
return apiRequest<AddMappingData>("/api/mappings", {
|
return apiClient.post<{ workspace: WorkspaceInfo }>("/api/workspace/ensure", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已映射目录执行元数据级目录对比,返回差异文件状态列表。
|
* 读取工作台固定上下文(serverUri / collection / workspace / mappings)。
|
||||||
*/
|
*/
|
||||||
export function compareFolder(request: CompareFolderRequest) {
|
export function getWorkspaceContext() {
|
||||||
return apiRequest<CompareFolderData>("/api/compare/folder", {
|
return apiClient.get<{ context: WorkspaceContext }>("/api/workspace/context")
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前 Workspace 的挂起更改列表,供右侧 Inspector 分组展示。
|
* 读取当前 Workspace 的 Mapping 列表。
|
||||||
*/
|
*/
|
||||||
export function listPendingChanges(request: PendingChangesRequest = {}) {
|
export function listMappings() {
|
||||||
const params = new URLSearchParams()
|
return apiClient.get<{ mappings: MappingInfo[] }>("/api/mappings")
|
||||||
if (request.collection) {
|
|
||||||
params.set("collection", request.collection)
|
|
||||||
}
|
|
||||||
if (request.serverPath) {
|
|
||||||
params.set("serverPath", request.serverPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = params.toString()
|
|
||||||
return apiRequest<PendingChangesData>(
|
|
||||||
query ? `/api/pending-changes?${query}` : "/api/pending-changes"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定服务端路径的最新版本,目录按 recursive 参数递归。
|
* 预校验 Mapping 目标路径,返回最终本地目标路径及是否已存在。
|
||||||
*/
|
*/
|
||||||
export function getLatest(request: GetLatestRequest) {
|
export function checkMappingTarget(body: { serverPath: string; localParentPath: string }) {
|
||||||
return apiRequest<GetLatestData>("/api/files/get-latest", {
|
return apiClient.post<CheckMappingTargetResult>("/api/mappings/check-target", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已映射且本地存在的文件或目录执行 checkout。
|
* 创建 Mapping,可选立即 Get Latest。
|
||||||
*/
|
*/
|
||||||
export function checkoutFiles(request: FileOperationRequest) {
|
export function addMapping(body: { serverPath: string; localPath: string; getLatest?: boolean }) {
|
||||||
return apiRequest<FileOperationData>("/api/files/checkout", {
|
return apiClient.post<{ mapping: MappingInfo; getLatest?: GetLatestResult }>("/api/mappings", { body })
|
||||||
method: "POST",
|
}
|
||||||
body: request,
|
|
||||||
})
|
/**
|
||||||
|
* 删除 Mapping,仅解除映射不删除本地文件。
|
||||||
|
*/
|
||||||
|
export function deleteMapping(body: { serverPath?: string; localPath?: string }) {
|
||||||
|
return apiClient.delete<{ mappings: MappingInfo[] }>("/api/mappings", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定服务端路径的最新文件。
|
||||||
|
* force=false(默认)为安全模式,本地改动会产生冲突而不被覆盖;force=true 强制覆盖本地。
|
||||||
|
*/
|
||||||
|
export function getLatest(body: { serverPath?: string; recursive?: boolean; force?: boolean } = {}) {
|
||||||
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-latest", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 changeset 版本并覆盖本地(对应 VS 的 Get Specific Version + Overwrite)。
|
||||||
|
*/
|
||||||
|
export function getVersion(body: { serverPath: string; changeset: number; recursive?: boolean }) {
|
||||||
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-version", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对已存在文件或目录执行 checkout。
|
||||||
|
*/
|
||||||
|
export function checkout(body: FileActionBody) {
|
||||||
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/checkout", { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将本地新增文件加入 pending add。
|
* 将本地新增文件加入 pending add。
|
||||||
*/
|
*/
|
||||||
export function addFiles(request: FileOperationRequest) {
|
export function addFiles(body: FileActionBody) {
|
||||||
return apiRequest<FileOperationData>("/api/files/add", {
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/add", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已版本控制文件或目录执行 pending delete。
|
* 对已版本控制文件或目录执行 pending delete。
|
||||||
*/
|
*/
|
||||||
export function deleteFiles(request: FileOperationRequest) {
|
export function deleteFiles(body: FileActionBody) {
|
||||||
return apiRequest<FileOperationData>("/api/files/delete", {
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/delete", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤销指定路径上的 pending changes。
|
* 撤销指定路径上的挂起更改。
|
||||||
*/
|
*/
|
||||||
export function undoFiles(request: FileOperationRequest) {
|
export function undoFiles(body: FileActionBody) {
|
||||||
return apiRequest<FileOperationData>("/api/files/undo", {
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
|
* 重命名文件或目录(同目录改名),产生 rename 挂起更改。
|
||||||
*/
|
*/
|
||||||
export function checkin(request: CheckinRequest) {
|
export function renameFile(body: { serverPath: string; newName: string }) {
|
||||||
return apiRequest<CheckinData>("/api/checkin", {
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/rename", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询文件或目录最近历史记录。
|
* 回滚变更集(只产生挂起更改):single 仅反做该 changeset,toVersion 反做其后全部改动。
|
||||||
*/
|
*/
|
||||||
export function queryHistory(path: string, folder: boolean) {
|
export function rollback(body: { serverPath: string; mode: "single" | "toVersion"; changeset: number }) {
|
||||||
const params = new URLSearchParams({ path, folder: String(folder) })
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/rollback", { body })
|
||||||
return apiRequest<HistoryData>(`/api/history?${params.toString()}`)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支:把源路径分叉到目标路径(产生 branch 挂起更改),changeset 缺省为 latest。
|
||||||
|
*/
|
||||||
|
export function branch(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
|
||||||
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/branch", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询源 → 目标的待合并变更集候选列表。
|
||||||
|
*/
|
||||||
|
export function mergeCandidates(params: { sourceServerPath: string; targetServerPath: string }) {
|
||||||
|
return apiClient.get<{ candidates: HistoryEntry[] }>("/api/files/merge-candidates", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并:源 → 目标(产生挂起更改,冲突走冲突处理),changeset 缺省为合并全部候选。
|
||||||
|
*/
|
||||||
|
export function merge(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
|
||||||
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/merge", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前 Workspace 的挂起更改。
|
||||||
|
*/
|
||||||
|
export function getPendingChanges(params: { serverPath?: string } = {}) {
|
||||||
|
return apiClient.get<{ pendingChanges: PendingChange[] }>("/api/pending-changes", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交带注释的签入,paths 为空时签入全部挂起更改。
|
||||||
|
*/
|
||||||
|
export function checkin(body: { paths?: string[]; comment: string }) {
|
||||||
|
return apiClient.post<{ checkin: CheckinResult }>("/api/checkin", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取服务端对象(文件或目录)的属性信息,供属性弹窗展示。
|
||||||
|
*/
|
||||||
|
export function getItemInfo(params: { serverPath: string }) {
|
||||||
|
return apiClient.get<{ item: ItemInfo }>("/api/items/info", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取服务器内容(latest 或指定 changeset),或读取本地映射文件内容。
|
||||||
|
*/
|
||||||
|
export function getFileContent(params: { serverPath?: string; localPath?: string; changeset?: number }) {
|
||||||
|
return apiClient.get<{ content: FileContent }>("/api/files/content", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对已映射目录执行目录对比。
|
||||||
|
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
|
||||||
|
*/
|
||||||
|
export function compareFolder(body: {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
recursive?: boolean
|
||||||
|
includeLocalOnly?: boolean
|
||||||
|
}) {
|
||||||
|
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文件或目录历史。
|
||||||
|
*/
|
||||||
|
export function getHistory(params: { path: string; folder?: boolean }) {
|
||||||
|
return apiClient.get<{ history: HistoryEntry[] }>("/api/history", { query: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定 changeset 影响的文件列表。
|
* 查询指定 changeset 影响的文件列表。
|
||||||
*/
|
*/
|
||||||
export function queryChangesetFiles(changeset: number) {
|
export function getChangesetFiles(params: { changeset: number }) {
|
||||||
const params = new URLSearchParams({ changeset: String(changeset) })
|
return apiClient.get<{ files: HistoryEntry[] }>("/api/history/changeset", { query: params })
|
||||||
return apiRequest<ChangesetFilesData>(
|
|
||||||
`/api/history/changeset?${params.toString()}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成本地文件和服务器 latest 之间的文本 diff。
|
* 生成本地文件与服务器 latest 的文本 Diff。
|
||||||
*/
|
*/
|
||||||
export function diffLocalLatest(request: DiffLocalLatestRequest) {
|
export function diffLocalLatest(body: { serverPath: string; localPath: string }) {
|
||||||
return apiRequest<TextDiffData>("/api/diff/local-latest", {
|
return apiClient.post<{ diff: TextDiff }>("/api/diff/local-latest", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成同一服务端文件两个历史版本之间的文本 diff。
|
* 生成同一服务端文件两个 changeset 之间的文本 Diff。
|
||||||
*/
|
*/
|
||||||
export function diffRevisions(request: DiffRevisionsRequest) {
|
export function diffRevisions(body: { serverPath: string; sourceChangeset: number; targetChangeset: number }) {
|
||||||
return apiRequest<TextDiffData>("/api/diff/revisions", {
|
return apiClient.post<{ diff: TextDiff }>("/api/diff/revisions", { body })
|
||||||
method: "POST",
|
|
||||||
body: request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取本地 API 服务最近操作日志,供底部 Console 手动刷新。
|
* 查询当前 Workspace 的冲突明细。
|
||||||
*/
|
*/
|
||||||
export function listOperationLogs() {
|
export function listConflicts(params: { serverPath?: string; recursive?: boolean } = {}) {
|
||||||
return apiRequest<OperationLogsData>("/api/logs")
|
return apiClient.get<{ conflicts: ConflictInfo[] }>("/api/conflicts", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对单个冲突应用取舍(采用服务器版本或保留本地版本)。
|
||||||
|
*/
|
||||||
|
export function applyConflict(body: { conflictId: number; resolution: ConflictResolution }) {
|
||||||
|
return apiClient.post<{ resolution: ConflictResolutionResult }>("/api/conflicts/apply", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取最近操作日志。
|
||||||
|
*/
|
||||||
|
export function getLogs() {
|
||||||
|
return apiClient.get<{ logs: OperationLogEntry[] }>("/api/logs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type {
|
||||||
|
ApiRequestOptions,
|
||||||
|
ApiResponse,
|
||||||
|
ApiResult,
|
||||||
|
AppConfig,
|
||||||
|
CheckMappingTargetResult,
|
||||||
|
CheckinResult,
|
||||||
|
Collection,
|
||||||
|
ConflictInfo,
|
||||||
|
ConflictResolution,
|
||||||
|
ConflictResolutionResult,
|
||||||
|
ConnectResult,
|
||||||
|
FileContent,
|
||||||
|
FileOperationResult,
|
||||||
|
FolderDiffItem,
|
||||||
|
GetLatestResult,
|
||||||
|
HistoryEntry,
|
||||||
|
ItemInfo,
|
||||||
|
MappingConfig,
|
||||||
|
MappingInfo,
|
||||||
|
OperationLogEntry,
|
||||||
|
PendingChange,
|
||||||
|
ServerItem,
|
||||||
|
TextDiff,
|
||||||
|
WorkspaceContext,
|
||||||
|
WorkspaceInfo,
|
||||||
|
} from "./types"
|
||||||
|
export { apiClient } from "./client"
|
||||||
|
export * as api from "./endpoints"
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
export interface ApiResult<TData = Record<string, unknown>> {
|
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
|
||||||
|
export interface ApiResult<T = Record<string, unknown>> {
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
operation: string
|
operation?: string
|
||||||
startedAt: number
|
startedAt?: number
|
||||||
endedAt: number
|
endedAt?: number
|
||||||
durationMs: number
|
durationMs?: number
|
||||||
logs: string[]
|
logs?: string[]
|
||||||
data: TData
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthData {
|
// API 客户端对外返回结构,统一封装网络层与业务层成功/失败。
|
||||||
status: string
|
export interface ApiResponse<T = Record<string, unknown>> {
|
||||||
host: string
|
ok: boolean
|
||||||
port: number
|
status: number
|
||||||
tokenFile: string
|
result: ApiResult<T> | null
|
||||||
configFile: string
|
data: T | null
|
||||||
connected: boolean
|
errorMessage: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 请求选项。
|
||||||
|
export interface ApiRequestOptions {
|
||||||
|
query?: Record<string, string | number | boolean | undefined | null>
|
||||||
|
body?: unknown
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单条 Mapping 配置,对应服务端 MappingConfig。
|
||||||
export interface MappingConfig {
|
export interface MappingConfig {
|
||||||
serverPath?: string
|
|
||||||
localPath?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TfsMappingInfo {
|
|
||||||
serverPath: string
|
serverPath: string
|
||||||
localPath: string
|
localPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务端持久化配置,对应 MacTfsServer.AppConfig。
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
serverUri?: string
|
serverUri?: string
|
||||||
authType?: string
|
authType?: string
|
||||||
@@ -37,215 +42,186 @@ export interface AppConfig {
|
|||||||
password?: string
|
password?: string
|
||||||
collection?: string
|
collection?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
mappings?: MappingConfig[]
|
mappings: MappingConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigData {
|
// 连接结果摘要,对应 /api/session/connect 返回的概要字段。
|
||||||
config: AppConfig
|
export interface ConnectResult {
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectData {
|
|
||||||
serverUri?: string
|
serverUri?: string
|
||||||
collectionCount?: number
|
collectionCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsCollectionInfo {
|
// Collection 信息,对应 core TfsCollectionInfo。
|
||||||
|
export interface Collection {
|
||||||
name: string
|
name: string
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsServerItem {
|
// 服务端目录项,对应服务端 serverItems 序列化结构。
|
||||||
|
export interface ServerItem {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
serverPath: string
|
serverPath: string
|
||||||
type: "folder" | "file"
|
type: "folder" | "file"
|
||||||
folder: boolean
|
folder: boolean
|
||||||
latestVersion: number
|
latestVersion: number
|
||||||
checkinDate?: number | null
|
checkinDate: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectionsData {
|
// Workspace Mapping 信息,对应 core TfsMappingInfo。
|
||||||
collections: TfsCollectionInfo[]
|
export interface MappingInfo {
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerTreeData {
|
|
||||||
path: string
|
|
||||||
items: TfsServerItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MappingsData {
|
|
||||||
mappings: TfsMappingInfo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MappingsRequest {
|
|
||||||
collection?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddMappingRequest {
|
|
||||||
serverPath: string
|
serverPath: string
|
||||||
localPath: string
|
localPath: string
|
||||||
getLatest: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddMappingData {
|
// Workspace 概要,对应 core TfsWorkspaceInfo。
|
||||||
mapping?: TfsMappingInfo
|
export interface WorkspaceInfo {
|
||||||
getLatest?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FolderDiffStatus =
|
|
||||||
| "localModified"
|
|
||||||
| "remoteChanged"
|
|
||||||
| "bothChanged"
|
|
||||||
| "localOnly"
|
|
||||||
| "remoteOnly"
|
|
||||||
| "notDownloaded"
|
|
||||||
| "localDeleted"
|
|
||||||
| "pendingEdit"
|
|
||||||
| "pendingAdd"
|
|
||||||
| "pendingDelete"
|
|
||||||
| "upToDate"
|
|
||||||
|
|
||||||
export interface CompareFolderRequest {
|
|
||||||
serverPath: string
|
|
||||||
localPath: string
|
|
||||||
recursive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FolderDiffItem {
|
|
||||||
serverPath: string
|
|
||||||
localPath: string
|
|
||||||
name: string
|
name: string
|
||||||
folder: boolean
|
ownerName: string
|
||||||
status: FolderDiffStatus
|
computer: string
|
||||||
localVersion: number
|
comment: string
|
||||||
latestVersion: number
|
created: boolean
|
||||||
|
mappings: MappingInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompareFolderData {
|
// 工作台固定上下文,对应 /api/workspace/context 的 context 字段。
|
||||||
diffs: FolderDiffItem[]
|
export interface WorkspaceContext {
|
||||||
}
|
serverUri?: string
|
||||||
|
authType?: string
|
||||||
export type PendingChangeStatus =
|
|
||||||
| "pendingEdit"
|
|
||||||
| "pendingAdd"
|
|
||||||
| "pendingDelete"
|
|
||||||
| "pendingRename"
|
|
||||||
| "pending"
|
|
||||||
|
|
||||||
export interface TfsPendingChangeInfo {
|
|
||||||
serverPath: string
|
|
||||||
localPath: string
|
|
||||||
name: string
|
|
||||||
folder: boolean
|
|
||||||
status: PendingChangeStatus
|
|
||||||
changeType: string
|
|
||||||
version: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingChangesData {
|
|
||||||
pendingChanges: TfsPendingChangeInfo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingChangesRequest {
|
|
||||||
collection?: string
|
collection?: string
|
||||||
serverPath?: string
|
workspace?: string
|
||||||
|
connected: boolean
|
||||||
|
mappings: MappingInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetLatestRequest {
|
// Mapping 目标预校验结果,对应 /api/mappings/check-target。
|
||||||
|
export interface CheckMappingTargetResult {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
recursive: boolean
|
localParentPath: string
|
||||||
|
name: string
|
||||||
|
targetPath: string
|
||||||
|
exists: boolean
|
||||||
|
allowed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsGetLatestResult {
|
// Get Latest 结果,对应 core TfsGetLatestResult。
|
||||||
|
export interface GetLatestResult {
|
||||||
updated: number
|
updated: number
|
||||||
operations: number
|
operations: number
|
||||||
conflicts: number
|
conflicts: number
|
||||||
failures: number
|
failures: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetLatestData {
|
// 文件操作结果,对应 core TfsFileOperationResult。
|
||||||
result?: TfsGetLatestResult
|
export interface FileOperationResult {
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileOperationRequest {
|
|
||||||
paths: string[]
|
|
||||||
recursive?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TfsFileOperationResult {
|
|
||||||
operation: string
|
operation: string
|
||||||
affected: number
|
affected: number
|
||||||
failures: string[]
|
failures: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileOperationData {
|
// 挂起更改项,对应 core TfsPendingChangeInfo。
|
||||||
result?: TfsFileOperationResult
|
export interface PendingChange {
|
||||||
|
serverPath: string
|
||||||
|
localPath: string
|
||||||
|
name: string
|
||||||
|
folder: boolean
|
||||||
|
status: string
|
||||||
|
changeType: string
|
||||||
|
version: number
|
||||||
|
/** rename 挂起更改的原服务端路径,其余为 null。 */
|
||||||
|
sourceServerPath: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckinRequest {
|
// 签入结果,对应 core TfsCheckinResult。
|
||||||
serverPaths: string[]
|
export interface CheckinResult {
|
||||||
comment: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TfsCheckinResult {
|
|
||||||
changeset: number
|
changeset: number
|
||||||
submittedChanges: number
|
submittedChanges: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckinData {
|
// 目录对比项,对应 core TfsFolderDiffItem。
|
||||||
checkin?: TfsCheckinResult
|
export interface FolderDiffItem {
|
||||||
|
serverPath: string
|
||||||
|
localPath: string
|
||||||
|
name: string
|
||||||
|
folder: boolean
|
||||||
|
status: string
|
||||||
|
localVersion: number
|
||||||
|
latestVersion: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsHistoryEntry {
|
// 历史记录项,对应 core TfsHistoryEntry。
|
||||||
|
export interface HistoryEntry {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
name: string
|
name: string
|
||||||
changeType: string
|
changeType: string
|
||||||
itemType: string
|
itemType: string
|
||||||
changeset: number
|
changeset: number
|
||||||
author: string
|
author: string
|
||||||
date?: number | null
|
date: number | null
|
||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryData {
|
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
|
||||||
history: TfsHistoryEntry[]
|
export interface ItemInfo {
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChangesetFilesData {
|
|
||||||
files: TfsHistoryEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiffLocalLatestRequest {
|
|
||||||
serverPath: string
|
serverPath: string
|
||||||
localPath: string
|
folder: boolean
|
||||||
|
changeset: number
|
||||||
|
checkinDate: number | null
|
||||||
|
author: string
|
||||||
|
comment: string
|
||||||
|
/** 文件大小(字节),目录为 null。 */
|
||||||
|
size: number | null
|
||||||
|
/** 文件编码名称,目录为 null。 */
|
||||||
|
encoding: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffRevisionsRequest {
|
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
||||||
|
export interface FileContent {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
sourceChangeset: number
|
changeset: number
|
||||||
targetChangeset: number
|
content: string
|
||||||
|
binary: boolean
|
||||||
|
size: number
|
||||||
|
tooLarge: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsTextDiff {
|
// 文本 Diff,对应 core TfsTextDiff。
|
||||||
|
export interface TextDiff {
|
||||||
sourceLabel: string
|
sourceLabel: string
|
||||||
targetLabel: string
|
targetLabel: string
|
||||||
lines: string[]
|
lines: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextDiffData {
|
// 冲突明细,对应 core TfsConflictInfo。
|
||||||
diff?: TfsTextDiff
|
export interface ConflictInfo {
|
||||||
|
conflictId: number
|
||||||
|
type: string
|
||||||
|
serverPath: string | null
|
||||||
|
localPath: string | null
|
||||||
|
yourServerItem: string | null
|
||||||
|
theirServerItem: string | null
|
||||||
|
baseServerItem: string | null
|
||||||
|
resolved: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TfsOperationLogEntry {
|
// 第一版支持的冲突取舍:采用服务器版本或保留本地版本。
|
||||||
|
export type ConflictResolution = "takeServer" | "keepLocal"
|
||||||
|
|
||||||
|
// 冲突应用结果,对应 core TfsConflictResolution。
|
||||||
|
export interface ConflictResolutionResult {
|
||||||
|
conflictId: number
|
||||||
|
resolution: string
|
||||||
|
resolved: boolean
|
||||||
|
remainingConflicts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作日志项,对应服务端 OperationLogEntry。
|
||||||
|
export interface OperationLogEntry {
|
||||||
operation: string
|
operation: string
|
||||||
summary: string
|
summary: string
|
||||||
startedAt: number
|
startedAt: number
|
||||||
endedAt: number
|
endedAt: number
|
||||||
durationMs: number
|
durationMs: number
|
||||||
success: boolean
|
success: boolean
|
||||||
errorMessage?: string
|
errorMessage: string | null
|
||||||
}
|
|
||||||
|
|
||||||
export interface OperationLogsData {
|
|
||||||
logs: TfsOperationLogEntry[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,110 @@
|
|||||||
export interface ServiceStatus {
|
import type { MactfsBridge, ServiceStatus } from "./types"
|
||||||
baseUrl: string
|
|
||||||
running: boolean
|
|
||||||
connected: boolean
|
|
||||||
message: string
|
|
||||||
tokenFile: string
|
|
||||||
configFile?: string
|
|
||||||
started?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MacTfsBridge {
|
const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
|
||||||
getToken(): Promise<string>
|
|
||||||
getServiceStatus(): Promise<ServiceStatus>
|
|
||||||
selectDirectory(): Promise<string | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
/**
|
||||||
interface Window {
|
* 获取 Electron 暴露的 window.mactfs 桥接对象,非 Electron 环境(含 SSR)返回 null。
|
||||||
mactfs?: MacTfsBridge
|
*/
|
||||||
|
export function getBridge(): MactfsBridge | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
return window.mactfs ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回 preload 暴露的 macTFS 桥接对象,避免组件直接依赖 Electron 细节。
|
* 判断当前是否运行在 Electron 桥接环境中。
|
||||||
*/
|
*/
|
||||||
export function getMactfsBridge() {
|
export function isElectron(): boolean {
|
||||||
return window.mactfs
|
return getBridge() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地 API 基础地址,非 Electron 环境回退到默认本地地址。
|
||||||
|
*/
|
||||||
|
export async function getApiBaseUrl(): Promise<string> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return DEFAULT_BASE_URL
|
||||||
|
}
|
||||||
|
return bridge.getApiBaseUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地服务 Bearer Token,非 Electron 环境返回 null。
|
||||||
|
*/
|
||||||
|
export async function getToken(): Promise<string | null> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return bridge.getToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询本地服务状态,非 Electron 环境返回未就绪状态并说明原因。
|
||||||
|
*/
|
||||||
|
export async function getServiceStatus(): Promise<ServiceStatus> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return unavailableStatus()
|
||||||
|
}
|
||||||
|
return bridge.getServiceStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉起并等待本地服务就绪,非 Electron 环境返回未就绪状态并说明原因。
|
||||||
|
*/
|
||||||
|
export async function startService(): Promise<ServiceStatus> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return unavailableStatus()
|
||||||
|
}
|
||||||
|
return bridge.startService()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开系统目录选择器,非 Electron 环境返回 null。
|
||||||
|
*/
|
||||||
|
export async function selectDirectory(): Promise<string | null> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return bridge.selectDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量检测本地绝对路径是否存在,非 Electron 环境返回 null 表示无法检测。
|
||||||
|
*/
|
||||||
|
export async function pathsExist(paths: string[]): Promise<Record<string, boolean> | null> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge || paths.length === 0) {
|
||||||
|
return bridge ? {} : null
|
||||||
|
}
|
||||||
|
return bridge.pathsExist(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在访达中打开本地路径(目录直接进入,文件定位选中),非 Electron 环境返回 false。
|
||||||
|
*/
|
||||||
|
export async function revealPath(targetPath: string, isFolder: boolean): Promise<boolean> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bridge.revealPath(targetPath, isFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造非 Electron 环境下的占位服务状态。
|
||||||
|
*/
|
||||||
|
function unavailableStatus(): ServiceStatus {
|
||||||
|
return {
|
||||||
|
running: false,
|
||||||
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
|
tokenAvailable: false,
|
||||||
|
health: null,
|
||||||
|
error: "当前不在 Electron 桌面环境,无法访问本地服务",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type { MactfsBridge, ServiceHealth, ServiceStatus } from "./types"
|
||||||
|
export {
|
||||||
|
getApiBaseUrl,
|
||||||
|
getBridge,
|
||||||
|
getServiceStatus,
|
||||||
|
getToken,
|
||||||
|
isElectron,
|
||||||
|
pathsExist,
|
||||||
|
revealPath,
|
||||||
|
selectDirectory,
|
||||||
|
startService,
|
||||||
|
} from "./bridge"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 本地 API 服务健康信息,对应服务端 /api/health 的 data 字段。
|
||||||
|
export interface ServiceHealth {
|
||||||
|
status: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
tokenFile: string
|
||||||
|
configFile: string
|
||||||
|
connected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染进程感知的本地服务状态,由 Electron 主进程计算返回。
|
||||||
|
export interface ServiceStatus {
|
||||||
|
running: boolean
|
||||||
|
baseUrl: string
|
||||||
|
tokenAvailable: boolean
|
||||||
|
health: ServiceHealth | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// preload 暴露到 window.mactfs 的窄接口契约。
|
||||||
|
export interface MactfsBridge {
|
||||||
|
isElectron: true
|
||||||
|
getApiBaseUrl(): Promise<string>
|
||||||
|
getToken(): Promise<string | null>
|
||||||
|
getServiceStatus(): Promise<ServiceStatus>
|
||||||
|
startService(): Promise<ServiceStatus>
|
||||||
|
selectDirectory(): Promise<string | null>
|
||||||
|
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
||||||
|
revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
mactfs?: MactfsBridge
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Monaco Editor 客户端引导模块:注册各语言 worker 后导出 monaco 实例。
|
||||||
|
// 该模块体积大,只允许在浏览器端通过动态 import 加载(如 Diff 弹窗内)。
|
||||||
|
import * as monaco from "monaco-editor"
|
||||||
|
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
|
||||||
|
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
|
||||||
|
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
|
||||||
|
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
|
||||||
|
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
|
||||||
|
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker(_workerId: string, label: string) {
|
||||||
|
if (label === "json") {
|
||||||
|
return new JsonWorker()
|
||||||
|
}
|
||||||
|
if (label === "css" || label === "scss" || label === "less") {
|
||||||
|
return new CssWorker()
|
||||||
|
}
|
||||||
|
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||||
|
return new HtmlWorker()
|
||||||
|
}
|
||||||
|
if (label === "typescript" || label === "javascript") {
|
||||||
|
return new TsWorker()
|
||||||
|
}
|
||||||
|
return new EditorWorker()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义主题:贴合应用本身的中性灰底色与红绿差异配色,弱化 Monaco 默认的高饱和蓝。
|
||||||
|
monaco.editor.defineTheme("mactfs-light", {
|
||||||
|
base: "vs",
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
"editor.background": "#ffffff",
|
||||||
|
"editor.foreground": "#1f1f1f",
|
||||||
|
"editor.lineHighlightBackground": "#00000005",
|
||||||
|
"editorLineNumber.foreground": "#a3a3a3",
|
||||||
|
"editorLineNumber.activeForeground": "#525252",
|
||||||
|
"editorCursor.foreground": "#4f46e5",
|
||||||
|
"focusBorder": "#00000000",
|
||||||
|
"diffEditor.insertedTextBackground": "#22c55e2e",
|
||||||
|
"diffEditor.insertedLineBackground": "#22c55e14",
|
||||||
|
"diffEditor.removedTextBackground": "#ef44442e",
|
||||||
|
"diffEditor.removedLineBackground": "#ef444414",
|
||||||
|
"diffEditorGutter.insertedLineBackground": "#22c55e21",
|
||||||
|
"diffEditorGutter.removedLineBackground": "#ef444421",
|
||||||
|
"diffEditorOverview.insertedForeground": "#22c55e99",
|
||||||
|
"diffEditorOverview.removedForeground": "#ef444499",
|
||||||
|
"diffEditor.unchangedRegionBackground": "#fafafa",
|
||||||
|
"diffEditor.unchangedRegionForeground": "#737373",
|
||||||
|
"scrollbarSlider.background": "#0000001f",
|
||||||
|
"scrollbarSlider.hoverBackground": "#00000033",
|
||||||
|
"scrollbarSlider.activeBackground": "#00000040",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
monaco.editor.defineTheme("mactfs-dark", {
|
||||||
|
base: "vs-dark",
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
"editor.background": "#1b1b1b",
|
||||||
|
"editor.foreground": "#fafafa",
|
||||||
|
"editor.lineHighlightBackground": "#ffffff08",
|
||||||
|
"editorLineNumber.foreground": "#6b6b6b",
|
||||||
|
"editorLineNumber.activeForeground": "#a3a3a3",
|
||||||
|
"editorCursor.foreground": "#818cf8",
|
||||||
|
"focusBorder": "#00000000",
|
||||||
|
"diffEditor.insertedTextBackground": "#22c55e30",
|
||||||
|
"diffEditor.insertedLineBackground": "#22c55e17",
|
||||||
|
"diffEditor.removedTextBackground": "#ef444430",
|
||||||
|
"diffEditor.removedLineBackground": "#ef444417",
|
||||||
|
"diffEditorGutter.insertedLineBackground": "#22c55e24",
|
||||||
|
"diffEditorGutter.removedLineBackground": "#ef444424",
|
||||||
|
"diffEditorOverview.insertedForeground": "#22c55e99",
|
||||||
|
"diffEditorOverview.removedForeground": "#ef444499",
|
||||||
|
"diffEditor.unchangedRegionBackground": "#222222",
|
||||||
|
"diffEditor.unchangedRegionForeground": "#9c9c9c",
|
||||||
|
"scrollbarSlider.background": "#ffffff1f",
|
||||||
|
"scrollbarSlider.hoverBackground": "#ffffff33",
|
||||||
|
"scrollbarSlider.activeBackground": "#ffffff40",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
|
||||||
|
*/
|
||||||
|
export function detectLanguage(path: string): string {
|
||||||
|
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase()
|
||||||
|
const dot = name.lastIndexOf(".")
|
||||||
|
const ext = dot >= 0 ? name.slice(dot) : ""
|
||||||
|
if (ext.length === 0) {
|
||||||
|
return "plaintext"
|
||||||
|
}
|
||||||
|
for (const language of monaco.languages.getLanguages()) {
|
||||||
|
if (language.extensions?.some((item) => item.toLowerCase() === ext)) {
|
||||||
|
return language.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "plaintext"
|
||||||
|
}
|
||||||
|
|
||||||
|
export { monaco }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 是否运行在 macOS 的 Electron 窗口内。
|
||||||
|
* 用于判断隐藏式标题栏场景:需要为红绿灯预留空间并启用顶栏拖拽。
|
||||||
|
*/
|
||||||
|
export function isMacElectron(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
return ua.includes("Electron") && ua.includes("Macintosh")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 登录页「最近连接」本地记录,仅存非敏感字段(不存密码)。
|
||||||
|
export interface RecentServer {
|
||||||
|
serverUri: string
|
||||||
|
domain: string
|
||||||
|
username: string
|
||||||
|
lastUsedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mactfs.recent-servers"
|
||||||
|
const MAX_ENTRIES = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取最近连接记录,本地存储不可用或数据损坏时返回空列表。
|
||||||
|
*/
|
||||||
|
export function loadRecentServers(): RecentServer[] {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as RecentServer[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入一条最近连接记录:按 serverUri + username 去重置顶,最多保留 5 条。
|
||||||
|
*/
|
||||||
|
export function saveRecentServer(entry: Omit<RecentServer, "lastUsedAt">): RecentServer[] {
|
||||||
|
const next: RecentServer[] = [
|
||||||
|
{ ...entry, lastUsedAt: Date.now() },
|
||||||
|
...loadRecentServers().filter(
|
||||||
|
(item) => item.serverUri !== entry.serverUri || item.username !== entry.username,
|
||||||
|
),
|
||||||
|
].slice(0, MAX_ENTRIES)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// 本地存储写入失败不影响连接流程
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// 对象动作统一模型:左侧树、中间列表、Pending Changes、目录对比结果
|
||||||
|
// 都转换成 FileTarget,再由 buildFileMenu 按状态生成同一套菜单与置灰规则。
|
||||||
|
|
||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
|
||||||
|
import { resolveLocalPath } from "./mapping"
|
||||||
|
import { normalizeServerPath } from "./path"
|
||||||
|
|
||||||
|
// 对象来源:决定菜单里展示哪一组动作。
|
||||||
|
export type FileTargetSource = "tree" | "list" | "pending" | "compare"
|
||||||
|
|
||||||
|
// 统一的对象目标描述。
|
||||||
|
export interface FileTarget {
|
||||||
|
source: FileTargetSource
|
||||||
|
folder: boolean
|
||||||
|
serverPath: string
|
||||||
|
/** 依据 Mapping 推导出的本地路径,未映射为 null。 */
|
||||||
|
localPath: string | null
|
||||||
|
/** 是否处于某条 Mapping 子树内。 */
|
||||||
|
mapped: boolean
|
||||||
|
/** serverPath 本身是否就是一条 Mapping 的根。 */
|
||||||
|
mappingRoot: boolean
|
||||||
|
/** 挂起更改状态(pendingEdit / pendingAdd / ...),无挂起为 null。 */
|
||||||
|
pendingStatus: string | null
|
||||||
|
/** 目录对比结果状态(localOnly / remoteChanged / ...),非对比来源为 null。 */
|
||||||
|
compareStatus?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部对象动作。
|
||||||
|
export type FileActionId =
|
||||||
|
| "map"
|
||||||
|
| "unmap"
|
||||||
|
| "getLatest"
|
||||||
|
| "forceGetLatest"
|
||||||
|
| "getSpecificVersion"
|
||||||
|
| "checkout"
|
||||||
|
| "rename"
|
||||||
|
| "delete"
|
||||||
|
| "undo"
|
||||||
|
| "add"
|
||||||
|
| "compare"
|
||||||
|
| "history"
|
||||||
|
| "viewFile"
|
||||||
|
| "diffLocalLatest"
|
||||||
|
| "branch"
|
||||||
|
| "merge"
|
||||||
|
| "revealInFinder"
|
||||||
|
| "addLocalFiles"
|
||||||
|
| "properties"
|
||||||
|
|
||||||
|
// 单个菜单项:是否可用与置灰原因。
|
||||||
|
export interface FileMenuItem {
|
||||||
|
id: FileActionId
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
danger?: boolean
|
||||||
|
/** 置灰原因,展示为菜单项后缀说明。 */
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由来源数据构造统一 FileTarget,本地路径与映射状态从 mappings 推导。
|
||||||
|
*/
|
||||||
|
export function makeFileTarget(input: {
|
||||||
|
source: FileTargetSource
|
||||||
|
folder: boolean
|
||||||
|
serverPath: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
pendingStatus?: string | null
|
||||||
|
compareStatus?: string | null
|
||||||
|
}): FileTarget {
|
||||||
|
const serverPath = normalizeServerPath(input.serverPath)
|
||||||
|
const localPath = resolveLocalPath(input.mappings, serverPath)
|
||||||
|
const mappingRoot = input.mappings.some(
|
||||||
|
(mapping) => normalizeServerPath(mapping.serverPath) === serverPath,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
source: input.source,
|
||||||
|
folder: input.folder,
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
mapped: localPath != null,
|
||||||
|
mappingRoot,
|
||||||
|
pendingStatus: input.pendingStatus ?? null,
|
||||||
|
compareStatus: input.compareStatus ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_MAPPED_REASON = "未映射到本地"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按对象状态生成右键菜单分组:同一路径在树和列表中产出完全一致的动作与置灰规则。
|
||||||
|
* Checkin、全局设置等不进入对象菜单。
|
||||||
|
*/
|
||||||
|
export function buildFileMenu(target: FileTarget): FileMenuItem[][] {
|
||||||
|
// 目录对比中的 localOnly 项:服务端不存在,只保留本地查看与加入版本控制。
|
||||||
|
if (target.source === "compare" && target.compareStatus === "localOnly") {
|
||||||
|
return buildLocalOnlyMenu(target)
|
||||||
|
}
|
||||||
|
return target.folder ? buildFolderMenu(target) : buildFileItemMenu(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅本地存在(localOnly)的对比结果项菜单:加入版本控制 + 本地查看。
|
||||||
|
*/
|
||||||
|
function buildLocalOnlyMenu(target: FileTarget): FileMenuItem[][] {
|
||||||
|
const sections: FileMenuItem[][] = []
|
||||||
|
if (!target.folder) {
|
||||||
|
sections.push([{ id: "viewFile", label: "查看文件", enabled: true }])
|
||||||
|
}
|
||||||
|
sections.push([
|
||||||
|
{
|
||||||
|
id: "add",
|
||||||
|
label: "加入版本控制",
|
||||||
|
enabled: target.localPath != null,
|
||||||
|
reason: target.localPath != null ? undefined : "缺少本地路径",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录菜单:本地工作区动作(未映射置灰)+ Mapping 动作 + 服务端动作。
|
||||||
|
*/
|
||||||
|
function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
|
||||||
|
const sections: FileMenuItem[][] = []
|
||||||
|
|
||||||
|
const workspaceActions: FileMenuItem[] = [
|
||||||
|
{
|
||||||
|
id: "getLatest",
|
||||||
|
label: "获取最新",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "forceGetLatest",
|
||||||
|
label: "强制获取最新…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
danger: true,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "checkout",
|
||||||
|
label: "签出编辑",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rename",
|
||||||
|
label: "重命名…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "挂起删除",
|
||||||
|
enabled: target.mapped,
|
||||||
|
danger: true,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "undo",
|
||||||
|
label: "撤销更改",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
sections.push(workspaceActions)
|
||||||
|
|
||||||
|
const mappingActions: FileMenuItem[] = []
|
||||||
|
if (!target.mapped) {
|
||||||
|
mappingActions.push({ id: "map", label: "映射到本地", enabled: true })
|
||||||
|
}
|
||||||
|
if (target.mappingRoot) {
|
||||||
|
mappingActions.push({ id: "unmap", label: "取消映射", enabled: true, danger: true })
|
||||||
|
}
|
||||||
|
if (mappingActions.length > 0) {
|
||||||
|
sections.push(mappingActions)
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push([
|
||||||
|
{
|
||||||
|
id: "revealInFinder",
|
||||||
|
label: "在访达中打开",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "addLocalFiles",
|
||||||
|
label: "添加本地文件…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
sections.push([
|
||||||
|
{
|
||||||
|
id: "compare",
|
||||||
|
label: "目录对比",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{ id: "history", label: "查看历史", enabled: true },
|
||||||
|
{ id: "branch", label: "分支…", enabled: true },
|
||||||
|
{ id: "merge", label: "合并…", enabled: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
sections.push(buildAdvancedSection(target))
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高级动作分组:获取特定版本(按 changeset 覆盖本地)与属性弹窗。
|
||||||
|
*/
|
||||||
|
function buildAdvancedSection(target: FileTarget): FileMenuItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "getSpecificVersion",
|
||||||
|
label: "获取特定版本…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
danger: true,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{ id: "properties", label: "属性…", enabled: true },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件菜单:查看 / 比较 / 历史 + 本地工作区动作,按挂起状态控制可用性。
|
||||||
|
* pendingAdd 文件不展示服务器比较与历史入口;pendingEdit 禁止直接 Get Latest 覆盖。
|
||||||
|
*/
|
||||||
|
function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
||||||
|
const sections: FileMenuItem[][] = []
|
||||||
|
const pending = target.pendingStatus
|
||||||
|
const isPendingAdd = pending === "pendingAdd"
|
||||||
|
|
||||||
|
const viewActions: FileMenuItem[] = [
|
||||||
|
{ id: "viewFile", label: "查看文件", enabled: true },
|
||||||
|
]
|
||||||
|
if (!isPendingAdd) {
|
||||||
|
viewActions.push({
|
||||||
|
id: "diffLocalLatest",
|
||||||
|
label: "与最新版本比较",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
})
|
||||||
|
viewActions.push({ id: "history", label: "查看历史", enabled: true })
|
||||||
|
}
|
||||||
|
viewActions.push({
|
||||||
|
id: "revealInFinder",
|
||||||
|
label: "在访达中显示",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
})
|
||||||
|
sections.push(viewActions)
|
||||||
|
|
||||||
|
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
|
||||||
|
if (target.source === "pending") {
|
||||||
|
sections.push([
|
||||||
|
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
|
||||||
|
])
|
||||||
|
// pendingAdd 项服务端尚不存在,属性查询无意义。
|
||||||
|
if (!isPendingAdd) {
|
||||||
|
sections.push([{ id: "properties", label: "属性…", enabled: true }])
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceActions: FileMenuItem[] = [
|
||||||
|
{
|
||||||
|
// 安全模式获取:本地改动会产生冲突而不会被覆盖,挂起状态下也允许执行。
|
||||||
|
id: "getLatest",
|
||||||
|
label: "获取最新",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "forceGetLatest",
|
||||||
|
label: "强制获取最新…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
danger: true,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "checkout",
|
||||||
|
label: "签出编辑",
|
||||||
|
enabled: target.mapped && pending == null,
|
||||||
|
reason: !target.mapped
|
||||||
|
? NOT_MAPPED_REASON
|
||||||
|
: pending === "pendingEdit"
|
||||||
|
? "已签出"
|
||||||
|
: pending != null
|
||||||
|
? "存在挂起更改"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rename",
|
||||||
|
label: "重命名…",
|
||||||
|
enabled: target.mapped && pending !== "pendingAdd" && pending !== "pendingDelete" && pending !== "pendingRename",
|
||||||
|
reason: !target.mapped
|
||||||
|
? NOT_MAPPED_REASON
|
||||||
|
: pending === "pendingAdd"
|
||||||
|
? "挂起新增,签入后才能重命名"
|
||||||
|
: pending === "pendingDelete"
|
||||||
|
? "已挂起删除"
|
||||||
|
: pending === "pendingRename"
|
||||||
|
? "已挂起重命名"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "挂起删除",
|
||||||
|
enabled: target.mapped && pending !== "pendingDelete",
|
||||||
|
danger: true,
|
||||||
|
reason: !target.mapped
|
||||||
|
? NOT_MAPPED_REASON
|
||||||
|
: pending === "pendingDelete"
|
||||||
|
? "已挂起删除"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "undo",
|
||||||
|
label: "撤销更改",
|
||||||
|
enabled: pending != null,
|
||||||
|
reason: pending == null ? "没有挂起更改" : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
sections.push(workspaceActions)
|
||||||
|
|
||||||
|
// pendingAdd 文件服务端尚不存在,不提供分支、合并、属性与获取特定版本。
|
||||||
|
if (!isPendingAdd) {
|
||||||
|
sections.push([
|
||||||
|
{ id: "branch", label: "分支…", enabled: true },
|
||||||
|
{ id: "merge", label: "合并…", enabled: true },
|
||||||
|
])
|
||||||
|
sections.push(buildAdvancedSection(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export { SERVER_ROOT_PATH } from "./session"
|
||||||
|
export type { WorkspaceSession } from "./session"
|
||||||
|
export {
|
||||||
|
getAncestorPaths,
|
||||||
|
getParentPath,
|
||||||
|
isSameOrUnderPath,
|
||||||
|
normalizeServerPath,
|
||||||
|
} from "./path"
|
||||||
|
export { findMapping, resolveLocalPath } from "./mapping"
|
||||||
|
export {
|
||||||
|
STATUS_BADGE_CLASSES,
|
||||||
|
STATUS_LABELS,
|
||||||
|
resolveItemLocalState,
|
||||||
|
statusBadgeClass,
|
||||||
|
statusLabel,
|
||||||
|
} from "./status"
|
||||||
|
export type { ItemLocalState } from "./status"
|
||||||
|
export { buildFileMenu, makeFileTarget } from "./actions"
|
||||||
|
export type {
|
||||||
|
FileActionId,
|
||||||
|
FileMenuItem,
|
||||||
|
FileTarget,
|
||||||
|
FileTargetSource,
|
||||||
|
} from "./actions"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
|
||||||
|
import { isSameOrUnderPath, normalizeServerPath } from "./path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
|
||||||
|
*/
|
||||||
|
export function findMapping(
|
||||||
|
mappings: MappingInfo[],
|
||||||
|
serverPath: string,
|
||||||
|
): MappingInfo | null {
|
||||||
|
const target = normalizeServerPath(serverPath)
|
||||||
|
let matched: MappingInfo | null = null
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (isSameOrUnderPath(mapping.serverPath, target)) {
|
||||||
|
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
|
||||||
|
matched = mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
|
||||||
|
*/
|
||||||
|
export function resolveLocalPath(
|
||||||
|
mappings: MappingInfo[],
|
||||||
|
serverPath: string,
|
||||||
|
): string | null {
|
||||||
|
const target = normalizeServerPath(serverPath)
|
||||||
|
const mapping = findMapping(mappings, target)
|
||||||
|
if (!mapping) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const base = normalizeServerPath(mapping.serverPath)
|
||||||
|
if (target === base) {
|
||||||
|
return mapping.localPath
|
||||||
|
}
|
||||||
|
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
|
||||||
|
const localBase = mapping.localPath.endsWith("/")
|
||||||
|
? mapping.localPath.slice(0, -1)
|
||||||
|
: mapping.localPath
|
||||||
|
return `${localBase}/${suffix}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { SERVER_ROOT_PATH } from "./session"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
|
||||||
|
*/
|
||||||
|
export function normalizeServerPath(serverPath: string): string {
|
||||||
|
let result = serverPath.trim()
|
||||||
|
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
|
||||||
|
result = result.slice(0, -1)
|
||||||
|
}
|
||||||
|
return result.length === 0 ? SERVER_ROOT_PATH : result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取服务端路径的父路径,根路径返回自身。
|
||||||
|
*/
|
||||||
|
export function getParentPath(serverPath: string): string {
|
||||||
|
const normalized = normalizeServerPath(serverPath)
|
||||||
|
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
|
||||||
|
return SERVER_ROOT_PATH
|
||||||
|
}
|
||||||
|
const index = normalized.lastIndexOf("/")
|
||||||
|
const parent = normalized.slice(0, index)
|
||||||
|
return parent === "$" ? SERVER_ROOT_PATH : parent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
|
||||||
|
*/
|
||||||
|
export function getAncestorPaths(serverPath: string): string[] {
|
||||||
|
const normalized = normalizeServerPath(serverPath)
|
||||||
|
const ancestors: string[] = []
|
||||||
|
let current = normalized
|
||||||
|
while (current !== SERVER_ROOT_PATH) {
|
||||||
|
current = getParentPath(current)
|
||||||
|
ancestors.unshift(current)
|
||||||
|
}
|
||||||
|
return ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 childPath 是否等于 basePath 或位于其子树内。
|
||||||
|
*/
|
||||||
|
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
|
||||||
|
const base = normalizeServerPath(basePath)
|
||||||
|
const child = normalizeServerPath(childPath)
|
||||||
|
if (base === child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
|
||||||
|
return child.startsWith(prefix)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
|
||||||
|
// 服务端目录树根路径,工作台导航的默认起点。
|
||||||
|
export const SERVER_ROOT_PATH = "$/"
|
||||||
|
|
||||||
|
// 工作台进入后固定的共享上下文,serverUri / collection / workspace 不再在台内切换。
|
||||||
|
export interface WorkspaceSession {
|
||||||
|
serverUri: string
|
||||||
|
collection: string
|
||||||
|
workspace: string
|
||||||
|
mappings: MappingInfo[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
|
||||||
|
|
||||||
|
// 业务状态对应的中文文案。
|
||||||
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
|
localModified: "本地已修改",
|
||||||
|
remoteChanged: "服务器已更新",
|
||||||
|
bothChanged: "两端都有修改",
|
||||||
|
localOnly: "仅本地存在",
|
||||||
|
remoteOnly: "仅服务器存在",
|
||||||
|
notDownloaded: "未下载",
|
||||||
|
localDeleted: "本地已删除",
|
||||||
|
pendingEdit: "签出编辑",
|
||||||
|
pendingAdd: "挂起新增",
|
||||||
|
pendingDelete: "挂起删除",
|
||||||
|
pendingRename: "挂起重命名",
|
||||||
|
pending: "挂起更改",
|
||||||
|
upToDate: "最新",
|
||||||
|
notMapped: "未映射",
|
||||||
|
mapped: "已映射",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
||||||
|
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||||
|
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||||
|
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||||
|
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||||
|
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||||
|
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||||
|
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
|
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||||
|
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||||
|
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
|
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||||
|
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||||
|
upToDate: "bg-muted text-muted-foreground",
|
||||||
|
notMapped: "bg-muted text-muted-foreground",
|
||||||
|
mapped: "bg-muted text-foreground/80",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取状态的中文文案,未知状态原样返回。
|
||||||
|
*/
|
||||||
|
export function statusLabel(status: string): string {
|
||||||
|
return STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取状态 badge 的配色 class,未知状态用中性灰。
|
||||||
|
*/
|
||||||
|
export function statusBadgeClass(status: string): string {
|
||||||
|
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
|
||||||
|
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
|
||||||
|
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
|
||||||
|
*/
|
||||||
|
export function resolveItemLocalState(
|
||||||
|
mapped: boolean,
|
||||||
|
localExists: boolean | null,
|
||||||
|
): ItemLocalState {
|
||||||
|
if (!mapped) {
|
||||||
|
return "notMapped"
|
||||||
|
}
|
||||||
|
if (localExists === false) {
|
||||||
|
return "notDownloaded"
|
||||||
|
}
|
||||||
|
return "mapped"
|
||||||
|
}
|
||||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把毫秒时间戳格式化为 `YYYY-MM-DD HH:mm`,空值返回占位符。
|
||||||
|
*/
|
||||||
|
export function formatDateTime(value: number | null | undefined): string {
|
||||||
|
if (value == null || value <= 0) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
const pad = (input: number) => String(input).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
{/* 首屏前同步应用主题偏好,避免暗色模式闪白 */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,408 +1,119 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
|
|
||||||
import { AppShell } from "~/components/app/app-shell"
|
import { ConnectView } from "~/components/app/connect-view"
|
||||||
|
import { WorkspaceShell } from "~/components/app/workspace-shell"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
import { getServiceStatus, isElectron, startService } from "~/lib/electron"
|
||||||
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
|
import type { ServiceStatus } from "~/lib/electron"
|
||||||
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
|
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
|
||||||
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
|
||||||
import {
|
|
||||||
connectSession,
|
|
||||||
getConfig,
|
|
||||||
listOperationLogs,
|
|
||||||
} from "~/lib/api/endpoints"
|
|
||||||
import type {
|
|
||||||
ApiResult,
|
|
||||||
AppConfig,
|
|
||||||
ConnectData,
|
|
||||||
HealthData,
|
|
||||||
TfsOperationLogEntry,
|
|
||||||
} from "~/lib/api/types"
|
|
||||||
import type { ServiceStatus } from "~/lib/electron/bridge"
|
|
||||||
import { getMactfsBridge } from "~/lib/electron/bridge"
|
|
||||||
|
|
||||||
interface ServiceViewState {
|
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
||||||
loading: boolean
|
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
||||||
serviceStatus?: ServiceStatus
|
|
||||||
healthResult?: ApiResult<HealthData>
|
|
||||||
errorMessage?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectionForm {
|
/**
|
||||||
serverUri: string
|
* 页面标题(同时作为 Electron 窗口标题)。
|
||||||
authType: string
|
*/
|
||||||
domain: string
|
export function meta() {
|
||||||
username: string
|
return [{ title: "MacTFS" }]
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_CONNECTION_FORM: ConnectionForm = {
|
|
||||||
serverUri: "",
|
|
||||||
authType: "ntlm-explicit",
|
|
||||||
domain: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将后端配置转换为表单可直接使用的字符串结构,避免受缺省字段影响。
|
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
||||||
|
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
||||||
*/
|
*/
|
||||||
function toConnectionForm(config?: AppConfig): ConnectionForm {
|
|
||||||
return {
|
|
||||||
serverUri: config?.serverUri || "",
|
|
||||||
authType: config?.authType || "ntlm-explicit",
|
|
||||||
domain: config?.domain || "",
|
|
||||||
username: config?.username || "",
|
|
||||||
password: config?.password || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [state, setState] = useState<ServiceViewState>({
|
const [phase, setPhase] = useState<AppPhase>("checking")
|
||||||
loading: true,
|
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||||
})
|
const [session, setSession] = useState<WorkspaceSession | null>(null)
|
||||||
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
|
const [selectedServerPath, setSelectedServerPath] = useState(SERVER_ROOT_PATH)
|
||||||
const [connecting, setConnecting] = useState(false)
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [connectResult, setConnectResult] = useState<ConnectData>()
|
|
||||||
const [preferredCollection, setPreferredCollection] = useState("")
|
|
||||||
const [selectedCollection, setSelectedCollection] = useState("")
|
|
||||||
const [selectedServerPath, setSelectedServerPath] = useState("")
|
|
||||||
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
|
|
||||||
const [workspaceActionKey, setWorkspaceActionKey] = useState("")
|
|
||||||
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
|
|
||||||
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
|
|
||||||
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 先确认服务状态,再读取默认配置并验证自动 token 请求。
|
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
|
||||||
*/
|
*/
|
||||||
async function loadServiceStatus() {
|
const bootstrap = useCallback(async () => {
|
||||||
setState((currentState) => ({
|
setPhase("checking")
|
||||||
...currentState,
|
let next = await getServiceStatus()
|
||||||
loading: true,
|
if (!next.running && isElectron()) {
|
||||||
errorMessage: undefined,
|
next = await startService()
|
||||||
}))
|
|
||||||
|
|
||||||
const bridge = getMactfsBridge()
|
|
||||||
if (!bridge) {
|
|
||||||
setState({
|
|
||||||
loading: false,
|
|
||||||
errorMessage:
|
|
||||||
"未检测到 Electron preload,请通过 Electron 启动 macTFS。",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
setStatus(next)
|
||||||
const serviceStatus = await bridge.getServiceStatus()
|
setPhase(next.running ? "connect" : "blocked")
|
||||||
const healthResult = serviceStatus.running
|
|
||||||
? await apiRequest<HealthData>("/api/health")
|
|
||||||
: undefined
|
|
||||||
const configResult = serviceStatus.running ? await getConfig() : undefined
|
|
||||||
|
|
||||||
if (configResult?.success) {
|
|
||||||
setForm(toConnectionForm(configResult.data.config))
|
|
||||||
setPreferredCollection(configResult.data.config.collection || "")
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({
|
|
||||||
loading: false,
|
|
||||||
serviceStatus,
|
|
||||||
healthResult,
|
|
||||||
errorMessage: configResult?.success
|
|
||||||
? undefined
|
|
||||||
: configResult?.errorMessage || configResult?.message,
|
|
||||||
})
|
|
||||||
setConnected(Boolean(healthResult?.data.connected))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return subscribeApiRequestLogs(upsertOperationLog)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServiceStatus()
|
void bootstrap()
|
||||||
|
}, [bootstrap])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
|
||||||
|
*/
|
||||||
|
const handleReady = useCallback((next: WorkspaceSession) => {
|
||||||
|
setSession(next)
|
||||||
|
setSelectedServerPath(SERVER_ROOT_PATH)
|
||||||
|
setPhase("workspace")
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
if (connected) {
|
* 从工作台返回连接入口,重新走连接流程。
|
||||||
loadOperationLogs()
|
*/
|
||||||
}
|
const handleReconnect = useCallback(() => {
|
||||||
}, [connected])
|
setSession(null)
|
||||||
|
setPhase("connect")
|
||||||
const serviceReady =
|
}, [])
|
||||||
state.serviceStatus?.running && state.healthResult?.success
|
|
||||||
const canConnect = serviceReady && !state.loading && !connecting
|
|
||||||
const serviceMessage = serviceReady
|
|
||||||
? "本地 API 已连接。"
|
|
||||||
: state.serviceStatus?.message ||
|
|
||||||
state.errorMessage ||
|
|
||||||
"正在检查本地 API 服务。"
|
|
||||||
const requestStatus = state.healthResult?.success
|
|
||||||
? state.healthResult.message
|
|
||||||
: state.healthResult?.errorMessage || state.errorMessage || "等待检查"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新连接表单字段,保持输入状态与受控表单一致。
|
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
|
||||||
*/
|
*/
|
||||||
function updateFormField(field: keyof ConnectionForm, value: string) {
|
const handleNavigate = useCallback((serverPath: string) => {
|
||||||
setForm((currentForm) => ({
|
setSelectedServerPath(serverPath)
|
||||||
...currentForm,
|
}, [])
|
||||||
[field]: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用连接 API,成功后进入已连接工作台状态。
|
* Mapping 创建 / 取消后更新固定上下文中的映射列表,浏览位置保持不变。
|
||||||
*/
|
*/
|
||||||
async function handleConnect(event: React.FormEvent<HTMLFormElement>) {
|
const handleMappingsChanged = useCallback((mappings: WorkspaceSession["mappings"]) => {
|
||||||
event.preventDefault()
|
setSession((prev) => (prev ? { ...prev, mappings } : prev))
|
||||||
setConnecting(true)
|
}, [])
|
||||||
setConnectResult(undefined)
|
|
||||||
setState((currentState) => ({
|
|
||||||
...currentState,
|
|
||||||
errorMessage: undefined,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await connectSession(form)
|
if (phase === "workspace" && session) {
|
||||||
setConnecting(false)
|
return (
|
||||||
|
<WorkspaceShell
|
||||||
if (!result.success) {
|
session={session}
|
||||||
setState((currentState) => ({
|
selectedServerPath={selectedServerPath}
|
||||||
...currentState,
|
onNavigate={handleNavigate}
|
||||||
errorMessage: result.errorMessage || result.message,
|
onMappingsChanged={handleMappingsChanged}
|
||||||
}))
|
onReconnect={handleReconnect}
|
||||||
return
|
/>
|
||||||
}
|
|
||||||
|
|
||||||
setConnected(true)
|
|
||||||
setConnectResult(result.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发右侧 Pending Changes 面板重新查询当前 Workspace 状态。
|
|
||||||
*/
|
|
||||||
function refreshPendingChanges() {
|
|
||||||
setPendingRefreshKey((currentKey) => currentKey + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换主工作区当前路径,并清理上一目录的选中项状态。
|
|
||||||
*/
|
|
||||||
function selectServerPath(path: string) {
|
|
||||||
setSelectedMappedItem(false)
|
|
||||||
setSelectedServerPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发中间工作区文件操作,顶部工具栏和底部操作区复用同一套执行逻辑。
|
|
||||||
*/
|
|
||||||
function runWorkspaceAction(action: string) {
|
|
||||||
setWorkspaceActionKey(`${action}:${Date.now()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并统一 API client 推送的请求日志,保留最近 80 条。
|
|
||||||
*/
|
|
||||||
function upsertOperationLog(entry: ApiRequestLogEntry) {
|
|
||||||
setOperationLogs((currentLogs) =>
|
|
||||||
[entry, ...currentLogs.filter((log) => log.id !== entry.id)].slice(0, 80)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (phase === "connect") {
|
||||||
* 从服务端读取最近操作日志,补齐当前 UI 会话之前的 API 操作记录。
|
return <ConnectView onReady={handleReady} />
|
||||||
*/
|
|
||||||
async function loadOperationLogs() {
|
|
||||||
setOperationLogsLoading(true)
|
|
||||||
const result = await listOperationLogs()
|
|
||||||
setOperationLogsLoading(false)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverLogs = result.data.logs.map(toOperationLog).reverse()
|
|
||||||
setOperationLogs((currentLogs) => [
|
|
||||||
...currentLogs.filter((log) => log.status === "running"),
|
|
||||||
...serverLogs,
|
|
||||||
].slice(0, 80))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将后端操作日志转换成底部 Console 的展示结构。
|
|
||||||
*/
|
|
||||||
function toOperationLog(
|
|
||||||
entry: TfsOperationLogEntry,
|
|
||||||
index: number
|
|
||||||
): ApiRequestLogEntry {
|
|
||||||
return {
|
|
||||||
id: `server:${entry.startedAt}:${entry.operation}:${index}`,
|
|
||||||
operation: entry.operation,
|
|
||||||
summary: entry.summary || "-",
|
|
||||||
startedAt: entry.startedAt,
|
|
||||||
endedAt: entry.endedAt,
|
|
||||||
durationMs: entry.durationMs,
|
|
||||||
success: entry.success,
|
|
||||||
errorMessage: entry.errorMessage,
|
|
||||||
status: entry.success ? "success" : "error",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-muted/40 p-4 text-sm">
|
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
||||||
<AppShell
|
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
||||||
connected={connected}
|
<h1 className="text-base font-semibold">MacTFS</h1>
|
||||||
serviceReady={Boolean(serviceReady)}
|
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
||||||
serviceLoading={state.loading}
|
|
||||||
serverUri={connectResult?.serverUri || form.serverUri}
|
|
||||||
username={form.username}
|
|
||||||
collectionCount={connectResult?.collectionCount}
|
|
||||||
serviceBaseUrl={state.serviceStatus?.baseUrl}
|
|
||||||
tokenFile={state.serviceStatus?.tokenFile}
|
|
||||||
serviceMessage={serviceMessage}
|
|
||||||
requestStatus={requestStatus}
|
|
||||||
operationLogs={operationLogs}
|
|
||||||
operationLogsLoading={operationLogsLoading}
|
|
||||||
workspaceGetLatestEnabled={connected && selectedMappedItem}
|
|
||||||
workspaceHistoryEnabled={connected && Boolean(selectedServerPath)}
|
|
||||||
sourceList={
|
|
||||||
<ServerTreePanel
|
|
||||||
connected={connected}
|
|
||||||
preferredCollection={preferredCollection}
|
|
||||||
onCollectionSelect={setSelectedCollection}
|
|
||||||
onPathSelect={selectServerPath}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
inspector={
|
|
||||||
<PendingChangesPanel
|
|
||||||
connected={connected}
|
|
||||||
collection={selectedCollection}
|
|
||||||
refreshKey={pendingRefreshKey}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onRefreshService={loadServiceStatus}
|
|
||||||
onRefreshOperationLogs={loadOperationLogs}
|
|
||||||
onWorkspaceGetLatest={() => runWorkspaceAction("getLatest")}
|
|
||||||
onWorkspaceHistory={() => runWorkspaceAction("history")}
|
|
||||||
>
|
|
||||||
{connected ? (
|
|
||||||
<FolderItemsPanel
|
|
||||||
connected={connected}
|
|
||||||
collection={selectedCollection}
|
|
||||||
serverPath={selectedServerPath}
|
|
||||||
actionKey={workspaceActionKey}
|
|
||||||
onPendingChangesRefresh={refreshPendingChanges}
|
|
||||||
onPathEnter={selectServerPath}
|
|
||||||
onSelectedMappedItemChange={setSelectedMappedItem}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="text-base font-medium">连接 TFS</h1>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
{serviceReady ? "本地 API 已就绪。" : serviceMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="grid gap-4" onSubmit={handleConnect}>
|
{phase === "checking" && (
|
||||||
<label className="grid gap-1.5">
|
<p className="mt-4 text-sm text-muted-foreground">正在检查本地服务…</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
TFS 地址
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
value={form.serverUri}
|
|
||||||
required
|
|
||||||
placeholder="http://tfs.example.com:8080/tfs/"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateFormField("serverUri", event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="grid gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
认证方式
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
value={form.authType}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateFormField("authType", event.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="ntlm-explicit">NTLM 显式账号密码</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
域
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
value={form.domain}
|
|
||||||
placeholder="domain"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateFormField("domain", event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="grid gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
用户名
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
value={form.username}
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateFormField("username", event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
密码
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateFormField("password", event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.errorMessage && (
|
|
||||||
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
|
||||||
{state.errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{phase === "blocked" && (
|
||||||
<Button type="submit" disabled={!canConnect}>
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
{connecting && <Loader2 className="animate-spin" />}
|
<p className="font-medium text-destructive">本地服务未就绪</p>
|
||||||
连接
|
<p className="text-muted-foreground">
|
||||||
|
{status?.error ?? "无法连接本地 API 服务"}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => void bootstrap()}>
|
||||||
|
重试连接
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,313 @@
|
|||||||
const { app, BrowserWindow, dialog, ipcMain } = require("electron")
|
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, nativeTheme } = require("electron")
|
||||||
const { spawn } = require("node:child_process")
|
const path = require("node:path")
|
||||||
const fs = require("node:fs")
|
const fs = require("node:fs")
|
||||||
const os = require("node:os")
|
const os = require("node:os")
|
||||||
const path = require("node:path")
|
const http = require("node:http")
|
||||||
|
const { spawn } = require("node:child_process")
|
||||||
|
|
||||||
const API_BASE_URL = "http://127.0.0.1:38765"
|
const API_HOST = "127.0.0.1"
|
||||||
const HEALTH_URL = `${API_BASE_URL}/api/health`
|
const API_PORT = 38765
|
||||||
|
const API_BASE_URL = `http://${API_HOST}:${API_PORT}`
|
||||||
|
const SERVER_MAIN_CLASS = "com.mydev.mactfs.server.MacTfsServer"
|
||||||
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
|
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
|
||||||
const MACTFS_DIRECTORY = path.resolve(__dirname, "../../mactfs")
|
const HEALTH_TIMEOUT_MS = 2000
|
||||||
const GRADLE_WRAPPER = path.resolve(__dirname, "../../tfsIntegration/gradlew")
|
const START_POLL_TIMES = 30
|
||||||
let serviceStartAttempted = false
|
const START_POLL_INTERVAL_MS = 1000
|
||||||
let serviceStartError = ""
|
|
||||||
|
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
|
||||||
|
let serverProcess = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算项目根目录,main.cjs 位于 mactfsui/electron 下,上溯两级即为单仓库根目录。
|
||||||
|
*/
|
||||||
|
function resolveProjectRoot() {
|
||||||
|
return path.resolve(__dirname, "..", "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析运行服务端使用的 Java 可执行文件,优先项目内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
||||||
|
*/
|
||||||
|
function resolveJavaBin() {
|
||||||
|
const projectRoot = resolveProjectRoot()
|
||||||
|
const bundled = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"zulu8.94.0.17-ca-jdk8.0.492-macosx_x64",
|
||||||
|
"Contents",
|
||||||
|
"Home",
|
||||||
|
"bin",
|
||||||
|
"java",
|
||||||
|
)
|
||||||
|
if (fs.existsSync(bundled)) {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
|
if (process.env.JAVA_HOME) {
|
||||||
|
const fromHome = path.join(process.env.JAVA_HOME, "bin", "java")
|
||||||
|
if (fs.existsSync(fromHome)) {
|
||||||
|
return fromHome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "java"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
|
||||||
|
*/
|
||||||
|
function resolveServerClasspath() {
|
||||||
|
const installLib = path.join(
|
||||||
|
resolveProjectRoot(),
|
||||||
|
"mactfs",
|
||||||
|
"build",
|
||||||
|
"install",
|
||||||
|
"mactfs",
|
||||||
|
"lib",
|
||||||
|
)
|
||||||
|
if (fs.existsSync(installLib)) {
|
||||||
|
return path.join(installLib, "*")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
|
||||||
|
*/
|
||||||
|
function resolveNativeDirectory() {
|
||||||
|
const projectRoot = resolveProjectRoot()
|
||||||
|
const installNative = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"mactfs",
|
||||||
|
"build",
|
||||||
|
"install",
|
||||||
|
"mactfs",
|
||||||
|
"lib",
|
||||||
|
"native",
|
||||||
|
)
|
||||||
|
if (fs.existsSync(installNative)) {
|
||||||
|
return installNative
|
||||||
|
}
|
||||||
|
const libNative = path.join(projectRoot, "tfsIntegration", "lib", "native")
|
||||||
|
if (fs.existsSync(libNative)) {
|
||||||
|
return libNative
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本地 Bearer Token,渲染进程不直接访问 token 文件,统一由主进程提供。
|
||||||
|
*/
|
||||||
|
function readToken() {
|
||||||
|
try {
|
||||||
|
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
|
||||||
|
return value.length > 0 ? value : null
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延时辅助函数,用于轮询等待服务端就绪。
|
||||||
|
*/
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 /api/health 检查本地服务是否就绪,返回结构化服务状态。
|
||||||
|
*/
|
||||||
|
function checkHealth() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const token = readToken()
|
||||||
|
const base = {
|
||||||
|
running: false,
|
||||||
|
baseUrl: API_BASE_URL,
|
||||||
|
tokenAvailable: token != null,
|
||||||
|
health: null,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
resolve({ ...base, error: "未找到本地服务 token,服务可能尚未启动" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = http.request(
|
||||||
|
`${API_BASE_URL}/api/health`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
timeout: HEALTH_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let raw = ""
|
||||||
|
response.on("data", (chunk) => {
|
||||||
|
raw += chunk
|
||||||
|
})
|
||||||
|
response.on("end", () => {
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
resolve({ ...base, error: `服务返回状态码 ${response.statusCode}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw)
|
||||||
|
resolve({
|
||||||
|
...base,
|
||||||
|
running: payload.success === true,
|
||||||
|
health: payload.data || null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ ...base, error: "服务健康检查响应解析失败" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
request.on("timeout", () => {
|
||||||
|
request.destroy()
|
||||||
|
resolve({ ...base, error: "服务健康检查超时" })
|
||||||
|
})
|
||||||
|
request.on("error", (error) => {
|
||||||
|
resolve({ ...base, error: `无法连接本地服务:${error.message}` })
|
||||||
|
})
|
||||||
|
request.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按本地开发约定拉起 mactfs-server,并轮询等待服务就绪。
|
||||||
|
*/
|
||||||
|
async function startService() {
|
||||||
|
const current = await checkHealth()
|
||||||
|
if (current.running) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
const classpath = resolveServerClasspath()
|
||||||
|
if (!classpath) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
error:
|
||||||
|
"未找到服务端构建产物,请先在 mactfs 目录执行 ../tfsIntegration/gradlew installDist",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!serverProcess || serverProcess.exitCode !== null) {
|
||||||
|
const args = ["-cp", classpath]
|
||||||
|
const nativeDirectory = resolveNativeDirectory()
|
||||||
|
if (nativeDirectory) {
|
||||||
|
args.push(`-Dcom.microsoft.tfs.jni.native.base-directory=${nativeDirectory}`)
|
||||||
|
}
|
||||||
|
args.push(SERVER_MAIN_CLASS)
|
||||||
|
serverProcess = spawn(resolveJavaBin(), args, {
|
||||||
|
cwd: path.join(resolveProjectRoot(), "mactfs"),
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
|
serverProcess.on("exit", () => {
|
||||||
|
serverProcess = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let attempt = 0; attempt < START_POLL_TIMES; attempt += 1) {
|
||||||
|
await delay(START_POLL_INTERVAL_MS)
|
||||||
|
const status = await checkHealth()
|
||||||
|
if (status.running) {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
error: "服务启动超时,请检查 JDK 与服务端构建产物",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
|
||||||
|
*/
|
||||||
|
function pathsExist(paths) {
|
||||||
|
const result = {}
|
||||||
|
if (!Array.isArray(paths)) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for (const target of paths) {
|
||||||
|
if (typeof target === "string" && target.length > 0) {
|
||||||
|
result[target] = fs.existsSync(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册渲染进程通过 preload 调用的窄接口,集中暴露 token、服务状态与目录选择能力。
|
||||||
|
*/
|
||||||
|
function registerIpcHandlers() {
|
||||||
|
ipcMain.handle("mactfs:get-api-base-url", () => API_BASE_URL)
|
||||||
|
ipcMain.handle("mactfs:get-token", () => readToken())
|
||||||
|
ipcMain.handle("mactfs:get-service-status", () => checkHealth())
|
||||||
|
ipcMain.handle("mactfs:start-service", () => startService())
|
||||||
|
ipcMain.handle("mactfs:select-directory", async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ["openDirectory", "createDirectory"],
|
||||||
|
})
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
|
||||||
|
ipcMain.handle("mactfs:reveal-path", async (event, targetPath, isFolder) => {
|
||||||
|
if (typeof targetPath !== "string" || targetPath.length === 0 || !fs.existsSync(targetPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 目录直接进入访达中的该目录,文件则在访达中定位并选中。
|
||||||
|
if (isFolder) {
|
||||||
|
const error = await shell.openPath(targetPath)
|
||||||
|
return error === ""
|
||||||
|
}
|
||||||
|
shell.showItemInFolder(targetPath)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析应用图标路径:开发态用 public/logo.png,打包态用构建产物中的 logo.png。
|
||||||
|
*/
|
||||||
|
function resolveAppIcon() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, "..", "public", "logo.png"),
|
||||||
|
path.join(__dirname, "..", "build", "client", "logo.png"),
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1280,
|
||||||
height: 800,
|
height: 840,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 640,
|
||||||
|
title: "MacTFS",
|
||||||
|
// 预设与主题一致的背景色并延迟显示,避免启动白闪。
|
||||||
|
show: false,
|
||||||
|
backgroundColor: nativeTheme.shouldUseDarkColors ? "#1d1d20" : "#fafafa",
|
||||||
|
// macOS 隐藏系统标题栏,红绿灯融入应用顶栏;其它平台回退系统标题栏。
|
||||||
|
...(process.platform === "darwin"
|
||||||
|
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
||||||
|
: {}),
|
||||||
|
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
preload: path.join(__dirname, "preload.cjs"),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
// 启动即铺满当前屏幕可视区域(保留程序坞与菜单栏,非独立全屏 Space)。
|
||||||
|
mainWindow.maximize()
|
||||||
|
mainWindow.show()
|
||||||
|
})
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.loadURL("http://localhost:5173")
|
mainWindow.loadURL("http://localhost:5173")
|
||||||
return
|
return
|
||||||
@@ -34,159 +316,15 @@ function createWindow() {
|
|||||||
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
app.setName("MacTFS")
|
||||||
* 读取 Java API 服务生成的本地 Bearer token,供 preload 和 health 检查复用。
|
|
||||||
*/
|
|
||||||
function readServerToken() {
|
|
||||||
if (!fs.existsSync(TOKEN_FILE)) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.readFileSync(TOKEN_FILE, "utf8").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用本地 API health 接口,确认服务、token 和连接状态是否可用。
|
|
||||||
*/
|
|
||||||
async function checkServiceHealth() {
|
|
||||||
const token = readServerToken()
|
|
||||||
if (!token) {
|
|
||||||
return {
|
|
||||||
baseUrl: API_BASE_URL,
|
|
||||||
running: false,
|
|
||||||
connected: false,
|
|
||||||
message: "未找到本地 API token,正在尝试启动服务。",
|
|
||||||
tokenFile: TOKEN_FILE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(HEALTH_URL, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok || !result.success) {
|
|
||||||
return {
|
|
||||||
baseUrl: API_BASE_URL,
|
|
||||||
running: false,
|
|
||||||
connected: false,
|
|
||||||
message:
|
|
||||||
result.errorMessage || result.message || "本地 API health 检查失败。",
|
|
||||||
tokenFile: TOKEN_FILE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: API_BASE_URL,
|
|
||||||
running: true,
|
|
||||||
connected: Boolean(result.data.connected),
|
|
||||||
message: result.message || "ok",
|
|
||||||
tokenFile: result.data.tokenFile || TOKEN_FILE,
|
|
||||||
configFile: result.data.configFile,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
baseUrl: API_BASE_URL,
|
|
||||||
running: false,
|
|
||||||
connected: false,
|
|
||||||
message: error instanceof Error ? error.message : "本地 API 服务未响应。",
|
|
||||||
tokenFile: TOKEN_FILE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在开发环境下通过已有 Gradle runServer 任务拉起本地 Java API 服务。
|
|
||||||
*/
|
|
||||||
function startLocalService() {
|
|
||||||
if (serviceStartAttempted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceStartAttempted = true
|
|
||||||
if (!fs.existsSync(GRADLE_WRAPPER)) {
|
|
||||||
serviceStartError = `未找到 Gradle 启动脚本:${GRADLE_WRAPPER}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(GRADLE_WRAPPER, ["runServer"], {
|
|
||||||
cwd: MACTFS_DIRECTORY,
|
|
||||||
detached: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
serviceStartError = error.message
|
|
||||||
})
|
|
||||||
child.unref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 等待一小段时间后重试 health,用于覆盖 Java 服务启动过程。
|
|
||||||
*/
|
|
||||||
function delay(ms) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保本地 API 服务可用;未运行时先启动,再轮询 health 返回最终状态。
|
|
||||||
*/
|
|
||||||
async function ensureServiceStatus() {
|
|
||||||
let status = await checkServiceHealth()
|
|
||||||
if (status.running) {
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
startLocalService()
|
|
||||||
for (let index = 0; index < 30; index += 1) {
|
|
||||||
await delay(1000)
|
|
||||||
status = await checkServiceHealth()
|
|
||||||
if (status.running) {
|
|
||||||
return {
|
|
||||||
...status,
|
|
||||||
started: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...status,
|
|
||||||
started: serviceStartAttempted,
|
|
||||||
message:
|
|
||||||
serviceStartError ||
|
|
||||||
"本地 API 服务未就绪,请确认 Java 8、Gradle 和 TFS 依赖可用。",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开系统目录选择器,返回用户选择的本地目录。
|
|
||||||
*/
|
|
||||||
async function selectDirectory() {
|
|
||||||
const window = BrowserWindow.getFocusedWindow()
|
|
||||||
const result = await dialog.showOpenDialog(window || undefined, {
|
|
||||||
properties: ["openDirectory", "createDirectory"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.canceled ? null : result.filePaths[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册 renderer 可调用的最小 Electron 桥接接口。
|
|
||||||
*/
|
|
||||||
function registerIpcHandlers() {
|
|
||||||
ipcMain.handle("mactfs:get-token", () => readServerToken())
|
|
||||||
ipcMain.handle("mactfs:get-service-status", () => ensureServiceStatus())
|
|
||||||
ipcMain.handle("mactfs:select-directory", () => selectDirectory())
|
|
||||||
}
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
|
if (process.platform === "darwin" && iconPath && app.dock) {
|
||||||
|
app.dock.setIcon(nativeImage.createFromPath(iconPath))
|
||||||
|
}
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
ensureServiceStatus()
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
@@ -201,3 +339,11 @@ app.on("window-all-closed", () => {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 应用退出时回收由本进程拉起的服务端,避免遗留孤儿进程。
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
if (serverProcess && serverProcess.exitCode === null) {
|
||||||
|
serverProcess.kill()
|
||||||
|
serverProcess = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer } = require("electron")
|
||||||
|
|
||||||
/**
|
// 仅向渲染进程暴露窄接口,渲染层不直接接触 fs、child_process 或 token 文件。
|
||||||
* 请求主进程返回本地 API Bearer token。
|
|
||||||
*/
|
|
||||||
function getToken() {
|
|
||||||
return ipcRenderer.invoke("mactfs:get-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求主进程确认本地 Java API 服务状态。
|
|
||||||
*/
|
|
||||||
function getServiceStatus() {
|
|
||||||
return ipcRenderer.invoke("mactfs:get-service-status")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求主进程打开本地目录选择器。
|
|
||||||
*/
|
|
||||||
function selectDirectory() {
|
|
||||||
return ipcRenderer.invoke("mactfs:select-directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("mactfs", {
|
contextBridge.exposeInMainWorld("mactfs", {
|
||||||
getToken,
|
isElectron: true,
|
||||||
getServiceStatus,
|
/**
|
||||||
selectDirectory,
|
* 获取本地 API 基础地址。
|
||||||
|
*/
|
||||||
|
getApiBaseUrl() {
|
||||||
|
return ipcRenderer.invoke("mactfs:get-api-base-url")
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取本地服务 Bearer Token。
|
||||||
|
*/
|
||||||
|
getToken() {
|
||||||
|
return ipcRenderer.invoke("mactfs:get-token")
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 查询本地 API 服务当前状态。
|
||||||
|
*/
|
||||||
|
getServiceStatus() {
|
||||||
|
return ipcRenderer.invoke("mactfs:get-service-status")
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 拉起本地 API 服务并等待就绪。
|
||||||
|
*/
|
||||||
|
startService() {
|
||||||
|
return ipcRenderer.invoke("mactfs:start-service")
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 打开系统目录选择器,返回所选目录路径。
|
||||||
|
*/
|
||||||
|
selectDirectory() {
|
||||||
|
return ipcRenderer.invoke("mactfs:select-directory")
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 批量检测本地绝对路径是否存在。
|
||||||
|
*/
|
||||||
|
pathsExist(paths) {
|
||||||
|
return ipcRenderer.invoke("mactfs:paths-exist", paths)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 在访达中打开:目录直接进入,文件定位并选中。
|
||||||
|
*/
|
||||||
|
revealPath(targetPath, isFolder) {
|
||||||
|
return ipcRenderer.invoke("mactfs:reveal-path", targetPath, isFolder)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,19 +6,19 @@
|
|||||||
"build": "react-router build",
|
"build": "react-router build",
|
||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"electron": "electron electron/main.cjs",
|
"electron": "electron electron/main.cjs",
|
||||||
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on tcp:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
"electron:dev": "concurrently -k \"pnpm dev\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
||||||
"start": "pnpm build && pnpm electron",
|
"start": "pnpm build && pnpm electron",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx}\""
|
"format": "prettier --write \"**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/roboto-slab": "^5.2.8",
|
|
||||||
"@react-router/node": "7.13.1",
|
"@react-router/node": "7.13.1",
|
||||||
"@react-router/serve": "7.13.1",
|
"@react-router/serve": "7.13.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"isbot": "^5.1.36",
|
"isbot": "^5.1.36",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"electron": "^42.2.0",
|
"electron": "^42.2.0",
|
||||||
|
"playwright-core": "^1.60.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fontsource-variable/roboto-slab':
|
|
||||||
specifier: ^5.2.8
|
|
||||||
version: 5.2.8
|
|
||||||
'@react-router/node':
|
'@react-router/node':
|
||||||
specifier: 7.13.1
|
specifier: 7.13.1
|
||||||
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
|
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
|
||||||
@@ -29,6 +26,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.16.0
|
specifier: ^1.16.0
|
||||||
version: 1.16.0(react@19.2.6)
|
version: 1.16.0(react@19.2.6)
|
||||||
|
monaco-editor:
|
||||||
|
specifier: ^0.55.1
|
||||||
|
version: 0.55.1
|
||||||
radix-ui:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -72,6 +72,9 @@ importers:
|
|||||||
electron:
|
electron:
|
||||||
specifier: ^42.2.0
|
specifier: ^42.2.0
|
||||||
version: 42.2.0
|
version: 42.2.0
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.60.0
|
||||||
|
version: 1.60.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.3
|
version: 3.8.3
|
||||||
@@ -410,9 +413,6 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.11':
|
'@floating-ui/utils@0.2.11':
|
||||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
'@fontsource-variable/roboto-slab@5.2.8':
|
|
||||||
resolution: {integrity: sha512-cdvOSwocP50xS01gXqnGxw7uFcCZQgn1IDlqiAmNuZP4uEVScYAKMDC+aWsbP1grn9QdMJ2frhcuVBNXniBVGA==}
|
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
|
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -1556,6 +1556,9 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2':
|
'@types/validate-npm-package-name@4.0.2':
|
||||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||||
|
|
||||||
@@ -1898,6 +1901,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dompurify@3.2.7:
|
||||||
|
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||||
|
|
||||||
dotenv@17.4.2:
|
dotenv@17.4.2:
|
||||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2494,6 +2500,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@14.0.0:
|
||||||
|
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2564,6 +2575,9 @@ packages:
|
|||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
|
monaco-editor@0.55.1:
|
||||||
|
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||||
|
|
||||||
morgan@1.10.1:
|
morgan@1.10.1:
|
||||||
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
|
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2739,6 +2753,11 @@ packages:
|
|||||||
pkg-types@2.3.1:
|
pkg-types@2.3.1:
|
||||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||||
|
|
||||||
|
playwright-core@1.60.0:
|
||||||
|
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss-selector-parser@7.1.1:
|
postcss-selector-parser@7.1.1:
|
||||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3717,8 +3736,6 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
'@fontsource-variable/roboto-slab@5.2.8': {}
|
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hapi/hoek': 11.0.7
|
'@hapi/hoek': 11.0.7
|
||||||
@@ -4857,6 +4874,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
@@ -5172,6 +5192,10 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.4: {}
|
diff@8.0.4: {}
|
||||||
|
|
||||||
|
dompurify@3.2.7:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dotenv@17.4.2: {}
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -5784,6 +5808,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@14.0.0: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
@@ -5829,6 +5855,11 @@ snapshots:
|
|||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
monaco-editor@0.55.1:
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.2.7
|
||||||
|
marked: 14.0.0
|
||||||
|
|
||||||
morgan@1.10.1:
|
morgan@1.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
basic-auth: 2.0.1
|
basic-auth: 2.0.1
|
||||||
@@ -5997,6 +6028,8 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
playwright-core@1.60.0: {}
|
||||||
|
|
||||||
postcss-selector-parser@7.1.1:
|
postcss-selector-parser@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |