Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb388d22d | ||
|
|
1bc913c54e |
@@ -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 秒
|
||||||
@@ -33,6 +33,7 @@ task runServer(type: JavaExec) {
|
|||||||
description = 'Start the macTFS local API server.'
|
description = 'Start the macTFS local API server.'
|
||||||
classpath = sourceSets.main.runtimeClasspath
|
classpath = sourceSets.main.runtimeClasspath
|
||||||
main = 'com.mydev.mactfs.server.MacTfsServer'
|
main = 'com.mydev.mactfs.server.MacTfsServer'
|
||||||
|
executable = file('../zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home/bin/java').absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(JavaCompile) {
|
tasks.withType(JavaCompile) {
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
JDK_HOME="$PROJECT_DIR/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home"
|
||||||
|
GRADLE_WRAPPER="$PROJECT_DIR/tfsIntegration/gradlew"
|
||||||
|
|
||||||
|
if [ ! -x "$JDK_HOME/bin/java" ]; then
|
||||||
|
echo "Missing bundled JDK: $JDK_HOME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export JAVA_HOME="$JDK_HOME"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
|
||||||
|
exec /usr/bin/arch -x86_64 "$GRADLE_WRAPPER" --no-daemon runServer
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$GRADLE_WRAPPER" --no-daemon runServer
|
||||||
@@ -39,6 +39,7 @@ import com.microsoft.tfs.core.httpclient.DefaultNTCredentials;
|
|||||||
import com.microsoft.tfs.core.httpclient.UsernamePasswordCredentials;
|
import com.microsoft.tfs.core.httpclient.UsernamePasswordCredentials;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -61,6 +62,7 @@ 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_RENDER_BYTES = 5L * 1024L * 1024L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
||||||
@@ -232,16 +234,24 @@ public class MacTfsCoreService {
|
|||||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
GetStatus status;
|
GetStatus status;
|
||||||
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
|
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
|
||||||
|
List<TfsConflictInfo> skipped = normalizedPath == null
|
||||||
|
? Collections.<TfsConflictInfo>emptyList()
|
||||||
|
: pendingEditConflictInfos(queryPendingChanges(workspace, Arrays.asList(normalizedPath), recursive));
|
||||||
if (normalizedPath == null) {
|
if (normalizedPath == null) {
|
||||||
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
||||||
logs.add("Get latest workspace: " + workspace.getName());
|
logs.add("Get latest workspace: " + workspace.getName());
|
||||||
|
} else if (!skipped.isEmpty()) {
|
||||||
|
GetRequest[] requests = nonPendingGetRequests(workspace, normalizedPath, recursive, skipped);
|
||||||
|
status = requests.length == 0 ? null : workspace.get(requests, GetOptions.GET_ALL);
|
||||||
|
logs.add("Get latest path: " + normalizedPath);
|
||||||
|
logs.add("Skipped pending edit items: " + skipped.size());
|
||||||
} 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}, GetOptions.GET_ALL);
|
||||||
logs.add("Get latest path: " + normalizedPath);
|
logs.add("Get latest path: " + normalizedPath);
|
||||||
}
|
}
|
||||||
return toGetLatestResult(status);
|
return toGetLatestResult(status, skipped);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,10 +288,28 @@ public class MacTfsCoreService {
|
|||||||
public TfsFileOperationResult call(List<String> logs) throws Exception {
|
public TfsFileOperationResult call(List<String> logs) throws Exception {
|
||||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||||
String[] items = toRequiredArray(paths, "paths");
|
String[] items = toRequiredArray(paths, "paths");
|
||||||
|
List<String> skipped = new ArrayList<String>();
|
||||||
|
List<TfsConflictInfo> conflicts = new ArrayList<TfsConflictInfo>();
|
||||||
|
for (String item : items) {
|
||||||
|
if (isServerPath(item)) {
|
||||||
|
CoreOperationResult<TfsGetLatestResult> latest = getLatest(config, collectionName, workspaceName, item, recursive);
|
||||||
|
if (!latest.isSuccess()) {
|
||||||
|
throw new IllegalStateException(latest.getErrorMessage());
|
||||||
|
}
|
||||||
|
if (latest.getData() != null) {
|
||||||
|
skipped.addAll(latest.getData().getSkipped());
|
||||||
|
conflicts.addAll(latest.getData().getConflictDetails());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!conflicts.isEmpty()) {
|
||||||
|
logs.add("Checkout stopped by conflicts: " + conflicts.size());
|
||||||
|
return new TfsFileOperationResult("checkout", 0, Collections.<String>emptyList(), skipped, conflicts);
|
||||||
|
}
|
||||||
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
||||||
int affected = workspace.pendEdit(items, recursionType, LockLevel.NONE, null, GetOptions.NONE, PendChangesOptions.NONE);
|
int affected = workspace.pendEdit(items, recursionType, LockLevel.NONE, null, GetOptions.NONE, PendChangesOptions.NONE);
|
||||||
logs.add("Checkout affected: " + affected);
|
logs.add("Checkout affected: " + affected);
|
||||||
return new TfsFileOperationResult("checkout", affected, Collections.<String>emptyList());
|
return new TfsFileOperationResult("checkout", affected, Collections.<String>emptyList(), skipped, conflicts);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -513,9 +541,26 @@ 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);
|
|
||||||
logs.add("File content loaded: " + item.getServerItem());
|
logs.add("File content loaded: " + item.getServerItem());
|
||||||
return new TfsFileContent(item.getServerItem(), item.getChangeSetID(), content, false);
|
return readFileContent(item.getServerItem(), null, item.getChangeSetID(), "server", tempFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本地文件内容,供 UI 在服务端完成 Mapping 边界校验后展示映射目录内文件。
|
||||||
|
*/
|
||||||
|
public CoreOperationResult<TfsFileContent> getLocalFileContent(final String serverPath,
|
||||||
|
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.isFile()) {
|
||||||
|
throw new IllegalArgumentException("Local file not found: " + localPath);
|
||||||
|
}
|
||||||
|
logs.add("Local file content loaded: " + file.getAbsolutePath());
|
||||||
|
return readFileContent(serverPath, file.getAbsolutePath(), 0, "local", file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -534,8 +579,14 @@ public class MacTfsCoreService {
|
|||||||
if (!latest.isSuccess()) {
|
if (!latest.isSuccess()) {
|
||||||
throw new IllegalStateException(latest.getErrorMessage());
|
throw new IllegalStateException(latest.getErrorMessage());
|
||||||
}
|
}
|
||||||
String localContent = new String(Files.readAllBytes(new File(require(localPath, "localPath")).toPath()), TEXT_CHARSET);
|
TfsFileContent local = readFileContent(serverPath, new File(require(localPath, "localPath")).getAbsolutePath(), 0, "local", new File(localPath));
|
||||||
TfsTextDiff diff = buildTextDiff(localPath, serverPath, localContent, latest.getData().getContent());
|
if (latest.getData() == null || !latest.getData().isRenderable()) {
|
||||||
|
throw new IllegalArgumentException("Server latest is not text renderable");
|
||||||
|
}
|
||||||
|
if (!local.isRenderable()) {
|
||||||
|
throw new IllegalArgumentException("Local file is not text renderable");
|
||||||
|
}
|
||||||
|
TfsTextDiff diff = buildTextDiff(localPath, serverPath, local.getContent(), latest.getData().getContent());
|
||||||
logs.add("Text diff lines: " + diff.getLines().size());
|
logs.add("Text diff lines: " + diff.getLines().size());
|
||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
@@ -561,6 +612,12 @@ public class MacTfsCoreService {
|
|||||||
if (!target.isSuccess()) {
|
if (!target.isSuccess()) {
|
||||||
throw new IllegalStateException(target.getErrorMessage());
|
throw new IllegalStateException(target.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
if (source.getData() == null || !source.getData().isRenderable()) {
|
||||||
|
throw new IllegalArgumentException("Source revision is not text renderable");
|
||||||
|
}
|
||||||
|
if (target.getData() == null || !target.getData().isRenderable()) {
|
||||||
|
throw new IllegalArgumentException("Target revision is not text renderable");
|
||||||
|
}
|
||||||
TfsTextDiff diff = buildTextDiff(
|
TfsTextDiff diff = buildTextDiff(
|
||||||
serverPath + ";C" + sourceChangeset,
|
serverPath + ";C" + sourceChangeset,
|
||||||
serverPath + ";C" + targetChangeset,
|
serverPath + ";C" + targetChangeset,
|
||||||
@@ -760,6 +817,46 @@ public class MacTfsCoreService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取文件内容元数据,大文件和二进制文件只返回大小和可渲染状态,避免 UI 直接加载不可展示内容。
|
||||||
|
*/
|
||||||
|
private TfsFileContent readFileContent(String serverPath, String localPath, int changeset, String source, File file) throws IOException {
|
||||||
|
long size = file.length();
|
||||||
|
boolean binary = isBinaryFile(file);
|
||||||
|
boolean renderable = !binary && size <= MAX_RENDER_BYTES;
|
||||||
|
String content = renderable ? new String(Files.readAllBytes(file.toPath()), TEXT_CHARSET) : "";
|
||||||
|
return new TfsFileContent(
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
changeset,
|
||||||
|
source,
|
||||||
|
size,
|
||||||
|
binary,
|
||||||
|
renderable,
|
||||||
|
TEXT_CHARSET.name(),
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过文件前 8KB 的 NUL 字节判断二进制文件,满足第一版文本查看的最小识别需求。
|
||||||
|
*/
|
||||||
|
private boolean isBinaryFile(File file) throws IOException {
|
||||||
|
FileInputStream inputStream = new FileInputStream(file);
|
||||||
|
try {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int length = inputStream.read(buffer);
|
||||||
|
for (int index = 0; index < length; index++) {
|
||||||
|
if (buffer[index] == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
inputStream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 pending change、工作区本地版本和服务端最新版本计算目录对比状态。
|
* 根据 pending change、工作区本地版本和服务端最新版本计算目录对比状态。
|
||||||
*/
|
*/
|
||||||
@@ -832,15 +929,94 @@ public class MacTfsCoreService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TfsGetLatestResult toGetLatestResult(GetStatus status) {
|
private TfsGetLatestResult toGetLatestResult(GetStatus status, List<TfsConflictInfo> skipped) {
|
||||||
|
int updated = status == null ? 0 : status.getNumUpdated();
|
||||||
|
int operations = status == null ? 0 : status.getNumOperations();
|
||||||
|
int failures = status == null ? 0 : status.getNumFailures();
|
||||||
return new TfsGetLatestResult(
|
return new TfsGetLatestResult(
|
||||||
status.getNumUpdated(),
|
updated,
|
||||||
status.getNumOperations(),
|
operations,
|
||||||
status.getNumConflicts(),
|
skipped == null ? 0 : skipped.size(),
|
||||||
status.getNumFailures()
|
failures,
|
||||||
|
skipped == null ? Collections.<String>emptyList() : conflictServerPaths(skipped),
|
||||||
|
skipped == null ? Collections.<TfsConflictInfo>emptyList() : skipped
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 pending edit 列表生成 UI 冲突明细,Get Latest 不覆盖用户本地修改。
|
||||||
|
*/
|
||||||
|
private List<TfsConflictInfo> pendingEditConflictInfos(PendingChange[] pendingChanges) {
|
||||||
|
List<TfsConflictInfo> result = new ArrayList<TfsConflictInfo>();
|
||||||
|
if (pendingChanges == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (PendingChange change : pendingChanges) {
|
||||||
|
if (change != null && change.isEdit()) {
|
||||||
|
result.add(new TfsConflictInfo(
|
||||||
|
change.getServerItem(),
|
||||||
|
change.getLocalItem(),
|
||||||
|
fileSize(change.getLocalItem()),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"pendingEdit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对递归 Get Latest 过滤掉 pending edit 子项,只同步其余服务端项。
|
||||||
|
*/
|
||||||
|
private GetRequest[] nonPendingGetRequests(Workspace workspace, String serverPath, boolean recursive, List<TfsConflictInfo> skipped) {
|
||||||
|
if (!recursive) {
|
||||||
|
return new GetRequest[0];
|
||||||
|
}
|
||||||
|
Map<String, TfsConflictInfo> skippedByPath = new LinkedHashMap<String, TfsConflictInfo>();
|
||||||
|
for (TfsConflictInfo conflict : skipped) {
|
||||||
|
skippedByPath.put(conflict.getServerPath().toLowerCase(), conflict);
|
||||||
|
}
|
||||||
|
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, serverPath, true);
|
||||||
|
List<GetRequest> requests = new ArrayList<GetRequest>();
|
||||||
|
for (String itemServerPath : serverItems.keySet()) {
|
||||||
|
if (!skippedByPath.containsKey(itemServerPath.toLowerCase())) {
|
||||||
|
requests.add(new GetRequest(new ItemSpec(itemServerPath, RecursionType.NONE), LatestVersionSpec.INSTANCE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requests.toArray(new GetRequest[requests.size()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取冲突明细中的服务端路径,兼容旧 UI 只展示跳过路径列表的场景。
|
||||||
|
*/
|
||||||
|
private List<String> conflictServerPaths(List<TfsConflictInfo> conflicts) {
|
||||||
|
List<String> result = new ArrayList<String>();
|
||||||
|
for (TfsConflictInfo conflict : conflicts) {
|
||||||
|
result.add(conflict.getServerPath());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断传入路径是否是 TFS 服务端路径。
|
||||||
|
*/
|
||||||
|
private boolean isServerPath(String path) {
|
||||||
|
return path != null && path.trim().startsWith("$/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回本地文件大小,文件不存在时按 0 处理,避免冲突明细缺字段。
|
||||||
|
*/
|
||||||
|
private long fileSize(String localPath) {
|
||||||
|
if (localPath == null || localPath.trim().isEmpty()) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
File file = new File(localPath);
|
||||||
|
return file.isFile() ? file.length() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
private List<TfsPendingChangeInfo> toPendingChanges(PendingChange[] pendingChanges) {
|
private List<TfsPendingChangeInfo> toPendingChanges(PendingChange[] pendingChanges) {
|
||||||
List<TfsPendingChangeInfo> result = new ArrayList<TfsPendingChangeInfo>();
|
List<TfsPendingChangeInfo> result = new ArrayList<TfsPendingChangeInfo>();
|
||||||
if (pendingChanges == null) {
|
if (pendingChanges == null) {
|
||||||
@@ -1384,12 +1560,20 @@ public class MacTfsCoreService {
|
|||||||
private final int operations;
|
private final int operations;
|
||||||
private final int conflicts;
|
private final int conflicts;
|
||||||
private final int failures;
|
private final int failures;
|
||||||
|
private final List<String> skipped;
|
||||||
|
private final List<TfsConflictInfo> conflictDetails;
|
||||||
|
|
||||||
public TfsGetLatestResult(int updated, int operations, int conflicts, int failures) {
|
public TfsGetLatestResult(int updated, int operations, int conflicts, int failures) {
|
||||||
|
this(updated, operations, conflicts, failures, Collections.<String>emptyList(), Collections.<TfsConflictInfo>emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TfsGetLatestResult(int updated, int operations, int conflicts, int failures, List<String> skipped, List<TfsConflictInfo> conflictDetails) {
|
||||||
this.updated = updated;
|
this.updated = updated;
|
||||||
this.operations = operations;
|
this.operations = operations;
|
||||||
this.conflicts = conflicts;
|
this.conflicts = conflicts;
|
||||||
this.failures = failures;
|
this.failures = failures;
|
||||||
|
this.skipped = skipped;
|
||||||
|
this.conflictDetails = conflictDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getUpdated() {
|
public int getUpdated() {
|
||||||
@@ -1407,6 +1591,14 @@ public class MacTfsCoreService {
|
|||||||
public int getFailures() {
|
public int getFailures() {
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getSkipped() {
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TfsConflictInfo> getConflictDetails() {
|
||||||
|
return conflictDetails;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TfsPendingChangeInfo {
|
public static class TfsPendingChangeInfo {
|
||||||
@@ -1461,11 +1653,19 @@ public class MacTfsCoreService {
|
|||||||
private final String operation;
|
private final String operation;
|
||||||
private final int affected;
|
private final int affected;
|
||||||
private final List<String> failures;
|
private final List<String> failures;
|
||||||
|
private final List<String> skipped;
|
||||||
|
private final List<TfsConflictInfo> conflictDetails;
|
||||||
|
|
||||||
public TfsFileOperationResult(String operation, int affected, List<String> failures) {
|
public TfsFileOperationResult(String operation, int affected, List<String> failures) {
|
||||||
|
this(operation, affected, failures, Collections.<String>emptyList(), Collections.<TfsConflictInfo>emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TfsFileOperationResult(String operation, int affected, List<String> failures, List<String> skipped, List<TfsConflictInfo> conflictDetails) {
|
||||||
this.operation = operation;
|
this.operation = operation;
|
||||||
this.affected = affected;
|
this.affected = affected;
|
||||||
this.failures = failures;
|
this.failures = failures;
|
||||||
|
this.skipped = skipped;
|
||||||
|
this.conflictDetails = conflictDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getOperation() {
|
public String getOperation() {
|
||||||
@@ -1479,6 +1679,14 @@ public class MacTfsCoreService {
|
|||||||
public List<String> getFailures() {
|
public List<String> getFailures() {
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getSkipped() {
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TfsConflictInfo> getConflictDetails() {
|
||||||
|
return conflictDetails;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TfsCheckinResult {
|
public static class TfsCheckinResult {
|
||||||
@@ -1603,13 +1811,27 @@ public class MacTfsCoreService {
|
|||||||
|
|
||||||
public static class TfsFileContent {
|
public static class TfsFileContent {
|
||||||
private final String serverPath;
|
private final String serverPath;
|
||||||
|
private final String localPath;
|
||||||
private final int changeset;
|
private final int changeset;
|
||||||
|
private final String source;
|
||||||
|
private final long size;
|
||||||
|
private final boolean renderable;
|
||||||
|
private final String encoding;
|
||||||
private final String content;
|
private final String content;
|
||||||
private final boolean binary;
|
private final boolean binary;
|
||||||
|
|
||||||
public TfsFileContent(String serverPath, int changeset, String content, boolean binary) {
|
public TfsFileContent(String serverPath, int changeset, String content, boolean binary) {
|
||||||
|
this(serverPath, null, changeset, "server", content == null ? 0L : content.length(), binary, !binary, TEXT_CHARSET.name(), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TfsFileContent(String serverPath, String localPath, int changeset, String source, long size, boolean binary, boolean renderable, String encoding, String content) {
|
||||||
this.serverPath = serverPath;
|
this.serverPath = serverPath;
|
||||||
|
this.localPath = localPath;
|
||||||
this.changeset = changeset;
|
this.changeset = changeset;
|
||||||
|
this.source = source;
|
||||||
|
this.size = size;
|
||||||
|
this.renderable = renderable;
|
||||||
|
this.encoding = encoding;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.binary = binary;
|
this.binary = binary;
|
||||||
}
|
}
|
||||||
@@ -1618,10 +1840,30 @@ public class MacTfsCoreService {
|
|||||||
return serverPath;
|
return serverPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getLocalPath() {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
public int getChangeset() {
|
public int getChangeset() {
|
||||||
return changeset;
|
return changeset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRenderable() {
|
||||||
|
return renderable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEncoding() {
|
||||||
|
return encoding;
|
||||||
|
}
|
||||||
|
|
||||||
public String getContent() {
|
public String getContent() {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
@@ -1631,6 +1873,54 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class TfsConflictInfo {
|
||||||
|
private final String serverPath;
|
||||||
|
private final String localPath;
|
||||||
|
private final long fileSize;
|
||||||
|
private final boolean binary;
|
||||||
|
private final boolean renderable;
|
||||||
|
private final boolean autoMergeable;
|
||||||
|
private final String reason;
|
||||||
|
|
||||||
|
public TfsConflictInfo(String serverPath, String localPath, long fileSize, boolean binary, boolean renderable, boolean autoMergeable, String reason) {
|
||||||
|
this.serverPath = serverPath;
|
||||||
|
this.localPath = localPath;
|
||||||
|
this.fileSize = fileSize;
|
||||||
|
this.binary = binary;
|
||||||
|
this.renderable = renderable;
|
||||||
|
this.autoMergeable = autoMergeable;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerPath() {
|
||||||
|
return serverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocalPath() {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFileSize() {
|
||||||
|
return fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBinary() {
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRenderable() {
|
||||||
|
return renderable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAutoMergeable() {
|
||||||
|
return autoMergeable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static class TfsTextDiff {
|
public static class TfsTextDiff {
|
||||||
private final String sourceLabel;
|
private final String sourceLabel;
|
||||||
private final String targetLabel;
|
private final String targetLabel;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ 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.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;
|
||||||
@@ -61,6 +63,7 @@ public class MacTfsServer {
|
|||||||
private static final long TIMEOUT_DIFF_MS = TimeUnit.SECONDS.toMillis(60);
|
private static final long TIMEOUT_DIFF_MS = TimeUnit.SECONDS.toMillis(60);
|
||||||
private static final long TIMEOUT_LONG_WRITE_MS = TimeUnit.SECONDS.toMillis(300);
|
private static final long TIMEOUT_LONG_WRITE_MS = TimeUnit.SECONDS.toMillis(300);
|
||||||
private static final long TIMEOUT_DEFAULT_MS = TimeUnit.SECONDS.toMillis(120);
|
private static final long TIMEOUT_DEFAULT_MS = TimeUnit.SECONDS.toMillis(120);
|
||||||
|
private static final String NATIVE_ARCH_ERROR = "当前 TFS SDK macOS native 库仅支持 x86_64。请使用项目内置 zulu8.94.0.17-ca-jdk8.0.492-macosx_x64 启动本地 API 服务。";
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final MacTfsCoreService coreService = new MacTfsCoreService();
|
private final MacTfsCoreService coreService = new MacTfsCoreService();
|
||||||
@@ -173,6 +176,9 @@ public class MacTfsServer {
|
|||||||
data.put("tokenFile", tokenStore.getTokenFile().getAbsolutePath());
|
data.put("tokenFile", tokenStore.getTokenFile().getAbsolutePath());
|
||||||
data.put("configFile", configStore.getConfigFile().getAbsolutePath());
|
data.put("configFile", configStore.getConfigFile().getAbsolutePath());
|
||||||
data.put("connected", Boolean.valueOf(sessionManager.isConnected()));
|
data.put("connected", Boolean.valueOf(sessionManager.isConnected()));
|
||||||
|
data.put("javaArch", System.getProperty("os.arch"));
|
||||||
|
data.put("javaHome", System.getProperty("java.home"));
|
||||||
|
data.put("nativeCompatible", Boolean.valueOf(isNativeCompatible()));
|
||||||
return ApiResult.success("ok", data);
|
return ApiResult.success("ok", data);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -201,6 +207,7 @@ public class MacTfsServer {
|
|||||||
Spark.post("/api/session/connect", (request, response) -> handle(request, response, "connect", TIMEOUT_CONNECT_MS, new ApiCallable() {
|
Spark.post("/api/session/connect", (request, response) -> handle(request, response, "connect", TIMEOUT_CONNECT_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
requireNativeCompatible();
|
||||||
AppConfig config = requestConfig(readBody(request));
|
AppConfig config = requestConfig(readBody(request));
|
||||||
CoreOperationResult<ConnectionSummary> result = coreService.testConnection(toTfsConfig(config));
|
CoreOperationResult<ConnectionSummary> result = coreService.testConnection(toTfsConfig(config));
|
||||||
if (result.isSuccess()) {
|
if (result.isSuccess()) {
|
||||||
@@ -219,6 +226,7 @@ public class MacTfsServer {
|
|||||||
Spark.get("/api/collections", (request, response) -> handle(request, response, "listCollections", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
Spark.get("/api/collections", (request, response) -> handle(request, response, "listCollections", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
requireNativeCompatible();
|
||||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
CoreOperationResult<List<TfsCollectionInfo>> result = coreService.listCollections(toTfsConfig(config));
|
CoreOperationResult<List<TfsCollectionInfo>> result = coreService.listCollections(toTfsConfig(config));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
@@ -280,6 +288,28 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/workspace/context", (request, response) -> handle(request, response, "workspaceContext", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
String collection = require(configValue(body, "collection", config.collection), "collection");
|
||||||
|
CoreOperationResult<TfsWorkspaceInfo> result = coreService.ensureWorkspace(toTfsConfig(config), collection, stringValue(body, "workspace"), "mactfs default workspace");
|
||||||
|
if (result.isSuccess() && result.getData() != null) {
|
||||||
|
config.collection = collection;
|
||||||
|
config.workspace = result.getData().getName();
|
||||||
|
config.mappings = mappingConfigs(result.getData().getMappings());
|
||||||
|
configStore.save(config);
|
||||||
|
sessionManager.setConfig(config);
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("collection", collection);
|
||||||
|
data.put("workspace", result.getData());
|
||||||
|
data.put("mappings", result.getData() == null || result.getData().getMappings() == null ? Collections.emptyList() : result.getData().getMappings());
|
||||||
|
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 {
|
||||||
@@ -356,6 +386,27 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/files/content", (request, response) -> handle(request, response, "fileContent", TIMEOUT_DIFF_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
|
||||||
|
String localPath = stringValue(body, "localPath");
|
||||||
|
boolean preferLocal = booleanValue(body, "preferLocal", true);
|
||||||
|
CoreOperationResult<TfsFileContent> result;
|
||||||
|
if (preferLocal && !isBlank(localPath)) {
|
||||||
|
validateMappedLocalPath(config, serverPath, localPath);
|
||||||
|
result = coreService.getLocalFileContent(serverPath, localPath);
|
||||||
|
} else {
|
||||||
|
result = coreService.getFileContent(toTfsConfig(config), require(config.collection, "collection"), serverPath, null);
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
data.put("file", result.getData());
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Spark.post("/api/files/checkout", (request, response) -> handle(request, response, "checkout", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
Spark.post("/api/files/checkout", (request, response) -> handle(request, response, "checkout", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
@@ -384,6 +435,13 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Spark.post("/api/conflicts/apply", (request, response) -> handle(request, response, "applyConflictChoice", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||||
|
@Override
|
||||||
|
public ApiResult call(Request request) throws Exception {
|
||||||
|
return applyConflictChoice(request);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
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 {
|
||||||
@@ -448,7 +506,10 @@ 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<TfsTextDiff> result = coreService.diffLocalLatest(toTfsConfig(config), require(config.collection, "collection"), require(stringValue(body, "serverPath"), "serverPath"), require(stringValue(body, "localPath"), "localPath"));
|
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
|
||||||
|
String localPath = require(stringValue(body, "localPath"), "localPath");
|
||||||
|
validateMappedLocalPath(config, serverPath, localPath);
|
||||||
|
CoreOperationResult<TfsTextDiff> result = coreService.diffLocalLatest(toTfsConfig(config), require(config.collection, "collection"), serverPath, localPath);
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diff", result.getData());
|
data.put("diff", result.getData());
|
||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
@@ -487,6 +548,7 @@ public class MacTfsServer {
|
|||||||
* 浏览服务端目录并补齐 UI 需要的路径和类型字段。
|
* 浏览服务端目录并补齐 UI 需要的路径和类型字段。
|
||||||
*/
|
*/
|
||||||
private ApiResult browseServerItems(Request request) throws Exception {
|
private ApiResult browseServerItems(Request request) throws Exception {
|
||||||
|
requireNativeCompatible();
|
||||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||||
String path = firstPresent(request.queryParams("path"), "$/");
|
String path = firstPresent(request.queryParams("path"), "$/");
|
||||||
String collection = firstPresent(request.queryParams("collection"), config.collection);
|
String collection = firstPresent(request.queryParams("collection"), config.collection);
|
||||||
@@ -501,6 +563,7 @@ public class MacTfsServer {
|
|||||||
* 分发 checkout、add、delete、undo 四类文件操作,保持请求结构一致。
|
* 分发 checkout、add、delete、undo 四类文件操作,保持请求结构一致。
|
||||||
*/
|
*/
|
||||||
private ApiResult fileOperation(Request request, String operation) throws Exception {
|
private ApiResult fileOperation(Request request, String operation) throws Exception {
|
||||||
|
requireNativeCompatible();
|
||||||
Map<String, Object> body = readBody(request);
|
Map<String, Object> body = readBody(request);
|
||||||
AppConfig config = requestConfig(body);
|
AppConfig config = requestConfig(body);
|
||||||
List<String> paths = requestPaths(body);
|
List<String> paths = requestPaths(body);
|
||||||
@@ -520,6 +583,98 @@ public class MacTfsServer {
|
|||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Get Latest / Checkout 冲突选择,服务器版本走同步,本地版本或自动合并结果走 checkout。
|
||||||
|
*/
|
||||||
|
private ApiResult applyConflictChoice(Request request) throws Exception {
|
||||||
|
requireNativeCompatible();
|
||||||
|
Map<String, Object> body = readBody(request);
|
||||||
|
AppConfig config = requestConfig(body);
|
||||||
|
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
|
||||||
|
String choice = firstPresent(stringValue(body, "choice"), "keepLocal");
|
||||||
|
CoreOperationResult<?> result;
|
||||||
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
|
if ("useServer".equals(choice)) {
|
||||||
|
result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), serverPath, false);
|
||||||
|
data.put("result", result.getData());
|
||||||
|
} else {
|
||||||
|
result = coreService.checkout(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), Arrays.asList(serverPath), false);
|
||||||
|
data.put("result", result.getData());
|
||||||
|
}
|
||||||
|
data.put("choice", choice);
|
||||||
|
return fromCore(result, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验本地文件路径必须落在对应 Mapping 目录内,避免 UI 读取任意本机文件。
|
||||||
|
*/
|
||||||
|
private void validateMappedLocalPath(AppConfig config, String serverPath, String localPath) {
|
||||||
|
MappingConfig mapping = findMappingConfig(config, serverPath);
|
||||||
|
if (mapping == null) {
|
||||||
|
throw new IllegalArgumentException("Server path is not mapped: " + serverPath);
|
||||||
|
}
|
||||||
|
File mappingRoot = new File(mapping.localPath).getAbsoluteFile();
|
||||||
|
File target = new File(localPath).getAbsoluteFile();
|
||||||
|
if (!target.getPath().equals(mappingRoot.getPath()) && !target.getPath().startsWith(mappingRoot.getPath() + File.separator)) {
|
||||||
|
throw new IllegalArgumentException("Local path is outside mapping directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找覆盖当前服务端路径的最长 Mapping,支持子目录和文件读取。
|
||||||
|
*/
|
||||||
|
private MappingConfig findMappingConfig(AppConfig config, String serverPath) {
|
||||||
|
MappingConfig result = null;
|
||||||
|
if (config.mappings == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (MappingConfig mapping : config.mappings) {
|
||||||
|
if (mapping.serverPath != null && isServerChildPath(serverPath, mapping.serverPath)) {
|
||||||
|
if (result == null || mapping.serverPath.length() > result.serverPath.length()) {
|
||||||
|
result = mapping;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断服务端路径是否位于 Mapping 服务端根路径下。
|
||||||
|
*/
|
||||||
|
private boolean isServerChildPath(String serverPath, String mappingPath) {
|
||||||
|
String item = serverPath == null ? "" : serverPath.toLowerCase();
|
||||||
|
String root = mappingPath == null ? "" : mappingPath.toLowerCase();
|
||||||
|
return item.equals(root) || item.startsWith(root + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Apple Silicon + arm64 JVM 下提前拒绝 TFS SDK 调用,避免 JNI 报 native 方法签名错误。
|
||||||
|
*/
|
||||||
|
private void requireNativeCompatible() {
|
||||||
|
if (!isNativeCompatible()) {
|
||||||
|
throw new IllegalStateException(NATIVE_ARCH_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前 JVM 架构是否能加载随项目分发的 TFS macOS x86_64 native 库。
|
||||||
|
*/
|
||||||
|
private boolean isNativeCompatible() {
|
||||||
|
String osName = System.getProperty("os.name", "").toLowerCase();
|
||||||
|
String arch = System.getProperty("os.arch", "").toLowerCase();
|
||||||
|
return !osName.contains("mac") || arch.contains("x86_64") || arch.contains("amd64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只对会进入 TFS SDK 的业务接口做 native 架构校验,health/config/logs 保持可用。
|
||||||
|
*/
|
||||||
|
private boolean requiresNative(String operation) {
|
||||||
|
return !("health".equals(operation)
|
||||||
|
|| "getConfig".equals(operation)
|
||||||
|
|| "saveConfig".equals(operation)
|
||||||
|
|| "operationLogs".equals(operation));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一执行 API 调用,集中处理耗时统计、超时、异常和操作日志。
|
* 统一执行 API 调用,集中处理耗时统计、超时、异常和操作日志。
|
||||||
*/
|
*/
|
||||||
@@ -529,6 +684,9 @@ public class MacTfsServer {
|
|||||||
Future<ApiResult> future = executorService.submit(new Callable<ApiResult>() {
|
Future<ApiResult> future = executorService.submit(new Callable<ApiResult>() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call() throws Exception {
|
public ApiResult call() throws Exception {
|
||||||
|
if (requiresNative(operation)) {
|
||||||
|
requireNativeCompatible();
|
||||||
|
}
|
||||||
return callable.call(request);
|
return callable.call(request);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { ReactNode } from "react"
|
import { useState, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
Cloud,
|
Cloud,
|
||||||
Download,
|
|
||||||
FolderTree,
|
FolderTree,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
History,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -20,6 +22,8 @@ interface AppShellProps {
|
|||||||
serviceLoading: boolean
|
serviceLoading: boolean
|
||||||
serverUri: string
|
serverUri: string
|
||||||
username: string
|
username: string
|
||||||
|
collection?: string
|
||||||
|
workspace?: string
|
||||||
collectionCount?: number
|
collectionCount?: number
|
||||||
serviceBaseUrl?: string
|
serviceBaseUrl?: string
|
||||||
tokenFile?: string
|
tokenFile?: string
|
||||||
@@ -27,14 +31,10 @@ interface AppShellProps {
|
|||||||
requestStatus: string
|
requestStatus: string
|
||||||
operationLogs: ApiRequestLogEntry[]
|
operationLogs: ApiRequestLogEntry[]
|
||||||
operationLogsLoading: boolean
|
operationLogsLoading: boolean
|
||||||
workspaceGetLatestEnabled: boolean
|
|
||||||
workspaceHistoryEnabled: boolean
|
|
||||||
sourceList?: ReactNode
|
sourceList?: ReactNode
|
||||||
inspector?: ReactNode
|
inspector?: ReactNode
|
||||||
onRefreshService(): void
|
onRefreshService(): void
|
||||||
onRefreshOperationLogs(): void
|
onRefreshOperationLogs(): void
|
||||||
onWorkspaceGetLatest(): void
|
|
||||||
onWorkspaceHistory(): void
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,8 @@ export function AppShell({
|
|||||||
serviceLoading,
|
serviceLoading,
|
||||||
serverUri,
|
serverUri,
|
||||||
username,
|
username,
|
||||||
|
collection,
|
||||||
|
workspace,
|
||||||
collectionCount,
|
collectionCount,
|
||||||
serviceBaseUrl,
|
serviceBaseUrl,
|
||||||
tokenFile,
|
tokenFile,
|
||||||
@@ -54,16 +56,22 @@ export function AppShell({
|
|||||||
requestStatus,
|
requestStatus,
|
||||||
operationLogs,
|
operationLogs,
|
||||||
operationLogsLoading,
|
operationLogsLoading,
|
||||||
workspaceGetLatestEnabled,
|
|
||||||
workspaceHistoryEnabled,
|
|
||||||
sourceList,
|
sourceList,
|
||||||
inspector,
|
inspector,
|
||||||
onRefreshService,
|
onRefreshService,
|
||||||
onRefreshOperationLogs,
|
onRefreshOperationLogs,
|
||||||
onWorkspaceGetLatest,
|
|
||||||
onWorkspaceHistory,
|
|
||||||
children,
|
children,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
|
const [sourceCollapsed, setSourceCollapsed] = useState(false)
|
||||||
|
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
|
||||||
|
const [consoleCollapsed, setConsoleCollapsed] = useState(false)
|
||||||
|
|
||||||
|
const columns = connected
|
||||||
|
? `${sourceCollapsed ? "40px" : "280px"} minmax(480px,1fr) ${
|
||||||
|
inspectorCollapsed ? "40px" : "340px"
|
||||||
|
}`
|
||||||
|
: "1fr"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100svh-32px)] min-h-[640px] flex-col overflow-hidden rounded-[8px] border bg-background text-sm shadow-sm">
|
<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">
|
<header className="flex h-13 shrink-0 items-center justify-between gap-3 border-b bg-muted/30 px-3">
|
||||||
@@ -84,25 +92,17 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex min-w-0 flex-1 justify-end gap-2 text-xs text-muted-foreground">
|
||||||
<Button
|
{connected && (
|
||||||
variant="outline"
|
<div className="hidden min-w-0 items-center gap-2 md:flex">
|
||||||
size="sm"
|
<span className="max-w-52 truncate font-mono">
|
||||||
disabled={!workspaceGetLatestEnabled}
|
Collection: {collection || "-"}
|
||||||
onClick={onWorkspaceGetLatest}
|
</span>
|
||||||
>
|
<span className="max-w-52 truncate font-mono">
|
||||||
<Download />
|
Workspace: {workspace || "-"}
|
||||||
Get Latest
|
</span>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!workspaceHistoryEnabled}
|
|
||||||
onClick={onWorkspaceHistory}
|
|
||||||
>
|
|
||||||
<History />
|
|
||||||
History
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -115,12 +115,38 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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]">
|
<div
|
||||||
<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">
|
className="grid min-h-0 flex-1"
|
||||||
|
style={{ gridTemplateColumns: columns }}
|
||||||
|
>
|
||||||
|
{connected && (
|
||||||
|
<aside className="flex min-h-0 min-w-0 flex-col border-r bg-muted/25">
|
||||||
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
||||||
|
{sourceCollapsed ? (
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="展开 Source List"
|
||||||
|
onClick={() => setSourceCollapsed(false)}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FolderTree className="size-4" />
|
<FolderTree className="size-4" />
|
||||||
Source List
|
<span className="min-w-0 flex-1 truncate">Source List</span>
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="收起 Source List"
|
||||||
|
onClick={() => setSourceCollapsed(true)}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!sourceCollapsed && (
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||||
{sourceList || (
|
{sourceList || (
|
||||||
<>
|
<>
|
||||||
@@ -133,28 +159,45 @@ export function AppShell({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex min-h-[320px] min-w-0 flex-col">
|
<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="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="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>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||||
</main>
|
</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">
|
{connected && (
|
||||||
|
<aside className="flex min-h-0 min-w-0 flex-col border-l bg-background">
|
||||||
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
|
||||||
|
{inspectorCollapsed ? (
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="展开 Changes"
|
||||||
|
onClick={() => setInspectorCollapsed(false)}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<GitPullRequest className="size-4" />
|
<GitPullRequest className="size-4" />
|
||||||
Inspector
|
<span className="min-w-0 flex-1 truncate">Changes</span>
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="收起 Changes"
|
||||||
|
onClick={() => setInspectorCollapsed(true)}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!inspectorCollapsed && (
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{inspector || (
|
{inspector || (
|
||||||
@@ -192,10 +235,37 @@ export function AppShell({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="h-40 shrink-0 border-t bg-muted/20">
|
<footer className={`${consoleCollapsed ? "h-9" : "h-40"} shrink-0 border-t bg-muted/20`}>
|
||||||
|
{consoleCollapsed ? (
|
||||||
|
<div className="flex h-9 items-center justify-between px-3 text-xs text-muted-foreground">
|
||||||
|
<span>Console</span>
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="展开 Console"
|
||||||
|
onClick={() => setConsoleCollapsed(false)}
|
||||||
|
>
|
||||||
|
<ChevronUp />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="flex h-8 shrink-0 items-center justify-end border-b px-2">
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="收起 Console"
|
||||||
|
onClick={() => setConsoleCollapsed(true)}
|
||||||
|
>
|
||||||
|
<ChevronDown />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
<OperationLogPanel
|
<OperationLogPanel
|
||||||
logs={operationLogs}
|
logs={operationLogs}
|
||||||
loading={operationLogsLoading}
|
loading={operationLogsLoading}
|
||||||
@@ -204,6 +274,9 @@ export function AppShell({
|
|||||||
username={username}
|
username={username}
|
||||||
onRefresh={onRefreshOperationLogs}
|
onRefresh={onRefreshOperationLogs}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react"
|
||||||
|
|
||||||
|
interface ContextMenuItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
danger?: boolean
|
||||||
|
onSelect(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
menu?: ContextMenuState
|
||||||
|
onClose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuTriggerProps {
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
onOpen(menu: ContextMenuState): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染桌面工具右键菜单,用于文件、目录树和 Pending Changes 对象操作。
|
||||||
|
*/
|
||||||
|
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("click", close)
|
||||||
|
window.addEventListener("keydown", close)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", close)
|
||||||
|
window.removeEventListener("keydown", close)
|
||||||
|
}
|
||||||
|
}, [menu, onClose])
|
||||||
|
|
||||||
|
if (!menu) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = menu.items.filter((item) => !item.hidden)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-48 overflow-hidden rounded-[6px] border bg-popover py-1 text-xs shadow-lg"
|
||||||
|
style={{ left: menu.x, top: menu.y }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={`flex h-7 w-full items-center px-2 text-left ${
|
||||||
|
item.danger ? "text-destructive" : "text-popover-foreground"
|
||||||
|
} ${item.disabled ? "opacity-50" : "hover:bg-muted"}`}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
item.onSelect()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给任意行元素接入右键菜单打开逻辑,保持业务组件只声明菜单项。
|
||||||
|
*/
|
||||||
|
export function ContextMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
items,
|
||||||
|
onOpen,
|
||||||
|
}: ContextMenuTriggerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onOpen({ x: event.clientX, y: event.clientY, items })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ContextMenuItem, ContextMenuState }
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
|
||||||
|
interface SimpleDialogProps {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
onClose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染轻量桌面弹窗,承载阶段五从主工作区移出的 Mapping、History、Diff 和文件查看内容。
|
||||||
|
*/
|
||||||
|
export function SimpleDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: SimpleDialogProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-6 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className={`flex max-h-[calc(100svh-48px)] w-[min(1040px,calc(100vw-48px))] min-w-0 flex-col overflow-hidden rounded-[8px] border bg-background shadow-xl ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-10 items-center justify-between gap-3 border-b bg-muted/20 px-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
|
{description && (
|
||||||
|
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { GitCompareArrows, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { applyConflictChoice } from "~/lib/api/endpoints"
|
||||||
|
import type { TfsConflictInfo } from "~/lib/api/types"
|
||||||
|
|
||||||
|
type ConflictChoice = "useServer" | "keepLocal" | "autoMerge"
|
||||||
|
|
||||||
|
interface ConflictDialogProps {
|
||||||
|
conflicts: TfsConflictInfo[]
|
||||||
|
open: boolean
|
||||||
|
onOpenDiff(conflict: TfsConflictInfo): void
|
||||||
|
onApplied(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHOICE_LABELS: Record<ConflictChoice, string> = {
|
||||||
|
useServer: "使用服务器版本",
|
||||||
|
keepLocal: "保留本地版本",
|
||||||
|
autoMerge: "自动合并",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理 Get Latest / Checkout 返回的冲突明细,允许逐文件或批量选择处理方式。
|
||||||
|
*/
|
||||||
|
export function ConflictDialog({
|
||||||
|
conflicts,
|
||||||
|
open,
|
||||||
|
onOpenDiff,
|
||||||
|
onApplied,
|
||||||
|
}: ConflictDialogProps) {
|
||||||
|
const [choices, setChoices] = useState<Record<string, ConflictChoice>>({})
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const nextChoices: Record<string, ConflictChoice> = {}
|
||||||
|
conflicts.forEach((conflict) => {
|
||||||
|
nextChoices[conflict.serverPath] = "keepLocal"
|
||||||
|
})
|
||||||
|
setChoices(nextChoices)
|
||||||
|
setMessage("")
|
||||||
|
}
|
||||||
|
}, [open, conflicts])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置冲突选择,之后仍允许用户逐文件改选。
|
||||||
|
*/
|
||||||
|
function setAll(choice: ConflictChoice) {
|
||||||
|
const nextChoices: Record<string, ConflictChoice> = {}
|
||||||
|
conflicts.forEach((conflict) => {
|
||||||
|
nextChoices[conflict.serverPath] = choice
|
||||||
|
})
|
||||||
|
setChoices(nextChoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前选择逐个调用后端应用冲突处理结果。
|
||||||
|
*/
|
||||||
|
async function applyChoices() {
|
||||||
|
setApplying(true)
|
||||||
|
setMessage("")
|
||||||
|
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
const choice = choices[conflict.serverPath] || "keepLocal"
|
||||||
|
const result = await applyConflictChoice({
|
||||||
|
serverPath: conflict.serverPath,
|
||||||
|
choice,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
setApplying(false)
|
||||||
|
setMessage(result.errorMessage || result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setApplying(false)
|
||||||
|
onApplied()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[min(640px,calc(100svh-96px))] min-h-0 flex-col">
|
||||||
|
<div className="flex min-h-10 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs">
|
||||||
|
<div className="text-muted-foreground">{conflicts.length} 个冲突文件</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button size="xs" variant="outline" onClick={() => setAll("useServer")}>
|
||||||
|
全部使用服务器版本
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="outline" onClick={() => setAll("keepLocal")}>
|
||||||
|
全部保留本地版本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid h-8 shrink-0 grid-cols-[minmax(260px,1fr)_160px_120px_160px_80px] border-b bg-muted/20 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">Diff</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
{conflicts.map((conflict) => (
|
||||||
|
<div
|
||||||
|
key={conflict.serverPath}
|
||||||
|
className="grid min-h-9 grid-cols-[minmax(260px,1fr)_160px_120px_160px_80px] items-center border-b px-3 text-xs"
|
||||||
|
>
|
||||||
|
<div className="truncate font-mono">{conflict.serverPath}</div>
|
||||||
|
<div className="truncate font-mono text-muted-foreground">
|
||||||
|
{conflict.localPath || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-muted-foreground">
|
||||||
|
{conflict.fileSize}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-[6px] border bg-background px-2"
|
||||||
|
value={choices[conflict.serverPath] || "keepLocal"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChoices((currentChoices) => ({
|
||||||
|
...currentChoices,
|
||||||
|
[conflict.serverPath]: event.target.value as ConflictChoice,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(CHOICE_LABELS)
|
||||||
|
.filter(
|
||||||
|
([choice]) =>
|
||||||
|
choice !== "autoMerge" || conflict.autoMergeable
|
||||||
|
)
|
||||||
|
.map(([choice, label]) => (
|
||||||
|
<option key={choice} value={choice}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!conflict.renderable}
|
||||||
|
onClick={() => onOpenDiff(conflict)}
|
||||||
|
>
|
||||||
|
<GitCompareArrows />
|
||||||
|
Diff
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-11 shrink-0 justify-end gap-2 border-t px-3 py-2">
|
||||||
|
<Button size="sm" disabled={applying} onClick={applyChoices}>
|
||||||
|
{applying && <Loader2 className="animate-spin" />}
|
||||||
|
应用选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { GitCompareArrows, Loader2, RefreshCw, X } from "lucide-react"
|
import { GitCompareArrows, Loader2, RefreshCw, Search, X } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
|
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
|
||||||
@@ -36,6 +36,7 @@ interface DiffRow {
|
|||||||
*/
|
*/
|
||||||
export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
||||||
const [diff, setDiff] = useState<TfsTextDiff>()
|
const [diff, setDiff] = useState<TfsTextDiff>()
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [message, setMessage] = useState("")
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
}, [request])
|
}, [request])
|
||||||
|
|
||||||
const rows = diff ? toDiffRows(diff.lines) : []
|
const rows = diff ? toDiffRows(diff.lines) : []
|
||||||
|
const lowerSearch = search.trim().toLowerCase()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据当前请求类型加载文本 diff。
|
* 根据当前请求类型加载文本 diff。
|
||||||
@@ -96,7 +98,7 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b bg-background">
|
<div className="flex h-[min(720px,calc(100svh-96px))] min-h-0 flex-col bg-background">
|
||||||
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
<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="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
@@ -108,6 +110,15 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<div className="flex h-7 items-center gap-1 rounded-[6px] border bg-background px-2">
|
||||||
|
<Search className="size-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="w-36 bg-transparent text-xs outline-none"
|
||||||
|
value={search}
|
||||||
|
placeholder="搜索"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -130,7 +141,7 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{diff && (
|
{diff && (
|
||||||
<div className="border-t">
|
<div className="min-h-0 flex-1 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="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">
|
<div className="flex min-w-0 items-center truncate font-mono">
|
||||||
{diff.sourceLabel}
|
{diff.sourceLabel}
|
||||||
@@ -139,7 +150,7 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
{diff.targetLabel}
|
{diff.targetLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 overflow-auto font-mono text-xs">
|
<div className="h-[calc(100%-2rem)] overflow-auto font-mono text-xs">
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-muted-foreground">
|
<div className="px-3 py-4 text-muted-foreground">
|
||||||
无可展示 diff 内容。
|
无可展示 diff 内容。
|
||||||
@@ -148,7 +159,14 @@ export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
|||||||
rows.map((row, index) => (
|
rows.map((row, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${index}:${row.kind}`}
|
key={`${index}:${row.kind}`}
|
||||||
className="grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b"
|
className={`grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b ${
|
||||||
|
lowerSearch &&
|
||||||
|
`${row.source || ""}\n${row.target || ""}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(lowerSearch)
|
||||||
|
? "bg-amber-50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Loader2, RefreshCw, Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { getFileContent } from "~/lib/api/endpoints"
|
||||||
|
|
||||||
|
interface FileViewerDialogProps {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
preferLocal: boolean
|
||||||
|
open: boolean
|
||||||
|
onClose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读展示本地映射文件或服务器 latest 文件内容,并处理大文件和二进制提示。
|
||||||
|
*/
|
||||||
|
export function FileViewerDialog({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
preferLocal,
|
||||||
|
open,
|
||||||
|
}: FileViewerDialogProps) {
|
||||||
|
const [content, setContent] = useState("")
|
||||||
|
const [source, setSource] = useState("")
|
||||||
|
const [size, setSize] = useState(0)
|
||||||
|
const [encoding, setEncoding] = useState("")
|
||||||
|
const [renderable, setRenderable] = useState(false)
|
||||||
|
const [binary, setBinary] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadContent()
|
||||||
|
}
|
||||||
|
}, [open, serverPath, localPath, preferLocal])
|
||||||
|
|
||||||
|
const lines = useMemo(() => content.split(/\r?\n/), [content])
|
||||||
|
const lowerSearch = search.trim().toLowerCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取文件内容,优先本地映射文件,不满足条件时由调用方指定读取服务器 latest。
|
||||||
|
*/
|
||||||
|
async function loadContent() {
|
||||||
|
setLoading(true)
|
||||||
|
setMessage("")
|
||||||
|
|
||||||
|
const result = await getFileContent({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
preferLocal,
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
const file = result.data.file
|
||||||
|
if (!result.success || !file) {
|
||||||
|
setContent("")
|
||||||
|
setRenderable(false)
|
||||||
|
setMessage(result.errorMessage || result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(file.content || "")
|
||||||
|
setSource(file.source)
|
||||||
|
setSize(file.size)
|
||||||
|
setEncoding(file.encoding)
|
||||||
|
setRenderable(file.renderable)
|
||||||
|
setBinary(file.binary)
|
||||||
|
setMessage("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[min(720px,calc(100svh-96px))] min-h-0 flex-col">
|
||||||
|
<div className="flex h-10 shrink-0 items-center justify-between gap-3 border-b px-3">
|
||||||
|
<div className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
来源:{source || (preferLocal ? "local" : "server")} · {size} bytes ·{" "}
|
||||||
|
{encoding || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<div className="flex h-7 items-center gap-1 rounded-[6px] border bg-background px-2">
|
||||||
|
<Search className="size-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="w-36 bg-transparent text-xs outline-none"
|
||||||
|
value={search}
|
||||||
|
placeholder="搜索"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新"
|
||||||
|
onClick={loadContent}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!renderable && !message ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{binary ? "二进制文件不支持文本预览。" : "文件超过 5MB,不直接渲染内容。"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-background font-mono text-xs">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const matched =
|
||||||
|
lowerSearch && line.toLowerCase().includes(lowerSearch)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`grid min-h-6 grid-cols-[56px_minmax(0,1fr)] border-b ${
|
||||||
|
matched ? "bg-amber-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-visible whitespace-pre-wrap px-2 py-1">
|
||||||
|
{line || " "}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { FileText, Folder, GitCompareArrows, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { ContextMenu } from "~/components/app/context-menu"
|
||||||
|
import type { ContextMenuItem, ContextMenuState } from "~/components/app/context-menu"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
addFiles,
|
||||||
|
checkoutFiles,
|
||||||
|
compareFolder,
|
||||||
|
deleteFiles,
|
||||||
|
getLatest,
|
||||||
|
undoFiles,
|
||||||
|
} from "~/lib/api/endpoints"
|
||||||
|
import type { FolderDiffItem, FolderDiffStatus } from "~/lib/api/types"
|
||||||
|
|
||||||
|
interface FolderCompareDialogProps {
|
||||||
|
serverPath: string
|
||||||
|
localPath: string
|
||||||
|
open: boolean
|
||||||
|
onPendingChangesRefresh(): void
|
||||||
|
onOpenDiff(diff: FolderDiffItem): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<FolderDiffStatus, string> = {
|
||||||
|
localModified: "本地修改",
|
||||||
|
remoteChanged: "服务端有更新",
|
||||||
|
bothChanged: "本地和服务端都变更",
|
||||||
|
localOnly: "本地新增",
|
||||||
|
remoteOnly: "服务端新增",
|
||||||
|
notDownloaded: "未下载",
|
||||||
|
localDeleted: "本地删除",
|
||||||
|
pendingEdit: "已签出编辑",
|
||||||
|
pendingAdd: "待新增",
|
||||||
|
pendingDelete: "待删除",
|
||||||
|
upToDate: "已同步",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示已映射目录的差异结果,并通过右键菜单执行差异项操作。
|
||||||
|
*/
|
||||||
|
export function FolderCompareDialog({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
open,
|
||||||
|
onPendingChangesRefresh,
|
||||||
|
onOpenDiff,
|
||||||
|
}: FolderCompareDialogProps) {
|
||||||
|
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
|
const [hideUpToDate, setHideUpToDate] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [operationPath, setOperationPath] = useState("")
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [menu, setMenu] = useState<ContextMenuState>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadCompare()
|
||||||
|
}
|
||||||
|
}, [open, serverPath, localPath])
|
||||||
|
|
||||||
|
const visibleDiffs = diffs.filter((diff) => {
|
||||||
|
if (hideUpToDate && diff.status === "upToDate") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return statusFilter === "all" || diff.status === statusFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用目录对比 API,返回当前本地目录与服务器目录的元数据差异。
|
||||||
|
*/
|
||||||
|
async function loadCompare() {
|
||||||
|
setLoading(true)
|
||||||
|
setMessage("")
|
||||||
|
const result = await compareFolder({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setDiffs([])
|
||||||
|
setMessage(result.errorMessage || result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiffs(result.data.diffs)
|
||||||
|
setMessage(
|
||||||
|
result.data.diffs.length
|
||||||
|
? `发现 ${result.data.diffs.length} 个差异。`
|
||||||
|
: "未发现差异。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单个目录对比差异项上的文件操作,完成后保留用户重新对比入口。
|
||||||
|
*/
|
||||||
|
async function runDiffAction(action: string, diff: FolderDiffItem) {
|
||||||
|
setOperationPath(diff.serverPath)
|
||||||
|
setMessage("")
|
||||||
|
const result =
|
||||||
|
action === "getLatest"
|
||||||
|
? await getLatest({
|
||||||
|
serverPath: diff.serverPath,
|
||||||
|
recursive: diff.folder,
|
||||||
|
})
|
||||||
|
: action === "checkout"
|
||||||
|
? await checkoutFiles({
|
||||||
|
paths: [diff.serverPath],
|
||||||
|
recursive: diff.folder,
|
||||||
|
})
|
||||||
|
: action === "add"
|
||||||
|
? await addFiles({
|
||||||
|
paths: [diff.localPath],
|
||||||
|
recursive: diff.folder,
|
||||||
|
})
|
||||||
|
: action === "delete"
|
||||||
|
? await deleteFiles({
|
||||||
|
paths: [diff.serverPath],
|
||||||
|
recursive: diff.folder,
|
||||||
|
})
|
||||||
|
: await undoFiles({
|
||||||
|
paths: [diff.serverPath],
|
||||||
|
recursive: diff.folder,
|
||||||
|
})
|
||||||
|
setOperationPath("")
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setMessage(result.errorMessage || result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== "getLatest") {
|
||||||
|
onPendingChangesRefresh()
|
||||||
|
}
|
||||||
|
setMessage("操作完成,请重新对比刷新差异。")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据差异状态生成右键菜单项,避免不可执行状态展示错误操作。
|
||||||
|
*/
|
||||||
|
function buildMenuItems(diff: FolderDiffItem): ContextMenuItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "diff",
|
||||||
|
label: "比较本地与服务器 Latest",
|
||||||
|
hidden:
|
||||||
|
diff.folder ||
|
||||||
|
!["localModified", "remoteChanged", "bothChanged", "pendingEdit"].includes(
|
||||||
|
diff.status
|
||||||
|
),
|
||||||
|
onSelect: () => onOpenDiff(diff),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "getLatest",
|
||||||
|
label: "获取最新版本",
|
||||||
|
hidden: !["remoteChanged", "remoteOnly", "notDownloaded"].includes(diff.status),
|
||||||
|
onSelect: () => runDiffAction("getLatest", diff),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "checkout",
|
||||||
|
label: "签出",
|
||||||
|
hidden: !["localModified"].includes(diff.status),
|
||||||
|
onSelect: () => runDiffAction("checkout", diff),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "add",
|
||||||
|
label: "新增本地文件到服务器",
|
||||||
|
hidden: diff.status !== "localOnly",
|
||||||
|
onSelect: () => runDiffAction("add", diff),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "删除",
|
||||||
|
hidden: diff.status !== "localDeleted",
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => runDiffAction("delete", diff),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "undo",
|
||||||
|
label: "撤销挂起更改",
|
||||||
|
hidden: !diff.status.startsWith("pending"),
|
||||||
|
onSelect: () => runDiffAction("undo", diff),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[min(720px,calc(100svh-96px))] min-h-0 flex-col">
|
||||||
|
<ContextMenu menu={menu} onClose={() => setMenu(undefined)} />
|
||||||
|
<div className="flex min-h-10 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs">
|
||||||
|
<div className="min-w-0 truncate font-mono text-muted-foreground">
|
||||||
|
{localPath}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideUpToDate}
|
||||||
|
onChange={(event) => setHideUpToDate(event.target.checked)}
|
||||||
|
/>
|
||||||
|
隐藏已同步
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-[6px] border bg-background px-2"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button size="sm" variant="outline" disabled={loading} onClick={loadCompare}>
|
||||||
|
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
重新对比
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="border-b bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid h-8 shrink-0 grid-cols-[minmax(160px,1fr)_128px_minmax(260px,1.4fr)_80px_80px] border-b bg-muted/20 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>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
{visibleDiffs.map((diff) => (
|
||||||
|
<div
|
||||||
|
key={diff.serverPath}
|
||||||
|
className="grid h-8 grid-cols-[minmax(160px,1fr)_128px_minmax(260px,1.4fr)_80px_80px] items-center border-b px-3 text-xs hover:bg-muted/50"
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
items: buildMenuItems(diff),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{operationPath === diff.serverPath ? (
|
||||||
|
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||||
|
) : diff.folder ? (
|
||||||
|
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{diff.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate">{STATUS_LABELS[diff.status]}</div>
|
||||||
|
<div className="truncate font-mono text-muted-foreground">
|
||||||
|
{diff.serverPath}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-muted-foreground">
|
||||||
|
{diff.localVersion}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-muted-foreground">
|
||||||
|
{diff.latestVersion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleDiffs.length === 0 && !loading && (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
当前筛选无差异项
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<GitCompareArrows className="size-4" />
|
||||||
|
正在对比
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { FolderOpen, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { addMapping } from "~/lib/api/endpoints"
|
||||||
|
import { getMactfsBridge } from "~/lib/electron/bridge"
|
||||||
|
|
||||||
|
interface MappingDialogProps {
|
||||||
|
serverPath: string
|
||||||
|
open: boolean
|
||||||
|
onClose(): void
|
||||||
|
onSaved(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务端目录到本地目录的 Mapping,供未映射目录右键菜单和弹窗流程复用。
|
||||||
|
*/
|
||||||
|
export function MappingDialog({
|
||||||
|
serverPath,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: MappingDialogProps) {
|
||||||
|
const [localPath, setLocalPath] = useState("")
|
||||||
|
const [getLatest, setGetLatest] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setLocalPath("")
|
||||||
|
setGetLatest(false)
|
||||||
|
setMessage("")
|
||||||
|
}
|
||||||
|
}, [open, serverPath])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Electron 主进程选择本地目录,避免渲染进程直接访问本机文件系统。
|
||||||
|
*/
|
||||||
|
async function selectLocalDirectory() {
|
||||||
|
const directory = await getMactfsBridge()?.selectDirectory()
|
||||||
|
if (directory) {
|
||||||
|
setLocalPath(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Mapping,成功后通知父组件刷新当前目录状态。
|
||||||
|
*/
|
||||||
|
async function saveMapping() {
|
||||||
|
if (!localPath) {
|
||||||
|
setMessage("请选择本地目录。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setMessage("")
|
||||||
|
const result = await addMapping({
|
||||||
|
serverPath,
|
||||||
|
localPath,
|
||||||
|
getLatest,
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setMessage(result.errorMessage || result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 p-4 text-sm">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
服务端路径
|
||||||
|
</span>
|
||||||
|
<div className="rounded-[6px] border bg-muted/20 px-2 py-1.5 font-mono text-xs">
|
||||||
|
{serverPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
本地目录
|
||||||
|
</span>
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<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={localPath}
|
||||||
|
onChange={(event) => setLocalPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={selectLocalDirectory}>
|
||||||
|
<FolderOpen />
|
||||||
|
选择目录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={getLatest}
|
||||||
|
onChange={(event) => setGetLatest(event.target.checked)}
|
||||||
|
/>
|
||||||
|
创建后立即 Get Latest
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={saving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={saving} onClick={saveMapping}>
|
||||||
|
{saving && <Loader2 className="animate-spin" />}
|
||||||
|
创建 Mapping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,34 +2,30 @@ import { useEffect, useState } from "react"
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Database,
|
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { listCollections, listServerTree } from "~/lib/api/endpoints"
|
import { listServerTree } from "~/lib/api/endpoints"
|
||||||
import type { TfsCollectionInfo, TfsServerItem } from "~/lib/api/types"
|
import type { TfsServerItem } from "~/lib/api/types"
|
||||||
|
|
||||||
interface ServerTreePanelProps {
|
interface ServerTreePanelProps {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
preferredCollection: string
|
collection: string
|
||||||
onCollectionSelect(collection: string): void
|
selectedPath: string
|
||||||
onPathSelect(path: string): void
|
onPathSelect(path: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染 Collection 与服务端目录树,并在展开目录时按需请求子节点。
|
* 渲染固定 Collection 下的服务端目录树,并在展开目录时按需请求子节点。
|
||||||
*/
|
*/
|
||||||
export function ServerTreePanel({
|
export function ServerTreePanel({
|
||||||
connected,
|
connected,
|
||||||
preferredCollection,
|
collection,
|
||||||
onCollectionSelect,
|
selectedPath,
|
||||||
onPathSelect,
|
onPathSelect,
|
||||||
}: ServerTreePanelProps) {
|
}: ServerTreePanelProps) {
|
||||||
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
|
|
||||||
const [selectedCollection, setSelectedCollection] = useState("")
|
|
||||||
const [selectedPath, setSelectedPath] = useState("")
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
|
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
|
||||||
const [itemsByPath, setItemsByPath] = useState<
|
const [itemsByPath, setItemsByPath] = useState<
|
||||||
Record<string, TfsServerItem[]>
|
Record<string, TfsServerItem[]>
|
||||||
@@ -38,55 +34,22 @@ export function ServerTreePanel({
|
|||||||
const [errorMessage, setErrorMessage] = useState("")
|
const [errorMessage, setErrorMessage] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected) {
|
if (!connected || !collection) {
|
||||||
setCollections([])
|
|
||||||
setSelectedCollection("")
|
|
||||||
setSelectedPath("")
|
|
||||||
setExpandedPaths([])
|
setExpandedPaths([])
|
||||||
setItemsByPath({})
|
setItemsByPath({})
|
||||||
return
|
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(["$/"])
|
setExpandedPaths(["$/"])
|
||||||
setItemsByPath({})
|
setItemsByPath({})
|
||||||
onCollectionSelect(collection.name)
|
loadServerItems(collection, "$/")
|
||||||
onPathSelect("$/")
|
}, [connected, collection])
|
||||||
await loadServerItems(collection.name, "$/")
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected && collection && selectedPath) {
|
||||||
|
expandToPath(selectedPath)
|
||||||
}
|
}
|
||||||
|
}, [connected, collection, selectedPath])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
|
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
|
||||||
@@ -109,11 +72,42 @@ export function ServerTreePanel({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据中间文件列表进入的目录路径,展开并选中左侧树对应父级。
|
||||||
|
*/
|
||||||
|
async function expandToPath(path: string) {
|
||||||
|
const parents = parentPaths(path)
|
||||||
|
setExpandedPaths((currentPaths) =>
|
||||||
|
Array.from(new Set([...currentPaths, ...parents]))
|
||||||
|
)
|
||||||
|
for (const parent of parents) {
|
||||||
|
if (!itemsByPath[parent]) {
|
||||||
|
await loadServerItems(collection, parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成服务端路径的父级目录列表,根路径固定为 $/。
|
||||||
|
*/
|
||||||
|
function parentPaths(path: string) {
|
||||||
|
const result = ["$/"]
|
||||||
|
if (!path || path === "$/") {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const parts = path.replace(/^\$\//, "").split("/").filter(Boolean)
|
||||||
|
let current = "$"
|
||||||
|
for (let index = 0; index < parts.length - 1; index++) {
|
||||||
|
current = `${current}/${parts[index]}`
|
||||||
|
result.push(current === "$" ? "$/" : current)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选中节点并通知中间区域当前服务端路径已变化。
|
* 选中节点并通知中间区域当前服务端路径已变化。
|
||||||
*/
|
*/
|
||||||
function selectPath(path: string) {
|
function selectPath(path: string) {
|
||||||
setSelectedPath(path)
|
|
||||||
onPathSelect(path)
|
onPathSelect(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +116,7 @@ export function ServerTreePanel({
|
|||||||
*/
|
*/
|
||||||
async function toggleFolder(item: TfsServerItem) {
|
async function toggleFolder(item: TfsServerItem) {
|
||||||
selectPath(item.serverPath)
|
selectPath(item.serverPath)
|
||||||
if (!item.folder || !selectedCollection) {
|
if (!item.folder || !collection) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +129,7 @@ export function ServerTreePanel({
|
|||||||
|
|
||||||
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
|
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
|
||||||
if (!itemsByPath[item.serverPath]) {
|
if (!itemsByPath[item.serverPath]) {
|
||||||
await loadServerItems(selectedCollection, item.serverPath)
|
await loadServerItems(collection, item.serverPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +175,7 @@ export function ServerTreePanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected || !collection) {
|
||||||
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
|
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,20 +195,9 @@ export function ServerTreePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{collections.map((collection) => (
|
<div className="truncate rounded-[6px] bg-background px-2 py-1.5 text-xs font-medium">
|
||||||
<button
|
{collection}
|
||||||
key={collection.id || collection.name}
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
<div className="grid gap-0.5 border-t pt-2">
|
<div className="grid gap-0.5 border-t pt-2">
|
||||||
@@ -226,8 +209,8 @@ export function ServerTreePanel({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
selectPath("$/")
|
selectPath("$/")
|
||||||
if (selectedCollection && !itemsByPath["$/"]) {
|
if (collection && !itemsByPath["$/"]) {
|
||||||
loadServerItems(selectedCollection, "$/")
|
loadServerItems(collection, "$/")
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { ContextMenu } from "~/components/app/context-menu"
|
||||||
|
import type { ContextMenuItem, ContextMenuState } from "~/components/app/context-menu"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
|
import { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
|
||||||
import type {
|
import type {
|
||||||
@@ -21,6 +23,10 @@ interface PendingChangesPanelProps {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
collection: string
|
collection: string
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
|
onOpenFile?(change: TfsPendingChangeInfo): void
|
||||||
|
onOpenDiff?(change: TfsPendingChangeInfo): void
|
||||||
|
onOpenHistory?(change: TfsPendingChangeInfo): void
|
||||||
|
onCheckinSuccess?(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
|
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
|
||||||
@@ -46,6 +52,10 @@ export function PendingChangesPanel({
|
|||||||
connected,
|
connected,
|
||||||
collection,
|
collection,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
|
onOpenFile,
|
||||||
|
onOpenDiff,
|
||||||
|
onOpenHistory,
|
||||||
|
onCheckinSuccess,
|
||||||
}: PendingChangesPanelProps) {
|
}: PendingChangesPanelProps) {
|
||||||
const [pendingChanges, setPendingChanges] = useState<
|
const [pendingChanges, setPendingChanges] = useState<
|
||||||
TfsPendingChangeInfo[]
|
TfsPendingChangeInfo[]
|
||||||
@@ -60,6 +70,7 @@ export function PendingChangesPanel({
|
|||||||
"success"
|
"success"
|
||||||
)
|
)
|
||||||
const [message, setMessage] = useState("")
|
const [message, setMessage] = useState("")
|
||||||
|
const [menu, setMenu] = useState<ContextMenuState>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected || !collection) {
|
if (!connected || !collection) {
|
||||||
@@ -218,6 +229,7 @@ export function PendingChangesPanel({
|
|||||||
|
|
||||||
setCheckinComment("")
|
setCheckinComment("")
|
||||||
await loadPendingChanges()
|
await loadPendingChanges()
|
||||||
|
onCheckinSuccess?.()
|
||||||
setIncludedKeys([])
|
setIncludedKeys([])
|
||||||
setMessageType("success")
|
setMessageType("success")
|
||||||
setMessage(
|
setMessage(
|
||||||
@@ -227,6 +239,7 @@ export function PendingChangesPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3">
|
<section className="grid gap-3">
|
||||||
|
<ContextMenu menu={menu} onClose={() => setMenu(undefined)} />
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium">Pending Changes</div>
|
<div className="font-medium">Pending Changes</div>
|
||||||
@@ -282,6 +295,9 @@ export function PendingChangesPanel({
|
|||||||
disabled={checkinLoading}
|
disabled={checkinLoading}
|
||||||
onUndo={undoPendingChange}
|
onUndo={undoPendingChange}
|
||||||
onMove={moveToExcluded}
|
onMove={moveToExcluded}
|
||||||
|
onContextMenu={(event, change) =>
|
||||||
|
openPendingContextMenu(event, change, "included")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PendingChangeGroup
|
<PendingChangeGroup
|
||||||
@@ -294,6 +310,9 @@ export function PendingChangesPanel({
|
|||||||
disabled={checkinLoading}
|
disabled={checkinLoading}
|
||||||
onUndo={undoPendingChange}
|
onUndo={undoPendingChange}
|
||||||
onMove={moveToIncluded}
|
onMove={moveToIncluded}
|
||||||
|
onContextMenu={(event, change) =>
|
||||||
|
openPendingContextMenu(event, change, "excluded")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
@@ -330,6 +349,56 @@ export function PendingChangesPanel({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 Pending Change 对象右键菜单,pending add 不提供服务器比较入口。
|
||||||
|
*/
|
||||||
|
function openPendingContextMenu(
|
||||||
|
event: React.MouseEvent,
|
||||||
|
change: TfsPendingChangeInfo,
|
||||||
|
group: "included" | "excluded"
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
const pendingAdd = change.status === "pendingAdd"
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
key: "include",
|
||||||
|
label: "移到 Included",
|
||||||
|
hidden: group === "included",
|
||||||
|
onSelect: () => moveToIncluded(change),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exclude",
|
||||||
|
label: "移到 Excluded",
|
||||||
|
hidden: group === "excluded",
|
||||||
|
onSelect: () => moveToExcluded(change),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "view",
|
||||||
|
label: "查看",
|
||||||
|
hidden: change.folder,
|
||||||
|
onSelect: () => onOpenFile?.(change),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "diff",
|
||||||
|
label: "比较本地与服务器 Latest",
|
||||||
|
hidden: change.folder || pendingAdd,
|
||||||
|
onSelect: () => onOpenDiff?.(change),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "history",
|
||||||
|
label: "查看历史",
|
||||||
|
onSelect: () => onOpenHistory?.(change),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "undo",
|
||||||
|
label: "撤销",
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => undoPendingChange(change),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
setMenu({ x: event.clientX, y: event.clientY, items })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingChangeGroupProps {
|
interface PendingChangeGroupProps {
|
||||||
@@ -342,6 +411,7 @@ interface PendingChangeGroupProps {
|
|||||||
disabled: boolean
|
disabled: boolean
|
||||||
onUndo(change: TfsPendingChangeInfo): void
|
onUndo(change: TfsPendingChangeInfo): void
|
||||||
onMove(change: TfsPendingChangeInfo): void
|
onMove(change: TfsPendingChangeInfo): void
|
||||||
|
onContextMenu(event: React.MouseEvent, change: TfsPendingChangeInfo): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -357,6 +427,7 @@ function PendingChangeGroup({
|
|||||||
disabled,
|
disabled,
|
||||||
onUndo,
|
onUndo,
|
||||||
onMove,
|
onMove,
|
||||||
|
onContextMenu,
|
||||||
}: PendingChangeGroupProps) {
|
}: PendingChangeGroupProps) {
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-2">
|
<section className="grid gap-2">
|
||||||
@@ -377,6 +448,7 @@ function PendingChangeGroup({
|
|||||||
<div
|
<div
|
||||||
key={change.serverPath}
|
key={change.serverPath}
|
||||||
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
|
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
|
||||||
|
onContextMenu={(event) => onContextMenu(event, change)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AppConfig,
|
AppConfig,
|
||||||
AddMappingData,
|
AddMappingData,
|
||||||
AddMappingRequest,
|
AddMappingRequest,
|
||||||
|
ApplyConflictChoiceRequest,
|
||||||
CheckinData,
|
CheckinData,
|
||||||
CheckinRequest,
|
CheckinRequest,
|
||||||
ChangesetFilesData,
|
ChangesetFilesData,
|
||||||
@@ -16,6 +17,8 @@ import type {
|
|||||||
ConnectData,
|
ConnectData,
|
||||||
DiffLocalLatestRequest,
|
DiffLocalLatestRequest,
|
||||||
DiffRevisionsRequest,
|
DiffRevisionsRequest,
|
||||||
|
FileContentData,
|
||||||
|
FileContentRequest,
|
||||||
GetLatestData,
|
GetLatestData,
|
||||||
GetLatestRequest,
|
GetLatestRequest,
|
||||||
HistoryData,
|
HistoryData,
|
||||||
@@ -26,6 +29,8 @@ import type {
|
|||||||
PendingChangesRequest,
|
PendingChangesRequest,
|
||||||
ServerTreeData,
|
ServerTreeData,
|
||||||
TextDiffData,
|
TextDiffData,
|
||||||
|
WorkspaceContextData,
|
||||||
|
WorkspaceContextRequest,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +57,16 @@ export function listCollections() {
|
|||||||
return apiRequest<CollectionsData>("/api/collections")
|
return apiRequest<CollectionsData>("/api/collections")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定当前 Collection 并自动使用或创建本机默认 Workspace。
|
||||||
|
*/
|
||||||
|
export function ensureWorkspaceContext(request: WorkspaceContextRequest) {
|
||||||
|
return apiRequest<WorkspaceContextData>("/api/workspace/context", {
|
||||||
|
method: "POST",
|
||||||
|
body: request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载指定 Collection 和服务端路径下的目录节点。
|
* 加载指定 Collection 和服务端路径下的目录节点。
|
||||||
*/
|
*/
|
||||||
@@ -141,6 +156,16 @@ export function getLatest(request: GetLatestRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取映射目录内本地文件或未映射文件的服务器 latest 内容。
|
||||||
|
*/
|
||||||
|
export function getFileContent(request: FileContentRequest) {
|
||||||
|
return apiRequest<FileContentData>("/api/files/content", {
|
||||||
|
method: "POST",
|
||||||
|
body: request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已映射且本地存在的文件或目录执行 checkout。
|
* 对已映射且本地存在的文件或目录执行 checkout。
|
||||||
*/
|
*/
|
||||||
@@ -181,6 +206,16 @@ export function undoFiles(request: FileOperationRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Get Latest / Checkout 冲突弹窗中的单文件选择。
|
||||||
|
*/
|
||||||
|
export function applyConflictChoice(request: ApplyConflictChoiceRequest) {
|
||||||
|
return apiRequest<FileOperationData>("/api/conflicts/apply", {
|
||||||
|
method: "POST",
|
||||||
|
body: request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
|
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export interface TfsMappingInfo {
|
|||||||
localPath: string
|
localPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TfsWorkspaceInfo {
|
||||||
|
name: string
|
||||||
|
ownerName: string
|
||||||
|
computer: string
|
||||||
|
comment: string
|
||||||
|
created: boolean
|
||||||
|
mappings: TfsMappingInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
serverUri?: string
|
serverUri?: string
|
||||||
authType?: string
|
authType?: string
|
||||||
@@ -49,6 +58,16 @@ export interface ConnectData {
|
|||||||
collectionCount?: number
|
collectionCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceContextRequest {
|
||||||
|
collection: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceContextData {
|
||||||
|
collection: string
|
||||||
|
workspace?: TfsWorkspaceInfo
|
||||||
|
mappings: TfsMappingInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TfsCollectionInfo {
|
export interface TfsCollectionInfo {
|
||||||
name: string
|
name: string
|
||||||
id: string
|
id: string
|
||||||
@@ -161,6 +180,8 @@ export interface TfsGetLatestResult {
|
|||||||
operations: number
|
operations: number
|
||||||
conflicts: number
|
conflicts: number
|
||||||
failures: number
|
failures: number
|
||||||
|
skipped?: string[]
|
||||||
|
conflictDetails?: TfsConflictInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetLatestData {
|
export interface GetLatestData {
|
||||||
@@ -176,6 +197,8 @@ export interface TfsFileOperationResult {
|
|||||||
operation: string
|
operation: string
|
||||||
affected: number
|
affected: number
|
||||||
failures: string[]
|
failures: string[]
|
||||||
|
skipped?: string[]
|
||||||
|
conflictDetails?: TfsConflictInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileOperationData {
|
export interface FileOperationData {
|
||||||
@@ -236,6 +259,43 @@ export interface TextDiffData {
|
|||||||
diff?: TfsTextDiff
|
diff?: TfsTextDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TfsFileContent {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
changeset: number
|
||||||
|
source: "local" | "server"
|
||||||
|
size: number
|
||||||
|
binary: boolean
|
||||||
|
renderable: boolean
|
||||||
|
encoding: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileContentRequest {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
preferLocal?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileContentData {
|
||||||
|
file?: TfsFileContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TfsConflictInfo {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
fileSize: number
|
||||||
|
binary: boolean
|
||||||
|
renderable: boolean
|
||||||
|
autoMergeable: boolean
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyConflictChoiceRequest {
|
||||||
|
serverPath: string
|
||||||
|
choice: "useServer" | "keepLocal" | "autoMerge"
|
||||||
|
}
|
||||||
|
|
||||||
export interface TfsOperationLogEntry {
|
export interface TfsOperationLogEntry {
|
||||||
operation: string
|
operation: string
|
||||||
summary: string
|
summary: string
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export interface ServiceStatus {
|
|||||||
message: string
|
message: string
|
||||||
tokenFile: string
|
tokenFile: string
|
||||||
configFile?: string
|
configFile?: string
|
||||||
|
javaArch?: string
|
||||||
|
javaHome?: string
|
||||||
|
nativeCompatible?: boolean
|
||||||
started?: boolean
|
started?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+229
-19
@@ -2,15 +2,22 @@ import { useEffect, useState } from "react"
|
|||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import { AppShell } from "~/components/app/app-shell"
|
import { AppShell } from "~/components/app/app-shell"
|
||||||
|
import { SimpleDialog } from "~/components/app/simple-dialog"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { DiffPanel } from "~/components/explorer/diff-panel"
|
||||||
|
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
|
||||||
|
import { FileViewerDialog } from "~/components/explorer/file-viewer-dialog"
|
||||||
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
||||||
|
import { HistoryPanel } from "~/components/explorer/history-panel"
|
||||||
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
|
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
|
||||||
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
|
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
|
||||||
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
|
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
|
||||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
||||||
import {
|
import {
|
||||||
connectSession,
|
connectSession,
|
||||||
|
ensureWorkspaceContext,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
listCollections,
|
||||||
listOperationLogs,
|
listOperationLogs,
|
||||||
} from "~/lib/api/endpoints"
|
} from "~/lib/api/endpoints"
|
||||||
import type {
|
import type {
|
||||||
@@ -18,7 +25,10 @@ import type {
|
|||||||
AppConfig,
|
AppConfig,
|
||||||
ConnectData,
|
ConnectData,
|
||||||
HealthData,
|
HealthData,
|
||||||
|
TfsCollectionInfo,
|
||||||
TfsOperationLogEntry,
|
TfsOperationLogEntry,
|
||||||
|
TfsPendingChangeInfo,
|
||||||
|
TfsWorkspaceInfo,
|
||||||
} from "~/lib/api/types"
|
} from "~/lib/api/types"
|
||||||
import type { ServiceStatus } from "~/lib/electron/bridge"
|
import type { ServiceStatus } from "~/lib/electron/bridge"
|
||||||
import { getMactfsBridge } from "~/lib/electron/bridge"
|
import { getMactfsBridge } from "~/lib/electron/bridge"
|
||||||
@@ -65,14 +75,21 @@ export default function Home() {
|
|||||||
})
|
})
|
||||||
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
|
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
|
||||||
const [connecting, setConnecting] = useState(false)
|
const [connecting, setConnecting] = useState(false)
|
||||||
|
const [collectionsLoading, setCollectionsLoading] = useState(false)
|
||||||
|
const [workspaceLoading, setWorkspaceLoading] = useState(false)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [connectResult, setConnectResult] = useState<ConnectData>()
|
const [connectResult, setConnectResult] = useState<ConnectData>()
|
||||||
|
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
|
||||||
const [preferredCollection, setPreferredCollection] = useState("")
|
const [preferredCollection, setPreferredCollection] = useState("")
|
||||||
const [selectedCollection, setSelectedCollection] = useState("")
|
const [selectedCollection, setSelectedCollection] = useState("")
|
||||||
|
const [workspace, setWorkspace] = useState<TfsWorkspaceInfo>()
|
||||||
const [selectedServerPath, setSelectedServerPath] = useState("")
|
const [selectedServerPath, setSelectedServerPath] = useState("")
|
||||||
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
|
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
|
||||||
const [workspaceActionKey, setWorkspaceActionKey] = useState("")
|
const [folderRefreshKey, setFolderRefreshKey] = useState(0)
|
||||||
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
|
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
|
||||||
|
const [fileViewTarget, setFileViewTarget] = useState<TfsPendingChangeInfo>()
|
||||||
|
const [historyTarget, setHistoryTarget] = useState<TfsPendingChangeInfo>()
|
||||||
|
const [diffRequest, setDiffRequest] = useState<DiffPanelRequest>()
|
||||||
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
|
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
|
||||||
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
|
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
|
||||||
|
|
||||||
@@ -105,6 +122,7 @@ export default function Home() {
|
|||||||
if (configResult?.success) {
|
if (configResult?.success) {
|
||||||
setForm(toConnectionForm(configResult.data.config))
|
setForm(toConnectionForm(configResult.data.config))
|
||||||
setPreferredCollection(configResult.data.config.collection || "")
|
setPreferredCollection(configResult.data.config.collection || "")
|
||||||
|
setSelectedCollection(configResult.data.config.collection || "")
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
@@ -115,7 +133,24 @@ export default function Home() {
|
|||||||
? undefined
|
? undefined
|
||||||
: configResult?.errorMessage || configResult?.message,
|
: configResult?.errorMessage || configResult?.message,
|
||||||
})
|
})
|
||||||
setConnected(Boolean(healthResult?.data.connected))
|
if (healthResult?.data.connected && configResult?.data.config.collection) {
|
||||||
|
setConnected(true)
|
||||||
|
setWorkspace(
|
||||||
|
configResult.data.config.workspace
|
||||||
|
? ({
|
||||||
|
name: configResult.data.config.workspace,
|
||||||
|
ownerName: "",
|
||||||
|
computer: "",
|
||||||
|
comment: "",
|
||||||
|
created: false,
|
||||||
|
mappings: [],
|
||||||
|
} satisfies TfsWorkspaceInfo)
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
setSelectedServerPath("$/")
|
||||||
|
} else {
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +214,67 @@ export default function Home() {
|
|||||||
|
|
||||||
setConnected(true)
|
setConnected(true)
|
||||||
setConnectResult(result.data)
|
setConnectResult(result.data)
|
||||||
|
await loadCollections()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载登录后的 Collection 列表,并按上次使用记录默认选中。
|
||||||
|
*/
|
||||||
|
async function loadCollections() {
|
||||||
|
setCollectionsLoading(true)
|
||||||
|
const result = await listCollections()
|
||||||
|
setCollectionsLoading(false)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setState((currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
errorMessage: result.errorMessage || result.message,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCollections(result.data.collections)
|
||||||
|
const preferred =
|
||||||
|
result.data.collections.find(
|
||||||
|
(collection) => collection.name === preferredCollection
|
||||||
|
) || result.data.collections[0]
|
||||||
|
setSelectedCollection(preferred?.name || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定当前 Collection,并让后端自动使用或创建 Workspace 后进入工作台。
|
||||||
|
*/
|
||||||
|
async function enterWorkspace() {
|
||||||
|
if (!selectedCollection) {
|
||||||
|
setState((currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
errorMessage: "请选择 Collection。",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspaceLoading(true)
|
||||||
|
setState((currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
errorMessage: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await ensureWorkspaceContext({
|
||||||
|
collection: selectedCollection,
|
||||||
|
})
|
||||||
|
setWorkspaceLoading(false)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setState((currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
errorMessage: result.errorMessage || result.message,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspace(result.data.workspace)
|
||||||
|
setPreferredCollection(result.data.collection)
|
||||||
|
setSelectedServerPath("$/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +284,13 @@ export default function Home() {
|
|||||||
setPendingRefreshKey((currentKey) => currentKey + 1)
|
setPendingRefreshKey((currentKey) => currentKey + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发中间文件列表刷新,供 Checkin 或右侧撤销后更新当前目录状态。
|
||||||
|
*/
|
||||||
|
function refreshFolderItems() {
|
||||||
|
setFolderRefreshKey((currentKey) => currentKey + 1)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换主工作区当前路径,并清理上一目录的选中项状态。
|
* 切换主工作区当前路径,并清理上一目录的选中项状态。
|
||||||
*/
|
*/
|
||||||
@@ -196,13 +299,6 @@ export default function Home() {
|
|||||||
setSelectedServerPath(path)
|
setSelectedServerPath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发中间工作区文件操作,顶部工具栏和底部操作区复用同一套执行逻辑。
|
|
||||||
*/
|
|
||||||
function runWorkspaceAction(action: string) {
|
|
||||||
setWorkspaceActionKey(`${action}:${Date.now()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并统一 API client 推送的请求日志,保留最近 80 条。
|
* 合并统一 API client 推送的请求日志,保留最近 80 条。
|
||||||
*/
|
*/
|
||||||
@@ -259,6 +355,8 @@ export default function Home() {
|
|||||||
serviceLoading={state.loading}
|
serviceLoading={state.loading}
|
||||||
serverUri={connectResult?.serverUri || form.serverUri}
|
serverUri={connectResult?.serverUri || form.serverUri}
|
||||||
username={form.username}
|
username={form.username}
|
||||||
|
collection={selectedCollection}
|
||||||
|
workspace={workspace?.name}
|
||||||
collectionCount={connectResult?.collectionCount}
|
collectionCount={connectResult?.collectionCount}
|
||||||
serviceBaseUrl={state.serviceStatus?.baseUrl}
|
serviceBaseUrl={state.serviceStatus?.baseUrl}
|
||||||
tokenFile={state.serviceStatus?.tokenFile}
|
tokenFile={state.serviceStatus?.tokenFile}
|
||||||
@@ -266,38 +364,102 @@ export default function Home() {
|
|||||||
requestStatus={requestStatus}
|
requestStatus={requestStatus}
|
||||||
operationLogs={operationLogs}
|
operationLogs={operationLogs}
|
||||||
operationLogsLoading={operationLogsLoading}
|
operationLogsLoading={operationLogsLoading}
|
||||||
workspaceGetLatestEnabled={connected && selectedMappedItem}
|
|
||||||
workspaceHistoryEnabled={connected && Boolean(selectedServerPath)}
|
|
||||||
sourceList={
|
sourceList={
|
||||||
<ServerTreePanel
|
<ServerTreePanel
|
||||||
connected={connected}
|
connected={connected && Boolean(workspace)}
|
||||||
preferredCollection={preferredCollection}
|
collection={selectedCollection}
|
||||||
onCollectionSelect={setSelectedCollection}
|
selectedPath={selectedServerPath}
|
||||||
onPathSelect={selectServerPath}
|
onPathSelect={selectServerPath}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
inspector={
|
inspector={
|
||||||
<PendingChangesPanel
|
<PendingChangesPanel
|
||||||
connected={connected}
|
connected={connected && Boolean(workspace)}
|
||||||
collection={selectedCollection}
|
collection={selectedCollection}
|
||||||
refreshKey={pendingRefreshKey}
|
refreshKey={pendingRefreshKey}
|
||||||
|
onOpenFile={setFileViewTarget}
|
||||||
|
onOpenDiff={(change) =>
|
||||||
|
setDiffRequest({
|
||||||
|
type: "localLatest",
|
||||||
|
serverPath: change.serverPath,
|
||||||
|
localPath: change.localPath,
|
||||||
|
label: `${change.serverPath} 本地 ↔ Latest`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenHistory={setHistoryTarget}
|
||||||
|
onCheckinSuccess={refreshFolderItems}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onRefreshService={loadServiceStatus}
|
onRefreshService={loadServiceStatus}
|
||||||
onRefreshOperationLogs={loadOperationLogs}
|
onRefreshOperationLogs={loadOperationLogs}
|
||||||
onWorkspaceGetLatest={() => runWorkspaceAction("getLatest")}
|
|
||||||
onWorkspaceHistory={() => runWorkspaceAction("history")}
|
|
||||||
>
|
>
|
||||||
{connected ? (
|
{connected && workspace ? (
|
||||||
<FolderItemsPanel
|
<FolderItemsPanel
|
||||||
connected={connected}
|
connected={connected}
|
||||||
collection={selectedCollection}
|
collection={selectedCollection}
|
||||||
serverPath={selectedServerPath}
|
serverPath={selectedServerPath}
|
||||||
actionKey={workspaceActionKey}
|
refreshKey={folderRefreshKey}
|
||||||
onPendingChangesRefresh={refreshPendingChanges}
|
onPendingChangesRefresh={refreshPendingChanges}
|
||||||
onPathEnter={selectServerPath}
|
onPathEnter={selectServerPath}
|
||||||
onSelectedMappedItemChange={setSelectedMappedItem}
|
onSelectedMappedItemChange={setSelectedMappedItem}
|
||||||
/>
|
/>
|
||||||
|
) : connected ? (
|
||||||
|
<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">选择 Collection</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Collection 确认后,后端会自动使用或创建默认 Workspace。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{collectionsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
正在加载 Collection
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Collection
|
||||||
|
</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={selectedCollection}
|
||||||
|
required
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedCollection(event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<option
|
||||||
|
key={collection.id || collection.name}
|
||||||
|
value={collection.name}
|
||||||
|
>
|
||||||
|
{collection.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<Button
|
||||||
|
disabled={!selectedCollection || workspaceLoading}
|
||||||
|
onClick={enterWorkspace}
|
||||||
|
>
|
||||||
|
{workspaceLoading && <Loader2 className="animate-spin" />}
|
||||||
|
进入工作台
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
|
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -402,6 +564,54 @@ export default function Home() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SimpleDialog
|
||||||
|
open={Boolean(fileViewTarget)}
|
||||||
|
title="File Viewer"
|
||||||
|
description={fileViewTarget?.serverPath}
|
||||||
|
onClose={() => setFileViewTarget(undefined)}
|
||||||
|
>
|
||||||
|
{fileViewTarget && (
|
||||||
|
<FileViewerDialog
|
||||||
|
open={Boolean(fileViewTarget)}
|
||||||
|
serverPath={fileViewTarget.serverPath}
|
||||||
|
localPath={fileViewTarget.localPath}
|
||||||
|
preferLocal
|
||||||
|
onClose={() => setFileViewTarget(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleDialog>
|
||||||
|
|
||||||
|
<SimpleDialog
|
||||||
|
open={Boolean(historyTarget)}
|
||||||
|
title="History"
|
||||||
|
description={historyTarget?.serverPath}
|
||||||
|
onClose={() => setHistoryTarget(undefined)}
|
||||||
|
>
|
||||||
|
{historyTarget && (
|
||||||
|
<HistoryPanel
|
||||||
|
path={historyTarget.serverPath}
|
||||||
|
folder={historyTarget.folder}
|
||||||
|
label={historyTarget.serverPath}
|
||||||
|
onOpenDiff={setDiffRequest}
|
||||||
|
onClose={() => setHistoryTarget(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleDialog>
|
||||||
|
|
||||||
|
<SimpleDialog
|
||||||
|
open={Boolean(diffRequest)}
|
||||||
|
title="Diff"
|
||||||
|
description={diffRequest?.label}
|
||||||
|
onClose={() => setDiffRequest(undefined)}
|
||||||
|
>
|
||||||
|
{diffRequest && (
|
||||||
|
<DiffPanel
|
||||||
|
request={diffRequest}
|
||||||
|
onClose={() => setDiffRequest(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleDialog>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ const API_BASE_URL = "http://127.0.0.1:38765"
|
|||||||
const HEALTH_URL = `${API_BASE_URL}/api/health`
|
const HEALTH_URL = `${API_BASE_URL}/api/health`
|
||||||
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 MACTFS_DIRECTORY = path.resolve(__dirname, "../../mactfs")
|
||||||
const GRADLE_WRAPPER = path.resolve(__dirname, "../../tfsIntegration/gradlew")
|
const RUN_SERVER_SCRIPT = path.resolve(__dirname, "../../mactfs/run-server.sh")
|
||||||
|
const BUNDLED_JDK_HOME = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home"
|
||||||
|
)
|
||||||
let serviceStartAttempted = false
|
let serviceStartAttempted = false
|
||||||
let serviceStartError = ""
|
let serviceStartError = ""
|
||||||
|
|
||||||
@@ -86,6 +90,8 @@ async function checkServiceHealth() {
|
|||||||
message: result.message || "ok",
|
message: result.message || "ok",
|
||||||
tokenFile: result.data.tokenFile || TOKEN_FILE,
|
tokenFile: result.data.tokenFile || TOKEN_FILE,
|
||||||
configFile: result.data.configFile,
|
configFile: result.data.configFile,
|
||||||
|
javaArch: result.data.javaArch,
|
||||||
|
nativeCompatible: result.data.nativeCompatible,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -107,14 +113,19 @@ function startLocalService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceStartAttempted = true
|
serviceStartAttempted = true
|
||||||
if (!fs.existsSync(GRADLE_WRAPPER)) {
|
if (!fs.existsSync(RUN_SERVER_SCRIPT)) {
|
||||||
serviceStartError = `未找到 Gradle 启动脚本:${GRADLE_WRAPPER}`
|
serviceStartError = `未找到后端启动脚本:${RUN_SERVER_SCRIPT}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(BUNDLED_JDK_HOME, "bin", "java"))) {
|
||||||
|
serviceStartError = `未找到项目内置 JDK:${BUNDLED_JDK_HOME}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(GRADLE_WRAPPER, ["runServer"], {
|
const child = spawn(RUN_SERVER_SCRIPT, [], {
|
||||||
cwd: MACTFS_DIRECTORY,
|
cwd: MACTFS_DIRECTORY,
|
||||||
detached: true,
|
detached: true,
|
||||||
|
env: serviceEnvironment(),
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,6 +135,26 @@ function startLocalService() {
|
|||||||
child.unref()
|
child.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TFS SDK 随带的 macOS JNI 库只有 x86_64,没有 arm64;Apple Silicon 下必须通过 Rosetta 启动 Java。
|
||||||
|
*/
|
||||||
|
function shouldUseRosettaJava() {
|
||||||
|
return process.platform === "darwin" && process.arch === "arm64"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定本项目随带的 x64 Zulu JDK,避免 Electron 继承系统 arm64 Java 导致 TFS JNI 加载失败。
|
||||||
|
*/
|
||||||
|
function serviceEnvironment() {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
JAVA_HOME: BUNDLED_JDK_HOME,
|
||||||
|
PATH: `${path.join(BUNDLED_JDK_HOME, "bin")}${path.delimiter}${
|
||||||
|
process.env.PATH || ""
|
||||||
|
}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 等待一小段时间后重试 health,用于覆盖 Java 服务启动过程。
|
* 等待一小段时间后重试 health,用于覆盖 Java 服务启动过程。
|
||||||
*/
|
*/
|
||||||
@@ -159,7 +190,9 @@ async function ensureServiceStatus() {
|
|||||||
started: serviceStartAttempted,
|
started: serviceStartAttempted,
|
||||||
message:
|
message:
|
||||||
serviceStartError ||
|
serviceStartError ||
|
||||||
"本地 API 服务未就绪,请确认 Java 8、Gradle 和 TFS 依赖可用。",
|
(shouldUseRosettaJava()
|
||||||
|
? `本地 API 服务未就绪。请确认 Rosetta 可用,项目内置 JDK 路径为:${BUNDLED_JDK_HOME}`
|
||||||
|
: "本地 API 服务未就绪,请确认 Java 8、Gradle 和 TFS 依赖可用。"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -26,10 +26,10 @@ blocked
|
|||||||
| Server API | 10 | 10 | 100% |
|
| Server API | 10 | 10 | 100% |
|
||||||
| CLI | 2 | 2 | 100% |
|
| CLI | 2 | 2 | 100% |
|
||||||
| Frontend | 13 | 13 | 100% |
|
| Frontend | 13 | 13 | 100% |
|
||||||
| Frontend Refactor | 0 | 10 | 0% |
|
| Frontend Refactor | 10 | 10 | 100% |
|
||||||
| Feature E2E | 0 | 5 | 0% |
|
| Feature E2E | 0 | 5 | 0% |
|
||||||
| Release | 0 | 4 | 0% |
|
| Release | 0 | 4 | 0% |
|
||||||
| Overall | 37 | 56 | 66% |
|
| Overall | 47 | 56 | 84% |
|
||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Frontend Refactor
|
|||||||
|
|
||||||
## 下一步任务
|
## 下一步任务
|
||||||
|
|
||||||
- [UIR-001-补齐后端接口能力.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-001-补齐后端接口能力.md)
|
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -100,16 +100,16 @@ Frontend Refactor
|
|||||||
|
|
||||||
| 任务 | 状态 | 优先级 |
|
| 任务 | 状态 | 优先级 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [UIR-001-补齐后端接口能力.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-001-补齐后端接口能力.md) | todo | P0 |
|
| [UIR-001-补齐后端接口能力.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-001-补齐后端接口能力.md) | done | P0 |
|
||||||
| [UIR-002-重构登录Collection与Workspace上下文.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-002-重构登录Collection与Workspace上下文.md) | todo | P0 |
|
| [UIR-002-重构登录Collection与Workspace上下文.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-002-重构登录Collection与Workspace上下文.md) | done | P0 |
|
||||||
| [UIR-003-重构工作台布局与同步导航.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-003-重构工作台布局与同步导航.md) | todo | P0 |
|
| [UIR-003-重构工作台布局与同步导航.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-003-重构工作台布局与同步导航.md) | done | P0 |
|
||||||
| [UIR-004-实现对象右键菜单.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-004-实现对象右键菜单.md) | todo | P0 |
|
| [UIR-004-实现对象右键菜单.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-004-实现对象右键菜单.md) | done | P0 |
|
||||||
| [UIR-005-实现MappingHistoryCompare弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-005-实现MappingHistoryCompare弹窗.md) | todo | P0 |
|
| [UIR-005-实现MappingHistoryCompare弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-005-实现MappingHistoryCompare弹窗.md) | done | P0 |
|
||||||
| [UIR-006-实现文件查看与Diff编辑器.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-006-实现文件查看与Diff编辑器.md) | todo | P0 |
|
| [UIR-006-实现文件查看与Diff编辑器.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-006-实现文件查看与Diff编辑器.md) | done | P0 |
|
||||||
| [UIR-007-实现GetLatestCheckout冲突流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-007-实现GetLatestCheckout冲突流程.md) | todo | P0 |
|
| [UIR-007-实现GetLatestCheckout冲突流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-007-实现GetLatestCheckout冲突流程.md) | done | P0 |
|
||||||
| [UIR-008-重构PendingChanges与Checkin体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-008-重构PendingChanges与Checkin体验.md) | todo | P0 |
|
| [UIR-008-重构PendingChanges与Checkin体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-008-重构PendingChanges与Checkin体验.md) | done | P0 |
|
||||||
| [UIR-009-拆分FolderItemsPanel与动作编排.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-009-拆分FolderItemsPanel与动作编排.md) | todo | P1 |
|
| [UIR-009-拆分FolderItemsPanel与动作编排.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-009-拆分FolderItemsPanel与动作编排.md) | done | P1 |
|
||||||
| [UIR-010-前端重构端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-010-前端重构端到端验收.md) | todo | P0 |
|
| [UIR-010-前端重构端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-010-前端重构端到端验收.md) | done | P0 |
|
||||||
|
|
||||||
## Feature E2E
|
## Feature E2E
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -83,9 +83,22 @@ cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 新增固定 Collection 后自动确保默认 Workspace 的 `/api/workspace/context`。
|
||||||
|
- 新增 `/api/files/content`,支持映射目录内本地文件读取和未映射文件服务器 latest 内容读取。
|
||||||
|
- 文件内容返回大小、来源、编码、二进制标识、是否可渲染和正文;超过 5MB 或二进制文件不返回可渲染正文。
|
||||||
|
- 本地文件读取和本地 latest diff 增加 Mapping 目录边界校验,非 Mapping 路径会被拒绝。
|
||||||
|
- Get Latest 遇到 pending edit 子项时返回跳过列表和冲突明细,不直接覆盖本地修改。
|
||||||
|
- Checkout 前自动执行 Get Latest,并在冲突时返回同一套冲突明细。
|
||||||
|
- 新增 `/api/conflicts/apply`,支持使用服务器版本或保留本地版本的冲突选择应用入口。
|
||||||
|
- 前端 API 类型和 endpoint 增加 WorkspaceContext、FileContent、ConflictChoice 相关类型与方法。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew test`。
|
||||||
|
- 结果:BUILD SUCCESSFUL。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未执行真实 TFS Get Latest / Checkout / 冲突应用验收,需后续 UIR-010 端到端任务覆盖。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -71,9 +71,19 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/routes/home.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/server-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/app/app-shell.tsx`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 登录连接成功后加载 Collection 列表,并按上次配置默认选中。
|
||||||
|
- 用户确认 Collection 后调用 `/api/workspace/context`,由后端自动使用或创建 Workspace。
|
||||||
|
- 工作台顶部展示固定 Collection 和 Workspace。
|
||||||
|
- `ServerTreePanel` 移除 Collection 列表与切换逻辑,只接收固定 Collection。
|
||||||
|
- 前端未提供 Workspace 手动选择控件。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未连接真实 TFS 验证上次 Collection 默认选中和 Workspace 创建结果,后续真实环境验收覆盖。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -77,9 +77,19 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/app-shell.tsx`
|
||||||
|
- `mactfsui/app/routes/home.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/server-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 顶部移除 Get Latest、History、Compare、Mapping 等文件对象操作按钮。
|
||||||
|
- 左侧 Source List、右侧 Changes、底部 Console 增加展开/收起状态。
|
||||||
|
- 当前路径状态保留在 `home.tsx`,左侧树选择会刷新中间列表。
|
||||||
|
- 中间列表双击目录进入路径,`ServerTreePanel` 根据 selectedPath 自动展开父级路径。
|
||||||
|
- 中间列表单击只选中行,不进入目录。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
|
- 已用 Playwright 打开 `http://127.0.0.1:5173/` 做渲染快照检查,连接页和工作台外壳非空白。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 浏览器检查无 Electron preload,未覆盖真实 Electron 登录后的三栏交互。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -75,9 +75,19 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/context-menu.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-compare-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/inspector/pending-changes-panel.tsx`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 新增轻量右键菜单组件。
|
||||||
|
- 中间文件列表文件和文件夹接入右键菜单。
|
||||||
|
- Pending Changes Included / Excluded 项接入右键菜单。
|
||||||
|
- 目录对比结果项接入右键菜单。
|
||||||
|
- pending edit 文件禁用直接 Get Latest,pending add 文件隐藏服务器比较入口。
|
||||||
|
- Checkin 仍保留在右侧 Changes 面板。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 左侧目录树右键菜单第一版未单独展开实现,当前对象操作主要覆盖中间列表、目录对比和 Pending Changes。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -73,9 +73,19 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/simple-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/mapping-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-compare-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/history-panel.tsx`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 新增通用弹窗组件。
|
||||||
|
- Mapping 创建迁移到弹窗,支持输入/选择本地目录和是否立即 Get Latest。
|
||||||
|
- History 改为通过弹窗打开。
|
||||||
|
- 目录对比改为弹窗,支持隐藏已同步和按状态筛选。
|
||||||
|
- 目录对比结果项通过右键菜单执行 Diff、Get Latest、Checkout、Add、Delete、Undo。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未在真实 TFS 工作区执行新增本地文件到服务器。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -73,9 +73,22 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`
|
||||||
|
- `mactfsui/app/components/explorer/file-viewer-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/diff-panel.tsx`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 新增文件只读查看弹窗,支持本地映射文件和服务器 latest 文件。
|
||||||
|
- 文件查看展示来源、大小、编码、搜索、行号和滚动区域。
|
||||||
|
- 后端文件内容接口返回大小、二进制、可渲染、编码和内容。
|
||||||
|
- 超过 5MB 或二进制文件不返回正文渲染。
|
||||||
|
- 本地文件读取和本地 latest diff 增加 Mapping 目录边界校验。
|
||||||
|
- Diff 面板改为弹窗内左右分栏,支持搜索、行号和差异高亮。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
|
- 已执行 `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew test`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未引入 Monaco / CodeMirror;当前实现沿用项目轻量文本 diff,满足只读查看、搜索、行号和高亮。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -81,9 +81,22 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`
|
||||||
|
- `mactfsui/app/components/explorer/conflict-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- Get Latest 遇到 pending edit 子项时返回 skipped 和 conflictDetails。
|
||||||
|
- Checkout 自动先执行 Get Latest,遇到冲突时复用同一冲突明细。
|
||||||
|
- 新增冲突选择弹窗,支持全部使用服务器版本、全部保留本地版本、单文件改选和 Diff 入口。
|
||||||
|
- 新增 `/api/conflicts/apply`,支持使用服务器版本和保留本地版本。
|
||||||
|
- 二进制或不可渲染文件不启用 Diff 入口。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
|
- 已执行 `../tfsIntegration/gradlew test`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 自动合并入口保留在 UI 选择中,但后端第一版仅按 checkout 结果处理,未实现三方文本自动合并。
|
||||||
|
- 未执行真实冲突文件的服务器/本地版本应用验证。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -71,9 +71,16 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/inspector/pending-changes-panel.tsx`
|
||||||
|
- `mactfsui/app/routes/home.tsx`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- Checkin 保留在右侧 Changes 面板,不使用弹窗。
|
||||||
|
- Included / Excluded 继续保留并支持组间移动。
|
||||||
|
- Pending Changes 项新增右键菜单,支持查看、比较、撤销、查看历史、移入 Included、移入 Excluded。
|
||||||
|
- pending add 文件隐藏服务器比较入口。
|
||||||
|
- Checkin 成功后触发当前目录刷新和 Pending Changes 刷新。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未执行真实 Checkin,避免默认修改 TFS 服务端状态。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -71,9 +71,18 @@ pnpm typecheck
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/mapping-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/file-viewer-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-compare-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/conflict-dialog.tsx`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 重写 `FolderItemsPanel` 为当前目录 items、mappings、pending 状态展示和对象菜单编排。
|
||||||
|
- 移除原内嵌 Mapping 表单、目录对比大块 JSX、选中差异操作区。
|
||||||
|
- History、Diff、Mapping、目录对比、文件查看、冲突处理改由弹窗承载。
|
||||||
|
- 动作编排保留在 `FolderItemsPanel` 和 `home.tsx`,未引入状态库。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `pnpm typecheck`,通过。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 后续可再把菜单动作类型抽成独立 `lib/tfs` 纯类型,但本次按最小改动保留在业务组件内。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
todo
|
done
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|
||||||
@@ -82,9 +82,18 @@ pnpm electron:dev
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
待完成后填写:
|
|
||||||
|
|
||||||
- 实际修改文件:
|
- 实际修改文件:
|
||||||
|
- `mactfsui`
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs`
|
||||||
|
- `task/frontend-refactor`
|
||||||
- 实际实现内容:
|
- 实际实现内容:
|
||||||
|
- 完成阶段五前端重构代码路径的静态验收。
|
||||||
|
- 覆盖登录 Collection 固定上下文、Workspace 自动确保、工作台折叠、同步导航、右键菜单、Mapping/History/Compare/File/Diff/Conflict 弹窗、Pending Changes 与 Checkin 刷新编排。
|
||||||
- 测试结果:
|
- 测试结果:
|
||||||
|
- 已执行 `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`,通过。
|
||||||
|
- 已执行 `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew test`,通过。
|
||||||
|
- 已启动 `pnpm dev --host 127.0.0.1`,并用 Playwright 打开 `http://127.0.0.1:5173/` 快照检查,页面非空白,连接页和 Console 正常渲染。
|
||||||
- 遗留问题:
|
- 遗留问题:
|
||||||
|
- 未执行 `pnpm electron:dev` 的真实 Electron 登录流程。
|
||||||
|
- 未执行真实 TFS 写操作,包括 Checkin、Checkout、Delete、Undo、冲突应用和自动合并。
|
||||||
|
- 左侧目录树右键菜单未单独实现,当前对象菜单覆盖中间文件列表、目录对比结果和 Pending Changes。
|
||||||
|
|||||||
Reference in New Issue
Block a user