Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb388d22d | ||
|
|
1bc913c54e | ||
|
|
8692edd2dd |
@@ -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.'
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
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) {
|
||||
|
||||
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
|
||||
@@ -4,7 +4,11 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 保存最近一次成功连接与映射结果,供第一阶段 CLI 复用输入。
|
||||
@@ -12,6 +16,7 @@ import java.util.Properties;
|
||||
public class LocalConfigStore {
|
||||
|
||||
private final File configFile;
|
||||
private final File serverConfigFile;
|
||||
|
||||
public LocalConfigStore() {
|
||||
File configDirectory = new File(System.getProperty("user.home"), ".mactfs");
|
||||
@@ -19,19 +24,25 @@ public class LocalConfigStore {
|
||||
configDirectory.mkdirs();
|
||||
}
|
||||
this.configFile = new File(configDirectory, "phase-one.properties");
|
||||
this.serverConfigFile = new File(configDirectory, "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取第一阶段配置;本地 API 配置存在时优先使用 config.json 的当前连接上下文。
|
||||
*/
|
||||
public Properties load() throws IOException {
|
||||
Properties properties = new Properties();
|
||||
if (!configFile.exists()) {
|
||||
return properties;
|
||||
if (configFile.exists()) {
|
||||
FileInputStream inputStream = new FileInputStream(configFile);
|
||||
try {
|
||||
properties.load(inputStream);
|
||||
} finally {
|
||||
inputStream.close();
|
||||
}
|
||||
}
|
||||
|
||||
FileInputStream inputStream = new FileInputStream(configFile);
|
||||
try {
|
||||
properties.load(inputStream);
|
||||
} finally {
|
||||
inputStream.close();
|
||||
if (serverConfigFile.exists()) {
|
||||
mergeServerConfig(properties);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
@@ -48,4 +59,46 @@ public class LocalConfigStore {
|
||||
public File getConfigFile() {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将第二阶段本地 API 配置转换为旧 CLI 可复用的属性名。
|
||||
*/
|
||||
private void mergeServerConfig(Properties properties) throws IOException {
|
||||
String json = new String(Files.readAllBytes(serverConfigFile.toPath()), StandardCharsets.UTF_8);
|
||||
copyJsonString(json, properties, "serverUri", "server-uri");
|
||||
copyJsonString(json, properties, "authType", "auth-type");
|
||||
copyJsonString(json, properties, "domain", "domain");
|
||||
copyJsonString(json, properties, "username", "username");
|
||||
copyJsonString(json, properties, "password", "password");
|
||||
copyJsonString(json, properties, "collection", "collection");
|
||||
copyJsonString(json, properties, "workspace", "workspace");
|
||||
copyJsonString(json, properties, "serverPath", "server-path");
|
||||
copyJsonString(json, properties, "localPath", "local-path");
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制 JSON 字符串字段,保持旧 CLI 配置读取逻辑不依赖服务端模块。
|
||||
*/
|
||||
private void copyJsonString(String json, Properties properties, String jsonKey, String propertyKey) {
|
||||
String value = jsonString(json, jsonKey);
|
||||
if (!isBlank(value)) {
|
||||
properties.setProperty(propertyKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从简单配置 JSON 中提取字符串字段。
|
||||
*/
|
||||
private String jsonString(String json, String key) {
|
||||
Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
|
||||
Matcher matcher = pattern.matcher(json);
|
||||
return matcher.find() ? matcher.group(1).replace("\\\"", "\"") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空。
|
||||
*/
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.microsoft.tfs.core.httpclient.DefaultNTCredentials;
|
||||
import com.microsoft.tfs.core.httpclient.UsernamePasswordCredentials;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
@@ -61,6 +62,7 @@ public class MacTfsCoreService {
|
||||
|
||||
private static final int HISTORY_LIMIT = 100;
|
||||
private static final Charset TEXT_CHARSET = Charset.forName("UTF-8");
|
||||
private static final long MAX_RENDER_BYTES = 5L * 1024L * 1024L;
|
||||
|
||||
/**
|
||||
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
||||
@@ -232,16 +234,24 @@ public class MacTfsCoreService {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
GetStatus status;
|
||||
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) {
|
||||
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
||||
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 {
|
||||
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
||||
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);
|
||||
}
|
||||
return toGetLatestResult(status);
|
||||
return toGetLatestResult(status, skipped);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -278,10 +288,28 @@ public class MacTfsCoreService {
|
||||
public TfsFileOperationResult call(List<String> logs) throws Exception {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
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;
|
||||
int affected = workspace.pendEdit(items, recursionType, LockLevel.NONE, null, GetOptions.NONE, PendChangesOptions.NONE);
|
||||
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
|
||||
);
|
||||
File tempFile = item.downloadFileToTempLocation(connection.versionControlClient, "mactfs");
|
||||
String content = new String(Files.readAllBytes(tempFile.toPath()), TEXT_CHARSET);
|
||||
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()) {
|
||||
throw new IllegalStateException(latest.getErrorMessage());
|
||||
}
|
||||
String localContent = new String(Files.readAllBytes(new File(require(localPath, "localPath")).toPath()), TEXT_CHARSET);
|
||||
TfsTextDiff diff = buildTextDiff(localPath, serverPath, localContent, latest.getData().getContent());
|
||||
TfsFileContent local = readFileContent(serverPath, new File(require(localPath, "localPath")).getAbsolutePath(), 0, "local", new File(localPath));
|
||||
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());
|
||||
return diff;
|
||||
}
|
||||
@@ -561,6 +612,12 @@ public class MacTfsCoreService {
|
||||
if (!target.isSuccess()) {
|
||||
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(
|
||||
serverPath + ";C" + sourceChangeset,
|
||||
serverPath + ";C" + targetChangeset,
|
||||
@@ -760,6 +817,46 @@ public class MacTfsCoreService {
|
||||
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、工作区本地版本和服务端最新版本计算目录对比状态。
|
||||
*/
|
||||
@@ -832,15 +929,94 @@ public class MacTfsCoreService {
|
||||
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(
|
||||
status.getNumUpdated(),
|
||||
status.getNumOperations(),
|
||||
status.getNumConflicts(),
|
||||
status.getNumFailures()
|
||||
updated,
|
||||
operations,
|
||||
skipped == null ? 0 : skipped.size(),
|
||||
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) {
|
||||
List<TfsPendingChangeInfo> result = new ArrayList<TfsPendingChangeInfo>();
|
||||
if (pendingChanges == null) {
|
||||
@@ -1384,12 +1560,20 @@ public class MacTfsCoreService {
|
||||
private final int operations;
|
||||
private final int conflicts;
|
||||
private final int failures;
|
||||
private final List<String> skipped;
|
||||
private final List<TfsConflictInfo> conflictDetails;
|
||||
|
||||
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.operations = operations;
|
||||
this.conflicts = conflicts;
|
||||
this.failures = failures;
|
||||
this.skipped = skipped;
|
||||
this.conflictDetails = conflictDetails;
|
||||
}
|
||||
|
||||
public int getUpdated() {
|
||||
@@ -1407,6 +1591,14 @@ public class MacTfsCoreService {
|
||||
public int getFailures() {
|
||||
return failures;
|
||||
}
|
||||
|
||||
public List<String> getSkipped() {
|
||||
return skipped;
|
||||
}
|
||||
|
||||
public List<TfsConflictInfo> getConflictDetails() {
|
||||
return conflictDetails;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TfsPendingChangeInfo {
|
||||
@@ -1461,11 +1653,19 @@ public class MacTfsCoreService {
|
||||
private final String operation;
|
||||
private final int affected;
|
||||
private final List<String> failures;
|
||||
private final List<String> skipped;
|
||||
private final List<TfsConflictInfo> conflictDetails;
|
||||
|
||||
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.affected = affected;
|
||||
this.failures = failures;
|
||||
this.skipped = skipped;
|
||||
this.conflictDetails = conflictDetails;
|
||||
}
|
||||
|
||||
public String getOperation() {
|
||||
@@ -1479,6 +1679,14 @@ public class MacTfsCoreService {
|
||||
public List<String> getFailures() {
|
||||
return failures;
|
||||
}
|
||||
|
||||
public List<String> getSkipped() {
|
||||
return skipped;
|
||||
}
|
||||
|
||||
public List<TfsConflictInfo> getConflictDetails() {
|
||||
return conflictDetails;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TfsCheckinResult {
|
||||
@@ -1603,13 +1811,27 @@ public class MacTfsCoreService {
|
||||
|
||||
public static class TfsFileContent {
|
||||
private final String serverPath;
|
||||
private final String localPath;
|
||||
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 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.localPath = localPath;
|
||||
this.changeset = changeset;
|
||||
this.source = source;
|
||||
this.size = size;
|
||||
this.renderable = renderable;
|
||||
this.encoding = encoding;
|
||||
this.content = content;
|
||||
this.binary = binary;
|
||||
}
|
||||
@@ -1618,10 +1840,30 @@ public class MacTfsCoreService {
|
||||
return serverPath;
|
||||
}
|
||||
|
||||
public String getLocalPath() {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
public int getChangeset() {
|
||||
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() {
|
||||
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 {
|
||||
private final String sourceLabel;
|
||||
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.TfsCheckinResult;
|
||||
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.TfsFileContent;
|
||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
|
||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
|
||||
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_LONG_WRITE_MS = TimeUnit.SECONDS.toMillis(300);
|
||||
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 MacTfsCoreService coreService = new MacTfsCoreService();
|
||||
@@ -173,6 +176,9 @@ public class MacTfsServer {
|
||||
data.put("tokenFile", tokenStore.getTokenFile().getAbsolutePath());
|
||||
data.put("configFile", configStore.getConfigFile().getAbsolutePath());
|
||||
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);
|
||||
}
|
||||
}));
|
||||
@@ -201,6 +207,7 @@ public class MacTfsServer {
|
||||
Spark.post("/api/session/connect", (request, response) -> handle(request, response, "connect", TIMEOUT_CONNECT_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
requireNativeCompatible();
|
||||
AppConfig config = requestConfig(readBody(request));
|
||||
CoreOperationResult<ConnectionSummary> result = coreService.testConnection(toTfsConfig(config));
|
||||
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() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
requireNativeCompatible();
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
CoreOperationResult<List<TfsCollectionInfo>> result = coreService.listCollections(toTfsConfig(config));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
@@ -280,10 +288,32 @@ 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() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
AppConfig config = requestConfig(queryConfig(request));
|
||||
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"));
|
||||
if (result.isSuccess()) {
|
||||
config.mappings = mappingConfigs(result.getData());
|
||||
@@ -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() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
@@ -384,10 +435,18 @@ 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() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
Map<String, Object> query = queryConfig(request);
|
||||
AppConfig config = requestConfig(query);
|
||||
CoreOperationResult<List<TfsPendingChangeInfo>> result = coreService.listPendingChanges(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("pendingChanges", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||
@@ -447,7 +506,10 @@ public class MacTfsServer {
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
Map<String, Object> body = readBody(request);
|
||||
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>();
|
||||
data.put("diff", result.getData());
|
||||
return fromCore(result, data);
|
||||
@@ -486,6 +548,7 @@ public class MacTfsServer {
|
||||
* 浏览服务端目录并补齐 UI 需要的路径和类型字段。
|
||||
*/
|
||||
private ApiResult browseServerItems(Request request) throws Exception {
|
||||
requireNativeCompatible();
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
String path = firstPresent(request.queryParams("path"), "$/");
|
||||
String collection = firstPresent(request.queryParams("collection"), config.collection);
|
||||
@@ -500,6 +563,7 @@ public class MacTfsServer {
|
||||
* 分发 checkout、add、delete、undo 四类文件操作,保持请求结构一致。
|
||||
*/
|
||||
private ApiResult fileOperation(Request request, String operation) throws Exception {
|
||||
requireNativeCompatible();
|
||||
Map<String, Object> body = readBody(request);
|
||||
AppConfig config = requestConfig(body);
|
||||
List<String> paths = requestPaths(body);
|
||||
@@ -519,6 +583,98 @@ public class MacTfsServer {
|
||||
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 调用,集中处理耗时统计、超时、异常和操作日志。
|
||||
*/
|
||||
@@ -528,6 +684,9 @@ public class MacTfsServer {
|
||||
Future<ApiResult> future = executorService.submit(new Callable<ApiResult>() {
|
||||
@Override
|
||||
public ApiResult call() throws Exception {
|
||||
if (requiresNative(operation)) {
|
||||
requireNativeCompatible();
|
||||
}
|
||||
return callable.call(request);
|
||||
}
|
||||
});
|
||||
@@ -570,6 +729,26 @@ public class MacTfsServer {
|
||||
return normalizeConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 GET 请求中的配置覆盖字段,避免 UI 切换 Collection 时读接口使用旧配置。
|
||||
*/
|
||||
private Map<String, Object> queryConfig(Request request) {
|
||||
Map<String, Object> query = new LinkedHashMap<String, Object>();
|
||||
copyQuery(request, query, "collection");
|
||||
copyQuery(request, query, "workspace");
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将非空 query 参数复制到请求配置覆盖对象。
|
||||
*/
|
||||
private void copyQuery(Request request, Map<String, Object> query, String key) {
|
||||
String value = request.queryParams(key);
|
||||
if (!isBlank(value)) {
|
||||
query.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把服务端配置转换为 core 层连接配置。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# mactfsui 前端 AI 规则
|
||||
|
||||
本文件适用于 `mactfsui` 目录下所有前端、Electron、样式和文档改动。后续 AI 在实现前端任务时必须优先遵守本规则。
|
||||
|
||||
## 一、必读上下文
|
||||
|
||||
开始任何前端改动前,必须先读取:
|
||||
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/AGENTS.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-api-product-prd.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md`
|
||||
- 当前任务文件
|
||||
|
||||
如果旧任务或旧文档提到 “VS 风格” 或 “Visual Studio Source Control Explorer”,前端视觉实现必须按本文件的 macOS 风格规则解释,不再照搬 Visual Studio 视觉。
|
||||
|
||||
## 二、强制 UI 风格
|
||||
|
||||
前端默认风格是:
|
||||
|
||||
```text
|
||||
shadcn/ui + macOS Source Workspace
|
||||
```
|
||||
|
||||
具体含义:
|
||||
|
||||
- 组件基础使用 shadcn/ui。
|
||||
- 整体布局参考 macOS Finder、Xcode Source Control、Fork / SourceTree。
|
||||
- 保留 TFS 工具所需的信息密度。
|
||||
- 视觉上要像 macOS 原生源码管理工作台,不像 Windows Visual Studio,也不像 Web SaaS 后台。
|
||||
|
||||
默认关键词:
|
||||
|
||||
- macOS native
|
||||
- lightweight
|
||||
- quiet
|
||||
- source-control focused
|
||||
- Finder-like table
|
||||
- Xcode-like inspector
|
||||
- Fork-like diff
|
||||
|
||||
## 三、布局规则
|
||||
|
||||
主界面必须采用 macOS 工作台布局:
|
||||
|
||||
```text
|
||||
顶部:macOS 风格工具栏
|
||||
左侧:Source List,展示 Collection / Server Tree
|
||||
中间:文件列表、目录对比、历史记录、Diff 主内容
|
||||
右侧:Inspector,展示 Pending Changes、当前文件操作、签入
|
||||
底部:可折叠 Console,展示操作日志
|
||||
```
|
||||
|
||||
建议尺寸:
|
||||
|
||||
- 顶部工具栏:48-56px
|
||||
- 左侧 Source List:260-320px
|
||||
- 右侧 Inspector:320-380px
|
||||
- 底部 Console:默认 150-220px,可折叠
|
||||
- 列表行高:28-34px
|
||||
- 按钮高度:28-32px
|
||||
- 圆角:6-8px
|
||||
|
||||
第一屏必须是连接配置或可操作工作台,不做 landing page、hero、产品介绍页。
|
||||
|
||||
## 四、shadcn/ui 使用规则
|
||||
|
||||
允许并优先使用:
|
||||
|
||||
- `Button`
|
||||
- `Input`
|
||||
- `Select`
|
||||
- `Dialog`
|
||||
- `Sheet`
|
||||
- `Tooltip`
|
||||
- `DropdownMenu`
|
||||
- `Tabs`
|
||||
- `Table`
|
||||
- `Badge`
|
||||
- `Checkbox`
|
||||
- `Textarea`
|
||||
- `Separator`
|
||||
- `ScrollArea`
|
||||
|
||||
规则:
|
||||
|
||||
- shadcn 基础组件只能放在 `app/components/ui`。
|
||||
- 业务组件放到 `app/components/*` 对应业务目录。
|
||||
- 图标使用 `lucide-react`。
|
||||
- 文件操作按钮优先使用图标 + tooltip。
|
||||
- 状态必须用文字或 badge 辅助,不能只靠颜色。
|
||||
- 路径和版本号可用等宽字体。
|
||||
|
||||
## 五、禁止的视觉方向
|
||||
|
||||
禁止把 macTFS 做成以下风格:
|
||||
|
||||
- Visual Studio / Windows 工具窗口风格
|
||||
- 满屏 Card 的 SaaS dashboard
|
||||
- 后台管理系统首页
|
||||
- 营销 landing page
|
||||
- 大 Hero
|
||||
- 大面积渐变背景
|
||||
- 装饰性 orb / blob
|
||||
- 过重深蓝、灰黑企业后台色
|
||||
- 大圆角、过大留白、低信息密度
|
||||
- 页面区域卡片套卡片
|
||||
|
||||
Card 只允许用于重复实体、弹窗内容或确实需要边界的局部工具,不允许把主布局区域都做成 card。
|
||||
|
||||
## 六、色彩与主题
|
||||
|
||||
默认使用浅色主题,整体背景应接近 macOS 工具软件:
|
||||
|
||||
- 主背景:浅灰或 shadcn muted background
|
||||
- 内容区:白色或轻微分层背景
|
||||
- 分隔:细边框、浅分割线
|
||||
- 主色:macOS system blue 方向
|
||||
|
||||
状态色只表达业务状态:
|
||||
|
||||
- `pendingEdit`:蓝色
|
||||
- `pendingAdd` / 成功:绿色
|
||||
- `pendingDelete` / 失败:红色
|
||||
- `notDownloaded` / 等待:琥珀色
|
||||
- `remoteChanged`:蓝紫色
|
||||
- `bothChanged`:红色强调
|
||||
- `upToDate`:中性灰
|
||||
|
||||
不要在组件里硬编码大段颜色,优先使用 Tailwind token 和 `app.css` 变量。
|
||||
|
||||
## 七、实现约束
|
||||
|
||||
- 继续使用现有 React 19、React Router 7、Electron、Tailwind CSS 4、shadcn/ui 技术栈。
|
||||
- 不新增 Vue、MicroFront、Redux、Zustand、TanStack Query,除非用户明确要求。
|
||||
- Electron 本机能力放在 main / preload,React 组件不直接读文件、不启动 Java 进程。
|
||||
- API 请求必须走统一 API client,不在组件里散落 `fetch`。
|
||||
- Bearer token 由 Electron 主进程读取后通过 preload 暴露给渲染层。
|
||||
- 新增函数必须按项目规则添加函数级注释。
|
||||
- 改任何文件前必须先执行 `chmod u+w <file>` 或 `chmod 644 <file>`。
|
||||
|
||||
## 八、验收要求
|
||||
|
||||
前端任务完成后至少验证:
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
涉及 UI 的改动还必须实际打开页面或 Electron 看一遍,确认:
|
||||
|
||||
- 主区域不空白。
|
||||
- 文案不重叠。
|
||||
- 工具栏、Source List、Inspector、Console 区域关系清晰。
|
||||
- 控件尺寸符合 macOS 工作台风格。
|
||||
- 没有出现 VS 风格或 SaaS dashboard 风格。
|
||||
|
||||
最终输出必须说明是否遵守本规则。
|
||||
@@ -0,0 +1,677 @@
|
||||
# macTFS 前端项目规范
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
`mactfsui` 是 macTFS 的 Electron + React 桌面 UI。它只负责本机桌面交互、状态展示和本地 API 调用,不直接实现 TFS 协议能力。
|
||||
|
||||
项目整体链路:
|
||||
|
||||
```text
|
||||
Electron Renderer
|
||||
-> Electron Preload / Main
|
||||
-> http://127.0.0.1:38765/api/*
|
||||
-> mactfs-server
|
||||
-> mactfs-core
|
||||
-> TFS SDK
|
||||
```
|
||||
|
||||
第一版前端目标是实现 macOS Source Workspace 风格的日常 TFS 操作界面,视觉参考 Finder、Xcode Source Control、Fork / SourceTree:
|
||||
|
||||
- 连接配置
|
||||
- Collection 服务端目录树
|
||||
- 当前目录文件列表
|
||||
- Workspace / Mapping 管理
|
||||
- Get Latest
|
||||
- 目录对比
|
||||
- checkout / add / delete / undo
|
||||
- Pending Changes
|
||||
- Checkin
|
||||
- History
|
||||
- Diff
|
||||
- 操作日志
|
||||
|
||||
不在第一版前端范围:
|
||||
|
||||
- 多 Profile
|
||||
- Work Item
|
||||
- Check-in Policy UI
|
||||
- 三方 merge
|
||||
- 冲突解决器
|
||||
- 实时 watcher
|
||||
- 自定义布局
|
||||
- 自动更新
|
||||
|
||||
## 二、关键设计决策
|
||||
|
||||
### 2.1 前端技术栈
|
||||
|
||||
推荐答案:继续使用现有技术栈,不新增 Vue、MicroFront 或大型状态库。
|
||||
|
||||
当前项目已经确定:
|
||||
|
||||
- React 19
|
||||
- React Router 7 framework mode
|
||||
- TypeScript strict
|
||||
- Vite
|
||||
- Electron
|
||||
- Tailwind CSS 4
|
||||
- shadcn/ui `radix-nova`
|
||||
- Radix UI
|
||||
- lucide-react
|
||||
- class-variance-authority
|
||||
- clsx + tailwind-merge
|
||||
|
||||
后续新增 UI 组件优先通过 shadcn/ui 生成到 `app/components/ui`,业务组件放到业务目录,不修改 shadcn 组件的职责边界。
|
||||
|
||||
### 2.2 架构复杂度
|
||||
|
||||
推荐答案:使用轻量分层,不引入 Redux、Zustand、TanStack Query。
|
||||
|
||||
原因:
|
||||
|
||||
- 第一版 API 是本机同步接口,页面规模可控。
|
||||
- PRD 明确第一版优先可用和清晰,不做复杂自动化。
|
||||
- 当前任务要求最小实现,过早引入状态库会增加维护成本。
|
||||
|
||||
状态先放在主界面路由和功能组件内,通过自定义 hook 拆分 API 调用。只有跨多个面板共用的状态,才上提到 `AppShell`。
|
||||
|
||||
### 2.3 Electron 与本机能力
|
||||
|
||||
推荐答案:Electron 主进程负责服务启动、token 读取、目录选择,渲染进程只通过 preload 暴露的窄接口访问。
|
||||
|
||||
约束:
|
||||
|
||||
- 不开启 renderer 的 Node.js 能力。
|
||||
- 不在 React 组件里读取本机文件。
|
||||
- 不在 React 组件里启动 Java 进程。
|
||||
- Bearer token 由主进程读取 `~/.mactfs/server-token` 后通过 preload 提供。
|
||||
- 选择本地目录通过 Electron dialog 完成。
|
||||
|
||||
### 2.4 页面组织
|
||||
|
||||
推荐答案:第一版以一个主路由承载工作台,登录配置是主路由内的连接态视图。
|
||||
|
||||
原因:
|
||||
|
||||
- 这是桌面工具,不需要营销页或多页面站点结构。
|
||||
- 登录成功后进入同一个 macOS Source Workspace 工作台。
|
||||
- Diff 后续如果需要独立沉浸视图,再新增 `routes/diff.tsx`。
|
||||
|
||||
## 三、推荐文件层级
|
||||
|
||||
后续按以下结构演进:
|
||||
|
||||
```text
|
||||
mactfsui/
|
||||
app/
|
||||
app.css
|
||||
root.tsx
|
||||
routes.ts
|
||||
routes/
|
||||
home.tsx
|
||||
diff.tsx # FE-012 需要独立页面时再新增
|
||||
components/
|
||||
app/
|
||||
app-shell.tsx # 主工作台布局
|
||||
connection-gate.tsx # health/config/connect 入口
|
||||
connection-bar.tsx # 顶部连接、Collection、Workspace 信息
|
||||
explorer/
|
||||
server-tree-panel.tsx # 左侧服务端目录树
|
||||
folder-items-panel.tsx # 中间目录文件列表
|
||||
mapping-dialog.tsx # Mapping 创建流程
|
||||
status-badge.tsx # TFS 状态展示
|
||||
pending/
|
||||
pending-changes-panel.tsx
|
||||
checkin-form.tsx
|
||||
compare/
|
||||
folder-compare-panel.tsx
|
||||
compare-actions.tsx
|
||||
history/
|
||||
history-panel.tsx
|
||||
changeset-files-panel.tsx
|
||||
diff/
|
||||
diff-view.tsx
|
||||
logs/
|
||||
operation-log-panel.tsx
|
||||
ui/
|
||||
button.tsx # shadcn/ui 生成组件
|
||||
hooks/
|
||||
use-api-operation.ts
|
||||
use-folder-selection.ts
|
||||
use-pending-selection.ts
|
||||
lib/
|
||||
api/
|
||||
client.ts # fetch、token、统一响应处理
|
||||
endpoints.ts # API 方法
|
||||
types.ts # 后端响应类型
|
||||
electron/
|
||||
bridge.ts # window.mactfs 类型封装
|
||||
tfs/
|
||||
status.ts # 状态文案、颜色、可用操作
|
||||
utils.ts
|
||||
electron/
|
||||
main.cjs
|
||||
preload.cjs # FE-001 新增
|
||||
```
|
||||
|
||||
目录规则:
|
||||
|
||||
- `routes` 只做页面编排,不堆业务细节。
|
||||
- `components/ui` 只放 shadcn/ui 基础组件。
|
||||
- `components/*` 按业务域组织,避免一个 `components/common` 装所有东西。
|
||||
- `lib/api` 只关心 HTTP 和类型,不关心界面状态。
|
||||
- `lib/tfs` 放 TFS 状态到 UI 表现的纯映射逻辑。
|
||||
- `hooks` 只抽真实复用的交互状态,不为未来扩展提前抽象。
|
||||
|
||||
## 四、前端分层规范
|
||||
|
||||
### 4.1 Electron Main
|
||||
|
||||
职责:
|
||||
|
||||
- 创建主窗口。
|
||||
- 开发环境加载 `http://localhost:5173`。
|
||||
- 生产环境加载 `build/client/index.html`。
|
||||
- 检查 `http://127.0.0.1:38765/api/health`。
|
||||
- 服务未启动时拉起本地 Java API 服务。
|
||||
- 读取 `~/.mactfs/server-token`。
|
||||
- 打开本地目录选择器。
|
||||
|
||||
开发期建议:
|
||||
|
||||
- 优先检测已有服务。
|
||||
- 服务不存在时可以通过 `cd ../mactfs && ../tfsIntegration/gradlew runServer` 拉起。
|
||||
- 发行期启动命令由 release 任务统一确定,前端不要长期硬编码 Gradle 路径。
|
||||
|
||||
### 4.2 Electron Preload
|
||||
|
||||
preload 只暴露必要能力:
|
||||
|
||||
```ts
|
||||
window.mactfs = {
|
||||
getToken(): Promise<string>
|
||||
selectDirectory(): Promise<string | null>
|
||||
getServiceStatus(): Promise<ServiceStatus>
|
||||
}
|
||||
```
|
||||
|
||||
不要把 `fs`、`child_process`、任意 shell 执行能力直接暴露给渲染进程。
|
||||
|
||||
### 4.3 React Route
|
||||
|
||||
`routes/home.tsx` 是第一版主入口:
|
||||
|
||||
- 初始化 health。
|
||||
- 读取配置。
|
||||
- 判断是否展示连接配置页或主工作台。
|
||||
- 持有跨面板共享状态:
|
||||
- 当前 Collection
|
||||
- 当前 Workspace
|
||||
- mappings
|
||||
- 当前选中的 serverPath
|
||||
- 当前选中的文件 / diff 项
|
||||
- pending included / excluded
|
||||
|
||||
路由组件不直接写复杂 JSX,具体区域拆到 `components/app` 和业务组件。
|
||||
|
||||
### 4.4 API 层
|
||||
|
||||
API 层固定返回后端统一响应,不在组件里重复拼 `fetch`。
|
||||
|
||||
统一响应:
|
||||
|
||||
```ts
|
||||
export interface ApiResult<TData> {
|
||||
success: boolean
|
||||
message: string
|
||||
errorMessage?: string
|
||||
operation: string
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
durationMs: number
|
||||
logs: string[]
|
||||
data: TData
|
||||
}
|
||||
```
|
||||
|
||||
API client 职责:
|
||||
|
||||
- 从 preload 获取 Bearer token。
|
||||
- 请求 `http://127.0.0.1:38765`。
|
||||
- 自动设置 `Authorization: Bearer <token>`。
|
||||
- 自动设置 JSON header。
|
||||
- 返回 `ApiResult<T>`。
|
||||
- 网络失败时返回统一的前端失败结果,供 UI 展示。
|
||||
|
||||
组件规则:
|
||||
|
||||
- 组件不直接读 token。
|
||||
- 组件不直接写 `fetch`。
|
||||
- 组件不猜测后端错误类型,只展示 `errorMessage || message`。
|
||||
|
||||
## 五、后端 API 对接规范
|
||||
|
||||
基础配置:
|
||||
|
||||
```text
|
||||
Base URL: http://127.0.0.1:38765
|
||||
Auth: Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
当前后端路由以 `MacTfsServer` 为准:
|
||||
|
||||
| 功能 | 方法 | 路径 | 前端 data 字段 |
|
||||
|---|---|---|---|
|
||||
| 健康检查 | GET | `/api/health` | `status`, `connected`, `tokenFile`, `configFile` |
|
||||
| 读取配置 | GET | `/api/config` | `config` |
|
||||
| 保存配置 | PUT | `/api/config` | `config` |
|
||||
| 连接 TFS | POST | `/api/session/connect` | `serverUri`, `collectionCount` |
|
||||
| Collection | GET | `/api/collections` | `collections` |
|
||||
| 目录树 | GET | `/api/server-tree?path=...` | `path`, `items` |
|
||||
| 目录文件 | GET | `/api/server-folder/items?path=...` | `path`, `items` |
|
||||
| Workspace | GET | `/api/workspace` | `workspace`, `mappings` |
|
||||
| 创建 Workspace | POST | `/api/workspace/ensure` | `workspace` |
|
||||
| Mapping 列表 | GET | `/api/mappings` | `mappings` |
|
||||
| 创建 Mapping | POST | `/api/mappings` | `mapping`, `getLatest` |
|
||||
| 删除 Mapping | DELETE | `/api/mappings` | `mappings` |
|
||||
| Get Latest | POST | `/api/files/get-latest` | `result` |
|
||||
| Checkout | POST | `/api/files/checkout` | `result` |
|
||||
| Add | POST | `/api/files/add` | `result` |
|
||||
| Delete | POST | `/api/files/delete` | `result` |
|
||||
| Undo | POST | `/api/files/undo` | `result` |
|
||||
| Pending Changes | GET | `/api/pending-changes` | `pendingChanges` |
|
||||
| Checkin | POST | `/api/checkin` | `checkin` |
|
||||
| 目录对比 | POST | `/api/compare/folder` | `diffs` |
|
||||
| 历史 | GET | `/api/history?path=...&folder=true|false` | `history` |
|
||||
| Changeset 文件 | GET | `/api/history/changeset?changeset=...` | `files` |
|
||||
| 本地 vs latest diff | POST | `/api/diff/local-latest` | `diff` |
|
||||
| 历史版本 diff | POST | `/api/diff/revisions` | `diff` |
|
||||
| 操作日志 | GET | `/api/logs` | `logs` |
|
||||
|
||||
常用类型字段:
|
||||
|
||||
```ts
|
||||
export interface AppConfig {
|
||||
serverUri?: string
|
||||
authType: "ntlm-explicit"
|
||||
domain?: string
|
||||
username?: string
|
||||
password?: string
|
||||
collection?: string
|
||||
workspace?: string
|
||||
mappings: MappingConfig[]
|
||||
}
|
||||
|
||||
export interface ServerItem {
|
||||
name: string
|
||||
path: string
|
||||
serverPath: string
|
||||
type: "folder" | "file"
|
||||
folder: boolean
|
||||
latestVersion: number
|
||||
checkinDate?: number
|
||||
}
|
||||
|
||||
export interface MappingInfo {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
export interface PendingChangeInfo {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: TfsStatus
|
||||
changeType: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface FolderDiffItem {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: TfsStatus
|
||||
localVersion: number
|
||||
latestVersion: number
|
||||
}
|
||||
```
|
||||
|
||||
## 六、状态模型
|
||||
|
||||
### 6.1 App 状态
|
||||
|
||||
主工作台维护:
|
||||
|
||||
- `serviceStatus`
|
||||
- `config`
|
||||
- `collections`
|
||||
- `selectedCollection`
|
||||
- `workspace`
|
||||
- `mappings`
|
||||
- `selectedServerPath`
|
||||
|
||||
这些状态来源于 `/api/health`、`/api/config`、`/api/collections`、`/api/workspace`。
|
||||
|
||||
### 6.2 Explorer 状态
|
||||
|
||||
目录树:
|
||||
|
||||
- 以 `serverPath` 作为节点 key。
|
||||
- 懒加载子节点。
|
||||
- 未映射目录也允许浏览。
|
||||
- 展开目录时调用 `/api/server-tree`。
|
||||
|
||||
文件列表:
|
||||
|
||||
- 选中左侧目录后调用 `/api/server-folder/items`。
|
||||
- 结合 mappings 计算是否已映射和本地路径。
|
||||
- 已映射但本地不存在时展示 `notDownloaded / 未下载`。
|
||||
|
||||
### 6.3 Mapping 状态
|
||||
|
||||
Mapping 创建流程:
|
||||
|
||||
```text
|
||||
选中服务端目录
|
||||
点击“映射到本地”
|
||||
调用 preload selectDirectory
|
||||
POST /api/mappings
|
||||
询问是否立即 Get Latest
|
||||
刷新 mappings、当前文件列表、操作日志
|
||||
```
|
||||
|
||||
不做复杂 Mapping 编辑。删除 Mapping 属于后续按任务需要实现的操作入口。
|
||||
|
||||
### 6.4 Compare 状态
|
||||
|
||||
目录对比只允许已映射目录触发。
|
||||
|
||||
前端状态:
|
||||
|
||||
- `diffs`
|
||||
- `selectedDiffKeys`
|
||||
- `hideUpToDate = true`
|
||||
- `compareLoading`
|
||||
|
||||
默认隐藏 `upToDate`,只展示差异。操作完成后提示用户重新对比或自动刷新当前对比列表,具体按任务实现范围决定。
|
||||
|
||||
### 6.5 Pending Changes 状态
|
||||
|
||||
右侧面板维护:
|
||||
|
||||
- `includedKeys`
|
||||
- `excludedKeys`
|
||||
- `pendingChanges`
|
||||
|
||||
规则:
|
||||
|
||||
- 后端返回的 pending changes 默认进入 Included。
|
||||
- 用户移动到 Excluded 后只保存在当前 UI 会话。
|
||||
- Checkin 只提交 Included。
|
||||
- Checkin 成功后清空 Included,Excluded 继续保留到当前 UI 会话结束。
|
||||
|
||||
### 6.6 Operation 状态
|
||||
|
||||
每个主要区域只保留当前操作:
|
||||
|
||||
- `idle`
|
||||
- `loading`
|
||||
- `success`
|
||||
- `error`
|
||||
|
||||
长耗时操作必须显示明确文案:
|
||||
|
||||
- 正在连接 TFS
|
||||
- 正在查询目录
|
||||
- 正在执行目录对比
|
||||
- 正在获取最新
|
||||
- 正在签入
|
||||
|
||||
第一版不做取消按钮。
|
||||
|
||||
## 七、TFS 状态与操作映射
|
||||
|
||||
状态文案统一放在 `app/lib/tfs/status.ts`。
|
||||
|
||||
```text
|
||||
localModified -> 本地修改
|
||||
remoteChanged -> 服务端有更新
|
||||
bothChanged -> 双边变更
|
||||
localOnly -> 本地新增
|
||||
remoteOnly -> 服务端新增
|
||||
notDownloaded -> 未下载
|
||||
localDeleted -> 本地删除
|
||||
pendingEdit -> 待签入编辑
|
||||
pendingAdd -> 待签入新增
|
||||
pendingDelete -> 待签入删除
|
||||
pendingRename -> 待签入重命名
|
||||
pending -> 待签入
|
||||
upToDate -> 最新
|
||||
```
|
||||
|
||||
操作入口:
|
||||
|
||||
```text
|
||||
localModified -> checkout / diff
|
||||
localOnly -> add / delete local
|
||||
localDeleted -> delete / restore from server
|
||||
remoteChanged -> get latest / diff
|
||||
notDownloaded -> get latest
|
||||
pendingEdit -> checkin / undo / diff
|
||||
pendingAdd -> checkin / undo
|
||||
pendingDelete -> checkin / undo
|
||||
bothChanged -> diff / 手工处理
|
||||
upToDate -> 无主要操作
|
||||
```
|
||||
|
||||
UI 不直接散落状态字符串判断。新增状态必须先补 `status.ts`,再接组件展示。
|
||||
|
||||
## 八、UI 风格规范
|
||||
|
||||
### 8.1 总体风格
|
||||
|
||||
参考 Finder、Xcode Source Control、Fork / SourceTree,优先 macOS 工作台效率:
|
||||
|
||||
- 信息密度高
|
||||
- 面板边界清晰
|
||||
- 控件安静克制
|
||||
- shadcn/ui 作为组件基础
|
||||
- macOS 原生工具软件气质
|
||||
- 不做营销页
|
||||
- 不做大 Hero
|
||||
- 不做装饰性渐变和卡片堆叠
|
||||
- 不使用大面积单一色调
|
||||
|
||||
第一屏就是可操作的 TFS 工作台或连接配置,不展示产品介绍页。
|
||||
|
||||
### 8.2 布局尺寸
|
||||
|
||||
推荐桌面布局:
|
||||
|
||||
```text
|
||||
顶部工具栏:48-56px
|
||||
左侧 Source List:260-320px
|
||||
中间文件列表 / 对比 / 历史:自适应
|
||||
右侧 Inspector / Pending:320-380px
|
||||
底部 Console / 操作日志:150-220px,可折叠
|
||||
```
|
||||
|
||||
窗口小于可用宽度时:
|
||||
|
||||
- 保持顶部和底部可读。
|
||||
- 左右面板允许压缩到最小宽度。
|
||||
- 中间列表横向滚动。
|
||||
- 不把核心操作隐藏到不可发现的位置。
|
||||
|
||||
### 8.3 组件使用
|
||||
|
||||
- 按钮使用 shadcn `Button`。
|
||||
- 图标使用 lucide-react。
|
||||
- 文件操作按钮优先使用图标 + tooltip。
|
||||
- 关键破坏性操作使用确认弹窗。
|
||||
- 目录 checkout 必须二次确认影响数量。
|
||||
- 状态用 badge,不用只靠颜色表达。
|
||||
- 路径使用等宽字体并允许横向滚动。
|
||||
- 列表行高保持 28-34px。
|
||||
- 按钮高度保持 28-32px。
|
||||
- 圆角控制在 6-8px。
|
||||
- 表格列宽固定或可伸缩,不因加载文案导致布局跳动。
|
||||
|
||||
### 8.4 色彩语义
|
||||
|
||||
颜色只表达业务状态:
|
||||
|
||||
- `pendingEdit`:蓝色
|
||||
- `pendingAdd` / 成功:绿色
|
||||
- `pendingDelete` / 失败:红色
|
||||
- `notDownloaded` / 等待:琥珀色
|
||||
- `remoteChanged`:紫色或蓝紫色
|
||||
- `bothChanged`:红色强调
|
||||
- `upToDate`:中性灰
|
||||
|
||||
颜色通过 Tailwind token 或 CSS 变量实现,不在组件里硬编码大段自定义颜色。
|
||||
|
||||
### 8.5 文案
|
||||
|
||||
界面文案使用中文,接口字段和路径保持原始英文。
|
||||
|
||||
推荐文案:
|
||||
|
||||
- 连接 TFS
|
||||
- 选择 Collection
|
||||
- 当前 Workspace
|
||||
- 映射到本地
|
||||
- 获取最新
|
||||
- 目录对比
|
||||
- 挂起更改
|
||||
- Included Changes
|
||||
- Excluded Changes
|
||||
- 签入注释
|
||||
- 操作日志
|
||||
|
||||
错误信息优先展示后端返回的 TFS 原始错误,外层只补充当前操作上下文。
|
||||
|
||||
## 九、开发实现规范
|
||||
|
||||
### 9.1 TypeScript
|
||||
|
||||
- 保持 `strict: true`。
|
||||
- API 响应类型放到 `app/lib/api/types.ts`。
|
||||
- 业务组件 props 使用 interface。
|
||||
- 不使用 `any` 承接后端响应,除非当前接口确实没有稳定结构。
|
||||
- 不在组件里重复定义相同类型。
|
||||
|
||||
### 9.2 注释
|
||||
|
||||
遵守项目根目录规则:
|
||||
|
||||
- 新增函数必须有简短函数级注释。
|
||||
- 修改核心逻辑时补充必要注释。
|
||||
- 注释说明业务作用和约束,不重复代码表面意思。
|
||||
|
||||
### 9.3 样式
|
||||
|
||||
- 优先 Tailwind class。
|
||||
- 复用 `cn` 合并 className。
|
||||
- 通用 variant 用 `class-variance-authority`。
|
||||
- 不新增全局样式,除非是全局 token、滚动条或布局基础样式。
|
||||
- shadcn 组件保持生成风格,不混入业务逻辑。
|
||||
|
||||
### 9.4 数据刷新
|
||||
|
||||
操作后的最小刷新规则:
|
||||
|
||||
- 连接成功:刷新 config、collections。
|
||||
- 选择 Collection:ensure workspace 后刷新 workspace、mappings、目录树根节点。
|
||||
- 创建 Mapping:刷新 mappings 和当前目录文件列表。
|
||||
- Get Latest:刷新当前目录文件列表和操作日志。
|
||||
- Checkout/Add/Delete/Undo:刷新 pending changes;目录对比页提示重新对比或刷新 diffs。
|
||||
- Checkin 成功:刷新 pending changes 和操作日志。
|
||||
- History/Diff:只刷新当前面板数据。
|
||||
|
||||
### 9.5 错误处理
|
||||
|
||||
- API 失败统一展示 `errorMessage || message`。
|
||||
- 组件内不要堆多层 try/catch。
|
||||
- 不做 retry 机制,除非任务明确要求。
|
||||
- 服务未启动时展示明确提示和重新检测入口。
|
||||
- 长操作超时按后端返回展示,不在前端重复实现超时体系。
|
||||
|
||||
### 9.6 文件编辑流程
|
||||
|
||||
每次改代码前必须先执行:
|
||||
|
||||
```bash
|
||||
chmod u+w <file>
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
chmod 644 <file>
|
||||
```
|
||||
|
||||
任务开发前必须读取:
|
||||
|
||||
- `docs/mactfs-api-product-prd.md`
|
||||
- `task/README.md`
|
||||
- `task/AI-RULES.md`
|
||||
- 当前 task 文件
|
||||
- 相关前端源码
|
||||
|
||||
前端任务默认验证:
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
涉及明显 UI 改动时,还要用浏览器或 Electron 实际看一遍布局,确认文字不重叠、主区域不空白、操作入口可见。
|
||||
|
||||
## 十、FE 任务落地顺序
|
||||
|
||||
严格按任务依赖推进,不跳过前置任务。
|
||||
|
||||
1. FE-001:Electron 启动服务、preload、API client。
|
||||
2. FE-002:连接配置视图,成功后进入工作台。
|
||||
3. FE-003:macOS Source Workspace 主布局。
|
||||
4. FE-004:Collection 服务端目录树。
|
||||
5. FE-005:当前目录文件列表。
|
||||
6. FE-006:Mapping 创建流程。
|
||||
7. FE-007:目录对比页面。
|
||||
8. FE-008:Pending Changes 面板。
|
||||
9. FE-009:文件操作交互。
|
||||
10. FE-010:签入流程。
|
||||
11. FE-011:历史记录界面。
|
||||
12. FE-012:文件 Diff 界面。
|
||||
13. FE-013:操作日志面板。
|
||||
|
||||
每个任务只做当前任务实现范围,不顺手扩展后续任务。
|
||||
|
||||
## 十一、验收基线
|
||||
|
||||
前端任一 P0 功能完成时,至少满足:
|
||||
|
||||
- `pnpm typecheck` 通过。
|
||||
- 当前任务验收标准逐条可说明。
|
||||
- API 调用带 Bearer token。
|
||||
- 服务慢时有明确等待状态。
|
||||
- 失败时显示可读错误。
|
||||
- 操作后刷新对应区域或明确提示用户刷新。
|
||||
- 不破坏现有 Electron 启动。
|
||||
- 不引入与现有栈不一致的新框架。
|
||||
|
||||
## 十二、当前项目现状
|
||||
|
||||
当前 `mactfsui` 仍处于模板状态:
|
||||
|
||||
- `app/routes/home.tsx` 只有默认欢迎内容。
|
||||
- `app/components/ui/button.tsx` 已有 shadcn Button。
|
||||
- `electron/main.cjs` 只创建窗口,尚未启动 Java API 服务。
|
||||
- 尚未实现 preload。
|
||||
- 尚未实现 API client。
|
||||
- 尚未实现业务布局和页面。
|
||||
|
||||
因此后续前端开发应从 FE-001 开始,先补齐 Electron 与 API 基础能力,再进入业务页面。
|
||||
@@ -1,13 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/roboto-slab";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-serif);
|
||||
--font-serif: 'Roboto Slab Variable', serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -125,6 +124,6 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-serif;
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Cloud,
|
||||
FolderTree,
|
||||
GitPullRequest,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
|
||||
import { OperationLogPanel } from "~/components/app/operation-log-panel"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
||||
|
||||
interface AppShellProps {
|
||||
connected: boolean
|
||||
serviceReady: boolean
|
||||
serviceLoading: boolean
|
||||
serverUri: string
|
||||
username: string
|
||||
collection?: string
|
||||
workspace?: string
|
||||
collectionCount?: number
|
||||
serviceBaseUrl?: string
|
||||
tokenFile?: string
|
||||
serviceMessage: string
|
||||
requestStatus: string
|
||||
operationLogs: ApiRequestLogEntry[]
|
||||
operationLogsLoading: boolean
|
||||
sourceList?: ReactNode
|
||||
inspector?: ReactNode
|
||||
onRefreshService(): void
|
||||
onRefreshOperationLogs(): void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 macOS Source Workspace 主框架,承载顶部工具栏、Source List、主内容、Inspector 和 Console。
|
||||
*/
|
||||
export function AppShell({
|
||||
connected,
|
||||
serviceReady,
|
||||
serviceLoading,
|
||||
serverUri,
|
||||
username,
|
||||
collection,
|
||||
workspace,
|
||||
collectionCount,
|
||||
serviceBaseUrl,
|
||||
tokenFile,
|
||||
serviceMessage,
|
||||
requestStatus,
|
||||
operationLogs,
|
||||
operationLogsLoading,
|
||||
sourceList,
|
||||
inspector,
|
||||
onRefreshService,
|
||||
onRefreshOperationLogs,
|
||||
children,
|
||||
}: 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 (
|
||||
<div className="flex h-[calc(100svh-32px)] min-h-[640px] flex-col overflow-hidden rounded-[8px] border bg-background text-sm shadow-sm">
|
||||
<header className="flex h-13 shrink-0 items-center justify-between gap-3 border-b bg-muted/30 px-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-7 items-center justify-center rounded-[7px] border bg-background text-primary">
|
||||
<Cloud className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">macTFS</span>
|
||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{connected ? "已连接" : "未连接"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{serverUri || "http://127.0.0.1:38765"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 justify-end gap-2 text-xs text-muted-foreground">
|
||||
{connected && (
|
||||
<div className="hidden min-w-0 items-center gap-2 md:flex">
|
||||
<span className="max-w-52 truncate font-mono">
|
||||
Collection: {collection || "-"}
|
||||
</span>
|
||||
<span className="max-w-52 truncate font-mono">
|
||||
Workspace: {workspace || "-"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={serviceLoading}
|
||||
onClick={onRefreshService}
|
||||
>
|
||||
<RefreshCw className={serviceLoading ? "animate-spin" : ""} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="grid min-h-0 flex-1"
|
||||
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">
|
||||
{sourceCollapsed ? (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="展开 Source List"
|
||||
onClick={() => setSourceCollapsed(false)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<FolderTree className="size-4" />
|
||||
<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>
|
||||
{!sourceCollapsed && (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{sourceList || (
|
||||
<>
|
||||
<div className="rounded-[6px] bg-background px-2 py-1.5 font-mono text-xs">
|
||||
$/
|
||||
</div>
|
||||
<div className="mt-2 px-2 text-xs text-muted-foreground">
|
||||
Collection:{collectionCount ?? "-"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="flex min-h-[320px] min-w-0 flex-col">
|
||||
<div className="flex h-9 items-center justify-between gap-3 border-b px-3">
|
||||
<div className="min-w-0 truncate font-medium">Source Workspace</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
</main>
|
||||
|
||||
{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">
|
||||
{inspectorCollapsed ? (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="展开 Changes"
|
||||
onClick={() => setInspectorCollapsed(false)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<GitPullRequest className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">Changes</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="收起 Changes"
|
||||
onClick={() => setInspectorCollapsed(true)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!inspectorCollapsed && (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<div className="grid gap-4">
|
||||
{inspector || (
|
||||
<section className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Pending Changes</span>
|
||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
0 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
|
||||
暂无挂起更改
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div className="font-medium">本地服务</div>
|
||||
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/30 px-3 py-2">
|
||||
{serviceReady ? (
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
) : (
|
||||
<TriangleAlert className="size-4 text-amber-600" />
|
||||
)}
|
||||
<span>{serviceReady ? "服务可用" : "服务未就绪"}</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-xs text-muted-foreground">
|
||||
<div className="truncate font-mono">
|
||||
{serviceBaseUrl || "http://127.0.0.1:38765"}
|
||||
</div>
|
||||
<div className="truncate font-mono">
|
||||
{tokenFile || "~/.mactfs/server-token"}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className={`${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
|
||||
logs={operationLogs}
|
||||
loading={operationLogsLoading}
|
||||
serviceMessage={serviceMessage}
|
||||
requestStatus={requestStatus}
|
||||
username={username}
|
||||
onRefresh={onRefreshOperationLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</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,169 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
||||
|
||||
interface OperationLogPanelProps {
|
||||
logs: ApiRequestLogEntry[]
|
||||
loading: boolean
|
||||
serviceMessage: string
|
||||
requestStatus: string
|
||||
username: string
|
||||
onRefresh(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染底部操作日志面板,展示 API 操作状态、耗时、路径摘要和错误信息。
|
||||
*/
|
||||
export function OperationLogPanel({
|
||||
logs,
|
||||
loading,
|
||||
serviceMessage,
|
||||
requestStatus,
|
||||
username,
|
||||
onRefresh,
|
||||
}: OperationLogPanelProps) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Terminal className="size-4" />
|
||||
<span>Console</span>
|
||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5">
|
||||
{logs.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="刷新服务端操作日志"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="grid gap-1 p-3 font-mono text-xs text-muted-foreground">
|
||||
<div>[service] {serviceMessage}</div>
|
||||
<div>[request] {requestStatus}</div>
|
||||
<div>[session] {username || "anonymous"}</div>
|
||||
<div>[logs] 暂无 API 操作日志</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="min-w-[920px]">
|
||||
<div className="sticky top-0 z-10 grid h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] border-b bg-muted px-3 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center">操作</div>
|
||||
<div className="flex items-center">状态</div>
|
||||
<div className="flex items-center">开始</div>
|
||||
<div className="flex items-center">结束</div>
|
||||
<div className="flex items-center">耗时</div>
|
||||
<div className="flex items-center">路径摘要</div>
|
||||
<div className="flex items-center">错误信息</div>
|
||||
</div>
|
||||
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="grid min-h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] items-center border-b px-3 font-mono text-xs"
|
||||
>
|
||||
<div className="truncate">{log.operation}</div>
|
||||
<div>
|
||||
<span
|
||||
className={`inline-flex max-w-full items-center gap-1 rounded-[6px] border px-1.5 py-0.5 ${statusClassName(log)}`}
|
||||
>
|
||||
{log.status === "success" ? (
|
||||
<CheckCircle2 className="size-3" />
|
||||
) : log.status === "running" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<TriangleAlert className="size-3" />
|
||||
)}
|
||||
{statusLabel(log)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatLogTime(log.startedAt)}
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground">
|
||||
{log.status === "running"
|
||||
? "执行中"
|
||||
: formatLogTime(log.endedAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatDuration(log)}
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground">
|
||||
{log.summary || "-"}
|
||||
</div>
|
||||
<div
|
||||
className={`truncate ${
|
||||
log.errorMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{log.status === "running"
|
||||
? "请求执行中"
|
||||
: log.errorMessage || "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日志状态返回中文状态文案。
|
||||
*/
|
||||
function statusLabel(log: ApiRequestLogEntry) {
|
||||
if (log.status === "running") {
|
||||
return "执行中"
|
||||
}
|
||||
return log.success ? "成功" : "失败"
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日志状态返回 badge 样式。
|
||||
*/
|
||||
function statusClassName(log: ApiRequestLogEntry) {
|
||||
if (log.status === "running") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700"
|
||||
}
|
||||
return log.success
|
||||
? "border-green-200 bg-green-50 text-green-700"
|
||||
: "border-red-200 bg-red-50 text-red-700"
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化操作日志时间戳。
|
||||
*/
|
||||
function formatLogTime(value?: number) {
|
||||
return value ? new Date(value).toLocaleString() : "-"
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化操作耗时,执行中的请求保持明确状态。
|
||||
*/
|
||||
function formatDuration(log: ApiRequestLogEntry) {
|
||||
if (log.status === "running") {
|
||||
return "执行中"
|
||||
}
|
||||
if (log.durationMs === undefined) {
|
||||
return "-"
|
||||
}
|
||||
return log.durationMs < 1000
|
||||
? `${log.durationMs} ms`
|
||||
: `${(log.durationMs / 1000).toFixed(1)} s`
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { GitCompareArrows, Loader2, RefreshCw, Search, X } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
|
||||
import type { TfsTextDiff } from "~/lib/api/types"
|
||||
|
||||
export type DiffPanelRequest =
|
||||
| {
|
||||
type: "localLatest"
|
||||
serverPath: string
|
||||
localPath: string
|
||||
label: string
|
||||
}
|
||||
| {
|
||||
type: "revisions"
|
||||
serverPath: string
|
||||
sourceChangeset: number
|
||||
targetChangeset: number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface DiffPanelProps {
|
||||
request: DiffPanelRequest
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
interface DiffRow {
|
||||
source?: string
|
||||
target?: string
|
||||
kind: "same" | "removed" | "added"
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示文本 Diff 面板,支持本地 vs latest 和两个历史版本对比。
|
||||
*/
|
||||
export function DiffPanel({ request, onClose }: DiffPanelProps) {
|
||||
const [diff, setDiff] = useState<TfsTextDiff>()
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
loadDiff()
|
||||
}, [request])
|
||||
|
||||
const rows = diff ? toDiffRows(diff.lines) : []
|
||||
const lowerSearch = search.trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* 根据当前请求类型加载文本 diff。
|
||||
*/
|
||||
async function loadDiff() {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
|
||||
const result =
|
||||
request.type === "localLatest"
|
||||
? await diffLocalLatest({
|
||||
serverPath: request.serverPath,
|
||||
localPath: request.localPath,
|
||||
})
|
||||
: await diffRevisions({
|
||||
serverPath: request.serverPath,
|
||||
sourceChangeset: request.sourceChangeset,
|
||||
targetChangeset: request.targetChangeset,
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
if (!result.success || !result.data.diff) {
|
||||
setDiff(undefined)
|
||||
setMessage(
|
||||
result.errorMessage ||
|
||||
result.message ||
|
||||
"大文件、二进制或不支持类型无法展示文本 diff。"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setDiff(result.data.diff)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端统一 diff lines 转为左右两栏展示行。
|
||||
*/
|
||||
function toDiffRows(lines: string[]): DiffRow[] {
|
||||
return lines.map((line) => {
|
||||
const prefix = line.charAt(0)
|
||||
const content = line.slice(1)
|
||||
if (prefix === "-") {
|
||||
return { source: content, kind: "removed" }
|
||||
}
|
||||
if (prefix === "+") {
|
||||
return { target: content, kind: "added" }
|
||||
}
|
||||
return { source: content, target: content, kind: "same" }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="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="min-w-0">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<GitCompareArrows className="size-3.5" />
|
||||
Diff
|
||||
</div>
|
||||
<div className="truncate font-mono text-muted-foreground">
|
||||
{request.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<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="刷新 Diff"
|
||||
onClick={loadDiff}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff && (
|
||||
<div className="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="flex min-w-0 items-center truncate font-mono">
|
||||
{diff.sourceLabel}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center truncate border-l pl-3 font-mono">
|
||||
{diff.targetLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100%-2rem)] overflow-auto font-mono text-xs">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-3 py-4 text-muted-foreground">
|
||||
无可展示 diff 内容。
|
||||
</div>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<div
|
||||
key={`${index}:${row.kind}`}
|
||||
className={`grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b ${
|
||||
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">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
className={`whitespace-pre-wrap px-2 py-1 ${
|
||||
row.kind === "removed"
|
||||
? "bg-red-50 text-red-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{row.source ?? ""}
|
||||
</div>
|
||||
<div className="border-l bg-muted/20 px-2 py-1 text-right text-muted-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
className={`whitespace-pre-wrap px-2 py-1 ${
|
||||
row.kind === "added"
|
||||
? "bg-green-50 text-green-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{row.target ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { FileText, Folder, Loader2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ContextMenu } from "~/components/app/context-menu"
|
||||
import type { ContextMenuItem, ContextMenuState } from "~/components/app/context-menu"
|
||||
import { SimpleDialog } from "~/components/app/simple-dialog"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
|
||||
import { DiffPanel } from "~/components/explorer/diff-panel"
|
||||
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
|
||||
import { FileViewerDialog } from "~/components/explorer/file-viewer-dialog"
|
||||
import { FolderCompareDialog } from "~/components/explorer/folder-compare-dialog"
|
||||
import { HistoryPanel } from "~/components/explorer/history-panel"
|
||||
import { MappingDialog } from "~/components/explorer/mapping-dialog"
|
||||
import {
|
||||
checkoutFiles,
|
||||
deleteFiles,
|
||||
getLatest,
|
||||
listMappings,
|
||||
listPendingChanges,
|
||||
listServerFolderItems,
|
||||
undoFiles,
|
||||
} from "~/lib/api/endpoints"
|
||||
import type {
|
||||
FolderDiffItem,
|
||||
TfsConflictInfo,
|
||||
TfsMappingInfo,
|
||||
TfsPendingChangeInfo,
|
||||
TfsServerItem,
|
||||
} from "~/lib/api/types"
|
||||
|
||||
interface FolderItemsPanelProps {
|
||||
connected: boolean
|
||||
collection: string
|
||||
serverPath: string
|
||||
refreshKey: number
|
||||
onPendingChangesRefresh(): void
|
||||
onPathEnter(path: string): void
|
||||
onSelectedMappedItemChange(mapped: boolean): void
|
||||
}
|
||||
|
||||
interface FolderItemView {
|
||||
item: TfsServerItem
|
||||
mapping?: TfsMappingInfo
|
||||
pending?: TfsPendingChangeInfo
|
||||
localPath: string
|
||||
mapped: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface HistoryTarget {
|
||||
path: string
|
||||
folder: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
interface FileViewTarget {
|
||||
serverPath: string
|
||||
localPath?: string
|
||||
preferLocal: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示当前目录下一级文件和文件夹,复杂对象操作通过右键菜单和弹窗编排。
|
||||
*/
|
||||
export function FolderItemsPanel({
|
||||
connected,
|
||||
collection,
|
||||
serverPath,
|
||||
refreshKey,
|
||||
onPendingChangesRefresh,
|
||||
onPathEnter,
|
||||
onSelectedMappedItemChange,
|
||||
}: FolderItemsPanelProps) {
|
||||
const [items, setItems] = useState<TfsServerItem[]>([])
|
||||
const [mappings, setMappings] = useState<TfsMappingInfo[]>([])
|
||||
const [pendingChanges, setPendingChanges] = useState<TfsPendingChangeInfo[]>([])
|
||||
const [selectedPath, setSelectedPath] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [operationPath, setOperationPath] = useState("")
|
||||
const [message, setMessage] = useState("")
|
||||
const [menu, setMenu] = useState<ContextMenuState>()
|
||||
const [mappingPath, setMappingPath] = useState("")
|
||||
const [historyTarget, setHistoryTarget] = useState<HistoryTarget>()
|
||||
const [fileViewTarget, setFileViewTarget] = useState<FileViewTarget>()
|
||||
const [compareTarget, setCompareTarget] = useState<FolderItemView>()
|
||||
const [diffRequest, setDiffRequest] = useState<DiffPanelRequest>()
|
||||
const [conflicts, setConflicts] = useState<TfsConflictInfo[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
onSelectedMappedItemChange(false)
|
||||
if (!connected || !collection || !serverPath) {
|
||||
setItems([])
|
||||
setMappings([])
|
||||
setPendingChanges([])
|
||||
setSelectedPath("")
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedPath("")
|
||||
loadFolderItems()
|
||||
}, [connected, collection, serverPath, refreshKey])
|
||||
|
||||
const viewItems = items.map(toFolderItemView)
|
||||
const currentMapping = findMapping(serverPath)
|
||||
const currentDirectoryLocalPath = toMappedLocalPath(serverPath, currentMapping)
|
||||
const selectedItem = viewItems.find(
|
||||
(viewItem) => viewItem.item.serverPath === selectedPath
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onSelectedMappedItemChange(Boolean(selectedItem?.mapped))
|
||||
}, [selectedItem?.mapped])
|
||||
|
||||
/**
|
||||
* 加载当前目录 items、Workspace mappings 和 pending changes,用于表格状态和菜单判断。
|
||||
*/
|
||||
async function loadFolderItems() {
|
||||
const requestServerPath = serverPath
|
||||
const requestCollection = collection
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
|
||||
const itemResult = await listServerFolderItems(
|
||||
requestServerPath,
|
||||
requestCollection
|
||||
)
|
||||
const mappingResult = await listMappings({ collection: requestCollection })
|
||||
const pendingResult = await listPendingChanges({
|
||||
collection: requestCollection,
|
||||
serverPath: requestServerPath,
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
if (requestServerPath !== serverPath || requestCollection !== collection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!itemResult.success) {
|
||||
setItems([])
|
||||
setMessage(itemResult.errorMessage || itemResult.message)
|
||||
return
|
||||
}
|
||||
|
||||
setItems(itemResult.data.items)
|
||||
setMappings(mappingResult.success ? mappingResult.data.mappings : [])
|
||||
setPendingChanges(
|
||||
pendingResult.success ? pendingResult.data.pendingChanges : []
|
||||
)
|
||||
setMessage(
|
||||
mappingResult.success
|
||||
? pendingResult.success
|
||||
? ""
|
||||
: pendingResult.errorMessage || pendingResult.message
|
||||
: mappingResult.errorMessage || mappingResult.message
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找覆盖当前服务端路径的最长 Mapping。
|
||||
*/
|
||||
function findMapping(serverItemPath: string) {
|
||||
return mappings
|
||||
.filter((mapping) =>
|
||||
isSameOrChildPath(serverItemPath, mapping.serverPath)
|
||||
)
|
||||
.sort((left, right) => right.serverPath.length - left.serverPath.length)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断服务端路径是否等于 Mapping 根路径或位于其子目录下。
|
||||
*/
|
||||
function isSameOrChildPath(serverItemPath: string, mappingPath: string) {
|
||||
const itemPath = serverItemPath.toLowerCase()
|
||||
const rootPath = mappingPath.toLowerCase()
|
||||
|
||||
return itemPath === rootPath || itemPath.startsWith(`${rootPath}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Mapping 根路径计算服务端项对应的本地路径。
|
||||
*/
|
||||
function toMappedLocalPath(
|
||||
serverItemPath: string,
|
||||
mapping?: TfsMappingInfo
|
||||
) {
|
||||
if (!mapping) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const relativePath = serverItemPath
|
||||
.slice(mapping.serverPath.length)
|
||||
.replace(/^\//, "")
|
||||
|
||||
return relativePath
|
||||
? `${mapping.localPath}/${relativePath}`
|
||||
: mapping.localPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找当前项上的 pending change,目录按包含关系判断。
|
||||
*/
|
||||
function findPendingChange(item: TfsServerItem) {
|
||||
return pendingChanges.find((change) =>
|
||||
item.folder
|
||||
? isSameOrChildPath(change.serverPath, item.serverPath)
|
||||
: change.serverPath === item.serverPath
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成中间列表展示用结构。
|
||||
*/
|
||||
function toFolderItemView(item: TfsServerItem): FolderItemView {
|
||||
const mapping = findMapping(item.serverPath)
|
||||
const pending = findPendingChange(item)
|
||||
|
||||
return {
|
||||
item,
|
||||
mapping,
|
||||
pending,
|
||||
localPath: toMappedLocalPath(item.serverPath, mapping),
|
||||
mapped: Boolean(mapping),
|
||||
status: pending?.status || (mapping ? "mapped" : "unmapped"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件或文件夹历史弹窗。
|
||||
*/
|
||||
function openHistory(path: string, folder: boolean) {
|
||||
setHistoryTarget({
|
||||
path,
|
||||
folder,
|
||||
label: path,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Get Latest,并在后端返回冲突明细时打开统一冲突弹窗。
|
||||
*/
|
||||
async function runGetLatest(viewItem: FolderItemView) {
|
||||
setOperationPath(viewItem.item.serverPath)
|
||||
setMessage("")
|
||||
const result = await getLatest({
|
||||
serverPath: viewItem.item.serverPath,
|
||||
recursive: viewItem.item.folder,
|
||||
})
|
||||
setOperationPath("")
|
||||
|
||||
const conflictDetails = result.data.result?.conflictDetails || []
|
||||
if (conflictDetails.length > 0) {
|
||||
setConflicts(conflictDetails)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 checkout,目录递归操作前使用原生确认减少误操作。
|
||||
*/
|
||||
async function runCheckout(viewItem: FolderItemView) {
|
||||
if (
|
||||
viewItem.item.folder &&
|
||||
!window.confirm("将递归签出当前目录,是否继续?")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setOperationPath(viewItem.item.serverPath)
|
||||
setMessage("")
|
||||
const result = await checkoutFiles({
|
||||
paths: [viewItem.item.serverPath],
|
||||
recursive: viewItem.item.folder,
|
||||
})
|
||||
setOperationPath("")
|
||||
|
||||
const conflictDetails = result.data.result?.conflictDetails || []
|
||||
if (conflictDetails.length > 0) {
|
||||
setConflicts(conflictDetails)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
onPendingChangesRefresh()
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 对版本控制项执行 pending delete。
|
||||
*/
|
||||
async function runDelete(viewItem: FolderItemView) {
|
||||
setOperationPath(viewItem.item.serverPath)
|
||||
setMessage("")
|
||||
const result = await deleteFiles({
|
||||
paths: [viewItem.item.serverPath],
|
||||
recursive: viewItem.item.folder,
|
||||
})
|
||||
setOperationPath("")
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
onPendingChangesRefresh()
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销当前对象上的 pending change。
|
||||
*/
|
||||
async function runUndo(viewItem: FolderItemView) {
|
||||
setOperationPath(viewItem.item.serverPath)
|
||||
setMessage("")
|
||||
const result = await undoFiles({
|
||||
paths: [viewItem.item.serverPath],
|
||||
recursive: viewItem.item.folder,
|
||||
})
|
||||
setOperationPath("")
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
onPendingChangesRefresh()
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据对象状态生成右键菜单,保持 pending add/edit 等判断集中在列表项模型上。
|
||||
*/
|
||||
function buildMenuItems(viewItem: FolderItemView): ContextMenuItem[] {
|
||||
const pendingAdd = viewItem.pending?.status === "pendingAdd"
|
||||
const pendingEdit = viewItem.pending?.status === "pendingEdit"
|
||||
|
||||
return [
|
||||
{
|
||||
key: "open",
|
||||
label: viewItem.item.folder ? "打开" : "查看",
|
||||
onSelect: () =>
|
||||
viewItem.item.folder
|
||||
? onPathEnter(viewItem.item.serverPath)
|
||||
: setFileViewTarget({
|
||||
serverPath: viewItem.item.serverPath,
|
||||
localPath: viewItem.mapped ? viewItem.localPath : undefined,
|
||||
preferLocal: viewItem.mapped,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "getLatest",
|
||||
label: "获取最新版本",
|
||||
disabled: !viewItem.mapped || pendingEdit,
|
||||
onSelect: () => runGetLatest(viewItem),
|
||||
},
|
||||
{
|
||||
key: "checkout",
|
||||
label: "签出",
|
||||
disabled: !viewItem.mapped || pendingAdd,
|
||||
onSelect: () => runCheckout(viewItem),
|
||||
},
|
||||
{
|
||||
key: "add",
|
||||
label: "新增本地文件到服务器",
|
||||
hidden: !viewItem.item.folder,
|
||||
disabled: !viewItem.mapped,
|
||||
onSelect: () => setCompareTarget(viewItem),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
disabled: pendingAdd,
|
||||
danger: true,
|
||||
onSelect: () => runDelete(viewItem),
|
||||
},
|
||||
{
|
||||
key: "undo",
|
||||
label: "撤销挂起更改",
|
||||
disabled: !viewItem.pending,
|
||||
onSelect: () => runUndo(viewItem),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "查看历史",
|
||||
onSelect: () => openHistory(viewItem.item.serverPath, viewItem.item.folder),
|
||||
},
|
||||
{
|
||||
key: "compareFolder",
|
||||
label: "与服务器目录对比",
|
||||
hidden: !viewItem.item.folder,
|
||||
disabled: !viewItem.mapped,
|
||||
onSelect: () => setCompareTarget(viewItem),
|
||||
},
|
||||
{
|
||||
key: "diffLatest",
|
||||
label: "比较本地与服务器 Latest",
|
||||
hidden: viewItem.item.folder || pendingAdd,
|
||||
disabled: !viewItem.mapped,
|
||||
onSelect: () =>
|
||||
setDiffRequest({
|
||||
type: "localLatest",
|
||||
serverPath: viewItem.item.serverPath,
|
||||
localPath: viewItem.localPath,
|
||||
label: `${viewItem.item.serverPath} 本地 ↔ Latest`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "mapping",
|
||||
label: "映射到本地",
|
||||
hidden: viewItem.mapped,
|
||||
disabled: !viewItem.item.folder,
|
||||
onSelect: () => setMappingPath(viewItem.item.serverPath),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
未连接
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!serverPath) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
未选择服务端目录
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<ContextMenu menu={menu} onClose={() => setMenu(undefined)} />
|
||||
|
||||
<div className="flex h-9 items-center justify-between gap-3 border-b bg-muted/10 px-3">
|
||||
<div className="min-w-0 truncate font-mono text-xs text-muted-foreground">
|
||||
{serverPath}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!currentMapping && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => setMappingPath(serverPath)}
|
||||
>
|
||||
映射到本地
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
title="刷新当前目录"
|
||||
onClick={loadFolderItems}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 items-center gap-3 border-b px-3 text-xs text-muted-foreground">
|
||||
<span className="truncate font-mono">
|
||||
{currentMapping ? currentDirectoryLocalPath : "当前目录未映射"}
|
||||
</span>
|
||||
{message && <span className="truncate text-destructive">{message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid h-8 grid-cols-[minmax(170px,1fr)_80px_minmax(260px,1.3fr)_minmax(220px,1.1fr)_100px] 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">
|
||||
{viewItems.length === 0 && !loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
当前目录无可显示项
|
||||
</div>
|
||||
) : (
|
||||
viewItems.map((viewItem) => (
|
||||
<div
|
||||
key={viewItem.item.serverPath}
|
||||
className={`grid h-8 grid-cols-[minmax(170px,1fr)_80px_minmax(260px,1.3fr)_minmax(220px,1.1fr)_100px] items-center border-b px-3 text-left text-xs ${
|
||||
selectedPath === viewItem.item.serverPath
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => setSelectedPath(viewItem.item.serverPath)}
|
||||
onDoubleClick={() =>
|
||||
viewItem.item.folder && onPathEnter(viewItem.item.serverPath)
|
||||
}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setSelectedPath(viewItem.item.serverPath)
|
||||
setMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
items: buildMenuItems(viewItem),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{operationPath === viewItem.item.serverPath ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
) : viewItem.item.folder ? (
|
||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate" title={viewItem.item.name}>
|
||||
{viewItem.item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{viewItem.item.folder ? "目录" : "文件"}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-muted-foreground"
|
||||
title={viewItem.item.serverPath}
|
||||
>
|
||||
{viewItem.item.serverPath}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-muted-foreground"
|
||||
title={viewItem.localPath}
|
||||
>
|
||||
{viewItem.localPath}
|
||||
</div>
|
||||
<div className="truncate">{viewItem.status}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SimpleDialog
|
||||
open={Boolean(mappingPath)}
|
||||
title="Mapping"
|
||||
description={mappingPath}
|
||||
className="w-[min(680px,calc(100vw-48px))]"
|
||||
onClose={() => setMappingPath("")}
|
||||
>
|
||||
<MappingDialog
|
||||
open={Boolean(mappingPath)}
|
||||
serverPath={mappingPath}
|
||||
onClose={() => setMappingPath("")}
|
||||
onSaved={loadFolderItems}
|
||||
/>
|
||||
</SimpleDialog>
|
||||
|
||||
<SimpleDialog
|
||||
open={Boolean(historyTarget)}
|
||||
title="History"
|
||||
description={historyTarget?.label}
|
||||
onClose={() => setHistoryTarget(undefined)}
|
||||
>
|
||||
{historyTarget && (
|
||||
<HistoryPanel
|
||||
path={historyTarget.path}
|
||||
folder={historyTarget.folder}
|
||||
label={historyTarget.label}
|
||||
onOpenDiff={setDiffRequest}
|
||||
onClose={() => setHistoryTarget(undefined)}
|
||||
/>
|
||||
)}
|
||||
</SimpleDialog>
|
||||
|
||||
<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={fileViewTarget.preferLocal}
|
||||
onClose={() => setFileViewTarget(undefined)}
|
||||
/>
|
||||
)}
|
||||
</SimpleDialog>
|
||||
|
||||
<SimpleDialog
|
||||
open={Boolean(compareTarget)}
|
||||
title="Folder Compare"
|
||||
description={compareTarget?.item.serverPath}
|
||||
onClose={() => setCompareTarget(undefined)}
|
||||
>
|
||||
{compareTarget && (
|
||||
<FolderCompareDialog
|
||||
open={Boolean(compareTarget)}
|
||||
serverPath={compareTarget.item.serverPath}
|
||||
localPath={compareTarget.localPath}
|
||||
onPendingChangesRefresh={onPendingChangesRefresh}
|
||||
onOpenDiff={(diff: FolderDiffItem) =>
|
||||
setDiffRequest({
|
||||
type: "localLatest",
|
||||
serverPath: diff.serverPath,
|
||||
localPath: diff.localPath,
|
||||
label: `${diff.serverPath} 本地 ↔ Latest`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SimpleDialog>
|
||||
|
||||
<SimpleDialog
|
||||
open={Boolean(diffRequest)}
|
||||
title="Diff"
|
||||
description={diffRequest?.label}
|
||||
onClose={() => setDiffRequest(undefined)}
|
||||
>
|
||||
{diffRequest && (
|
||||
<DiffPanel
|
||||
request={diffRequest}
|
||||
onClose={() => setDiffRequest(undefined)}
|
||||
/>
|
||||
)}
|
||||
</SimpleDialog>
|
||||
|
||||
<SimpleDialog
|
||||
open={conflicts.length > 0}
|
||||
title="Resolve Conflicts"
|
||||
onClose={() => setConflicts([])}
|
||||
>
|
||||
<ConflictDialog
|
||||
open={conflicts.length > 0}
|
||||
conflicts={conflicts}
|
||||
onOpenDiff={(conflict) =>
|
||||
conflict.localPath &&
|
||||
setDiffRequest({
|
||||
type: "localLatest",
|
||||
serverPath: conflict.serverPath,
|
||||
localPath: conflict.localPath,
|
||||
label: `${conflict.serverPath} 本地 ↔ Latest`,
|
||||
})
|
||||
}
|
||||
onApplied={() => {
|
||||
setConflicts([])
|
||||
onPendingChangesRefresh()
|
||||
loadFolderItems()
|
||||
}}
|
||||
/>
|
||||
</SimpleDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
GitCompareArrows,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
|
||||
import { queryChangesetFiles, queryHistory } from "~/lib/api/endpoints"
|
||||
import type { TfsHistoryEntry } from "~/lib/api/types"
|
||||
|
||||
interface HistoryPanelProps {
|
||||
path: string
|
||||
folder: boolean
|
||||
label: string
|
||||
onOpenDiff(request: DiffPanelRequest): void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示文件或目录历史,并支持目录 changeset 文件列表和历史 diff 入口。
|
||||
*/
|
||||
export function HistoryPanel({
|
||||
path,
|
||||
folder,
|
||||
label,
|
||||
onOpenDiff,
|
||||
onClose,
|
||||
}: HistoryPanelProps) {
|
||||
const [history, setHistory] = useState<TfsHistoryEntry[]>([])
|
||||
const [changesetFiles, setChangesetFiles] = useState<TfsHistoryEntry[]>([])
|
||||
const [selectedChangeset, setSelectedChangeset] = useState<number>()
|
||||
const [selectedRevisionKeys, setSelectedRevisionKeys] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filesLoading, setFilesLoading] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [diffMessage, setDiffMessage] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [path, folder])
|
||||
|
||||
const selectedRevisions = history.filter((entry) =>
|
||||
selectedRevisionKeys.includes(toHistoryKey(entry))
|
||||
)
|
||||
|
||||
/**
|
||||
* 查询当前文件或目录最近 100 条历史记录。
|
||||
*/
|
||||
async function loadHistory() {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
setDiffMessage("")
|
||||
setChangesetFiles([])
|
||||
setSelectedChangeset(undefined)
|
||||
setSelectedRevisionKeys([])
|
||||
|
||||
const result = await queryHistory(path, folder)
|
||||
setLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setHistory([])
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
setHistory(result.data.history)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询目录历史中某个 changeset 影响的文件列表。
|
||||
*/
|
||||
async function loadChangesetFiles(changeset: number) {
|
||||
setFilesLoading(true)
|
||||
setMessage("")
|
||||
setDiffMessage("")
|
||||
setSelectedChangeset(changeset)
|
||||
|
||||
const result = await queryChangesetFiles(changeset)
|
||||
setFilesLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setChangesetFiles([])
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
setChangesetFiles(result.data.files)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 changeset 作为历史版本选择 key。
|
||||
*/
|
||||
function toHistoryKey(entry: TfsHistoryEntry) {
|
||||
return String(entry.changeset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择最多两个文件历史版本,供后续 Diff 面板使用。
|
||||
*/
|
||||
function toggleRevision(entry: TfsHistoryEntry, checked: boolean) {
|
||||
const key = toHistoryKey(entry)
|
||||
setSelectedRevisionKeys((currentKeys) => {
|
||||
if (!checked) {
|
||||
return currentKeys.filter((currentKey) => currentKey !== key)
|
||||
}
|
||||
if (currentKeys.includes(key)) {
|
||||
return currentKeys
|
||||
}
|
||||
return [...currentKeys, key].slice(-2)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化后端毫秒时间戳。
|
||||
*/
|
||||
function formatHistoryDate(date?: number | null) {
|
||||
return date ? new Date(date).toLocaleString() : "-"
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开用户选择的两个历史版本 diff。
|
||||
*/
|
||||
function enterHistoryDiff() {
|
||||
if (selectedRevisions.length !== 2) {
|
||||
return
|
||||
}
|
||||
|
||||
onOpenDiff({
|
||||
type: "revisions",
|
||||
serverPath: path,
|
||||
sourceChangeset: selectedRevisions[0].changeset,
|
||||
targetChangeset: selectedRevisions[1].changeset,
|
||||
label: `${path};C${selectedRevisions[0].changeset} ↔ C${selectedRevisions[1].changeset}`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 changeset 文件列表中的单文件 diff 入口。
|
||||
*/
|
||||
function enterChangesetFileDiff(file: TfsHistoryEntry) {
|
||||
setDiffMessage(
|
||||
`已选择 ${file.serverPath} @${file.changeset}。请打开该文件历史并选择两个版本进入 Diff。`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b bg-background">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">History</div>
|
||||
<div className="truncate font-mono text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
title="刷新历史"
|
||||
onClick={loadHistory}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffMessage && (
|
||||
<div className="border-t bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
{diffMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid max-h-80 border-t md:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="min-h-0 overflow-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="grid h-7 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center">Diff</div>
|
||||
<div className="flex items-center">Changeset</div>
|
||||
<div className="flex items-center">变更</div>
|
||||
<div className="flex items-center">作者</div>
|
||||
<div className="flex items-center">时间</div>
|
||||
<div className="flex items-center">Comment</div>
|
||||
</div>
|
||||
|
||||
{history.length === 0 && !loading ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground">
|
||||
暂无历史记录。
|
||||
</div>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<div
|
||||
key={toHistoryKey(entry)}
|
||||
className={`grid min-h-8 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] items-center border-b px-3 text-xs ${
|
||||
selectedChangeset === entry.changeset
|
||||
? "bg-primary/10"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={folder}
|
||||
checked={selectedRevisionKeys.includes(
|
||||
toHistoryKey(entry)
|
||||
)}
|
||||
aria-label={`选择 changeset ${entry.changeset}`}
|
||||
onChange={(event) =>
|
||||
toggleRevision(entry, event.target.checked)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="font-mono">{entry.changeset}</div>
|
||||
<div className="truncate text-muted-foreground">
|
||||
{entry.changeType || "-"}
|
||||
</div>
|
||||
<div className="truncate">{entry.author || "-"}</div>
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatHistoryDate(entry.date)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="truncate">{entry.comment || "-"}</span>
|
||||
{folder && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => loadChangesetFiles(entry.changeset)}
|
||||
>
|
||||
文件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 border-t bg-muted/10 p-2 text-xs md:border-t-0 md:border-l">
|
||||
{folder ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
Changeset {selectedChangeset ?? "-"}
|
||||
</span>
|
||||
{filesLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
</div>
|
||||
{changesetFiles.length === 0 ? (
|
||||
<div className="text-muted-foreground">
|
||||
点击目录历史 changeset 查看影响文件。
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-64 gap-2 overflow-auto">
|
||||
{changesetFiles.map((file) => (
|
||||
<div
|
||||
key={`${file.changeset}:${file.serverPath}`}
|
||||
className="grid gap-1 border-b pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{file.itemType.toLowerCase().includes("folder") ? (
|
||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono">
|
||||
{file.serverPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{file.changeType || "-"}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => enterChangesetFileDiff(file)}
|
||||
>
|
||||
<GitCompareArrows />
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<div className="font-medium">历史 Diff</div>
|
||||
<div className="text-muted-foreground">
|
||||
选择两个历史版本后进入 Diff。
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={selectedRevisions.length !== 2}
|
||||
onClick={enterHistoryDiff}
|
||||
>
|
||||
<GitCompareArrows />
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { listServerTree } from "~/lib/api/endpoints"
|
||||
import type { TfsServerItem } from "~/lib/api/types"
|
||||
|
||||
interface ServerTreePanelProps {
|
||||
connected: boolean
|
||||
collection: string
|
||||
selectedPath: string
|
||||
onPathSelect(path: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染固定 Collection 下的服务端目录树,并在展开目录时按需请求子节点。
|
||||
*/
|
||||
export function ServerTreePanel({
|
||||
connected,
|
||||
collection,
|
||||
selectedPath,
|
||||
onPathSelect,
|
||||
}: ServerTreePanelProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
|
||||
const [itemsByPath, setItemsByPath] = useState<
|
||||
Record<string, TfsServerItem[]>
|
||||
>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !collection) {
|
||||
setExpandedPaths([])
|
||||
setItemsByPath({})
|
||||
return
|
||||
}
|
||||
|
||||
setExpandedPaths(["$/"])
|
||||
setItemsByPath({})
|
||||
loadServerItems(collection, "$/")
|
||||
}, [connected, collection])
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && collection && selectedPath) {
|
||||
expandToPath(selectedPath)
|
||||
}
|
||||
}, [connected, collection, selectedPath])
|
||||
|
||||
/**
|
||||
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
|
||||
*/
|
||||
async function loadServerItems(collection: string, path: string) {
|
||||
setLoading(true)
|
||||
setErrorMessage("")
|
||||
|
||||
const result = await listServerTree(path, collection)
|
||||
setLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
setItemsByPath((currentItems) => ({
|
||||
...currentItems,
|
||||
[path]: result.data.items,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据中间文件列表进入的目录路径,展开并选中左侧树对应父级。
|
||||
*/
|
||||
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) {
|
||||
onPathSelect(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开或收起目录;首次展开时才加载子节点。
|
||||
*/
|
||||
async function toggleFolder(item: TfsServerItem) {
|
||||
selectPath(item.serverPath)
|
||||
if (!item.folder || !collection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (expandedPaths.includes(item.serverPath)) {
|
||||
setExpandedPaths((currentPaths) =>
|
||||
currentPaths.filter((path) => path !== item.serverPath)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
|
||||
if (!itemsByPath[item.serverPath]) {
|
||||
await loadServerItems(collection, item.serverPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按层级渲染目录树节点。
|
||||
*/
|
||||
function renderItems(path: string, level = 0) {
|
||||
const items = itemsByPath[path] || []
|
||||
|
||||
return items.map((item) => {
|
||||
const expanded = expandedPaths.includes(item.serverPath)
|
||||
const selected = selectedPath === item.serverPath
|
||||
|
||||
return (
|
||||
<div key={item.serverPath}>
|
||||
<button
|
||||
className={`flex h-7 w-full items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
|
||||
selected ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + level * 14 }}
|
||||
onClick={() => toggleFolder(item)}
|
||||
>
|
||||
{item.folder ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
{item.folder ? (
|
||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{item.name}</span>
|
||||
</button>
|
||||
|
||||
{item.folder && expanded && renderItems(item.serverPath, level + 1)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (!connected || !collection) {
|
||||
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1">
|
||||
<div className="truncate rounded-[6px] bg-background px-2 py-1.5 text-xs font-medium">
|
||||
{collection}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0.5 border-t pt-2">
|
||||
<button
|
||||
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left font-mono text-xs ${
|
||||
selectedPath === "$/"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => {
|
||||
selectPath("$/")
|
||||
if (collection && !itemsByPath["$/"]) {
|
||||
loadServerItems(collection, "$/")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||
$/
|
||||
</button>
|
||||
{renderItems("$/")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} 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 { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
|
||||
import type {
|
||||
PendingChangeStatus,
|
||||
TfsPendingChangeInfo,
|
||||
} from "~/lib/api/types"
|
||||
|
||||
interface PendingChangesPanelProps {
|
||||
connected: boolean
|
||||
collection: string
|
||||
refreshKey: number
|
||||
onOpenFile?(change: TfsPendingChangeInfo): void
|
||||
onOpenDiff?(change: TfsPendingChangeInfo): void
|
||||
onOpenHistory?(change: TfsPendingChangeInfo): void
|
||||
onCheckinSuccess?(): void
|
||||
}
|
||||
|
||||
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
|
||||
pendingEdit: "编辑",
|
||||
pendingAdd: "新增",
|
||||
pendingDelete: "删除",
|
||||
pendingRename: "重命名",
|
||||
pending: "挂起",
|
||||
}
|
||||
|
||||
const PENDING_STATUS_CLASSES: Record<PendingChangeStatus, string> = {
|
||||
pendingEdit: "border-blue-200 bg-blue-50 text-blue-700",
|
||||
pendingAdd: "border-green-200 bg-green-50 text-green-700",
|
||||
pendingDelete: "border-red-200 bg-red-50 text-red-700",
|
||||
pendingRename: "border-violet-200 bg-violet-50 text-violet-700",
|
||||
pending: "border-border bg-muted/30 text-muted-foreground",
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Inspector 中展示当前 Workspace 的 Included / Excluded Pending Changes。
|
||||
*/
|
||||
export function PendingChangesPanel({
|
||||
connected,
|
||||
collection,
|
||||
refreshKey,
|
||||
onOpenFile,
|
||||
onOpenDiff,
|
||||
onOpenHistory,
|
||||
onCheckinSuccess,
|
||||
}: PendingChangesPanelProps) {
|
||||
const [pendingChanges, setPendingChanges] = useState<
|
||||
TfsPendingChangeInfo[]
|
||||
>([])
|
||||
const [includedKeys, setIncludedKeys] = useState<string[]>([])
|
||||
const [excludedKeys, setExcludedKeys] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [operationKey, setOperationKey] = useState("")
|
||||
const [checkinComment, setCheckinComment] = useState("")
|
||||
const [checkinLoading, setCheckinLoading] = useState(false)
|
||||
const [messageType, setMessageType] = useState<"success" | "error">(
|
||||
"success"
|
||||
)
|
||||
const [message, setMessage] = useState("")
|
||||
const [menu, setMenu] = useState<ContextMenuState>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !collection) {
|
||||
setPendingChanges([])
|
||||
setIncludedKeys([])
|
||||
setExcludedKeys([])
|
||||
setCheckinComment("")
|
||||
setCheckinLoading(false)
|
||||
setMessageType("success")
|
||||
setMessage("")
|
||||
return
|
||||
}
|
||||
|
||||
loadPendingChanges()
|
||||
}, [connected, collection, refreshKey])
|
||||
|
||||
const includedChanges = pendingChanges.filter((change) =>
|
||||
includedKeys.includes(toPendingChangeKey(change))
|
||||
)
|
||||
const excludedChanges = pendingChanges.filter((change) =>
|
||||
excludedKeys.includes(toPendingChangeKey(change))
|
||||
)
|
||||
const collectionReady = connected && Boolean(collection)
|
||||
const canCheckin =
|
||||
includedChanges.length > 0 &&
|
||||
checkinComment.trim().length > 0 &&
|
||||
!checkinLoading
|
||||
|
||||
/**
|
||||
* 查询后端 pending changes,并保留当前会话内用户移动到 Excluded 的选择。
|
||||
*/
|
||||
async function loadPendingChanges() {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
|
||||
const result = await listPendingChanges({ collection })
|
||||
setLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setPendingChanges([])
|
||||
setIncludedKeys([])
|
||||
setExcludedKeys([])
|
||||
setMessageType("error")
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
const nextChanges = result.data.pendingChanges
|
||||
const nextKeys = nextChanges.map(toPendingChangeKey)
|
||||
setPendingChanges(nextChanges)
|
||||
setExcludedKeys((currentKeys) =>
|
||||
currentKeys.filter((currentKey) => nextKeys.includes(currentKey))
|
||||
)
|
||||
setIncludedKeys((currentKeys) => {
|
||||
const knownKeys = new Set([...currentKeys, ...excludedKeys])
|
||||
const excludedKeySet = new Set(excludedKeys)
|
||||
const includedKeySet = new Set(currentKeys)
|
||||
|
||||
return nextKeys.filter((key) => {
|
||||
if (excludedKeySet.has(key)) {
|
||||
return false
|
||||
}
|
||||
return knownKeys.has(key) ? includedKeySet.has(key) : true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用服务端路径作为 Pending Change 的会话 key。
|
||||
*/
|
||||
function toPendingChangeKey(change: TfsPendingChangeInfo) {
|
||||
return change.serverPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个挂起更改移动到 Excluded Changes。
|
||||
*/
|
||||
function moveToExcluded(change: TfsPendingChangeInfo) {
|
||||
const key = toPendingChangeKey(change)
|
||||
setIncludedKeys((currentKeys) =>
|
||||
currentKeys.filter((currentKey) => currentKey !== key)
|
||||
)
|
||||
setExcludedKeys((currentKeys) =>
|
||||
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个挂起更改移回 Included Changes,后续签入只读取 Included。
|
||||
*/
|
||||
function moveToIncluded(change: TfsPendingChangeInfo) {
|
||||
const key = toPendingChangeKey(change)
|
||||
setExcludedKeys((currentKeys) =>
|
||||
currentKeys.filter((currentKey) => currentKey !== key)
|
||||
)
|
||||
setIncludedKeys((currentKeys) =>
|
||||
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销指定 pending change,完成后刷新右侧挂起更改列表。
|
||||
*/
|
||||
async function undoPendingChange(change: TfsPendingChangeInfo) {
|
||||
const key = toPendingChangeKey(change)
|
||||
setOperationKey(key)
|
||||
setMessage("")
|
||||
|
||||
const result = await undoFiles({
|
||||
paths: [change.serverPath],
|
||||
recursive: change.folder,
|
||||
})
|
||||
setOperationKey("")
|
||||
|
||||
if (!result.success) {
|
||||
setMessage(result.errorMessage || result.message)
|
||||
setMessageType("error")
|
||||
return
|
||||
}
|
||||
|
||||
setMessageType("success")
|
||||
setMessage(`Undo 完成,影响 ${result.data.result?.affected ?? 0} 项。`)
|
||||
await loadPendingChanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Included Changes,成功后清空 Included 并保留 Excluded 会话状态。
|
||||
*/
|
||||
async function submitIncludedChanges() {
|
||||
const comment = checkinComment.trim()
|
||||
if (!comment) {
|
||||
setMessageType("error")
|
||||
setMessage("Comment 必填。")
|
||||
return
|
||||
}
|
||||
if (includedChanges.length === 0) {
|
||||
setMessageType("error")
|
||||
setMessage("没有可签入的 Included Changes。")
|
||||
return
|
||||
}
|
||||
|
||||
setCheckinLoading(true)
|
||||
setMessage("")
|
||||
|
||||
const result = await checkin({
|
||||
serverPaths: includedChanges.map((change) => change.serverPath),
|
||||
comment,
|
||||
})
|
||||
setCheckinLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setMessageType("error")
|
||||
setMessage(result.errorMessage || result.message)
|
||||
return
|
||||
}
|
||||
|
||||
setCheckinComment("")
|
||||
await loadPendingChanges()
|
||||
onCheckinSuccess?.()
|
||||
setIncludedKeys([])
|
||||
setMessageType("success")
|
||||
setMessage(
|
||||
`Checkin 成功,changeset ${result.data.checkin?.changeset ?? "-"},提交 ${result.data.checkin?.submittedChanges ?? 0} 项。`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<ContextMenu menu={menu} onClose={() => setMenu(undefined)} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Pending Changes</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
签入范围:Included Changes
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{pendingChanges.length} 项
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled={!collectionReady || loading || checkinLoading}
|
||||
title="刷新挂起更改"
|
||||
onClick={loadPendingChanges}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collectionReady ? (
|
||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{connected ? "请选择 Collection" : "未连接"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-[6px] border px-3 py-2 text-xs ${
|
||||
messageType === "error"
|
||||
? "border-destructive/30 bg-destructive/10 text-destructive"
|
||||
: "bg-muted/20 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PendingChangeGroup
|
||||
title="Included Changes"
|
||||
changes={includedChanges}
|
||||
emptyText={loading ? "正在查询挂起更改" : "暂无 Included Changes"}
|
||||
actionTitle="移到 Excluded"
|
||||
actionIcon="down"
|
||||
operationKey={operationKey}
|
||||
disabled={checkinLoading}
|
||||
onUndo={undoPendingChange}
|
||||
onMove={moveToExcluded}
|
||||
onContextMenu={(event, change) =>
|
||||
openPendingContextMenu(event, change, "included")
|
||||
}
|
||||
/>
|
||||
|
||||
<PendingChangeGroup
|
||||
title="Excluded Changes"
|
||||
changes={excludedChanges}
|
||||
emptyText="暂无 Excluded Changes"
|
||||
actionTitle="移回 Included"
|
||||
actionIcon="up"
|
||||
operationKey={operationKey}
|
||||
disabled={checkinLoading}
|
||||
onUndo={undoPendingChange}
|
||||
onMove={moveToIncluded}
|
||||
onContextMenu={(event, change) =>
|
||||
openPendingContextMenu(event, change, "excluded")
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">Checkin</span>
|
||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
|
||||
Included {includedChanges.length}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-20 rounded-[6px] border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
value={checkinComment}
|
||||
placeholder="Comment"
|
||||
disabled={checkinLoading}
|
||||
onChange={(event) => setCheckinComment(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCheckin}
|
||||
onClick={submitIncludedChanges}
|
||||
>
|
||||
{checkinLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Upload />
|
||||
)}
|
||||
Checkin
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
只提交 Included Changes,Excluded 保留在当前 UI 会话。
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
/**
|
||||
* 打开 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 {
|
||||
title: string
|
||||
changes: TfsPendingChangeInfo[]
|
||||
emptyText: string
|
||||
actionTitle: string
|
||||
actionIcon: "up" | "down"
|
||||
operationKey: string
|
||||
disabled: boolean
|
||||
onUndo(change: TfsPendingChangeInfo): void
|
||||
onMove(change: TfsPendingChangeInfo): void
|
||||
onContextMenu(event: React.MouseEvent, change: TfsPendingChangeInfo): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Pending Changes 单个分组,并提供组间移动入口。
|
||||
*/
|
||||
function PendingChangeGroup({
|
||||
title,
|
||||
changes,
|
||||
emptyText,
|
||||
actionTitle,
|
||||
actionIcon,
|
||||
operationKey,
|
||||
disabled,
|
||||
onUndo,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: PendingChangeGroupProps) {
|
||||
return (
|
||||
<section className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">{title}</span>
|
||||
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
|
||||
{changes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{changes.length === 0 ? (
|
||||
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-56 gap-2 overflow-auto">
|
||||
{changes.map((change) => (
|
||||
<div
|
||||
key={change.serverPath}
|
||||
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
|
||||
onContextMenu={(event) => onContextMenu(event, change)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{change.folder ? (
|
||||
<Folder className="size-3.5 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-medium">{change.name}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="Undo"
|
||||
disabled={disabled || operationKey === change.serverPath}
|
||||
onClick={() => onUndo(change)}
|
||||
>
|
||||
{operationKey === change.serverPath ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title={actionTitle}
|
||||
disabled={disabled || operationKey === change.serverPath}
|
||||
onClick={() => onMove(change)}
|
||||
>
|
||||
{actionIcon === "down" ? <ArrowDown /> : <ArrowUp />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{change.serverPath}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded-[6px] border px-1.5 py-0.5 ${PENDING_STATUS_CLASSES[change.status]}`}
|
||||
>
|
||||
{change.status} / {PENDING_STATUS_LABELS[change.status]}
|
||||
</span>
|
||||
{change.changeType && (
|
||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-muted-foreground">
|
||||
{change.changeType}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 font-mono text-muted-foreground">
|
||||
v{change.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getMactfsBridge } from "~/lib/electron/bridge"
|
||||
|
||||
import type { ApiResult } from "./types"
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:38765"
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
|
||||
body?: unknown
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type ApiRequestLogStatus = "running" | "success" | "error"
|
||||
|
||||
export interface ApiRequestLogEntry {
|
||||
id: string
|
||||
operation: string
|
||||
summary: string
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
durationMs?: number
|
||||
success?: boolean
|
||||
errorMessage?: string
|
||||
status: ApiRequestLogStatus
|
||||
logs?: string[]
|
||||
}
|
||||
|
||||
type ApiRequestLogListener = (entry: ApiRequestLogEntry) => void
|
||||
|
||||
const apiRequestLogListeners = new Set<ApiRequestLogListener>()
|
||||
let apiRequestLogIndex = 0
|
||||
|
||||
/**
|
||||
* 订阅统一 API client 的请求日志,供底部 Console 展示每次操作状态。
|
||||
*/
|
||||
export function subscribeApiRequestLogs(listener: ApiRequestLogListener) {
|
||||
apiRequestLogListeners.add(listener)
|
||||
|
||||
return () => {
|
||||
apiRequestLogListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向所有日志订阅方发布一条请求状态更新。
|
||||
*/
|
||||
function notifyApiRequestLog(entry: ApiRequestLogEntry) {
|
||||
apiRequestLogListeners.forEach((listener) => listener(entry))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本次请求的前端日志 id,用于 running 和完成态更新同一行。
|
||||
*/
|
||||
function nextApiRequestLogId(apiPath: string) {
|
||||
apiRequestLogIndex += 1
|
||||
return `${Date.now()}:${apiRequestLogIndex}:${apiPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求体或 query 中提取路径摘要,便于 Console 快速定位操作对象。
|
||||
*/
|
||||
function summarizeApiRequest(apiPath: string, body?: unknown) {
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
const bodySummary = summarizeRequestBody(body as Record<string, unknown>)
|
||||
if (bodySummary) {
|
||||
return bodySummary
|
||||
}
|
||||
}
|
||||
|
||||
const query = apiPath.split("?")[1]
|
||||
return query || apiPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 按后端常用字段生成请求体摘要。
|
||||
*/
|
||||
function summarizeRequestBody(body: Record<string, unknown>) {
|
||||
return [
|
||||
"serverPath",
|
||||
"path",
|
||||
"localPath",
|
||||
"paths",
|
||||
"serverPaths",
|
||||
"changeset",
|
||||
"sourceChangeset",
|
||||
"targetChangeset",
|
||||
]
|
||||
.filter((key) => body[key] !== undefined && body[key] !== "")
|
||||
.map((key) => `${key}=${formatSummaryValue(body[key])}`)
|
||||
.join(" | ")
|
||||
}
|
||||
|
||||
/**
|
||||
* 将摘要字段值转换成单行文本。
|
||||
*/
|
||||
function formatSummaryValue(value: unknown) {
|
||||
return Array.isArray(value) ? value.join(", ") : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建前端统一失败响应,供网络或 Electron 桥接不可用时展示明确提示。
|
||||
*/
|
||||
function createFailureResult<TData>(
|
||||
message: string,
|
||||
startedAt = Date.now()
|
||||
): ApiResult<TData> {
|
||||
const endedAt = Date.now()
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
errorMessage: message,
|
||||
operation: "frontendRequest",
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: endedAt - startedAt,
|
||||
logs: [],
|
||||
data: {} as TData,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将统一 API 响应转换为底部 Console 可展示的日志行。
|
||||
*/
|
||||
function toApiRequestLogEntry(
|
||||
id: string,
|
||||
summary: string,
|
||||
fallbackStartedAt: number,
|
||||
result: ApiResult<unknown>
|
||||
): ApiRequestLogEntry {
|
||||
return {
|
||||
id,
|
||||
operation: result.operation,
|
||||
summary,
|
||||
startedAt: result.startedAt || fallbackStartedAt,
|
||||
endedAt: result.endedAt,
|
||||
durationMs: result.durationMs,
|
||||
success: result.success,
|
||||
errorMessage: result.errorMessage || (result.success ? "" : result.message),
|
||||
status: result.success ? "success" : "error",
|
||||
logs: result.logs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过统一 API client 调用本地 Java API,并自动附加 Bearer token。
|
||||
*/
|
||||
export async function apiRequest<TData>(
|
||||
apiPath: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<ApiResult<TData>> {
|
||||
const startedAt = Date.now()
|
||||
const logId = nextApiRequestLogId(apiPath)
|
||||
const summary = summarizeApiRequest(apiPath, options.body)
|
||||
|
||||
notifyApiRequestLog({
|
||||
id: logId,
|
||||
operation: apiPath,
|
||||
summary,
|
||||
startedAt,
|
||||
status: "running",
|
||||
})
|
||||
|
||||
const bridge = getMactfsBridge()
|
||||
if (!bridge) {
|
||||
const result = createFailureResult<TData>(
|
||||
"未检测到 Electron preload,无法读取本地 API token。",
|
||||
startedAt
|
||||
)
|
||||
notifyApiRequestLog(
|
||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
const token = await bridge.getToken()
|
||||
if (!token) {
|
||||
const result = createFailureResult<TData>(
|
||||
"未找到本地 API token,请确认本地服务已启动。",
|
||||
startedAt
|
||||
)
|
||||
notifyApiRequestLog(
|
||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = new Headers(options.headers)
|
||||
headers.set("Authorization", `Bearer ${token}`)
|
||||
if (options.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
|
||||
...options,
|
||||
headers,
|
||||
body:
|
||||
options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
})
|
||||
|
||||
const result = (await response.json()) as ApiResult<TData>
|
||||
notifyApiRequestLog(
|
||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
const result = createFailureResult<TData>(
|
||||
error instanceof Error ? error.message : "本地 API 请求失败。",
|
||||
startedAt
|
||||
)
|
||||
notifyApiRequestLog(
|
||||
toApiRequestLogEntry(logId, summary, startedAt, result)
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { apiRequest } from "./client"
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
AddMappingData,
|
||||
AddMappingRequest,
|
||||
ApplyConflictChoiceRequest,
|
||||
CheckinData,
|
||||
CheckinRequest,
|
||||
ChangesetFilesData,
|
||||
CompareFolderData,
|
||||
CompareFolderRequest,
|
||||
CollectionsData,
|
||||
FileOperationData,
|
||||
FileOperationRequest,
|
||||
ConfigData,
|
||||
ConnectData,
|
||||
DiffLocalLatestRequest,
|
||||
DiffRevisionsRequest,
|
||||
FileContentData,
|
||||
FileContentRequest,
|
||||
GetLatestData,
|
||||
GetLatestRequest,
|
||||
HistoryData,
|
||||
MappingsData,
|
||||
MappingsRequest,
|
||||
OperationLogsData,
|
||||
PendingChangesData,
|
||||
PendingChangesRequest,
|
||||
ServerTreeData,
|
||||
TextDiffData,
|
||||
WorkspaceContextData,
|
||||
WorkspaceContextRequest,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 读取后端保存的默认连接配置,用于再次打开时回填表单。
|
||||
*/
|
||||
export function getConfig() {
|
||||
return apiRequest<ConfigData>("/api/config")
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交连接配置给后端;连接成功时后端会保存为默认配置。
|
||||
*/
|
||||
export function connectSession(config: AppConfig) {
|
||||
return apiRequest<ConnectData>("/api/session/connect", {
|
||||
method: "POST",
|
||||
body: config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前连接可见的 TFS Collection 列表。
|
||||
*/
|
||||
export function listCollections() {
|
||||
return apiRequest<CollectionsData>("/api/collections")
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定当前 Collection 并自动使用或创建本机默认 Workspace。
|
||||
*/
|
||||
export function ensureWorkspaceContext(request: WorkspaceContextRequest) {
|
||||
return apiRequest<WorkspaceContextData>("/api/workspace/context", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定 Collection 和服务端路径下的目录节点。
|
||||
*/
|
||||
export function listServerTree(path: string, collection?: string) {
|
||||
const params = new URLSearchParams({ path })
|
||||
if (collection) {
|
||||
params.set("collection", collection)
|
||||
}
|
||||
|
||||
return apiRequest<ServerTreeData>(`/api/server-tree?${params.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前 Workspace 的 Mapping 列表,用于判断服务端项是否已映射到本地。
|
||||
*/
|
||||
export function listMappings(request: MappingsRequest = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (request.collection) {
|
||||
params.set("collection", request.collection)
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return apiRequest<MappingsData>(
|
||||
query ? `/api/mappings?${query}` : "/api/mappings"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定目录下一级文件和文件夹,供中间文件列表展示。
|
||||
*/
|
||||
export function listServerFolderItems(path: string, collection?: string) {
|
||||
const params = new URLSearchParams({ path })
|
||||
if (collection) {
|
||||
params.set("collection", collection)
|
||||
}
|
||||
|
||||
return apiRequest<ServerTreeData>(
|
||||
`/api/server-folder/items?${params.toString()}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务端路径到本地目录的 Mapping,可选择保存后立即 Get Latest。
|
||||
*/
|
||||
export function addMapping(request: AddMappingRequest) {
|
||||
return apiRequest<AddMappingData>("/api/mappings", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已映射目录执行元数据级目录对比,返回差异文件状态列表。
|
||||
*/
|
||||
export function compareFolder(request: CompareFolderRequest) {
|
||||
return apiRequest<CompareFolderData>("/api/compare/folder", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前 Workspace 的挂起更改列表,供右侧 Inspector 分组展示。
|
||||
*/
|
||||
export function listPendingChanges(request: PendingChangesRequest = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (request.collection) {
|
||||
params.set("collection", request.collection)
|
||||
}
|
||||
if (request.serverPath) {
|
||||
params.set("serverPath", request.serverPath)
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return apiRequest<PendingChangesData>(
|
||||
query ? `/api/pending-changes?${query}` : "/api/pending-changes"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定服务端路径的最新版本,目录按 recursive 参数递归。
|
||||
*/
|
||||
export function getLatest(request: GetLatestRequest) {
|
||||
return apiRequest<GetLatestData>("/api/files/get-latest", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取映射目录内本地文件或未映射文件的服务器 latest 内容。
|
||||
*/
|
||||
export function getFileContent(request: FileContentRequest) {
|
||||
return apiRequest<FileContentData>("/api/files/content", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已映射且本地存在的文件或目录执行 checkout。
|
||||
*/
|
||||
export function checkoutFiles(request: FileOperationRequest) {
|
||||
return apiRequest<FileOperationData>("/api/files/checkout", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地新增文件加入 pending add。
|
||||
*/
|
||||
export function addFiles(request: FileOperationRequest) {
|
||||
return apiRequest<FileOperationData>("/api/files/add", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已版本控制文件或目录执行 pending delete。
|
||||
*/
|
||||
export function deleteFiles(request: FileOperationRequest) {
|
||||
return apiRequest<FileOperationData>("/api/files/delete", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销指定路径上的 pending changes。
|
||||
*/
|
||||
export function undoFiles(request: FileOperationRequest) {
|
||||
return apiRequest<FileOperationData>("/api/files/undo", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 Get Latest / Checkout 冲突弹窗中的单文件选择。
|
||||
*/
|
||||
export function applyConflictChoice(request: ApplyConflictChoiceRequest) {
|
||||
return apiRequest<FileOperationData>("/api/conflicts/apply", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
|
||||
*/
|
||||
export function checkin(request: CheckinRequest) {
|
||||
return apiRequest<CheckinData>("/api/checkin", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文件或目录最近历史记录。
|
||||
*/
|
||||
export function queryHistory(path: string, folder: boolean) {
|
||||
const params = new URLSearchParams({ path, folder: String(folder) })
|
||||
return apiRequest<HistoryData>(`/api/history?${params.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定 changeset 影响的文件列表。
|
||||
*/
|
||||
export function queryChangesetFiles(changeset: number) {
|
||||
const params = new URLSearchParams({ changeset: String(changeset) })
|
||||
return apiRequest<ChangesetFilesData>(
|
||||
`/api/history/changeset?${params.toString()}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地文件和服务器 latest 之间的文本 diff。
|
||||
*/
|
||||
export function diffLocalLatest(request: DiffLocalLatestRequest) {
|
||||
return apiRequest<TextDiffData>("/api/diff/local-latest", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同一服务端文件两个历史版本之间的文本 diff。
|
||||
*/
|
||||
export function diffRevisions(request: DiffRevisionsRequest) {
|
||||
return apiRequest<TextDiffData>("/api/diff/revisions", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地 API 服务最近操作日志,供底部 Console 手动刷新。
|
||||
*/
|
||||
export function listOperationLogs() {
|
||||
return apiRequest<OperationLogsData>("/api/logs")
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
export interface ApiResult<TData = Record<string, unknown>> {
|
||||
success: boolean
|
||||
message: string
|
||||
errorMessage?: string
|
||||
operation: string
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
durationMs: number
|
||||
logs: string[]
|
||||
data: TData
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
status: string
|
||||
host: string
|
||||
port: number
|
||||
tokenFile: string
|
||||
configFile: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface MappingConfig {
|
||||
serverPath?: string
|
||||
localPath?: string
|
||||
}
|
||||
|
||||
export interface TfsMappingInfo {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
export interface TfsWorkspaceInfo {
|
||||
name: string
|
||||
ownerName: string
|
||||
computer: string
|
||||
comment: string
|
||||
created: boolean
|
||||
mappings: TfsMappingInfo[]
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
serverUri?: string
|
||||
authType?: string
|
||||
domain?: string
|
||||
username?: string
|
||||
password?: string
|
||||
collection?: string
|
||||
workspace?: string
|
||||
mappings?: MappingConfig[]
|
||||
}
|
||||
|
||||
export interface ConfigData {
|
||||
config: AppConfig
|
||||
}
|
||||
|
||||
export interface ConnectData {
|
||||
serverUri?: string
|
||||
collectionCount?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceContextRequest {
|
||||
collection: string
|
||||
}
|
||||
|
||||
export interface WorkspaceContextData {
|
||||
collection: string
|
||||
workspace?: TfsWorkspaceInfo
|
||||
mappings: TfsMappingInfo[]
|
||||
}
|
||||
|
||||
export interface TfsCollectionInfo {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TfsServerItem {
|
||||
name: string
|
||||
path: string
|
||||
serverPath: string
|
||||
type: "folder" | "file"
|
||||
folder: boolean
|
||||
latestVersion: number
|
||||
checkinDate?: number | null
|
||||
}
|
||||
|
||||
export interface CollectionsData {
|
||||
collections: TfsCollectionInfo[]
|
||||
}
|
||||
|
||||
export interface ServerTreeData {
|
||||
path: string
|
||||
items: TfsServerItem[]
|
||||
}
|
||||
|
||||
export interface MappingsData {
|
||||
mappings: TfsMappingInfo[]
|
||||
}
|
||||
|
||||
export interface MappingsRequest {
|
||||
collection?: string
|
||||
}
|
||||
|
||||
export interface AddMappingRequest {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
getLatest: boolean
|
||||
}
|
||||
|
||||
export interface AddMappingData {
|
||||
mapping?: TfsMappingInfo
|
||||
getLatest?: unknown
|
||||
}
|
||||
|
||||
export type FolderDiffStatus =
|
||||
| "localModified"
|
||||
| "remoteChanged"
|
||||
| "bothChanged"
|
||||
| "localOnly"
|
||||
| "remoteOnly"
|
||||
| "notDownloaded"
|
||||
| "localDeleted"
|
||||
| "pendingEdit"
|
||||
| "pendingAdd"
|
||||
| "pendingDelete"
|
||||
| "upToDate"
|
||||
|
||||
export interface CompareFolderRequest {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
recursive: boolean
|
||||
}
|
||||
|
||||
export interface FolderDiffItem {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: FolderDiffStatus
|
||||
localVersion: number
|
||||
latestVersion: number
|
||||
}
|
||||
|
||||
export interface CompareFolderData {
|
||||
diffs: FolderDiffItem[]
|
||||
}
|
||||
|
||||
export type PendingChangeStatus =
|
||||
| "pendingEdit"
|
||||
| "pendingAdd"
|
||||
| "pendingDelete"
|
||||
| "pendingRename"
|
||||
| "pending"
|
||||
|
||||
export interface TfsPendingChangeInfo {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: PendingChangeStatus
|
||||
changeType: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface PendingChangesData {
|
||||
pendingChanges: TfsPendingChangeInfo[]
|
||||
}
|
||||
|
||||
export interface PendingChangesRequest {
|
||||
collection?: string
|
||||
serverPath?: string
|
||||
}
|
||||
|
||||
export interface GetLatestRequest {
|
||||
serverPath: string
|
||||
recursive: boolean
|
||||
}
|
||||
|
||||
export interface TfsGetLatestResult {
|
||||
updated: number
|
||||
operations: number
|
||||
conflicts: number
|
||||
failures: number
|
||||
skipped?: string[]
|
||||
conflictDetails?: TfsConflictInfo[]
|
||||
}
|
||||
|
||||
export interface GetLatestData {
|
||||
result?: TfsGetLatestResult
|
||||
}
|
||||
|
||||
export interface FileOperationRequest {
|
||||
paths: string[]
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface TfsFileOperationResult {
|
||||
operation: string
|
||||
affected: number
|
||||
failures: string[]
|
||||
skipped?: string[]
|
||||
conflictDetails?: TfsConflictInfo[]
|
||||
}
|
||||
|
||||
export interface FileOperationData {
|
||||
result?: TfsFileOperationResult
|
||||
}
|
||||
|
||||
export interface CheckinRequest {
|
||||
serverPaths: string[]
|
||||
comment: string
|
||||
}
|
||||
|
||||
export interface TfsCheckinResult {
|
||||
changeset: number
|
||||
submittedChanges: number
|
||||
}
|
||||
|
||||
export interface CheckinData {
|
||||
checkin?: TfsCheckinResult
|
||||
}
|
||||
|
||||
export interface TfsHistoryEntry {
|
||||
serverPath: string
|
||||
name: string
|
||||
changeType: string
|
||||
itemType: string
|
||||
changeset: number
|
||||
author: string
|
||||
date?: number | null
|
||||
comment: string
|
||||
}
|
||||
|
||||
export interface HistoryData {
|
||||
history: TfsHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface ChangesetFilesData {
|
||||
files: TfsHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DiffLocalLatestRequest {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
export interface DiffRevisionsRequest {
|
||||
serverPath: string
|
||||
sourceChangeset: number
|
||||
targetChangeset: number
|
||||
}
|
||||
|
||||
export interface TfsTextDiff {
|
||||
sourceLabel: string
|
||||
targetLabel: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface TextDiffData {
|
||||
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 {
|
||||
operation: string
|
||||
summary: string
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
durationMs: number
|
||||
success: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface OperationLogsData {
|
||||
logs: TfsOperationLogEntry[]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface ServiceStatus {
|
||||
baseUrl: string
|
||||
running: boolean
|
||||
connected: boolean
|
||||
message: string
|
||||
tokenFile: string
|
||||
configFile?: string
|
||||
javaArch?: string
|
||||
javaHome?: string
|
||||
nativeCompatible?: boolean
|
||||
started?: boolean
|
||||
}
|
||||
|
||||
export interface MacTfsBridge {
|
||||
getToken(): Promise<string>
|
||||
getServiceStatus(): Promise<ServiceStatus>
|
||||
selectDirectory(): Promise<string | null>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mactfs?: MacTfsBridge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 preload 暴露的 macTFS 桥接对象,避免组件直接依赖 Electron 细节。
|
||||
*/
|
||||
export function getMactfsBridge() {
|
||||
return window.mactfs
|
||||
}
|
||||
@@ -1,16 +1,618 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/app/app-shell"
|
||||
import { SimpleDialog } from "~/components/app/simple-dialog"
|
||||
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 { HistoryPanel } from "~/components/explorer/history-panel"
|
||||
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
|
||||
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
|
||||
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
|
||||
import type { ApiRequestLogEntry } from "~/lib/api/client"
|
||||
import {
|
||||
connectSession,
|
||||
ensureWorkspaceContext,
|
||||
getConfig,
|
||||
listCollections,
|
||||
listOperationLogs,
|
||||
} from "~/lib/api/endpoints"
|
||||
import type {
|
||||
ApiResult,
|
||||
AppConfig,
|
||||
ConnectData,
|
||||
HealthData,
|
||||
TfsCollectionInfo,
|
||||
TfsOperationLogEntry,
|
||||
TfsPendingChangeInfo,
|
||||
TfsWorkspaceInfo,
|
||||
} from "~/lib/api/types"
|
||||
import type { ServiceStatus } from "~/lib/electron/bridge"
|
||||
import { getMactfsBridge } from "~/lib/electron/bridge"
|
||||
|
||||
interface ServiceViewState {
|
||||
loading: boolean
|
||||
serviceStatus?: ServiceStatus
|
||||
healthResult?: ApiResult<HealthData>
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
interface ConnectionForm {
|
||||
serverUri: string
|
||||
authType: string
|
||||
domain: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTION_FORM: ConnectionForm = {
|
||||
serverUri: "",
|
||||
authType: "ntlm-explicit",
|
||||
domain: "",
|
||||
username: "",
|
||||
password: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端配置转换为表单可直接使用的字符串结构,避免受缺省字段影响。
|
||||
*/
|
||||
function toConnectionForm(config?: AppConfig): ConnectionForm {
|
||||
return {
|
||||
serverUri: config?.serverUri || "",
|
||||
authType: config?.authType || "ntlm-explicit",
|
||||
domain: config?.domain || "",
|
||||
username: config?.username || "",
|
||||
password: config?.password || "",
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [state, setState] = useState<ServiceViewState>({
|
||||
loading: true,
|
||||
})
|
||||
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [collectionsLoading, setCollectionsLoading] = useState(false)
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connectResult, setConnectResult] = useState<ConnectData>()
|
||||
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
|
||||
const [preferredCollection, setPreferredCollection] = useState("")
|
||||
const [selectedCollection, setSelectedCollection] = useState("")
|
||||
const [workspace, setWorkspace] = useState<TfsWorkspaceInfo>()
|
||||
const [selectedServerPath, setSelectedServerPath] = useState("")
|
||||
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
|
||||
const [folderRefreshKey, setFolderRefreshKey] = useState(0)
|
||||
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 [operationLogsLoading, setOperationLogsLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* 先确认服务状态,再读取默认配置并验证自动 token 请求。
|
||||
*/
|
||||
async function loadServiceStatus() {
|
||||
setState((currentState) => ({
|
||||
...currentState,
|
||||
loading: true,
|
||||
errorMessage: undefined,
|
||||
}))
|
||||
|
||||
const bridge = getMactfsBridge()
|
||||
if (!bridge) {
|
||||
setState({
|
||||
loading: false,
|
||||
errorMessage:
|
||||
"未检测到 Electron preload,请通过 Electron 启动 macTFS。",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const serviceStatus = await bridge.getServiceStatus()
|
||||
const healthResult = serviceStatus.running
|
||||
? await apiRequest<HealthData>("/api/health")
|
||||
: undefined
|
||||
const configResult = serviceStatus.running ? await getConfig() : undefined
|
||||
|
||||
if (configResult?.success) {
|
||||
setForm(toConnectionForm(configResult.data.config))
|
||||
setPreferredCollection(configResult.data.config.collection || "")
|
||||
setSelectedCollection(configResult.data.config.collection || "")
|
||||
}
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
serviceStatus,
|
||||
healthResult,
|
||||
errorMessage: configResult?.success
|
||||
? undefined
|
||||
: configResult?.errorMessage || configResult?.message,
|
||||
})
|
||||
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(() => {
|
||||
return subscribeApiRequestLogs(upsertOperationLog)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadServiceStatus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
loadOperationLogs()
|
||||
}
|
||||
}, [connected])
|
||||
|
||||
const serviceReady =
|
||||
state.serviceStatus?.running && state.healthResult?.success
|
||||
const canConnect = serviceReady && !state.loading && !connecting
|
||||
const serviceMessage = serviceReady
|
||||
? "本地 API 已连接。"
|
||||
: state.serviceStatus?.message ||
|
||||
state.errorMessage ||
|
||||
"正在检查本地 API 服务。"
|
||||
const requestStatus = state.healthResult?.success
|
||||
? state.healthResult.message
|
||||
: state.healthResult?.errorMessage || state.errorMessage || "等待检查"
|
||||
|
||||
/**
|
||||
* 更新连接表单字段,保持输入状态与受控表单一致。
|
||||
*/
|
||||
function updateFormField(field: keyof ConnectionForm, value: string) {
|
||||
setForm((currentForm) => ({
|
||||
...currentForm,
|
||||
[field]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用连接 API,成功后进入已连接工作台状态。
|
||||
*/
|
||||
async function handleConnect(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setConnecting(true)
|
||||
setConnectResult(undefined)
|
||||
setState((currentState) => ({
|
||||
...currentState,
|
||||
errorMessage: undefined,
|
||||
}))
|
||||
|
||||
const result = await connectSession(form)
|
||||
setConnecting(false)
|
||||
|
||||
if (!result.success) {
|
||||
setState((currentState) => ({
|
||||
...currentState,
|
||||
errorMessage: result.errorMessage || result.message,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
setConnected(true)
|
||||
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("$/")
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发右侧 Pending Changes 面板重新查询当前 Workspace 状态。
|
||||
*/
|
||||
function refreshPendingChanges() {
|
||||
setPendingRefreshKey((currentKey) => currentKey + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发中间文件列表刷新,供 Checkin 或右侧撤销后更新当前目录状态。
|
||||
*/
|
||||
function refreshFolderItems() {
|
||||
setFolderRefreshKey((currentKey) => currentKey + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换主工作区当前路径,并清理上一目录的选中项状态。
|
||||
*/
|
||||
function selectServerPath(path: string) {
|
||||
setSelectedMappedItem(false)
|
||||
setSelectedServerPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并统一 API client 推送的请求日志,保留最近 80 条。
|
||||
*/
|
||||
function upsertOperationLog(entry: ApiRequestLogEntry) {
|
||||
setOperationLogs((currentLogs) =>
|
||||
[entry, ...currentLogs.filter((log) => log.id !== entry.id)].slice(0, 80)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端读取最近操作日志,补齐当前 UI 会话之前的 API 操作记录。
|
||||
*/
|
||||
async function loadOperationLogs() {
|
||||
setOperationLogsLoading(true)
|
||||
const result = await listOperationLogs()
|
||||
setOperationLogsLoading(false)
|
||||
|
||||
if (!result.success) {
|
||||
return
|
||||
}
|
||||
|
||||
const serverLogs = result.data.logs.map(toOperationLog).reverse()
|
||||
setOperationLogs((currentLogs) => [
|
||||
...currentLogs.filter((log) => log.status === "running"),
|
||||
...serverLogs,
|
||||
].slice(0, 80))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端操作日志转换成底部 Console 的展示结构。
|
||||
*/
|
||||
function toOperationLog(
|
||||
entry: TfsOperationLogEntry,
|
||||
index: number
|
||||
): ApiRequestLogEntry {
|
||||
return {
|
||||
id: `server:${entry.startedAt}:${entry.operation}:${index}`,
|
||||
operation: entry.operation,
|
||||
summary: entry.summary || "-",
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
durationMs: entry.durationMs,
|
||||
success: entry.success,
|
||||
errorMessage: entry.errorMessage,
|
||||
status: entry.success ? "success" : "error",
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh p-6">
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button className="mt-2">Button</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-svh bg-muted/40 p-4 text-sm">
|
||||
<AppShell
|
||||
connected={connected}
|
||||
serviceReady={Boolean(serviceReady)}
|
||||
serviceLoading={state.loading}
|
||||
serverUri={connectResult?.serverUri || form.serverUri}
|
||||
username={form.username}
|
||||
collection={selectedCollection}
|
||||
workspace={workspace?.name}
|
||||
collectionCount={connectResult?.collectionCount}
|
||||
serviceBaseUrl={state.serviceStatus?.baseUrl}
|
||||
tokenFile={state.serviceStatus?.tokenFile}
|
||||
serviceMessage={serviceMessage}
|
||||
requestStatus={requestStatus}
|
||||
operationLogs={operationLogs}
|
||||
operationLogsLoading={operationLogsLoading}
|
||||
sourceList={
|
||||
<ServerTreePanel
|
||||
connected={connected && Boolean(workspace)}
|
||||
collection={selectedCollection}
|
||||
selectedPath={selectedServerPath}
|
||||
onPathSelect={selectServerPath}
|
||||
/>
|
||||
}
|
||||
inspector={
|
||||
<PendingChangesPanel
|
||||
connected={connected && Boolean(workspace)}
|
||||
collection={selectedCollection}
|
||||
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}
|
||||
onRefreshOperationLogs={loadOperationLogs}
|
||||
>
|
||||
{connected && workspace ? (
|
||||
<FolderItemsPanel
|
||||
connected={connected}
|
||||
collection={selectedCollection}
|
||||
serverPath={selectedServerPath}
|
||||
refreshKey={folderRefreshKey}
|
||||
onPendingChangesRefresh={refreshPendingChanges}
|
||||
onPathEnter={selectServerPath}
|
||||
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="min-w-0">
|
||||
<h1 className="text-base font-medium">连接 TFS</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{serviceReady ? "本地 API 已就绪。" : serviceMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleConnect}>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
TFS 地址
|
||||
</span>
|
||||
<input
|
||||
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
value={form.serverUri}
|
||||
required
|
||||
placeholder="http://tfs.example.com:8080/tfs/"
|
||||
onChange={(event) =>
|
||||
updateFormField("serverUri", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
认证方式
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
value={form.authType}
|
||||
onChange={(event) =>
|
||||
updateFormField("authType", event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="ntlm-explicit">NTLM 显式账号密码</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
域
|
||||
</span>
|
||||
<input
|
||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
value={form.domain}
|
||||
placeholder="domain"
|
||||
onChange={(event) =>
|
||||
updateFormField("domain", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
用户名
|
||||
</span>
|
||||
<input
|
||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
value={form.username}
|
||||
required
|
||||
autoComplete="username"
|
||||
onChange={(event) =>
|
||||
updateFormField("username", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
密码
|
||||
</span>
|
||||
<input
|
||||
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
type="password"
|
||||
value={form.password}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
onChange={(event) =>
|
||||
updateFormField("password", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{state.errorMessage && (
|
||||
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{state.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={!canConnect}>
|
||||
{connecting && <Loader2 className="animate-spin" />}
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+202
-1
@@ -1,6 +1,21 @@
|
||||
const { app, BrowserWindow } = require("electron")
|
||||
const { app, BrowserWindow, dialog, ipcMain } = require("electron")
|
||||
const { spawn } = require("node:child_process")
|
||||
const fs = require("node:fs")
|
||||
const os = require("node:os")
|
||||
const path = require("node:path")
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:38765"
|
||||
const HEALTH_URL = `${API_BASE_URL}/api/health`
|
||||
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
|
||||
const MACTFS_DIRECTORY = path.resolve(__dirname, "../../mactfs")
|
||||
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 serviceStartError = ""
|
||||
|
||||
/**
|
||||
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
||||
*/
|
||||
@@ -8,6 +23,11 @@ function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -18,7 +38,188 @@ function createWindow() {
|
||||
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Java API 服务生成的本地 Bearer token,供 preload 和 health 检查复用。
|
||||
*/
|
||||
function readServerToken() {
|
||||
if (!fs.existsSync(TOKEN_FILE)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fs.readFileSync(TOKEN_FILE, "utf8").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用本地 API health 接口,确认服务、token 和连接状态是否可用。
|
||||
*/
|
||||
async function checkServiceHealth() {
|
||||
const token = readServerToken()
|
||||
if (!token) {
|
||||
return {
|
||||
baseUrl: API_BASE_URL,
|
||||
running: false,
|
||||
connected: false,
|
||||
message: "未找到本地 API token,正在尝试启动服务。",
|
||||
tokenFile: TOKEN_FILE,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(HEALTH_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
baseUrl: API_BASE_URL,
|
||||
running: false,
|
||||
connected: false,
|
||||
message:
|
||||
result.errorMessage || result.message || "本地 API health 检查失败。",
|
||||
tokenFile: TOKEN_FILE,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: API_BASE_URL,
|
||||
running: true,
|
||||
connected: Boolean(result.data.connected),
|
||||
message: result.message || "ok",
|
||||
tokenFile: result.data.tokenFile || TOKEN_FILE,
|
||||
configFile: result.data.configFile,
|
||||
javaArch: result.data.javaArch,
|
||||
nativeCompatible: result.data.nativeCompatible,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
baseUrl: API_BASE_URL,
|
||||
running: false,
|
||||
connected: false,
|
||||
message: error instanceof Error ? error.message : "本地 API 服务未响应。",
|
||||
tokenFile: TOKEN_FILE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在开发环境下通过已有 Gradle runServer 任务拉起本地 Java API 服务。
|
||||
*/
|
||||
function startLocalService() {
|
||||
if (serviceStartAttempted) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceStartAttempted = true
|
||||
if (!fs.existsSync(RUN_SERVER_SCRIPT)) {
|
||||
serviceStartError = `未找到后端启动脚本:${RUN_SERVER_SCRIPT}`
|
||||
return
|
||||
}
|
||||
if (!fs.existsSync(path.join(BUNDLED_JDK_HOME, "bin", "java"))) {
|
||||
serviceStartError = `未找到项目内置 JDK:${BUNDLED_JDK_HOME}`
|
||||
return
|
||||
}
|
||||
|
||||
const child = spawn(RUN_SERVER_SCRIPT, [], {
|
||||
cwd: MACTFS_DIRECTORY,
|
||||
detached: true,
|
||||
env: serviceEnvironment(),
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
serviceStartError = error.message
|
||||
})
|
||||
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 服务启动过程。
|
||||
*/
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保本地 API 服务可用;未运行时先启动,再轮询 health 返回最终状态。
|
||||
*/
|
||||
async function ensureServiceStatus() {
|
||||
let status = await checkServiceHealth()
|
||||
if (status.running) {
|
||||
return status
|
||||
}
|
||||
|
||||
startLocalService()
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
await delay(1000)
|
||||
status = await checkServiceHealth()
|
||||
if (status.running) {
|
||||
return {
|
||||
...status,
|
||||
started: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...status,
|
||||
started: serviceStartAttempted,
|
||||
message:
|
||||
serviceStartError ||
|
||||
(shouldUseRosettaJava()
|
||||
? `本地 API 服务未就绪。请确认 Rosetta 可用,项目内置 JDK 路径为:${BUNDLED_JDK_HOME}`
|
||||
: "本地 API 服务未就绪,请确认 Java 8、Gradle 和 TFS 依赖可用。"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开系统目录选择器,返回用户选择的本地目录。
|
||||
*/
|
||||
async function selectDirectory() {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
const result = await dialog.showOpenDialog(window || undefined, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
})
|
||||
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 renderer 可调用的最小 Electron 桥接接口。
|
||||
*/
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle("mactfs:get-token", () => readServerToken())
|
||||
ipcMain.handle("mactfs:get-service-status", () => ensureServiceStatus())
|
||||
ipcMain.handle("mactfs:select-directory", () => selectDirectory())
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers()
|
||||
ensureServiceStatus()
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
/**
|
||||
* 请求主进程返回本地 API Bearer token。
|
||||
*/
|
||||
function getToken() {
|
||||
return ipcRenderer.invoke("mactfs:get-token")
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求主进程确认本地 Java API 服务状态。
|
||||
*/
|
||||
function getServiceStatus() {
|
||||
return ipcRenderer.invoke("mactfs:get-service-status")
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求主进程打开本地目录选择器。
|
||||
*/
|
||||
function selectDirectory() {
|
||||
return ipcRenderer.invoke("mactfs:select-directory")
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("mactfs", {
|
||||
getToken,
|
||||
getServiceStatus,
|
||||
selectDirectory,
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"electron": "electron electron/main.cjs",
|
||||
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
||||
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on tcp:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
||||
"start": "pnpm build && pnpm electron",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\""
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
@@ -0,0 +1,42 @@
|
||||
async page => {
|
||||
const result = {
|
||||
pendingBefore: false,
|
||||
commentFilled: false,
|
||||
checkinClicked: false,
|
||||
successMessage: "",
|
||||
pendingAfterZero: false,
|
||||
bodySample: "",
|
||||
}
|
||||
|
||||
let bodyText = await page.locator("body").innerText()
|
||||
result.pendingBefore = bodyText.includes("Included 1") && bodyText.includes("pendingEdit / 编辑")
|
||||
|
||||
const comment = page.getByPlaceholder("Comment")
|
||||
await comment.fill("AI UI retest checkin 2026-06-08 17:42")
|
||||
result.commentFilled = true
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const checkinButton = page.getByRole("button", { name: "Checkin" }).last()
|
||||
if (await checkinButton.isEnabled().catch(() => false)) {
|
||||
await checkinButton.click()
|
||||
await page.waitForTimeout(3500)
|
||||
result.checkinClicked = true
|
||||
}
|
||||
|
||||
bodyText = await page.locator("body").innerText()
|
||||
result.successMessage = bodyText
|
||||
.split("\n")
|
||||
.find((line) => line.includes("Checkin 成功")) || ""
|
||||
result.pendingAfterZero = bodyText.includes("0 项") && bodyText.includes("暂无 Included Changes")
|
||||
result.bodySample = bodyText
|
||||
.split("\n")
|
||||
.filter((line) => /Pending Changes|Included|stage4-e2e|pendingEdit|编辑|Checkin|changeset|暂无/.test(line))
|
||||
.slice(0, 80)
|
||||
.join("\n")
|
||||
|
||||
await page.screenshot({ path: "output/playwright/mactfs-retest/checkin-flow-final.png", fullPage: true })
|
||||
await page.evaluate((data) => {
|
||||
document.body.setAttribute("data-mactfs-checkin-flow", JSON.stringify(data))
|
||||
}, result)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,57 @@
|
||||
async page => {
|
||||
const target = "stage4-e2e-20260608124119.txt"
|
||||
const result = {
|
||||
selected: false,
|
||||
getLatestClicked: false,
|
||||
getLatestMessage: "",
|
||||
checkoutClicked: false,
|
||||
checkoutMessage: "",
|
||||
pendingVisible: false,
|
||||
checkinVisible: false,
|
||||
bodySample: "",
|
||||
}
|
||||
|
||||
const targetRow = page.getByRole("button", { name: new RegExp(target) }).first()
|
||||
await targetRow.click()
|
||||
await page.waitForTimeout(800)
|
||||
result.selected = (await page.locator("body").innerText()).includes(`已选中:$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs/${target}`)
|
||||
|
||||
const bottomGetLatest = page.getByRole("button", { name: "Get Latest" }).last()
|
||||
if (await bottomGetLatest.isEnabled().catch(() => false)) {
|
||||
await bottomGetLatest.click()
|
||||
await page.waitForTimeout(2500)
|
||||
result.getLatestClicked = true
|
||||
}
|
||||
|
||||
let bodyText = await page.locator("body").innerText()
|
||||
result.getLatestMessage = bodyText
|
||||
.split("\n")
|
||||
.find((line) => line.includes("Get Latest 完成")) || ""
|
||||
|
||||
await targetRow.click()
|
||||
await page.waitForTimeout(500)
|
||||
const checkout = page.getByRole("button", { name: "Checkout" }).last()
|
||||
if (await checkout.isEnabled().catch(() => false)) {
|
||||
await checkout.click()
|
||||
await page.waitForTimeout(2500)
|
||||
result.checkoutClicked = true
|
||||
}
|
||||
|
||||
bodyText = await page.locator("body").innerText()
|
||||
result.checkoutMessage = bodyText
|
||||
.split("\n")
|
||||
.find((line) => line.includes("Checkout 完成")) || ""
|
||||
result.pendingVisible = bodyText.includes("stage4-e2e-20260608124119.txt") && bodyText.includes("编辑")
|
||||
result.checkinVisible = bodyText.includes("Checkin") && bodyText.includes("Included")
|
||||
result.bodySample = bodyText
|
||||
.split("\n")
|
||||
.filter((line) => /Get Latest|Checkout|Pending Changes|Included|stage4-e2e|编辑|Checkin/.test(line))
|
||||
.slice(0, 60)
|
||||
.join("\n")
|
||||
|
||||
await page.screenshot({ path: "output/playwright/mactfs-retest/file-flow-after-checkout.png", fullPage: true })
|
||||
await page.evaluate((data) => {
|
||||
document.body.setAttribute("data-mactfs-file-flow", JSON.stringify(data))
|
||||
}, result)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
@@ -0,0 +1,89 @@
|
||||
async page => {
|
||||
const token = '5P5VxCVaR_EXK5s6WdPW69Gny6ccq28e7fN5Gu8nuX4'
|
||||
const serverPath = "$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs"
|
||||
const result = {
|
||||
url: "",
|
||||
connected: false,
|
||||
hasMissingCollection: false,
|
||||
pkusehrVisible: false,
|
||||
targetVisible: false,
|
||||
mappedVisible: false,
|
||||
getLatestVisible: false,
|
||||
checkoutVisible: false,
|
||||
pendingText: "",
|
||||
errors: [],
|
||||
steps: [],
|
||||
}
|
||||
|
||||
await page.addInitScript((initToken) => {
|
||||
window.mactfs = {
|
||||
getToken: async () => initToken,
|
||||
getServiceStatus: async () => ({
|
||||
running: true,
|
||||
message: "服务可用",
|
||||
url: "http://127.0.0.1:38765",
|
||||
tokenFile: "/Users/fenghp/.mactfs/server-token",
|
||||
}),
|
||||
}
|
||||
}, token)
|
||||
|
||||
await page.goto("http://localhost:5173")
|
||||
await page.waitForLoadState("networkidle")
|
||||
result.url = page.url()
|
||||
result.steps.push("loaded")
|
||||
|
||||
const connect = page.getByRole("button", { name: "连接" })
|
||||
if (await connect.isVisible().catch(() => false)) {
|
||||
await connect.click()
|
||||
await page.waitForTimeout(2500)
|
||||
result.steps.push("connected-click")
|
||||
}
|
||||
|
||||
result.connected = await page.getByText("macTFS 已连接").isVisible().catch(() => false)
|
||||
result.pkusehrVisible = await page.getByRole("button", { name: "PKUSEHR" }).isVisible().catch(() => false)
|
||||
result.hasMissingCollection = await page.getByText("Missing required field: collection").isVisible().catch(() => false)
|
||||
|
||||
const pkusehr = page.getByRole("button", { name: "PKUSEHR" })
|
||||
if (await pkusehr.isVisible().catch(() => false)) {
|
||||
await pkusehr.click()
|
||||
await page.waitForTimeout(2500)
|
||||
result.steps.push("pkusehr-click")
|
||||
}
|
||||
|
||||
const pathSegments = [
|
||||
"北京排水集团人力系统",
|
||||
"5SRC",
|
||||
"MicroFront",
|
||||
"subapp_pm",
|
||||
"mactfs",
|
||||
]
|
||||
|
||||
for (const segment of pathSegments) {
|
||||
const locator = page.getByRole("button", { name: new RegExp(`^${segment}(\\s|$)`) }).first()
|
||||
if (await locator.isVisible().catch(() => false)) {
|
||||
await locator.dblclick().catch(async () => locator.click())
|
||||
await page.waitForTimeout(1800)
|
||||
result.steps.push(`click:${segment}`)
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = await page.locator("body").innerText()
|
||||
result.targetVisible = bodyText.includes(serverPath)
|
||||
result.mappedVisible =
|
||||
bodyText.includes("/Users/fenghp/Desktop/DEV/mactfs-ai-workspace") &&
|
||||
bodyText.includes("已映射")
|
||||
result.hasMissingCollection = bodyText.includes("Missing required field: collection")
|
||||
result.getLatestVisible = bodyText.includes("Get Latest")
|
||||
result.checkoutVisible = bodyText.includes("Checkout")
|
||||
result.pendingText = bodyText
|
||||
.split("\n")
|
||||
.filter((line) => /Pending Changes|Included Changes|Excluded Changes|pendingEdit|Missing required/.test(line))
|
||||
.slice(0, 20)
|
||||
.join("\n")
|
||||
|
||||
await page.screenshot({ path: "output/playwright/mactfs-retest/ui-retest-final.png", fullPage: true })
|
||||
await page.evaluate((data) => {
|
||||
document.body.setAttribute("data-mactfs-retest", JSON.stringify(data))
|
||||
}, result)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
+18
-2
@@ -49,7 +49,23 @@
|
||||
- 底部操作日志
|
||||
- Mapping、目录对比、文件操作、历史、Diff、签入
|
||||
|
||||
## 阶段 5:Feature E2E
|
||||
## 阶段 5:Frontend Refactor
|
||||
|
||||
目标:根据前端重构与 UI 功能调整方案,补齐后端接口能力并重构桌面工作台交互。
|
||||
|
||||
输出:
|
||||
|
||||
- 登录页固定 Collection,上次 Collection 默认选中
|
||||
- Workspace 自动使用或创建
|
||||
- 左右栏和底部日志收起
|
||||
- 左侧树与中间文件列表同步导航
|
||||
- 对象右键菜单
|
||||
- Mapping、History、目录对比弹窗
|
||||
- 文件查看与 Diff 编辑器
|
||||
- Get Latest / Checkout 冲突选择弹窗
|
||||
- Pending Changes 右键菜单与 Checkin 体验重构
|
||||
|
||||
## 阶段 6:Feature E2E
|
||||
|
||||
目标:按真实业务链路做端到端验收。
|
||||
|
||||
@@ -61,7 +77,7 @@
|
||||
- Pending Changes 与 Checkin 验收
|
||||
- History 与 Diff 验收
|
||||
|
||||
## 阶段 6:Release
|
||||
## 阶段 7:Release
|
||||
|
||||
目标:完成个人可用的 macOS 桌面应用交付。
|
||||
|
||||
|
||||
+33
-17
@@ -25,18 +25,19 @@ blocked
|
||||
| Core | 12 | 12 | 100% |
|
||||
| Server API | 10 | 10 | 100% |
|
||||
| CLI | 2 | 2 | 100% |
|
||||
| Frontend | 0 | 13 | 0% |
|
||||
| Frontend | 13 | 13 | 100% |
|
||||
| Frontend Refactor | 10 | 10 | 100% |
|
||||
| Feature E2E | 0 | 5 | 0% |
|
||||
| Release | 0 | 4 | 0% |
|
||||
| Overall | 24 | 46 | 52% |
|
||||
| Overall | 47 | 56 | 84% |
|
||||
|
||||
## 当前阶段
|
||||
|
||||
Frontend
|
||||
Frontend Refactor
|
||||
|
||||
## 下一步任务
|
||||
|
||||
- [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md)
|
||||
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)
|
||||
|
||||
## Core
|
||||
|
||||
@@ -81,19 +82,34 @@ Frontend
|
||||
|
||||
| 任务 | 状态 | 优先级 |
|
||||
|---|---|---|
|
||||
| [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md) | todo | P0 |
|
||||
| [FE-002-实现登录配置页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-实现登录配置页.md) | todo | P0 |
|
||||
| [FE-003-实现VS风格主布局.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现VS风格主布局.md) | todo | P0 |
|
||||
| [FE-004-实现Collection服务端目录树.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现Collection服务端目录树.md) | todo | P0 |
|
||||
| [FE-005-实现中间目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现中间目录文件列表.md) | todo | P0 |
|
||||
| [FE-006-实现Mapping创建流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现Mapping创建流程.md) | todo | P0 |
|
||||
| [FE-007-实现目录对比页面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md) | todo | P0 |
|
||||
| [FE-008-实现右侧挂起更改面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md) | todo | P0 |
|
||||
| [FE-009-实现文件操作交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md) | todo | P0 |
|
||||
| [FE-010-实现签入流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md) | todo | P0 |
|
||||
| [FE-011-实现历史记录界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md) | todo | P0 |
|
||||
| [FE-012-实现文件Diff界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md) | todo | P0 |
|
||||
| [FE-013-实现操作日志面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md) | todo | P1 |
|
||||
| [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md) | done | P0 |
|
||||
| [FE-002-实现登录配置页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-实现登录配置页.md) | done | P0 |
|
||||
| [FE-003-实现VS风格主布局.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现VS风格主布局.md) | done | P0 |
|
||||
| [FE-004-实现Collection服务端目录树.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现Collection服务端目录树.md) | done | P0 |
|
||||
| [FE-005-实现中间目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现中间目录文件列表.md) | done | P0 |
|
||||
| [FE-006-实现Mapping创建流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现Mapping创建流程.md) | done | P0 |
|
||||
| [FE-007-实现目录对比页面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md) | done | P0 |
|
||||
| [FE-008-实现右侧挂起更改面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md) | done | P0 |
|
||||
| [FE-009-实现文件操作交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md) | done | P0 |
|
||||
| [FE-010-实现签入流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md) | done | P0 |
|
||||
| [FE-011-实现历史记录界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md) | done | P0 |
|
||||
| [FE-012-实现文件Diff界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md) | done | P0 |
|
||||
| [FE-013-实现操作日志面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md) | done | P1 |
|
||||
|
||||
## Frontend Refactor
|
||||
|
||||
| 任务 | 状态 | 优先级 |
|
||||
|---|---|---|
|
||||
| [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) | done | 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) | done | 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) | done | 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) | done | P0 |
|
||||
| [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) | done | P0 |
|
||||
|
||||
## Feature E2E
|
||||
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
# macTFS 前端重构与 UI 功能调整方案
|
||||
|
||||
## 一、背景与问题
|
||||
|
||||
当前 `mactfsui` 前端已经实现连接、Collection 目录树、文件列表、Mapping、目录对比、文件操作、Pending Changes、Checkin、History、Diff 和操作日志,但现有页面把过多功能压在一个工作台中,导致主操作区职责不清。
|
||||
|
||||
主要问题:
|
||||
|
||||
- Collection 在左侧树里可切换,但 TFS 的日常工作通常围绕一个已选 Collection 和 Workspace 展开,运行时频繁切换 Collection 会让目录树、Mapping、Pending Changes、History、Diff 的上下文变复杂。
|
||||
- `FolderItemsPanel` 同时承载目录文件列表、Mapping 表单、目录对比结果、批量差异操作、History 面板、Diff 面板和选中项操作区,中间区域被多类临时功能挤占。
|
||||
- `AppShell` 顶部有全局 `Get Latest`、`History` 入口,但这些操作实际应绑定到文件、文件夹或当前目录上下文,不适合作为全局按钮。
|
||||
- 右侧 Inspector 同时展示 Pending Changes、Checkin 和本地服务信息,页面宽度不足时 Pending Changes 可用空间被压缩。
|
||||
- 文件/目录相关操作目前主要依赖按钮,缺少符合桌面工具习惯的右键菜单。
|
||||
|
||||
## 二、重构目标
|
||||
|
||||
本次 UI 重构应围绕“连接后固定上下文,主区专注浏览,操作绑定对象,详情进入弹窗”来调整。
|
||||
|
||||
目标:
|
||||
|
||||
- 登录阶段完成 Collection 选择,进入工作台后不再提供 Collection 切换。
|
||||
- 主工作台保持三栏结构,但左右区域可收缩,保证中间文件列表有足够空间。
|
||||
- 中间区域只展示当前目录文件列表和必要的路径/状态信息,不再内嵌 History、Diff、目录对比详情。
|
||||
- 文件、文件夹、目录树节点上的操作统一放入右键菜单。
|
||||
- History、Diff、目录对比、Mapping 使用弹窗承载;Checkin 保留在右侧 Changes 面板。
|
||||
- 底部操作日志保留为可收缩区域,不长期占用大量主工作区高度。
|
||||
|
||||
## 三、推荐信息架构
|
||||
|
||||
### 3.1 登录与连接页
|
||||
|
||||
推荐把登录页拆成连接信息和 Collection 选择两个步骤,但仍保留在 `routes/home.tsx` 的未连接视图内,避免现在就引入复杂路由。
|
||||
|
||||
流程:
|
||||
|
||||
1. 用户填写 TFS 地址、认证方式、域、用户名、密码。
|
||||
2. 点击连接后调用 `/api/session/connect`。
|
||||
3. 连接成功后加载 `/api/collections`。
|
||||
4. 用户选择一个 Collection。
|
||||
5. 系统自动使用本机已有 Workspace;如果没有,则创建默认 Workspace。
|
||||
6. 将选中的 Collection 和当前 Workspace 保存到配置或当前会话状态。
|
||||
7. 进入主工作台。
|
||||
|
||||
推荐规则:
|
||||
|
||||
- 登录页必须选择 Collection;如果只有一个 Collection,默认选中但仍展示名称。
|
||||
- 如果配置中有上次使用的 Collection,登录页默认选中该 Collection。
|
||||
- 工作台内不提供 Collection 切换按钮。
|
||||
- 不允许用户手动选择 Workspace;本机已有 Workspace 就自动使用,没有就创建默认 Workspace。
|
||||
- 默认 Workspace 命名规则由后端程序固定,不在前端暴露配置。
|
||||
- 需要更换 Collection 时,走“断开/重新连接”或“连接设置”流程。
|
||||
|
||||
原因:
|
||||
|
||||
- Collection 是后续目录树、Mapping、Pending Changes、History、Diff 的共同上游上下文。
|
||||
- 进入工作台后固定 Collection,可以减少跨面板状态重置和误操作。
|
||||
- 当前 `ServerTreePanel` 内部维护 Collection 列表和切换逻辑,后续应下沉为只接收固定 `collection` 的目录树。
|
||||
|
||||
### 3.2 主工作台布局
|
||||
|
||||
推荐布局:
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Top Bar: macTFS | Server | Collection | Workspace | Actions │
|
||||
├──────────────┬────────────────────────────────┬──────────────┤
|
||||
│ Source Tree │ Current Folder Items │ Changes │
|
||||
│ collapsible │ main workspace │ collapsible │
|
||||
├──────────────┴────────────────────────────────┴──────────────┤
|
||||
│ Operation Console collapsible │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
区域职责:
|
||||
|
||||
- Top Bar:展示连接状态、服务器、Collection、Workspace、刷新服务、打开设置;不放文件操作主按钮。
|
||||
- Source Tree:只展示固定 Collection 下的服务端目录树,支持右键目录操作。
|
||||
- Current Folder Items:展示当前目录下文件/文件夹列表,是主工作区。
|
||||
- Changes:展示 Pending Changes 摘要、Included/Excluded、Checkin 入口,可收缩。
|
||||
- Operation Console:展示操作日志,可收缩,默认高度更低。
|
||||
|
||||
左右收缩建议:
|
||||
|
||||
- 左侧 Source Tree 默认宽度 280px,支持收起到 40px。
|
||||
- 右侧 Changes 默认宽度 340px,支持收起到 40px。
|
||||
- 中间使用 `minmax(480px, 1fr)`,优先保证文件列表可读。
|
||||
- 左右栏收起状态都只保留展开按钮,不显示路径、列表或 Pending Changes 数量徽标。
|
||||
|
||||
不建议第一版做任意拖拽宽度:
|
||||
|
||||
- 当前项目没有可复用的 resizable 组件。
|
||||
- 第一版使用固定展开/收起宽度更符合最小改动原则。
|
||||
- 后续如果确实需要拖拽,再基于 Radix/shadcn 增补 `ResizablePanel`。
|
||||
|
||||
### 3.3 中间文件列表
|
||||
|
||||
中间区域应只保留高频浏览信息:
|
||||
|
||||
- 当前路径面包屑或路径栏。
|
||||
- 当前目录 Mapping 状态。
|
||||
- 文件/文件夹表格。
|
||||
- 选中项状态摘要。
|
||||
- 必要的刷新入口。
|
||||
|
||||
表格列建议:
|
||||
|
||||
- 名称
|
||||
- 状态
|
||||
- 最新版本
|
||||
- 本地路径
|
||||
- 修改时间
|
||||
|
||||
应从中间区域移出的内容:
|
||||
|
||||
- History 面板。
|
||||
- Diff 面板。
|
||||
- 目录对比详情列表。
|
||||
- 大块 Mapping 创建表单。
|
||||
- 批量差异操作面板。
|
||||
- 选中项底部操作区。
|
||||
|
||||
中间文件列表的目录进入操作必须和左侧目录树保持同步。用户在中间双击或通过右键菜单“打开”进入目录时,左侧树应自动展开到该目录并选中对应节点;用户在左侧树选择目录时,中间文件列表也应切换到同一路径。
|
||||
|
||||
中间文件列表的单击只负责选中行,不进入目录;进入目录只通过双击或右键菜单“打开”触发。
|
||||
|
||||
## 四、右键菜单设计
|
||||
|
||||
### 4.1 右键菜单使用原则
|
||||
|
||||
推荐将“绑定到文件或文件夹”的操作全部放到右键菜单中。
|
||||
|
||||
适用对象:
|
||||
|
||||
- 左侧目录树节点。
|
||||
- 中间文件列表行。
|
||||
- Pending Changes 列表项。
|
||||
- 目录对比结果项。
|
||||
|
||||
不建议只依赖右键菜单:
|
||||
|
||||
- `Checkin` 仍应在右侧 Changes 面板有明确入口,因为它是跨文件集合操作。
|
||||
- `刷新`、`连接设置`、`打开日志` 这类全局操作保留在顶部或面板标题区。
|
||||
|
||||
### 4.2 左侧目录树右键菜单
|
||||
|
||||
目录树节点菜单:
|
||||
|
||||
- 获取最新版本
|
||||
- 映射到本地
|
||||
- 查看历史
|
||||
- 与服务器文件对比
|
||||
- 刷新子目录
|
||||
|
||||
启用条件:
|
||||
|
||||
- 获取最新版本:目录已映射。
|
||||
- 映射到本地:目录未映射。
|
||||
- 查看历史:已连接且有服务端路径。
|
||||
- 与服务器文件对比:目录已映射。
|
||||
- 刷新子目录:目录节点可用。
|
||||
|
||||
左侧目录树必须支持右键菜单。目录树右键菜单和中间文件列表右键菜单应复用同一套文件操作判断,避免同一路径在两个区域出现不同可用操作。
|
||||
|
||||
### 4.3 中间文件列表右键菜单
|
||||
|
||||
文件夹菜单:
|
||||
|
||||
- 打开
|
||||
- 获取最新版本
|
||||
- 签出
|
||||
- 新增本地文件到服务器
|
||||
- 删除
|
||||
- 撤销挂起更改
|
||||
- 查看历史
|
||||
- 与服务器目录对比
|
||||
- 映射到本地
|
||||
|
||||
文件菜单:
|
||||
|
||||
- 查看
|
||||
- 获取最新版本
|
||||
- 签出
|
||||
- 删除
|
||||
- 撤销挂起更改
|
||||
- 查看历史
|
||||
- 比较本地与服务器 Latest
|
||||
|
||||
启用条件:
|
||||
|
||||
- 获取最新版本:已映射且没有 pending edit;文件夹递归执行;发现本地与服务器不一致时,必须让用户选择使用服务器版本或保留本地版本。
|
||||
- 签出:已映射且为版本控制项;执行时自动先获取最新版本再签出;文件夹允许递归签出,但执行前必须确认。
|
||||
- 新增本地文件到服务器:仅文件夹或当前目录已映射时展示,先对比本地映射目录和服务器目录,再把选中的本地独有项加入 pending add。
|
||||
- 删除:仅服务器已有的版本控制项可用,执行 pending delete;不删除本地未入库的 `localOnly` 文件。
|
||||
- 撤销挂起更改:该项存在 pending change。
|
||||
- 查看历史:服务端路径存在。
|
||||
- 查看:文件可用;已映射且存在本地路径时查看本地文件,未映射时查看服务器文件。
|
||||
- 与服务器目录对比:文件夹、已映射,用于找出本地和服务器不同的文件,方便后续签出、新增、获取最新等操作。
|
||||
- 比较本地与服务器 Latest:文件、已映射、有本地路径,用于查看本地修改和服务器代码的异同。
|
||||
- 映射到本地:仅未映射目录展示。
|
||||
|
||||
### 4.4 Pending Changes 右键菜单
|
||||
|
||||
Pending Change 菜单:
|
||||
|
||||
- 移到 Included
|
||||
- 移到 Excluded
|
||||
- 查看
|
||||
- 撤销
|
||||
- 查看历史
|
||||
- 比较本地与服务器 Latest
|
||||
|
||||
Checkin 不放到单个 pending item 的右键菜单中,避免用户误以为只签入单项。签入仍通过右侧 Changes 面板提交 Included Changes。
|
||||
|
||||
Pending Changes 中的查看入口用于打开本地文件只读内容;比较入口用于查看待签入文件的本地内容与服务器 latest 的差异;撤销用于取消该文件的 pending change;历史记录用于查看该文件或目录的提交历史。
|
||||
|
||||
Pending Changes 的查看和比较入口只对文件显示;文件夹保留撤销、查看历史、移到 Included 和移到 Excluded。
|
||||
pending add 文件没有服务器版本,只显示查看和撤销,不显示比较本地与服务器 Latest。
|
||||
|
||||
## 五、弹窗与独立视图设计
|
||||
|
||||
### 5.1 History 弹窗
|
||||
|
||||
History 应改为弹窗或大尺寸 Dialog,不再嵌入中间文件列表。
|
||||
|
||||
触发入口:
|
||||
|
||||
- 文件/文件夹右键菜单“查看历史”。
|
||||
- 目录树节点右键菜单“查看历史”。
|
||||
|
||||
弹窗内容:
|
||||
|
||||
- 顶部展示目标路径。
|
||||
- 左侧或主表格展示 changeset 列表。
|
||||
- 右侧展示选中 changeset 的文件列表。
|
||||
- 文件历史支持选择两个版本后打开 Diff。
|
||||
|
||||
推荐尺寸:
|
||||
|
||||
- 桌面端宽度使用 `min(1100px, calc(100vw - 48px))`。
|
||||
- 高度使用 `min(760px, calc(100vh - 48px))`。
|
||||
|
||||
### 5.2 Diff 弹窗
|
||||
|
||||
Diff 应改为独立 Dialog 或专用工作视图,不再占用文件列表高度。
|
||||
|
||||
触发入口:
|
||||
|
||||
- 文件右键菜单“Diff 本地与服务器 Latest”。
|
||||
- History 弹窗选择两个版本后打开。
|
||||
- 目录对比弹窗中对单个文件打开。
|
||||
|
||||
推荐方式:
|
||||
|
||||
- 第一版使用 Dialog。
|
||||
- Diff 视觉效果需要重做,不沿用当前紧凑表格视觉。
|
||||
- 使用更清晰的左右分栏:左侧本地或源版本,右侧服务器 latest 或目标版本。
|
||||
- 必须支持代码高亮、搜索、行号、差异高亮、文件路径、版本标签和刷新入口。
|
||||
- 长文本区域使用独立滚动,不让内容撑开弹窗。
|
||||
- 单个文件超过 5MB 时不直接渲染 Diff,只提示文件过大。
|
||||
- 第一版需要引入代码查看/Diff 编辑器能力,优先评估 Monaco Editor:只读查看使用 Monaco Editor,Diff 使用 Monaco Diff Editor。
|
||||
- 如果 Monaco 包体或集成成本不可接受,再评估 CodeMirror 6 + MergeView。
|
||||
|
||||
### 5.3 文件查看弹窗
|
||||
|
||||
文件右键菜单需要提供“查看”入口。已映射且存在本地路径时查看本地文件;未映射时查看服务器文件。
|
||||
|
||||
触发入口:
|
||||
|
||||
- 文件列表文件右键菜单“查看”。
|
||||
- Pending Changes 文件右键菜单“查看”。
|
||||
|
||||
弹窗内容:
|
||||
|
||||
- 顶部展示文件名、本地路径和服务器路径。
|
||||
- 主体展示只读文件内容。
|
||||
- 明确标识当前查看来源:本地文件或服务器文件。
|
||||
- 文本文件必须支持代码高亮、搜索、行号和滚动区域。
|
||||
- 单个文件超过 5MB 时不直接渲染内容,只提示文件过大。
|
||||
- 二进制文件或无法读取的文件展示明确提示,不尝试渲染内容。
|
||||
|
||||
实现建议:
|
||||
|
||||
- 第一版先实现只读查看,不提供编辑能力。
|
||||
- 第一版需要选择编辑器组件承载查看能力,优先评估 Monaco Editor;如果 Monaco 不适合,再评估 CodeMirror 6。
|
||||
- 本地文件读取必须限制在当前 Mapping 目录内,禁止通过 UI 查看任意本机路径。
|
||||
|
||||
### 5.4 目录对比弹窗
|
||||
|
||||
目录对比结果应从 `FolderItemsPanel` 移到单独弹窗。
|
||||
|
||||
触发入口:
|
||||
|
||||
- 目录树节点右键菜单“与服务器文件对比”。
|
||||
- 文件列表目录行右键菜单“与服务器文件对比”。
|
||||
- 当前目录路径栏上的对比图标按钮。
|
||||
|
||||
弹窗内容:
|
||||
|
||||
- 顶部展示服务端路径、本地路径、重新对比按钮、隐藏已同步开关。
|
||||
- 支持按差异状态筛选,例如本地修改、服务器更新、本地新增、待删除。
|
||||
- 主表格展示差异项。
|
||||
- 差异项对象操作通过右键菜单触发,顶部或底部只保留刷新、筛选和已选数量等状态入口。
|
||||
|
||||
差异项操作:
|
||||
|
||||
- 获取最新版本
|
||||
- 签出
|
||||
- 新增本地文件到服务器
|
||||
- 删除
|
||||
- 撤销
|
||||
- Diff
|
||||
|
||||
说明:
|
||||
|
||||
- 目录对比是一次独立任务,不应长期占据主文件列表区域。
|
||||
- 获取最新版本遇到本地与服务器不一致时,必须展示冲突选择,用户明确选择使用服务器版本或保留本地版本后再继续。
|
||||
- 新增本地文件到服务器只处理目录对比中的 `localOnly` 项,用户选中后加入 pending add。
|
||||
- 操作完成后可以提示“重新对比以刷新结果”。
|
||||
|
||||
### 5.5 Mapping 弹窗
|
||||
|
||||
Mapping 创建应从中间内联表单改为 Dialog。
|
||||
|
||||
触发入口:
|
||||
|
||||
- 未映射目录右键菜单“映射到本地”。
|
||||
- 当前目录未映射时路径栏显示一个小型“映射”按钮。
|
||||
|
||||
弹窗字段:
|
||||
|
||||
- 服务端路径,只读。
|
||||
- 本地目录。
|
||||
- 是否立即 Get Latest。
|
||||
- 创建按钮。
|
||||
|
||||
Mapping 创建时,本地目录必须由用户选择或输入;Workspace 自动处理不代表 Mapping 本地路径自动决定。
|
||||
是否立即 Get Latest 由用户选择,可以勾选也可以不勾选。
|
||||
|
||||
### 5.6 Checkin 面板
|
||||
|
||||
Checkin 保留在右侧 Changes 面板,不使用弹窗。
|
||||
|
||||
- 右侧面板展示 Included/Excluded 列表和 comment。
|
||||
- 点击 Checkin 直接提交 Included。
|
||||
|
||||
### 5.7 Get Latest 冲突选择弹窗
|
||||
|
||||
Get Latest 遇到本地与服务器不一致时,使用集中弹窗处理,不逐个文件连续弹窗。
|
||||
|
||||
弹窗内容:
|
||||
|
||||
- 顶部展示当前操作路径、冲突数量、批量操作入口。
|
||||
- 主表格展示冲突文件列表。
|
||||
- 每个文件必须选择处理方式:使用服务器版本、保留本地版本、自动合并。
|
||||
- 每个冲突文件提供 Diff 入口,用于对比本地版本和服务器版本。
|
||||
- 支持批量选择“全部使用服务器版本”和“全部保留本地版本”。
|
||||
- 批量操作只作为快速填充选择,用户仍可对单个文件改选处理方式。
|
||||
- 自动合并只对文本文件开放,二进制文件不展示自动合并。
|
||||
- 二进制文件或无法生成文本 Diff 的文件,只允许选择使用服务器版本或保留本地版本。
|
||||
- 选择使用服务器版本时,覆盖本地文件并保持无 pending change。
|
||||
- 选择保留本地版本时,该文件需要自动进入 pending edit。
|
||||
|
||||
自动合并约束:
|
||||
|
||||
- 自动合并必须基于三份内容:共同基线版本、本地版本、服务器版本。
|
||||
- 如果后端只能提供本地和服务器两份内容,不能可靠自动合并,只能让用户选择服务器或本地。
|
||||
- 自动合并成功后,结果仍应在弹窗中标记为“已自动合并”,用户确认后再写入本地文件。
|
||||
- 自动合并结果确认应用后,作为本地修改进入 pending edit。
|
||||
- 自动合并失败或存在冲突块时,回退为手动选择服务器或本地;第一版不提供手动编辑冲突块。
|
||||
|
||||
候选实现:
|
||||
|
||||
- 自动合并算法优先评估 `node-diff3`,它提供文本三方 diff 和 merge 能力,适合在有基线版本时做行级自动合并。
|
||||
- 第一版不引入 Monaco 或 CodeMirror 作为冲突解决 UI,冲突弹窗先使用项目现有 React + Radix/shadcn 风格实现;后续如果需要代码编辑器级体验,再评估 CodeMirror MergeView 或 Monaco Diff Editor。
|
||||
|
||||
## 六、组件重构建议
|
||||
|
||||
### 6.1 路由层
|
||||
|
||||
`routes/home.tsx` 继续作为主入口,但职责应减少。
|
||||
|
||||
保留职责:
|
||||
|
||||
- 服务状态检查。
|
||||
- 连接表单状态。
|
||||
- 登录后固定 Collection。
|
||||
- 当前选中路径。
|
||||
- 左侧目录树展开路径。
|
||||
- 打开/关闭 Dialog 的全局状态。
|
||||
|
||||
移出职责:
|
||||
|
||||
- 具体文件操作实现。
|
||||
- History/Diff/Compare 详情 UI。
|
||||
- Pending Changes 细节状态。
|
||||
|
||||
### 6.2 AppShell
|
||||
|
||||
`AppShell` 应调整为纯布局组件。
|
||||
|
||||
建议变更:
|
||||
|
||||
- 删除顶部全局 `Get Latest` 和 `History` 按钮。
|
||||
- 新增左侧/右侧/底部收起按钮。
|
||||
- 顶部只展示连接状态和上下文信息。
|
||||
- `sourceList`、`inspector`、`console` 保持插槽式传入。
|
||||
|
||||
### 6.3 ServerTreePanel
|
||||
|
||||
`ServerTreePanel` 应改为固定 Collection 的目录树。
|
||||
|
||||
建议 props:
|
||||
|
||||
```ts
|
||||
interface ServerTreePanelProps {
|
||||
connected: boolean
|
||||
collection: string
|
||||
selectedPath: string
|
||||
onPathSelect(path: string): void
|
||||
onOpenContextMenu(target: TfsServerItem): void
|
||||
}
|
||||
```
|
||||
|
||||
需要移除:
|
||||
|
||||
- 内部 `collections` 状态。
|
||||
- Collection 列表渲染。
|
||||
- `onCollectionSelect`。
|
||||
- `preferredCollection`。
|
||||
- 完全内部维护的当前选中路径和展开路径。
|
||||
|
||||
### 6.4 FolderItemsPanel
|
||||
|
||||
`FolderItemsPanel` 应拆分。
|
||||
|
||||
保留:
|
||||
|
||||
- 加载当前目录 items。
|
||||
- 加载 mappings。
|
||||
- 计算 `FolderItemView`。
|
||||
- 表格选择和进入目录。
|
||||
|
||||
移出:
|
||||
|
||||
- `HistoryPanel`。
|
||||
- `DiffPanel`。
|
||||
- 目录对比状态和差异操作。
|
||||
- Mapping 创建表单。
|
||||
- 选中项底部大操作区。
|
||||
|
||||
新增回调:
|
||||
|
||||
```ts
|
||||
interface FolderItemsPanelProps {
|
||||
connected: boolean
|
||||
collection: string
|
||||
serverPath: string
|
||||
onPathEnter(path: string): void
|
||||
onOpenHistory(target: FileTarget): void
|
||||
onOpenDiff(target: FileTarget): void
|
||||
onOpenCompare(target: FileTarget): void
|
||||
onOpenMapping(target: FileTarget): void
|
||||
onRunFileOperation(action: FileAction, target: FileTarget): void
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 新增组件
|
||||
|
||||
建议新增:
|
||||
|
||||
- `components/app/collapsible-sidebar.tsx`
|
||||
- `components/app/connection-gate.tsx`
|
||||
- `components/explorer/file-context-menu.tsx`
|
||||
- `components/explorer/mapping-dialog.tsx`
|
||||
- `components/compare/folder-compare-dialog.tsx`
|
||||
- `components/history/history-dialog.tsx`
|
||||
- `components/diff/diff-dialog.tsx`
|
||||
|
||||
说明:
|
||||
|
||||
- 如果项目后续继续使用 shadcn/Radix,右键菜单和 Dialog 优先基于 Radix primitives。
|
||||
- 当前 `package.json` 已有 `radix-ui`,不需要为第一版右键菜单引入额外大型依赖。
|
||||
|
||||
## 七、推荐实施顺序
|
||||
|
||||
### 阶段一:固定 Collection 上下文
|
||||
|
||||
改动:
|
||||
|
||||
- 登录成功后加载 Collection。
|
||||
- 登录页选择 Collection 后进入工作台。
|
||||
- `ServerTreePanel` 不再渲染 Collection 列表。
|
||||
- 工作台顶部展示固定 Collection。
|
||||
|
||||
收益:
|
||||
|
||||
- 先解决后续所有面板共享上下文不稳定的问题。
|
||||
|
||||
### 阶段二:布局收缩
|
||||
|
||||
改动:
|
||||
|
||||
- `AppShell` 增加左侧、右侧、底部收起状态。
|
||||
- 删除顶部文件操作按钮。
|
||||
- 底部操作日志默认缩小或可收起。
|
||||
|
||||
收益:
|
||||
|
||||
- 立即改善中间空间不足问题。
|
||||
|
||||
### 阶段三:右键菜单
|
||||
|
||||
改动:
|
||||
|
||||
- 文件列表行接入右键菜单。
|
||||
- 目录树节点接入右键菜单。
|
||||
- Pending Changes 项接入右键菜单。
|
||||
- 将现有选中项按钮迁移到菜单动作。
|
||||
|
||||
收益:
|
||||
|
||||
- 操作与对象绑定,减少全局按钮造成的上下文歧义。
|
||||
|
||||
### 阶段四:弹窗化详情功能
|
||||
|
||||
改动:
|
||||
|
||||
- History 改为 Dialog。
|
||||
- Diff 改为 Dialog。
|
||||
- 目录对比改为 Dialog。
|
||||
- Mapping 创建改为 Dialog。
|
||||
|
||||
收益:
|
||||
|
||||
- 中间区域回归文件浏览。
|
||||
- 复杂功能获得更大展示空间。
|
||||
|
||||
### 阶段五:拆分 FolderItemsPanel
|
||||
|
||||
改动:
|
||||
|
||||
- 将 `FolderItemsPanel` 中的 History、Diff、Compare、Mapping 逻辑拆出。
|
||||
- 保留文件列表主职责。
|
||||
- 文件操作统一由 `home.tsx` 或轻量 action hook 编排。
|
||||
|
||||
收益:
|
||||
|
||||
- 降低单组件复杂度,后续维护更清晰。
|
||||
|
||||
## 八、风险与影响
|
||||
|
||||
可能影响:
|
||||
|
||||
- `routes/home.tsx`:连接、Collection、弹窗状态和工作台编排会调整。
|
||||
- `components/app/app-shell.tsx`:布局结构和顶部操作按钮会调整。
|
||||
- `components/explorer/server-tree-panel.tsx`:Collection 切换逻辑会移除。
|
||||
- `components/explorer/folder-items-panel.tsx`:会拆出大量功能。
|
||||
- `components/inspector/pending-changes-panel.tsx`:右键菜单和 Checkin 入口可能调整。
|
||||
- `components/explorer/history-panel.tsx`:会从内嵌面板改为 Dialog 内容组件。
|
||||
- `components/explorer/diff-panel.tsx`:会从内嵌面板改为 Dialog 内容组件。
|
||||
|
||||
接口影响:
|
||||
|
||||
- 现有 API 可支持部分 UI 调整,但文件查看、服务器文件内容、冲突处理和默认 Workspace 等能力需要后端补充。
|
||||
- 如果登录页要把 Collection 持久保存到后端配置,可能需要确认 `/api/session/connect` 或 `/api/config` 是否保存 `collection` 字段。
|
||||
- 如果要精确控制右键菜单启用条件,可能需要 pending changes 和映射状态在文件列表中更稳定地合并。
|
||||
|
||||
业务影响:
|
||||
|
||||
- 进入工作台后不再直接切换 Collection。
|
||||
- History、Diff、Compare 的打开方式从中间内嵌变为弹窗。
|
||||
- 文件操作入口从显式按钮为主,调整为右键菜单为主。
|
||||
|
||||
## 九、后端接口能力清单
|
||||
|
||||
后续重构前需要确认或补充以下后端能力。
|
||||
|
||||
连接与 Workspace:
|
||||
|
||||
- 登录页选择 Collection 后保存上次 Collection。
|
||||
- 根据 Collection 自动查找本机已有 Workspace。
|
||||
- 没有 Workspace 时按程序固定规则创建默认 Workspace。
|
||||
- 返回当前实际使用的 Workspace 名称和基础信息。
|
||||
|
||||
文件查看:
|
||||
|
||||
- 读取 Mapping 目录内本地文件内容,必须校验路径位于 Mapping 目录内。
|
||||
- 读取未映射文件的服务器 latest 内容。
|
||||
- 返回文件大小、是否二进制、可否文本渲染、文件编码和内容。
|
||||
- 超过 5MB 时返回大小信息,前端不直接渲染内容。
|
||||
|
||||
Diff 与历史:
|
||||
|
||||
- 获取本地文件与服务器 latest 的文本 Diff。
|
||||
- 获取两个历史 changeset 的文本 Diff。
|
||||
- 支持 Diff 前返回文件大小和二进制判断。
|
||||
- 支持文件历史和目录历史继续按弹窗使用。
|
||||
|
||||
目录对比:
|
||||
|
||||
- 返回目录差异状态,至少覆盖本地修改、服务器更新、本地新增、服务器新增、待删除、已同步。
|
||||
- 返回每个差异项是否可执行获取最新、签出、新增本地文件到服务器、删除、撤销、比较。
|
||||
- 返回 pending edit 子项,用于递归 Get Latest 时跳过并提示。
|
||||
|
||||
Get Latest 与 Checkout 冲突处理:
|
||||
|
||||
- Get Latest 文件夹递归执行。
|
||||
- pending edit 文件禁止直接 Get Latest;目录递归时跳过 pending edit 子文件并返回跳过列表。
|
||||
- Checkout 自动先 Get Latest,再签出。
|
||||
- Checkout 自动 Get Latest 遇到冲突时返回同一套冲突明细。
|
||||
- 冲突明细需要包含服务端路径、本地路径、文件大小、是否二进制、是否可自动合并。
|
||||
- 冲突文件需要支持读取本地版本、服务器 latest 版本;自动合并需要共同基线版本。
|
||||
- 支持应用用户选择:使用服务器版本、保留本地版本、自动合并结果。
|
||||
- 使用服务器版本时覆盖本地且不产生 pending change。
|
||||
- 保留本地版本时自动进入 pending edit。
|
||||
- 自动合并结果确认应用后写入本地并进入 pending edit。
|
||||
|
||||
Pending Changes:
|
||||
|
||||
- 返回 pending change 类型,用于区分 pending add、pending edit、pending delete。
|
||||
- pending add 文件不提供服务器比较。
|
||||
- 支持单项撤销、Included/Excluded 前端会话状态和 Checkin 提交 Included。
|
||||
|
||||
## 十、待确认问题
|
||||
|
||||
第一优先级问题:
|
||||
|
||||
- 更换 Collection 是否必须支持“不断开连接直接切换”?
|
||||
|
||||
推荐答案:
|
||||
|
||||
- 不支持。Collection 在登录页选定后固定,切换 Collection 需要回到连接设置重新选择。
|
||||
|
||||
原因:
|
||||
|
||||
- 这符合当前重构目标,也能最大限度减少 Mapping、Pending Changes、History、Diff 的上下文混乱。
|
||||
|
||||
第二优先级问题:
|
||||
|
||||
- Checkin 是否也要右键触发?
|
||||
|
||||
推荐答案:
|
||||
|
||||
- 不作为主要入口。右键可以提供“移到 Included/Excluded”和“撤销”,Checkin 仍放在 Changes 面板中。
|
||||
|
||||
原因:
|
||||
|
||||
- Checkin 是批量提交行为,依赖 comment 和 Included 集合,不是单文件即时动作。
|
||||
|
||||
第三优先级问题:
|
||||
|
||||
- 左右栏是否需要拖拽调整宽度?
|
||||
|
||||
推荐答案:
|
||||
|
||||
- 第一版只做展开/收起,不做拖拽。
|
||||
|
||||
原因:
|
||||
|
||||
- 当前问题的核心是空间分配和职责混杂,固定收缩已经能解决大部分问题,且改动更小。
|
||||
|
||||
## 十一、已确认决策
|
||||
|
||||
- 工作台内不支持切换 Collection。用户需要更换 Collection 时,必须回到连接设置重新选择并重新进入工作台。
|
||||
- 登录页默认选中上次使用的 Collection,但进入工作台后仍不允许切换 Collection。
|
||||
- Workspace 不允许用户手动选择;本机已有 Workspace 就自动使用,没有就创建默认 Workspace。
|
||||
- 默认 Workspace 命名规则由后端程序固定,前端只展示最终使用的 Workspace。
|
||||
- Mapping 创建时本地目录由用户选择或输入。
|
||||
- Mapping 创建后是否立即 Get Latest 由用户选择,不强制。
|
||||
- Checkin 保留在右侧 Changes 面板作为主入口,不放到单个文件或文件夹右键菜单中。
|
||||
- Checkin 不使用弹窗,右侧 Changes 面板直接展示 Included/Excluded、comment 和提交按钮。
|
||||
- 左右栏第一版只支持展开/收起,不做拖拽调整宽度。
|
||||
- History、Diff、目录对比和 Mapping 第一版都使用弹窗,不做新路由,也不继续内嵌在中间文件列表中。
|
||||
- 底部操作日志保留并支持收起,避免长期占用主工作区高度。
|
||||
- 右侧 Changes 面板收起后不显示 Pending Changes 数量徽标。
|
||||
- 左侧 Source Tree 收起后只显示展开按钮,不显示路径或图标列表。
|
||||
- 顶部工具栏移除文件和目录操作,只保留连接状态、服务器、Collection、Workspace、刷新和设置等全局入口。
|
||||
- 文件列表取消选中项底部操作区,文件和目录对象操作全部进入右键菜单。
|
||||
- 左侧目录树必须支持右键菜单,且左侧树展开/选中状态必须与中间文件列表目录导航保持一致。
|
||||
- 中间文件列表单击只选中,双击或右键“打开”才进入目录。
|
||||
- 第一版不支持键盘快捷键。
|
||||
- 当前路径和左侧目录树展开路径统一提升到 `home.tsx` 管理,再传给左侧树和中间文件列表。
|
||||
- 目录对比结果项的文件操作也使用右键菜单,不在弹窗底部或右侧额外放操作按钮区。
|
||||
- 目录对比弹窗需要支持按差异状态筛选,例如本地修改、服务器更新、本地新增、待删除。
|
||||
- 新增本地文件到服务器是文件夹级操作,只在文件夹或当前目录上下文出现,通过本地映射目录和服务器目录对比找出本地独有项,用户选中后加入 pending add。
|
||||
- 删除只对服务器已有的版本控制项执行 pending delete,不删除本地未加入服务器的 `localOnly` 文件。
|
||||
- 文件夹“与服务器目录对比”用于找出本地与服务器不同的文件,方便后续签出、新增、获取最新等操作;文件“比较本地与服务器 Latest”用于查看本地修改和服务器代码异同。
|
||||
- 右侧 Pending Changes 项必须支持右键菜单,至少包含比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
|
||||
- 右侧 Pending Changes 的比较入口只对文件显示,文件夹不展示内容比较。
|
||||
- pending add 文件没有服务器版本,Pending Changes 右键菜单只显示查看和撤销,不显示比较本地与服务器 Latest。
|
||||
- 文件右键菜单需要提供“查看”入口,打开只读文件查看弹窗;Pending Changes 的文件项也需要提供查看入口。
|
||||
- 文件“查看”已映射时查看本地文件,未映射时查看服务器文件。
|
||||
- 文件查看和 Diff 单个文件超过 5MB 时不直接渲染,只提示文件过大。
|
||||
- 本地文件查看必须限制在 Mapping 目录内,不能读取任意本机路径。
|
||||
- Diff 视觉效果需要升级为更清晰的弹窗左右分栏,不沿用当前紧凑表格视觉。
|
||||
- 文件查看和 Diff 第一版必须支持代码高亮、搜索和行号;优先评估 Monaco Editor,备选 CodeMirror 6。
|
||||
- 文件夹允许递归签出,但执行前必须让用户确认影响范围。
|
||||
- 获取最新版本对文件夹递归执行;如果发现本地与服务器不一致,必须由用户选择使用服务器版本或保留本地版本。
|
||||
- Get Latest 冲突选择使用集中弹窗,用户在弹窗内逐文件选择使用服务器版本、保留本地版本或自动合并。
|
||||
- Get Latest 冲突弹窗允许批量选择处理方式,但必须保留单个文件独立改选能力。
|
||||
- Get Latest 冲突弹窗中的每个冲突文件都需要提供 Diff 入口,辅助判断使用服务器、本地或自动合并。
|
||||
- Get Latest 冲突中选择使用服务器版本时,覆盖本地文件并保持无 pending change。
|
||||
- Get Latest 冲突中选择保留本地版本时,该文件需要自动签出为 pending edit。
|
||||
- 自动合并只有在后端能提供共同基线版本、本地版本和服务器版本三份内容时才启用;无基线或二进制文件不做自动合并。
|
||||
- 二进制文件或无法生成文本 Diff 的文件不展示自动合并,只允许使用服务器版本或保留本地版本。
|
||||
- 自动合并成功后不能直接写入本地,必须等用户在冲突弹窗中确认后再应用结果。
|
||||
- 自动合并结果确认应用后,需要作为本地修改进入 pending edit。
|
||||
- 自动合并失败或存在冲突块时,只回退到使用服务器版本或保留本地版本二选一;第一版不做手动冲突块编辑。
|
||||
- 已经处于 pending edit 的文件禁止直接获取最新版本;checkout 流程应先更新到最新再签出。
|
||||
- 文件夹递归获取最新版本时,如果子文件已处于 pending edit,则跳过该子文件并在结果中提示。
|
||||
- Checkout 执行时自动先获取最新版本再签出,不要求用户额外手动执行 Get Latest。
|
||||
- Checkout 自动获取最新版本时如果遇到本地与服务器不一致,复用 Get Latest 冲突选择弹窗处理。
|
||||
- Checkin 成功后刷新当前目录文件列表和 Pending Changes;只有涉及新增或删除目录时,才刷新左侧树对应节点。
|
||||
@@ -0,0 +1,104 @@
|
||||
# UIR-001 补齐后端接口能力
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-013
|
||||
- SERVER-006
|
||||
- SERVER-007
|
||||
- SERVER-008
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
补齐前端重构所需的后端接口能力,确保文件查看、服务器内容读取、默认 Workspace、目录对比、Get Latest / Checkout 冲突处理可以被 UI 调用。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 登录后保存上次使用的 Collection。
|
||||
- 根据 Collection 自动查找本机已有 Workspace。
|
||||
- 本机没有 Workspace 时按程序固定规则创建默认 Workspace。
|
||||
- 返回当前实际使用的 Workspace 名称和基础信息。
|
||||
- 提供 Mapping 目录内本地文件内容读取能力,并校验路径不能越过 Mapping 目录。
|
||||
- 提供未映射文件的服务器 latest 内容读取能力。
|
||||
- 文件内容接口返回文件大小、是否二进制、是否可文本渲染、文件编码和内容。
|
||||
- 文件超过 5MB 时返回大小信息,前端不直接渲染内容。
|
||||
- Diff 接口支持本地文件与服务器 latest、两个历史 changeset。
|
||||
- 目录对比接口返回差异状态、可执行动作、pending edit 子项。
|
||||
- Get Latest 支持递归、跳过 pending edit 子文件并返回跳过列表。
|
||||
- Checkout 自动先 Get Latest,再签出。
|
||||
- Get Latest / Checkout 遇到冲突时返回统一冲突明细。
|
||||
- 冲突明细包含服务端路径、本地路径、文件大小、是否二进制、是否可自动合并。
|
||||
- 支持应用冲突选择:使用服务器版本、保留本地版本、自动合并结果。
|
||||
- Pending Changes 返回 pending add / edit / delete 类型,支持菜单判断。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现前端页面。
|
||||
- 不实现 Monaco 或 CodeMirror。
|
||||
- 不做手动冲突块编辑。
|
||||
- 不改变现有 TFS 协议封装以外的业务规则。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfs/src/main/java/com/mydev/mactfs](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs)
|
||||
- [mactfs/src/main/java/com/mydev/mactfs/server](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 登录连接后能返回固定 Collection 和当前 Workspace。
|
||||
- 没有 Workspace 时能创建默认 Workspace。
|
||||
- 已映射文件能读取本地内容,未映射文件能读取服务器内容。
|
||||
- 非 Mapping 目录路径读取被拒绝。
|
||||
- 大于 5MB 文件不返回正文渲染内容。
|
||||
- pending add 文件不会提供服务器比较能力。
|
||||
- Get Latest / Checkout 冲突能返回前端弹窗所需明细。
|
||||
- 应用“使用服务器版本”后无 pending change。
|
||||
- 应用“保留本地版本”后进入 pending edit。
|
||||
- 自动合并结果确认应用后进入 pending edit。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
../tfsIntegration/gradlew test
|
||||
../tfsIntegration/gradlew runServer
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
- 实际修改文件:
|
||||
- `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 端到端任务覆盖。
|
||||
@@ -0,0 +1,89 @@
|
||||
# UIR-002 重构登录 Collection 与 Workspace 上下文
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-001
|
||||
- FE-002
|
||||
- FE-004
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
将 Collection 选择前置到登录页,进入工作台后固定 Collection;Workspace 由后端自动选择或创建,前端不提供 Workspace 选择。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 登录成功后加载 Collection 列表。
|
||||
- 如果配置中有上次使用的 Collection,登录页默认选中。
|
||||
- 只有一个 Collection 时默认选中但仍展示名称。
|
||||
- 用户必须在登录页确认 Collection 后进入工作台。
|
||||
- 工作台内移除 Collection 切换入口。
|
||||
- 工作台顶部展示固定 Collection 和当前 Workspace。
|
||||
- Workspace 不允许用户手动选择。
|
||||
- 本机已有 Workspace 时自动使用,没有则由后端创建默认 Workspace。
|
||||
- `ServerTreePanel` 改为只接收固定 Collection。
|
||||
- 移除 `ServerTreePanel` 内部 Collection 列表和 `onCollectionSelect`。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做多 Profile。
|
||||
- 不支持工作台内切换 Collection。
|
||||
- 不允许用户选择 Workspace。
|
||||
- 不实现 Mapping 弹窗重构。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
|
||||
- [mactfsui/app/components/app/app-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 登录页可以选择 Collection。
|
||||
- 登录页默认选中上次使用的 Collection。
|
||||
- 进入工作台后不能切换 Collection。
|
||||
- 左侧树只展示当前固定 Collection 的目录。
|
||||
- 顶部展示服务器、Collection、Workspace。
|
||||
- 前端没有 Workspace 选择控件。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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 创建结果,后续真实环境验收覆盖。
|
||||
@@ -0,0 +1,95 @@
|
||||
# UIR-003 重构工作台布局与同步导航
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-002
|
||||
- FE-003
|
||||
- FE-005
|
||||
- FE-013
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
重构主工作台布局,让左侧 Source Tree、右侧 Changes、底部操作日志都支持收起,并保证左侧树和中间文件列表导航状态一致。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- `AppShell` 删除顶部文件/目录操作按钮。
|
||||
- 顶部只保留连接状态、服务器、Collection、Workspace、刷新和设置等全局入口。
|
||||
- 左侧 Source Tree 支持展开/收起。
|
||||
- 左侧收起后只显示展开按钮,不显示路径或图标列表。
|
||||
- 右侧 Changes 支持展开/收起。
|
||||
- 右侧收起后只显示展开按钮,不显示 Pending Changes 数量徽标。
|
||||
- 底部操作日志支持展开/收起。
|
||||
- 不实现拖拽调整宽度。
|
||||
- 当前路径和左侧目录树展开路径统一提升到 `home.tsx`。
|
||||
- 用户在中间双击或右键“打开”进入目录时,左侧树自动展开并选中对应节点。
|
||||
- 用户在左侧树选择目录时,中间文件列表切换到同一路径。
|
||||
- 中间文件列表单击只选中,不进入目录。
|
||||
- 第一版不支持键盘快捷键。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做拖拽调整宽度。
|
||||
- 不做自定义布局保存。
|
||||
- 不做键盘快捷键。
|
||||
- 不做文件查看、Diff、History 弹窗。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/app/app-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
|
||||
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||
- [mactfsui/app/components/app/operation-log-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/operation-log-panel.tsx)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 顶部不再出现 Get Latest、History 等文件操作按钮。
|
||||
- 左侧、右侧、底部可以收起和展开。
|
||||
- 收起状态只显示展开按钮。
|
||||
- 中间文件列表空间明显增大。
|
||||
- 左侧选择目录会刷新中间列表。
|
||||
- 中间进入目录会展开并选中左侧对应目录。
|
||||
- 单击文件列表行只选中,双击文件夹才进入。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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 登录后的三栏交互。
|
||||
@@ -0,0 +1,93 @@
|
||||
# UIR-004 实现对象右键菜单
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-003
|
||||
- FE-008
|
||||
- FE-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
将文件、文件夹、目录树节点、目录对比结果项、Pending Changes 项的对象操作统一迁移到右键菜单。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 左侧目录树节点接入右键菜单。
|
||||
- 中间文件列表文件夹接入右键菜单。
|
||||
- 中间文件列表文件接入右键菜单。
|
||||
- Pending Changes Included / Excluded 项接入右键菜单。
|
||||
- 目录对比结果项接入右键菜单。
|
||||
- 取消中间文件列表选中项底部操作区。
|
||||
- 文件夹菜单包含:打开、获取最新版本、签出、新增本地文件到服务器、删除、撤销挂起更改、查看历史、与服务器目录对比、映射到本地。
|
||||
- 文件菜单包含:查看、获取最新版本、签出、删除、撤销挂起更改、查看历史、比较本地与服务器 Latest。
|
||||
- Pending Changes 文件项包含:查看、比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
|
||||
- Pending Changes 文件夹项不展示内容比较。
|
||||
- pending add 文件只显示查看和撤销,不显示比较本地与服务器 Latest。
|
||||
- Checkin 不放到单个文件或文件夹右键菜单。
|
||||
- 根据 mapped、pending edit、pending add、folder、localPath 等状态控制菜单可用性。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现具体弹窗内容。
|
||||
- 不实现键盘快捷键。
|
||||
- 不实现 Checkin 弹窗。
|
||||
- 不实现手动冲突块编辑。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
|
||||
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||
- [mactfsui/app/components/inspector/pending-changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx)
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 左侧树、文件列表、Pending Changes 均可打开右键菜单。
|
||||
- 顶部和底部不再承担文件对象操作。
|
||||
- pending edit 文件禁止直接 Get Latest。
|
||||
- pending add 文件不显示服务器比较。
|
||||
- 文件夹与服务器目录对比入口可用。
|
||||
- 文件比较本地与服务器 Latest 入口可用。
|
||||
- Checkin 仍只在右侧 Changes 面板提交 Included Changes。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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。
|
||||
@@ -0,0 +1,91 @@
|
||||
# UIR-005 实现 Mapping、History、目录对比弹窗
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-004
|
||||
- FE-006
|
||||
- FE-007
|
||||
- FE-011
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
将 Mapping、History、目录对比从中间文件列表内嵌区域迁移到弹窗,释放主工作区空间。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Mapping 创建改为弹窗。
|
||||
- Mapping 弹窗展示服务端路径、本地目录、是否立即 Get Latest、创建按钮。
|
||||
- Mapping 本地目录由用户选择或输入。
|
||||
- 是否立即 Get Latest 由用户选择,不强制。
|
||||
- History 改为大尺寸弹窗。
|
||||
- History 弹窗展示目标路径、changeset 列表、目录 changeset 文件列表。
|
||||
- History 文件版本支持进入 Diff。
|
||||
- 目录对比改为弹窗。
|
||||
- 目录对比弹窗展示服务端路径、本地路径、重新对比、隐藏已同步。
|
||||
- 目录对比弹窗支持按状态筛选,例如本地修改、服务器更新、本地新增、待删除。
|
||||
- 目录对比结果项操作使用右键菜单。
|
||||
- 新增本地文件到服务器只处理 `localOnly` 项,用户选中后加入 pending add。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现文件查看弹窗。
|
||||
- 不实现 Diff 编辑器升级。
|
||||
- 不实现 Get Latest 冲突弹窗。
|
||||
- 不做新路由。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||
- [mactfsui/app/components/explorer/history-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 中间文件列表不再内嵌 Mapping 表单、History 面板、目录对比结果。
|
||||
- 未映射目录可以通过弹窗创建 Mapping。
|
||||
- History 弹窗可以展示文件或目录历史。
|
||||
- 目录对比弹窗可以筛选差异状态。
|
||||
- 目录对比结果项通过右键菜单执行对象操作。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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 工作区执行新增本地文件到服务器。
|
||||
@@ -0,0 +1,94 @@
|
||||
# UIR-006 实现文件查看与 Diff 编辑器
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-001
|
||||
- UIR-004
|
||||
- FE-012
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现文件只读查看弹窗和视觉升级后的 Diff 弹窗,支持代码高亮、搜索、行号和清晰的左右分栏对比。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 文件右键“查看”打开只读文件查看弹窗。
|
||||
- Pending Changes 文件项右键“查看”打开只读文件查看弹窗。
|
||||
- 已映射文件查看本地文件。
|
||||
- 未映射文件查看服务器 latest 文件。
|
||||
- 文件查看弹窗标识当前查看来源:本地文件或服务器文件。
|
||||
- 文件查看支持代码高亮、搜索、行号和滚动区域。
|
||||
- 单个文件超过 5MB 时不直接渲染,只提示文件过大。
|
||||
- 本地文件查看必须限制在 Mapping 目录内。
|
||||
- 二进制文件或无法读取文件展示明确提示。
|
||||
- Diff 弹窗改为清晰左右分栏。
|
||||
- Diff 支持代码高亮、搜索、行号、差异高亮、文件路径、版本标签、刷新入口。
|
||||
- 优先评估 Monaco Editor;如果 Monaco 不适合,再评估 CodeMirror 6。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不提供文件编辑能力。
|
||||
- 不做手动冲突块编辑。
|
||||
- 不做多文件 Diff 标签页。
|
||||
- 不做大于 5MB 文件渲染。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/package.json](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/package.json)
|
||||
- [mactfsui/app/components/explorer/diff-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/diff-panel.tsx)
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 文件查看弹窗可以查看本地映射文件。
|
||||
- 未映射文件可以查看服务器内容。
|
||||
- 查看弹窗有代码高亮、搜索和行号。
|
||||
- Diff 弹窗有左右分栏、代码高亮、搜索、行号和差异高亮。
|
||||
- 大于 5MB 文件不渲染内容。
|
||||
- 本地非 Mapping 路径不能被查看。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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,满足只读查看、搜索、行号和高亮。
|
||||
@@ -0,0 +1,102 @@
|
||||
# UIR-007 实现 Get Latest 与 Checkout 冲突流程
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-001
|
||||
- UIR-006
|
||||
- FE-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现 Get Latest / Checkout 的统一冲突选择弹窗,支持逐文件选择服务器版本、本地版本或自动合并。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Get Latest 对文件夹递归执行。
|
||||
- pending edit 文件禁止直接 Get Latest。
|
||||
- 文件夹递归 Get Latest 遇到 pending edit 子文件时跳过并提示。
|
||||
- Checkout 自动先 Get Latest,再签出。
|
||||
- Checkout 自动 Get Latest 遇到冲突时复用同一个冲突弹窗。
|
||||
- 冲突弹窗集中展示冲突文件列表。
|
||||
- 每个冲突文件可选择使用服务器版本、保留本地版本、自动合并。
|
||||
- 支持批量全部使用服务器版本。
|
||||
- 支持批量全部保留本地版本。
|
||||
- 批量操作后仍允许单文件改选。
|
||||
- 每个冲突文件提供 Diff 入口。
|
||||
- 二进制或无法文本 Diff 的文件不展示自动合并。
|
||||
- 使用服务器版本时覆盖本地且保持无 pending change。
|
||||
- 保留本地版本时自动签出为 pending edit。
|
||||
- 自动合并需要共同基线版本、本地版本、服务器版本。
|
||||
- 自动合并成功后用户确认再应用。
|
||||
- 自动合并结果应用后进入 pending edit。
|
||||
- 自动合并失败或存在冲突块时,只回退到服务器/本地二选一。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现手动冲突块编辑。
|
||||
- 不对二进制文件做自动合并。
|
||||
- 不支持 pending edit 文件直接 Get Latest。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 文件夹 Get Latest 可以递归执行。
|
||||
- pending edit 文件右键不允许直接 Get Latest。
|
||||
- 递归 Get Latest 跳过 pending edit 子文件并提示。
|
||||
- Checkout 自动先 Get Latest。
|
||||
- Checkout 冲突复用 Get Latest 冲突弹窗。
|
||||
- 冲突弹窗支持批量和单项选择。
|
||||
- 冲突文件可以打开 Diff。
|
||||
- 使用服务器版本后无 pending change。
|
||||
- 保留本地版本后进入 pending edit。
|
||||
- 自动合并结果确认后进入 pending edit。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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 结果处理,未实现三方文本自动合并。
|
||||
- 未执行真实冲突文件的服务器/本地版本应用验证。
|
||||
@@ -0,0 +1,86 @@
|
||||
# UIR-008 重构 Pending Changes 与 Checkin 体验
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-004
|
||||
- UIR-006
|
||||
- FE-008
|
||||
- FE-010
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
保留右侧 Changes 面板作为 Checkin 主入口,并为 Pending Changes 项完善右键菜单和刷新策略。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Checkin 保留在右侧 Changes 面板。
|
||||
- 不实现 Checkin 弹窗。
|
||||
- 右侧面板直接展示 Included / Excluded、comment 和提交按钮。
|
||||
- Pending Changes 项支持右键菜单。
|
||||
- 文件项支持查看、比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
|
||||
- 文件夹项不展示内容比较。
|
||||
- pending add 文件只显示查看和撤销,不显示服务器比较。
|
||||
- 撤销指定 pending change 后刷新 Pending Changes。
|
||||
- Checkin 成功后刷新当前目录文件列表和 Pending Changes。
|
||||
- 只有涉及新增或删除目录时,刷新左侧树对应节点。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现 Checkin Dialog。
|
||||
- 不做 Work Item。
|
||||
- 不做 Check-in Policy UI。
|
||||
- 不做右侧收起数量徽标。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/inspector/pending-changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Checkin 不使用弹窗。
|
||||
- Included / Excluded 仍可维护。
|
||||
- Pending Changes 项右键菜单可用。
|
||||
- pending add 文件没有比较入口。
|
||||
- Checkin 成功后 Pending Changes 清空或更新。
|
||||
- 当前目录状态刷新。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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 服务端状态。
|
||||
@@ -0,0 +1,88 @@
|
||||
# UIR-009 拆分 FolderItemsPanel 与动作编排
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P1
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-005
|
||||
- UIR-006
|
||||
- UIR-007
|
||||
- UIR-008
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
拆分当前过重的 `FolderItemsPanel`,让中间文件列表只负责目录项展示、选择和进入目录,复杂功能由弹窗和动作编排承担。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- `FolderItemsPanel` 保留加载当前目录 items。
|
||||
- 保留加载 mappings 和计算 `FolderItemView`。
|
||||
- 保留表格选择和双击进入目录。
|
||||
- 移除内嵌 History。
|
||||
- 移除内嵌 Diff。
|
||||
- 移除目录对比状态和差异操作。
|
||||
- 移除 Mapping 创建表单。
|
||||
- 移除选中项底部操作区。
|
||||
- 在 `home.tsx` 或轻量 action hook 中编排弹窗打开和文件操作。
|
||||
- 统一 FileTarget / FileAction 类型,供右键菜单、弹窗和操作函数复用。
|
||||
- 保持项目现有风格,不引入大型状态库。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不引入 Redux、Zustand、TanStack Query。
|
||||
- 不重构无关 API client。
|
||||
- 不改变已确认业务规则。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
- [mactfsui/app/lib](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `FolderItemsPanel` 不再包含 History、Diff、Compare、Mapping 大块 JSX。
|
||||
- 中间文件列表只展示目录项和必要状态。
|
||||
- 所有对象操作仍可通过右键菜单触发。
|
||||
- 弹窗打开和关闭状态清晰可维护。
|
||||
- `pnpm typecheck` 通过。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
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` 纯类型,但本次按最小改动保留在业务组件内。
|
||||
@@ -0,0 +1,99 @@
|
||||
# UIR-010 前端重构端到端验收
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend-refactor
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- UIR-001
|
||||
- UIR-002
|
||||
- UIR-003
|
||||
- UIR-004
|
||||
- UIR-005
|
||||
- UIR-006
|
||||
- UIR-007
|
||||
- UIR-008
|
||||
- UIR-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
|
||||
|
||||
## 目标
|
||||
|
||||
对前端重构后的核心流程做端到端验收,确认工作台布局、右键菜单、弹窗、文件查看、Diff、冲突处理、Checkin 都符合设计方案。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 验证登录页 Collection 默认选中和固定上下文。
|
||||
- 验证 Workspace 自动使用或创建。
|
||||
- 验证工作台左右和底部收起。
|
||||
- 验证左侧树和中间文件列表同步导航。
|
||||
- 验证左侧树、文件列表、Pending Changes 右键菜单。
|
||||
- 验证 Mapping 弹窗。
|
||||
- 验证 History 弹窗。
|
||||
- 验证目录对比弹窗和状态筛选。
|
||||
- 验证文件查看弹窗代码高亮、搜索、行号。
|
||||
- 验证 Diff 弹窗左右分栏、搜索、行号、差异高亮。
|
||||
- 验证 Get Latest 递归、pending edit 跳过、冲突弹窗。
|
||||
- 验证 Checkout 自动 Get Latest 和冲突复用。
|
||||
- 验证 Pending Changes 右键撤销、查看、比较、Included/Excluded。
|
||||
- 验证 Checkin 后刷新当前目录和 Pending Changes。
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做 Release 打包。
|
||||
- 不做 Work Item。
|
||||
- 不做 Check-in Policy UI。
|
||||
- 不做手动冲突块编辑。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui)
|
||||
- [testing](/Users/fenghp/Desktop/DEV/project/mydev/testing)
|
||||
- [task/frontend-refactor](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 所有 UIR 阶段任务通过 `pnpm typecheck`。
|
||||
- Electron 环境下可以完成登录、浏览、Mapping、目录对比、文件查看、Diff、Get Latest、Checkout、Pending Changes、Checkin 基本流程。
|
||||
- 未映射文件查看服务器内容。
|
||||
- 已映射文件查看本地内容。
|
||||
- 大文件不直接渲染。
|
||||
- 本地非 Mapping 路径不能查看。
|
||||
- 冲突处理三种选择结果符合文档。
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
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。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -59,4 +59,37 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/electron/main.cjs`
|
||||
- `mactfsui/electron/preload.cjs`
|
||||
- `mactfsui/app/lib/electron/bridge.ts`
|
||||
- `mactfsui/app/lib/api/client.ts`
|
||||
- `mactfsui/app/lib/api/types.ts`
|
||||
- `mactfsui/app/routes/home.tsx`
|
||||
- `mactfsui/package.json`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-001-实现Electron启动服务与API客户端.md`
|
||||
- 实际实现内容:
|
||||
- Electron 主进程增加 preload 配置、contextIsolation、nodeIntegration 关闭。
|
||||
- Electron 主进程增加本地 token 读取、`/api/health` 检查、未运行时通过 `../tfsIntegration/gradlew runServer` 拉起本地 Java API 服务。
|
||||
- 新增 preload 窄接口:`getToken`、`getServiceStatus`。
|
||||
- 新增 renderer 侧 Electron bridge 类型封装。
|
||||
- 新增统一 API client,所有请求自动读取 token 并附加 `Authorization: Bearer <token>`。
|
||||
- 首页替换脚手架内容,展示本地 API 服务状态、token 路径和请求状态;服务未就绪时给出明确提示。
|
||||
- 调整 `electron:dev` 等待条件为 `wait-on tcp:5173`,避免 React Router dev 的 HTTP 探测差异阻塞 Electron 启动。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/preload.cjs`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew runServer` 后请求 `/api/health`:通过,返回 `success=true`。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后拉起 Java API 服务,`curl /api/health` 返回 `success=true`。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,非 Electron 环境显示明确的 preload 缺失提示,不是空白页。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 TFS 连接、目录浏览或文件操作;本任务只要求本地服务、health 和 API client,不涉及真实 TFS 业务操作。
|
||||
- 验收标准确认:
|
||||
- Electron 启动后可连接本地 API:满足,`electron:dev` 启动后 38765 端口监听,health 返回 `success=true`。
|
||||
- API 请求能自动带 token:满足,`apiRequest` 统一从 preload 获取 token 并设置 Bearer header,首页通过该 client 请求 health。
|
||||
- 服务未启动时 UI 有明确提示:满足,首页根据 bridge/service 状态展示未就绪或 preload 缺失提示。
|
||||
- 遗留问题:
|
||||
- 生产环境打包启动命令仍待 Release 阶段确定,当前按前端规范使用开发期 Gradle `runServer`。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -64,4 +64,33 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/app/lib/api/types.ts`
|
||||
- `mactfsui/app/lib/api/endpoints.ts`
|
||||
- `mactfsui/app/routes/home.tsx`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-002-实现登录配置页.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `AppConfig`、`ConfigData`、`ConnectData` 等前端 API 类型。
|
||||
- 新增 `getConfig`、`connectSession` API endpoint 封装,复用 FE-001 的统一 `apiRequest` 自动 Bearer token 能力。
|
||||
- 首页增加连接配置表单,支持 `serverUri`、`authType`、`domain`、`username`、`password`。
|
||||
- 页面初始化时在本地服务可用后读取 `/api/config`,将默认配置回填到表单。
|
||||
- 点击连接后调用 `/api/session/connect`,展示连接中状态;失败展示后端错误,成功进入已连接工作台状态。
|
||||
- 保留右侧本地服务状态面板,服务未启动或 preload 缺失时继续显示明确提示。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs && node -c electron/preload.cjs`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,连接表单与服务状态面板可见,非 Electron 环境显示 preload 缺失提示。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 与 config 接口可访问。
|
||||
- 使用无效 TFS 地址请求 `/api/session/connect`:通过,后端返回 `success=false` 和错误信息,配置未被保存。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 TFS 账号的连接成功验证;当前环境没有可用于验收的真实 TFS 凭据,且任务规则要求谨慎处理真实 TFS 操作。
|
||||
- 验收标准确认:
|
||||
- 可填写配置并调用连接 API:满足,表单字段完整,连接提交走 `connectSession`。
|
||||
- 连接成功进入主界面:满足,`connectSession` 返回成功后进入已连接工作台状态;真实 TFS 成功连接未在当前环境执行。
|
||||
- 连接失败显示错误:满足,无效连接返回的错误会写入页面错误提示。
|
||||
- 再次打开能回填配置:满足,页面初始化读取 `/api/config` 并回填表单。
|
||||
- 遗留问题:
|
||||
- 真实 TFS 成功连接需要在具备有效 TFS 地址和账号后做端到端验收。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -58,4 +58,31 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/app/components/app/app-shell.tsx`
|
||||
- `mactfsui/app/routes/home.tsx`
|
||||
- `mactfsui/app/app.css`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-003-实现VS风格主布局.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `AppShell` 主工作台组件,包含顶部工具栏、左侧 Source List、中间 Source Workspace、右侧 Inspector、底部 Console。
|
||||
- 首页改为通过 `AppShell` 承载连接配置页和已连接后的文件列表占位,保留 FE-002 的连接表单逻辑。
|
||||
- 右侧 Inspector 预留 Pending Changes 区域,并展示本地 API 服务状态。
|
||||
- 底部 Console 展示 service、request、session 三类基础日志状态。
|
||||
- 增加桌面与窄屏响应式网格:桌面三栏,较窄宽度下 Inspector 下移。
|
||||
- 将全局字体从脚手架 slab serif 调整为 macOS/system sans,符合 `mactfsui/AGENTS.md` 的 macOS Source Workspace 风格要求。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,顶部工具栏、Source List、Source Workspace、Inspector、Console 均可见;截图检查未发现空白或重叠。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 返回 `success=true`。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 Collection、目录树、Pending Changes 数据加载;本任务只实现布局区域,不实现具体业务数据。
|
||||
- 验收标准确认:
|
||||
- UI 不再是默认模板页:满足,首页已替换为 macOS Source Workspace 工作台。
|
||||
- 主界面布局与 PRD 区域一致:满足,包含顶部连接/Workspace 信息、左侧目录树区、中间文件区、右侧挂起更改区、底部操作日志区。
|
||||
- 桌面窗口下可正常使用:满足,浏览器与 Electron dev 验证通过。
|
||||
- 遗留问题:
|
||||
- 具体 Collection 目录树、文件列表、Pending Changes 数据加载留给后续 FE-004、FE-005、FE-008。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -58,4 +58,32 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/app/lib/api/types.ts`
|
||||
- `mactfsui/app/lib/api/endpoints.ts`
|
||||
- `mactfsui/app/components/app/app-shell.tsx`
|
||||
- `mactfsui/app/components/explorer/server-tree-panel.tsx`
|
||||
- `mactfsui/app/routes/home.tsx`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-004-实现Collection服务端目录树.md`
|
||||
- 实际实现内容:
|
||||
- 新增 Collection、服务端目录节点相关前端类型。
|
||||
- 新增 `listCollections`、`listServerTree` API endpoint 封装,继续复用统一 Bearer token client。
|
||||
- `AppShell` 左侧 Source List 支持传入业务树组件。
|
||||
- 新增 `ServerTreePanel`,连接成功后加载 Collection,默认加载第一个 Collection 的 `$/` 根目录。
|
||||
- 展开目录时按服务端路径调用 `/api/server-tree` 懒加载子节点,不要求目录已映射到本地。
|
||||
- 选中 Collection 或目录节点后通过回调更新首页当前 Collection 和当前服务端路径,中间区域展示当前路径。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下 Source List、主内容、Inspector、Console 均稳定显示。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 TFS Collection 与目录树成功浏览;当前环境没有可用于验收的真实 TFS 连接配置,且任务规则要求谨慎处理真实 TFS 操作。
|
||||
- 验收标准确认:
|
||||
- 可浏览 Collection 下目录树:代码路径满足,真实 TFS 数据未在当前环境执行。
|
||||
- 展开节点不要求本地已映射:满足,展开时只使用服务端 `collection + path` 调用 `/api/server-tree`。
|
||||
- 选中目录能更新当前路径:满足,`ServerTreePanel` 通过 `onPathSelect` 通知首页并更新中间区域当前路径。
|
||||
- 遗留问题:
|
||||
- 需要在具备真实 TFS 连接后补充 Collection / 目录树端到端验收。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -58,4 +58,33 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/app/lib/api/types.ts`
|
||||
- `mactfsui/app/lib/api/endpoints.ts`
|
||||
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `mactfsui/app/routes/home.tsx`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-005-实现中间目录文件列表.md`
|
||||
- 实际实现内容:
|
||||
- 新增 Mapping 与目录文件列表相关前端类型。
|
||||
- 新增 `listMappings`、`listServerFolderItems` API endpoint 封装。
|
||||
- 新增 `FolderItemsPanel`,根据当前 Collection 和服务端路径加载下一级文件 / 文件夹。
|
||||
- 中间列表展示名称、类型、服务端路径、本地路径、映射状态、状态。
|
||||
- 根据 Mapping 计算本地路径;已映射项展示 `notDownloaded / 未下载` 状态,未映射项展示 `未映射`。
|
||||
- 支持文件 / 目录行选中,并在底部展示已选中路径。
|
||||
- 基础操作入口按映射状态展示:未映射项只保留 History,已映射项显示 Get Latest / Checkout 入口。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下主布局和中间区域稳定显示。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 TFS 目录点击后加载文件列表;当前环境没有可用于验收的真实 TFS 连接配置。
|
||||
- 未验证真实本地文件是否存在;当前后端目录接口未返回本地存在状态,前端只能根据 Mapping 计算本地路径并展示 `notDownloaded / 未下载` 的基础状态。
|
||||
- 验收标准确认:
|
||||
- 目录点击后中间列表更新:代码路径满足,左侧选中路径会传给 `FolderItemsPanel` 并触发 `/api/server-folder/items`。
|
||||
- 未映射目录不展示本地操作:满足,未映射项不显示 Get Latest / Checkout 本地操作入口。
|
||||
- 已映射未下载项状态清晰:满足,已映射项展示 `notDownloaded / 未下载` 状态;真实本地存在性需后续后端状态字段或目录对比补强。
|
||||
- 遗留问题:
|
||||
- 真实 TFS 目录文件列表、真实本地存在状态需要后续端到端环境补验。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -59,4 +59,36 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 完成日期:2026-06-08
|
||||
- 实际修改文件:
|
||||
- `mactfsui/electron/main.cjs`
|
||||
- `mactfsui/electron/preload.cjs`
|
||||
- `mactfsui/app/lib/electron/bridge.ts`
|
||||
- `mactfsui/app/lib/api/types.ts`
|
||||
- `mactfsui/app/lib/api/endpoints.ts`
|
||||
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `task/README.md`
|
||||
- `task/frontend/FE-006-实现Mapping创建流程.md`
|
||||
- 实际实现内容:
|
||||
- Electron 主进程新增本地目录选择器 IPC:`mactfs:select-directory`。
|
||||
- preload 和 renderer bridge 新增 `selectDirectory()`。
|
||||
- 新增 `AddMappingRequest`、`AddMappingData` 类型和 `addMapping` endpoint 封装。
|
||||
- 中间目录面板在当前服务端目录未映射时展示“映射到本地”。
|
||||
- Mapping 表单支持选择本地目录、手动输入本地路径、选择是否立即 `Get Latest`。
|
||||
- 调用 `/api/mappings` 创建 Mapping,成功后刷新当前目录列表和 Mapping 状态。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs && node -c electron/preload.cjs`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
|
||||
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下主布局稳定显示。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 返回 `success=true`。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 Mapping 创建;当前环境没有可用于验收的真实 TFS 连接、Workspace 和本地目录选择上下文。
|
||||
- 未实际点击系统目录选择弹窗;已完成 main/preload/bridge 代码接入和 CJS 语法校验。
|
||||
- 验收标准确认:
|
||||
- 可创建 Mapping:代码路径满足,提交 `/api/mappings`。
|
||||
- 可选择是否立即 Get Latest:满足,表单提供 `立即 Get Latest` 选项并传递 `getLatest`。
|
||||
- 不立即下载时后续仍可对下级文件 Get Latest:满足,不勾选时仅保存 Mapping;下级文件列表会根据刷新后的 Mapping 状态展示 Get Latest 入口。
|
||||
- 遗留问题:
|
||||
- 真实 Mapping 创建和目录选择弹窗需要在具备有效 TFS 连接后做端到端验收。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -59,4 +59,31 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md`
|
||||
- 实际实现内容:
|
||||
- 新增目录对比请求、响应和差异状态类型。
|
||||
- 新增 `compareFolder` 统一 API 封装,对接 `POST /api/compare/folder` 的 `diffs` 返回结构。
|
||||
- 在中间目录文件面板增加“目录对比”区域,已映射目录可执行 Compare,未映射目录仅展示不可对比状态。
|
||||
- 目录对比请求期间展示“正在执行目录对比,TFS 网络请求可能需要一段时间。”。
|
||||
- 对比结果默认隐藏 `upToDate`,支持切换显示状态。
|
||||
- 差异列表支持逐项勾选和当前筛选结果批量勾选。
|
||||
- 按状态展示可用操作入口:`checkout`、`diff`、`add`、`delete local`、`delete`、`restore from server`、`get latest`、`checkin`、`undo`、`手工处理`。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:页面工作台结构可渲染,连接页、Source Workspace、Inspector、Console 可见。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 TFS 目录对比请求;当前环境没有真实 TFS 凭据、已连接会话和真实 Mapping,且该操作依赖外部 TFS 网络状态。
|
||||
- Browser 截图调用超时,但 DOM 快照已确认本地页面基础结构渲染正常。
|
||||
- 是否满足验收标准:
|
||||
- 可展示 `localModified`、`localOnly`、`notDownloaded`、`pendingEdit`、`pendingAdd`、`pendingDelete` 等状态文案和操作入口。
|
||||
- 可勾选指定差异文件,并可批量勾选当前筛选结果。
|
||||
- 未映射目录不显示 Compare 执行按钮,并展示不能执行对比的状态。
|
||||
- 遗留问题:
|
||||
- 当前任务只展示可用操作入口,不执行真实文件操作;具体操作交互属于后续 FE-009。
|
||||
- 真实 TFS 成功路径需要在具备测试 Collection、Workspace 和 Mapping 后做端到端验收。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -59,4 +59,34 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `TfsPendingChangeInfo`、`PendingChangeStatus`、`PendingChangesData` 类型。
|
||||
- 新增 `listPendingChanges` API 封装,对接 `GET /api/pending-changes` 的 `pendingChanges` 返回结构。
|
||||
- 新增右侧 `PendingChangesPanel`,连接后查询当前 Workspace 的 pending changes。
|
||||
- 后端返回的 pending changes 默认进入 Included Changes。
|
||||
- 支持单个文件在 Included Changes 和 Excluded Changes 之间移动。
|
||||
- Included / Excluded 状态只维护在 React 当前 UI 会话内,不写入后端或本地存储。
|
||||
- AppShell 支持传入自定义 Inspector 内容,Home 中接入 PendingChangesPanel。
|
||||
- 面板展示“后续签入只读取 Included Changes”及 Included 数量,供后续签入流程读取 Included 语义。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:右侧 Inspector 可渲染 Pending Changes 面板;当前非 Electron 环境显示未连接状态。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 pending changes 查询成功路径;当前环境没有真实 TFS 凭据、已连接会话和真实 Workspace pending changes。
|
||||
- 未执行真实签入;签入流程属于后续 FE-010,本任务只维护 Included / Excluded 会话状态。
|
||||
- 是否满足验收标准:
|
||||
- pending changes 查询入口已实现,连接后会在右侧面板展示。
|
||||
- 后端返回的 pending changes 默认进入 Included Changes。
|
||||
- 用户可通过每项右侧按钮在 Included / Excluded 之间移动。
|
||||
- 面板明确保留 Included Changes 作为后续签入读取范围。
|
||||
- 遗留问题:
|
||||
- 真实 TFS pending changes 展示和移动后的签入行为,需要在 FE-010 或后续端到端验收任务中验证。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -62,4 +62,39 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md`
|
||||
- 实际实现内容:
|
||||
- 新增 Get Latest、checkout、add、delete、undo 的前端 API 类型和 endpoint 封装。
|
||||
- 目录对比结果中按状态展示可执行文件操作按钮:
|
||||
- `notDownloaded`、`remoteChanged`、`remoteOnly` 可 Get Latest。
|
||||
- `localModified` 可 checkout。
|
||||
- `localOnly` 可 add。
|
||||
- `localDeleted` 可 pending delete。
|
||||
- `pendingEdit`、`pendingAdd`、`pendingDelete` 可 undo。
|
||||
- 目录 checkout 前使用当前选择项数量做二次确认。
|
||||
- 操作成功后展示影响数量,并提示重新 Compare 刷新差异。
|
||||
- Get Latest 成功后刷新当前目录列表。
|
||||
- checkout、add、delete、undo 成功后触发右侧 Pending Changes 刷新。
|
||||
- 文件列表底部选中项接入 Get Latest 和 Checkout 操作入口。
|
||||
- 右侧 Pending Changes 面板新增每项 Undo 操作入口,并支持外部刷新信号。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Pending Changes 基础结构可渲染;当前非 Electron 环境显示未连接状态。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 checkout、add、delete、undo、get latest;这些操作会修改真实 TFS Workspace 或本地工作区,当前环境没有真实 TFS 凭据和测试 Workspace。
|
||||
- 目录 checkout 的真实影响数量由当前选择项数量确认,不做递归文件数预扫描;后续端到端验收可结合真实 TFS 数据校验。
|
||||
- 是否满足验收标准:
|
||||
- 目录 checkout 前会显示二次确认并包含当前影响项数量。
|
||||
- 不同 diff 状态只展示对应文件操作入口。
|
||||
- 操作后会刷新当前目录列表或右侧 Pending Changes,并提示重新 Compare。
|
||||
- 遗留问题:
|
||||
- 未实现本地未版本控制文件的直接删除;当前任务范围只要求 `localOnly` 可 add。
|
||||
- 真实操作成功路径需要在后续端到端验收中验证。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -61,4 +61,31 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `CheckinRequest`、`TfsCheckinResult`、`CheckinData` 类型。
|
||||
- 新增 `checkin` API 封装,对接 `POST /api/checkin`。
|
||||
- 在右侧 Pending Changes 面板新增 Checkin 区域。
|
||||
- 新增 comment 输入框,comment 为空时 Checkin 按钮不可用,并在执行函数内保留必填校验。
|
||||
- Checkin 只提交当前 Included Changes 的 `serverPath`,Excluded Changes 不参与提交。
|
||||
- 签入中禁用刷新、移动和提交按钮,并显示 loading 状态。
|
||||
- 签入成功后显示 changeset 和提交数量,清空 comment 与 Included Changes,保留 Excluded 会话状态。
|
||||
- 签入失败时展示 `errorMessage || message`,用于直接显示 TFS policy、权限、冲突等错误。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Pending Changes 基础结构可渲染;当前非 Electron 环境显示未连接状态。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实 checkin;该操作会提交真实 TFS Workspace 的 pending changes,当前环境没有真实测试凭据、Workspace 和可提交变更。
|
||||
- 是否满足验收标准:
|
||||
- comment 为空不能签入。
|
||||
- Excluded Changes 不会进入 checkin 请求。
|
||||
- 成功后 Included 清空。
|
||||
- Excluded 保留在当前 UI 会话内。
|
||||
- 遗留问题:
|
||||
- 真实签入成功、policy 失败、权限失败、冲突失败路径需要在后续端到端验收中验证。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -59,4 +59,34 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `TfsHistoryEntry`、`HistoryData`、`ChangesetFilesData` 类型。
|
||||
- 新增 `queryHistory` 和 `queryChangesetFiles` API 封装。
|
||||
- 新增 `HistoryPanel`,展示文件或目录最近历史记录。
|
||||
- 历史列表展示 changeset、变更类型、作者、时间、comment。
|
||||
- 当前目录和选中文件 / 目录均可打开 History。
|
||||
- 目录历史可点击 changeset 查看影响文件列表。
|
||||
- 文件历史支持选择两个历史版本并进入 Diff 入口。
|
||||
- changeset 文件列表支持单文件 Diff 入口提示;真实 Diff 渲染由后续 FE-012 接入。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Source Workspace 基础结构可渲染;当前非 Electron 环境显示未连接状态。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实历史查询成功路径;当前环境没有真实 TFS 凭据、连接会话和可查询的 TFS 路径。
|
||||
- 未执行真实 Diff 渲染;完整 Diff 界面属于后续 FE-012。
|
||||
- 是否满足验收标准:
|
||||
- 文件可打开历史入口。
|
||||
- 目录可打开历史入口。
|
||||
- 目录历史 changeset 可查询并展示影响文件列表。
|
||||
- 可选择历史版本或 changeset 文件进入 Diff 入口。
|
||||
- 遗留问题:
|
||||
- 历史 Diff 当前只记录入口选择,不渲染实际 diff 内容,待 FE-012 完成。
|
||||
- 真实 TFS 历史成功路径需要在后续端到端验收中验证。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -58,4 +58,34 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/diff-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md`
|
||||
- 实际实现内容:
|
||||
- 新增 `DiffLocalLatestRequest`、`DiffRevisionsRequest`、`TfsTextDiff`、`TextDiffData` 类型。
|
||||
- 新增 `diffLocalLatest` 和 `diffRevisions` API 封装,对接 `POST /api/diff/local-latest` 与 `POST /api/diff/revisions`。
|
||||
- 新增 `DiffPanel`,支持本地文件 vs 服务器 latest、两个历史 changeset 之间的文本 diff 展示。
|
||||
- `DiffPanel` 将后端统一 diff lines 转成左右两栏文本展示,并在接口失败或无 diff 内容时提示大文件、二进制或不支持类型无法展示文本 diff。
|
||||
- `HistoryPanel` 支持选择两个文件历史版本后打开真实 Diff 面板。
|
||||
- `FolderItemsPanel` 新增 Diff 打开状态,目录对比结果中选择单个可对比文件后可打开本地 vs latest Diff。
|
||||
- 切换连接、Collection 或服务端目录时同步关闭已打开的 History 和 Diff 面板,避免旧路径内容残留。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:工作台、连接页、Inspector、Console 基础结构可渲染;当前非 Electron 环境显示未检测到 preload。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实本地 vs latest Diff;当前环境没有真实 TFS 凭据、连接会话和可对比的映射工作区。
|
||||
- 未执行真实历史版本 A/B Diff;当前环境没有可查询的 TFS 文件历史。
|
||||
- 未验证真实大文件或二进制文件响应;该提示由 Diff API 失败或空 diff 响应统一展示。
|
||||
- 是否满足验收标准:
|
||||
- 目录对比结果已接入单文件 Diff 入口。
|
||||
- 文件历史已支持选择两个版本并打开 Diff 面板。
|
||||
- 大文件、二进制或不支持类型在 Diff 面板中有明确提示。
|
||||
- 遗留问题:
|
||||
- 真实 TFS Diff 成功路径、权限失败、二进制文件和大文件路径需要在后续端到端验收中验证。
|
||||
- Browser 非 Electron 预览中控制台存在 lucide/react `Invalid hook call` 报错;当前页面 DOM 仍可渲染,且未连接状态不会挂载本次 FE-012 的 Diff 组件,建议后续作为前端整体问题单独排查。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -60,4 +60,33 @@ pnpm typecheck
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
- 实际修改文件:
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/client.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/operation-log-panel.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md`
|
||||
- 实际实现内容:
|
||||
- 在统一 `apiRequest` 中新增请求日志订阅机制,请求发出时记录 `running`,请求返回或失败时更新为成功 / 失败。
|
||||
- 请求日志记录操作名称、开始时间、结束时间、耗时、路径摘要、成功 / 失败、错误信息和后端 logs。
|
||||
- 新增 `TfsOperationLogEntry`、`OperationLogsData` 类型。
|
||||
- 新增 `listOperationLogs` API 封装,对接 `GET /api/logs`。
|
||||
- 新增 `OperationLogPanel`,替换底部静态 Console,按表格展示操作、状态、开始、结束、耗时、路径摘要和错误信息。
|
||||
- `Home` 订阅统一 API 日志事件,并支持从服务端刷新最近操作日志。
|
||||
- 已执行测试:
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:底部 Console 初始显示 0 条日志和 service/request/session 兜底信息。
|
||||
- Browser 点击“刷新服务端操作日志”:当前非 Electron 环境下生成 1 条 `frontendRequest` 失败日志,状态、开始时间、结束时间、耗时、路径摘要 `/api/logs` 和错误信息均可读。
|
||||
- 未执行测试及原因:
|
||||
- 未执行真实后端 `/api/logs` 成功路径;当前 Browser 预览没有 Electron preload,无法读取本地 API token。
|
||||
- 未执行真实慢 TFS 请求期间的长时间 running 状态;当前环境没有可用真实 TFS 连接和慢请求场景。
|
||||
- 是否满足验收标准:
|
||||
- 通过统一 API client 发出的主要操作都会产生底部日志记录。
|
||||
- 请求发出时会先显示执行中状态,TFS 慢请求期间 UI 可明确看到正在执行。
|
||||
- 失败日志展示操作名称、路径摘要和 `errorMessage || message`,信息可读。
|
||||
- 遗留问题:
|
||||
- 真实 `/api/logs` 成功路径和慢请求 running 状态需要在后续端到端验收中验证。
|
||||
|
||||
+14
@@ -198,6 +198,20 @@ env JAVA_HOME=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.49
|
||||
- 改用不依赖旧 Workspace 的 `--action test-connection --reuse-config true --output json` 验证旧入口兼容性,结果通过。
|
||||
- 未修改 `~/.mactfs/phase-one.properties`。
|
||||
|
||||
修复状态:已修复。
|
||||
|
||||
修复时间:2026-06-08
|
||||
|
||||
修复内容:
|
||||
|
||||
- `LocalConfigStore.load()` 在读取旧 `phase-one.properties` 后,会优先合并本地 API 使用的 `~/.mactfs/config.json`。
|
||||
- 旧 `--action ... --reuse-config true` 入口可复用第二阶段 API 配置中的 `serverUri`、认证字段、`collection`、`workspace` 和首条 Mapping,避免继续使用本机历史 Workspace。
|
||||
|
||||
修复后验证:
|
||||
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew build`:通过。
|
||||
- 隔离 `user.home` 只放置 `~/.mactfs/config.json` 后,执行旧入口 `--action test-connection --reuse-config true --output json`,返回 `Connection refused`,说明 CLI 已读取 `config.json` 中的 `serverUri`,不再因缺少 `--server-uri` 或旧 Workspace 失败。
|
||||
|
||||
## 八、未覆盖项
|
||||
|
||||
- 未通过 CLI API 执行 `add`、`checkout`、`delete`、`undo`、`checkin` 的真实写入链路;这些已在阶段二 API 中覆盖,本轮避免重复写入 TFS。
|
||||
@@ -0,0 +1,224 @@
|
||||
# macTFS 阶段四 Feature E2E 测试报告
|
||||
|
||||
## 一、测试依据
|
||||
|
||||
- AI 测试流程:`TFS-AI测试流程.md`
|
||||
- 前端规范:`mactfsui/FRONTEND_SPEC.md`
|
||||
- 前端 AI 规则:`mactfsui/AGENTS.md`
|
||||
- Feature E2E 任务:`task/feature/FEATURE-001` 至 `task/feature/FEATURE-005`
|
||||
- PRD:`docs/mactfs-api-product-prd.md`
|
||||
- 执行时间:2026-06-08 12:37-12:48
|
||||
- 执行入口:Electron UI、mactfs 本地 Java API、真实 TFS SDK
|
||||
|
||||
阶段四在当前任务体系中定义为 `Feature E2E`,测试目标是验证 UI、API、Core/TFS SDK 组成的日常 TFS 操作链路。
|
||||
|
||||
## 二、测试环境
|
||||
|
||||
- 服务地址:`http://127.0.0.1:38765`
|
||||
- 前端地址:`http://localhost:5173/`
|
||||
- Java:项目内 x86_64 JDK 8
|
||||
- Electron:`mactfsui` 本地 Electron 应用
|
||||
- 本地 API 服务启动命令:
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
env JAVA_HOME=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home \
|
||||
arch -x86_64 ../tfsIntegration/gradlew -q runServer
|
||||
```
|
||||
|
||||
- 前端启动命令:
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm dev
|
||||
NODE_ENV=development pnpm electron
|
||||
```
|
||||
|
||||
## 三、测试边界
|
||||
|
||||
写操作严格限制在测试流程指定目录:
|
||||
|
||||
- Collection:`PKUSEHR`
|
||||
- Workspace:`mactfs-ai-subapp-pm-mactfs`
|
||||
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
|
||||
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
|
||||
|
||||
本轮不测试:
|
||||
|
||||
- 删除 Workspace
|
||||
- Work Item
|
||||
- Check-in Policy UI
|
||||
- 三方 merge / 冲突解决器
|
||||
- 二进制可视化 diff
|
||||
- UI 侧再次执行真实 checkin 写操作
|
||||
|
||||
说明:
|
||||
|
||||
- API 层完成了真实 TFS 全流程写入验证。
|
||||
- Electron UI 侧只做连接、服务状态、目录浏览、Pending、Console 和工作台可见性验证,避免通过手动 UI 再次产生额外 TFS 写操作。
|
||||
|
||||
## 四、构建与静态验证
|
||||
|
||||
| 验证项 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 后端 / Core 构建 | `env JAVA_HOME=... arch -x86_64 ../tfsIntegration/gradlew build` | 通过,`BUILD SUCCESSFUL` |
|
||||
| 前端类型检查 | `pnpm typecheck` | 通过,`react-router typegen && tsc` 无错误 |
|
||||
|
||||
## 五、Feature E2E 测试结果
|
||||
|
||||
| Feature | 测试项 | 入口 | 结果 | 关键结果 |
|
||||
|---|---|---|---|---|
|
||||
| FEATURE-001 | TFS 连接 | API / Electron UI | 通过 | `collectionCount=2`,UI 显示 `macTFS 已连接` |
|
||||
| FEATURE-001 | Collection 查询 | API / Electron UI | 通过 | 返回 `PE`、`PKUSEHR`,UI Source List 可切换 Collection |
|
||||
| FEATURE-001 | 服务端目录浏览 | API / Electron UI | 通过 | 指定测试目录可浏览;UI 可展示 `PKUSEHR` 根目录和中间文件列表 |
|
||||
| FEATURE-002 | Workspace 复用 | API | 通过 | 复用 `mactfs-ai-subapp-pm-mactfs`,`created=false` |
|
||||
| FEATURE-002 | Mapping 删除 / 创建 / 查询 | API | 通过 | 测试 Mapping 删除后恢复,最终 mapping 数量为 1 |
|
||||
| FEATURE-002 | 不立即 Get Latest 的 Mapping | API | 通过 | `getLatest=false` 创建 Mapping 成功,后续单独执行 Get Latest |
|
||||
| FEATURE-002 | Get Latest | API | 通过 | `updated=2`,`operations=3`,`conflicts=0`,`failures=0` |
|
||||
| FEATURE-003 | 干净目录对比 | API | 通过 | Get Latest 后 `diffs=0` |
|
||||
| FEATURE-003 | localOnly 差异识别 | API | 通过 | 本地新增文件识别为 `localOnly` |
|
||||
| FEATURE-003 | localOnly 执行 add | API | 通过 | `affected=1`,pending 状态为 `pendingAdd` |
|
||||
| FEATURE-003 | pending delete 执行 undo | API | 通过 | delete 后 `pendingDelete`,undo 后 pending 清空 |
|
||||
| FEATURE-004 | Pending Changes 查询 | API / Electron UI | 通过 | API 最终 pending 为 0;UI Inspector 显示 Pending Changes、Included / Excluded 分组 |
|
||||
| FEATURE-004 | comment 必填 | API / UI 状态 | 通过 | 空 comment API 返回 `Missing required field: comment`;UI Checkin 按钮在 Included 为 0 或 comment 空时禁用 |
|
||||
| FEATURE-004 | Checkin 新增文件 | API | 通过 | changeset `678131`,提交 1 项 |
|
||||
| FEATURE-004 | Checkin 修改文件 | API | 通过 | changeset `678132`,提交 1 项 |
|
||||
| FEATURE-005 | 文件历史 | API | 通过 | 测试文件 history 数量 2,最新 changeset `678132` |
|
||||
| FEATURE-005 | 目录历史 | API | 通过 | 测试目录 history 数量 6,最新 changeset `678132` |
|
||||
| FEATURE-005 | changeset 文件列表 | API | 通过 | changeset `678132` 返回 1 个文件,包含本轮测试文件 |
|
||||
| FEATURE-005 | 本地 vs latest diff | API | 通过 | diff 行数 4,包含 `stage4 e2e edit` |
|
||||
| FEATURE-005 | 两个历史版本 diff | API | 通过 | `678131` vs `678132` diff 行数 4,包含修改行 |
|
||||
| Frontend | Electron preload / token / service | Electron UI | 通过 | UI 显示服务可用、token 文件 `/Users/fenghp/.mactfs/server-token` |
|
||||
| Frontend | 操作日志 Console | Electron UI | 通过 | Console 显示 50+ 条 API 操作日志,成功 / 执行中状态可见 |
|
||||
|
||||
## 六、关键执行结果
|
||||
|
||||
本轮 API 全流程关键输出:
|
||||
|
||||
```text
|
||||
health success=true, connected=false
|
||||
connect collectionCount=2
|
||||
collections PE,PKUSEHR
|
||||
server-folder items=2
|
||||
ensure-workspace workspace=mactfs-ai-subapp-pm-mactfs, created=false
|
||||
create-mapping serverPath=$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs, localPath=/Users/fenghp/Desktop/DEV/mactfs-ai-workspace
|
||||
get-latest updated=2, operations=3, conflicts=0, failures=0
|
||||
pending-initial count=0
|
||||
compare-clean diffs=0
|
||||
compare-local-only status=localOnly, name=stage4-e2e-20260608124119.txt
|
||||
add-file affected=1
|
||||
pending-add status=pendingAdd, changeType=add, lock
|
||||
checkin-empty-comment success=false, message=Missing required field: comment
|
||||
checkin-add changeset=678131, submitted=1
|
||||
pending-after-add-checkin count=0
|
||||
checkout-file affected=1
|
||||
pending-edit status=pendingEdit, changeType=lock, edit
|
||||
diff-local-latest lines=4, hasEdit=true
|
||||
checkin-edit changeset=678132, submitted=1
|
||||
history-file count=2, latest=678132
|
||||
history-folder count=6, latest=678132
|
||||
changeset-files count=1, containsTest=true
|
||||
diff-revisions lines=4, hasEdit=true
|
||||
delete-file affected=1
|
||||
pending-delete status=pendingDelete, changeType=delete, lock
|
||||
undo-delete affected=1
|
||||
pending-final count=0
|
||||
operation-logs count=34
|
||||
```
|
||||
|
||||
## 七、测试产生的数据
|
||||
|
||||
本轮新增并提交了 1 个测试文件,随后又提交了 1 次修改:
|
||||
|
||||
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs/stage4-e2e-20260608124119.txt`
|
||||
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
|
||||
- 新增 changeset:`678131`
|
||||
- 修改 changeset:`678132`
|
||||
|
||||
最终状态:
|
||||
|
||||
- 测试 Workspace 保留。
|
||||
- 测试 Mapping 保留 1 条。
|
||||
- 测试文件保留在服务端测试目录中。
|
||||
- 删除操作仅验证到 `pendingDelete`,随后已 `undo`,未提交删除。
|
||||
- 最终 pending changes 为 0。
|
||||
|
||||
## 八、UI 验证记录
|
||||
|
||||
Electron UI 实际可见状态:
|
||||
|
||||
- 顶部显示 `macTFS 已连接`。
|
||||
- 顶部显示 TFS 地址 `http://100.113.212.90:20094/tfs/`。
|
||||
- Source List 显示 `PE`、`PKUSEHR`。
|
||||
- 切换 `PKUSEHR` 后,根目录可加载并展示大量服务端目录。
|
||||
- 中间 `Source Workspace` 文件列表可展示名称、类型、服务端路径、本地路径、映射、状态列。
|
||||
- Inspector 显示 Pending Changes、Included Changes、Excluded Changes、Checkin 输入区。
|
||||
- 空 pending 状态下 Checkin 按钮禁用。
|
||||
- 本地服务显示 `服务可用`,服务地址为 `http://127.0.0.1:38765`。
|
||||
- Console 显示 API 操作日志,包括 `serverTree`、`serverFolderItems`、`listMappings` 等成功记录。
|
||||
|
||||
普通浏览器访问 `http://localhost:5173/` 时,因没有 Electron preload,页面显示:
|
||||
|
||||
```text
|
||||
未检测到 Electron preload,请通过 Electron 启动 macTFS。
|
||||
```
|
||||
|
||||
该行为符合前端规范:React 组件不直接读取本机 token,必须通过 Electron preload 获取本机能力。
|
||||
|
||||
## 九、发现问题与风险
|
||||
|
||||
### P2-1:顶部工具栏全局 Get Latest / History / Compare / Mapping 按钮仍为禁用展示
|
||||
|
||||
现象:
|
||||
|
||||
- Electron UI 顶部工具栏中的 `Get Latest`、`History` 按钮为 disabled。
|
||||
- 主内容标题栏中的全局 `Compare`、`Mapping` 按钮也为 disabled。
|
||||
- 当前可操作入口主要在中间目录面板内:目录区域的 `History`、映射提示、已映射目录下的 `Compare`、选中文件后的 `Get Latest / Checkout`。
|
||||
|
||||
影响:
|
||||
|
||||
- 不影响 API / Core 能力。
|
||||
- 不影响当前已实现的面板内操作链路。
|
||||
- 可能影响用户对顶部工具栏的预期,后续可以决定是接入当前选中目录,还是隐藏未完成的全局按钮。
|
||||
|
||||
建议:
|
||||
|
||||
- 后续前端任务中补齐顶部工具栏与当前选中路径、Mapping 状态、History/Diff 面板的联动。
|
||||
- 或在未接入前移除禁用按钮,避免误导。
|
||||
|
||||
### P2-2:深层目录 UI 手动验证效率较低
|
||||
|
||||
现象:
|
||||
|
||||
- `PKUSEHR` 根目录目录项较多,手动通过 Source List 展开到 `$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs` 较慢。
|
||||
- 通过 Computer Use 手动点击时,长列表滚动和元素编号变化容易误点。
|
||||
|
||||
影响:
|
||||
|
||||
- 不影响真实 API 能力。
|
||||
- 会影响人工验收指定深层路径时的效率。
|
||||
|
||||
建议:
|
||||
|
||||
- 前端后续可增加服务端路径输入 / 快速跳转。
|
||||
- E2E 自动化可在 Electron 启动调试端口后使用稳定的 DOM 自动化工具继续补充深层 UI 点击脚本。
|
||||
|
||||
## 十、未覆盖项
|
||||
|
||||
- 未通过 UI 再次执行真实 add/checkin/delete 写操作,原因是 API 层已完成真实写入验证,本轮 UI 只做可见性和只读链路确认,避免重复污染 TFS 测试目录。
|
||||
- 未验证 Excluded 不参与签入的真实 UI 行为;当前 API checkin 支持 `serverPaths` 精确提交,UI 代码按 Included Changes 传入路径,本轮未制造多个 pending 文件做 UI 分组签入。
|
||||
- 未验证目录对比面板中每个差异操作按钮的 UI 点击路径;API 已覆盖 `localOnly -> add`、`pendingDelete -> undo`,UI 面板结构已存在。
|
||||
- 未验证二进制文件 diff、三方 merge 和冲突解决,这些不属于第一版范围。
|
||||
|
||||
## 十一、结论
|
||||
|
||||
阶段四 Feature E2E 的核心链路通过:
|
||||
|
||||
- 真实 TFS 连接、Collection、目录浏览可用。
|
||||
- Workspace / Mapping / Get Latest 可用。
|
||||
- 目录对比可识别干净目录和本地新增差异。
|
||||
- Pending Changes、comment 必填、add、checkout、delete、undo、checkin 可用。
|
||||
- 文件历史、目录历史、changeset 文件列表、本地 vs latest diff、历史版本 diff 可用。
|
||||
- Electron UI 能通过 preload 读取 token 和服务状态,进入已连接工作台,并展示 Source List、文件列表、Inspector、Console。
|
||||
|
||||
当前主要遗留是前端顶部全局工具栏按钮仍为禁用展示,以及深层目录人工导航效率较低。除这些前端体验项外,本轮阶段四 Feature E2E 主链路验收通过。
|
||||
@@ -0,0 +1,138 @@
|
||||
# macTFS UI 全流程复测报告
|
||||
|
||||
## 一、测试范围
|
||||
|
||||
- 测试时间:2026-06-08 17:25-17:43
|
||||
- 测试入口:Electron UI + Playwright 辅助操作同一前端页面
|
||||
- 服务端:`http://127.0.0.1:38765`
|
||||
- 前端:`http://localhost:5173`
|
||||
- TFS 地址:`http://100.113.212.90:20094/tfs/`
|
||||
- Collection:`PKUSEHR`
|
||||
- Workspace:`mactfs-ai-subapp-pm-mactfs`
|
||||
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
|
||||
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
|
||||
|
||||
本轮复测基于上一轮问题修复后的代码执行。写操作仍严格限制在指定测试目录内。
|
||||
|
||||
## 二、测试结论
|
||||
|
||||
本轮复测主流程通过:连接、切换 `PKUSEHR`、进入指定项目、识别 Mapping、文件级 Get Latest、Checkout、修改本地文件、Pending Changes 展示、UI Checkin 均成功。
|
||||
|
||||
本轮签入生成 changeset `678201`,最终 pending changes 为 0。
|
||||
|
||||
上一轮的关键问题中,以下已修复或明显改善:
|
||||
|
||||
- `Missing required field: collection` 未再出现在 `PKUSEHR` 流程中。
|
||||
- `listMappings` / `pendingChanges` 切到 `PKUSEHR` 后均带 `collection=PKUSEHR` 并返回成功。
|
||||
- 中间文件列表支持双击进入目录。
|
||||
- 文件级 Get Latest 后状态可变为 `upToDate / 已同步`。
|
||||
- Checkout 后右侧 Pending 能显示 `pendingEdit / 编辑`。
|
||||
- Checkin 表单能提交 Included Changes 并展示成功 changeset。
|
||||
|
||||
## 三、流程执行结果
|
||||
|
||||
| 步骤 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 启动服务端 | 通过 | `GET /api/health` 返回 `success=true` |
|
||||
| 启动前端/Electron | 通过 | Electron 显示本地服务可用 |
|
||||
| 连接 TFS | 通过 | `/api/session/connect` 返回 200,UI 进入已连接工作台 |
|
||||
| 切换到 PKUSEHR | 通过 | `/api/server-tree?collection=PKUSEHR`、`/api/server-folder/items?collection=PKUSEHR` 返回 200 |
|
||||
| 定位目标目录 | 通过 | 页面进入 `$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs` |
|
||||
| 识别 Mapping | 通过 | 页面显示当前目录已映射到 `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace` |
|
||||
| 文件级 Get Latest | 通过 | UI 显示 `Get Latest 完成,操作 1 项。`,接口日志 `getLatest success=true` |
|
||||
| Checkout | 通过 | UI 显示 `Checkout 完成,影响 1 项。`,接口日志 `checkout success=true` |
|
||||
| 修改本地文件 | 通过 | 修改前执行 `chmod u+w`,追加 `ui retest edit 20260608174200` |
|
||||
| Pending 展示 | 通过 | UI Included Changes 显示 `stage4-e2e-20260608124119.txt` 和 `pendingEdit / 编辑` |
|
||||
| Checkin | 通过 | UI 显示 `Checkin 成功,changeset 678201,提交 1 项。` |
|
||||
| 最终状态 | 通过 | `GET /api/pending-changes` 返回 0 项 |
|
||||
|
||||
## 四、仍需关注的问题
|
||||
|
||||
### P1:登录后默认进入 PE 会触发 Workspace 不匹配错误
|
||||
|
||||
现象:
|
||||
|
||||
- 登录后 UI 首次加载默认 Collection 为 `PE`。
|
||||
- 当前 workspace 是 `mactfs-ai-subapp-pm-mactfs`,属于 `PKUSEHR` 测试链路。
|
||||
- 因此 UI 初始请求出现:
|
||||
- `/api/pending-changes?collection=PE` 500
|
||||
- `/api/mappings?collection=PE` 500
|
||||
- 错误信息:`Workspace not found: mactfs-ai-subapp-pm-mactfs`
|
||||
|
||||
影响:
|
||||
|
||||
- 虽然切换到 `PKUSEHR` 后可以恢复,但用户会先看到失败日志。
|
||||
- 当前工作区和默认 Collection 的匹配关系仍不稳定。
|
||||
|
||||
建议:
|
||||
|
||||
- 登录后优先选中配置中的 `collection=PKUSEHR`,不要默认选第一个 Collection。
|
||||
- 或在切换 Collection 时同步校验 workspace 是否属于该 Collection,不匹配时不要请求 Pending / Mapping。
|
||||
|
||||
### P2:Electron Computer Use 插件本轮只能读窗口,点击动作被拒绝
|
||||
|
||||
现象:
|
||||
|
||||
- `get_app_state` 能读取 Electron 窗口。
|
||||
- `click` 多次返回 `Computer Use is not active ... first must call get_app_state`。
|
||||
- 已按同一 app name、bundle id、完整 app path 重试,仍无法点击。
|
||||
|
||||
影响:
|
||||
|
||||
- 本轮真实 UI 操作改由 Playwright 辅助执行同一前端页面,并注入等价 `window.mactfs` preload 桥接。
|
||||
- Electron 可视状态仍已确认,但点击动作不是通过 Computer Use 完成。
|
||||
|
||||
建议:
|
||||
|
||||
- 后续如必须验证 macOS 原生 Electron 点击,可在 Computer Use 插件恢复后补跑一次。
|
||||
|
||||
## 五、修复项回归结果
|
||||
|
||||
| 上轮问题 | 本轮结果 |
|
||||
| --- | --- |
|
||||
| `Missing required field: collection` 残留 | PKUSEHR 流程未复现 |
|
||||
| 中间文件列表不能进入目录 | 已支持双击进入,成功进入 `mactfs` |
|
||||
| Get Latest 后状态未刷新 | 文件级 Get Latest 后可显示 `upToDate / 已同步` |
|
||||
| Pending 面板不显示真实变更 | Checkout 后显示 `pendingEdit / 编辑` |
|
||||
| Checkin 无法走 UI | UI Checkin 成功,changeset `678201` |
|
||||
| 顶部/底部操作状态不一致 | 文件选中后底部操作可用;顶部按钮是否完全一致未单独覆盖 |
|
||||
|
||||
## 六、测试数据
|
||||
|
||||
本次修改文件:
|
||||
|
||||
- `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
|
||||
|
||||
追加内容:
|
||||
|
||||
```text
|
||||
ui retest edit 20260608174200
|
||||
```
|
||||
|
||||
签入信息:
|
||||
|
||||
- Comment:`AI UI retest checkin 2026-06-08 17:42`
|
||||
- Changeset:`678201`
|
||||
- 最终 pending changes:`0`
|
||||
|
||||
## 七、辅助产物
|
||||
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/ui-retest-final.png`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/file-flow-after-checkout.png`
|
||||
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/checkin-flow-final.png`
|
||||
|
||||
## 八、修复记录
|
||||
|
||||
修复时间:2026-06-08
|
||||
|
||||
修复内容:
|
||||
|
||||
- `ServerTreePanel` 增加配置 Collection 优先选择逻辑,登录后优先展开 `~/.mactfs/config.json` 中保存的 Collection。
|
||||
- `Home` 将后端配置中的 `collection` 传入 Source List,避免连接后先默认进入第一个 Collection。
|
||||
- 保留无配置 Collection 时回退第一个 Collection 的行为,避免空配置首次使用无法自动浏览。
|
||||
|
||||
修复后验证:
|
||||
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm build`:通过。
|
||||
- 通过 `python3 -m http.server 4174 --bind 127.0.0.1` 打开构建产物:工作台主结构可渲染,Source List / Inspector / Console 均可见。
|
||||
@@ -0,0 +1,174 @@
|
||||
# macTFS UI 手工全流程测试报告
|
||||
|
||||
## 一、测试范围
|
||||
|
||||
- 测试时间:2026-06-08 16:27-16:40
|
||||
- 测试入口:Electron UI(Computer Use)
|
||||
- 服务端:`http://127.0.0.1:38765`
|
||||
- 前端:`http://localhost:5173`
|
||||
- TFS 地址:`http://100.113.212.90:20094/tfs/`
|
||||
- Collection:`PKUSEHR`
|
||||
- Workspace:`mactfs-ai-subapp-pm-mactfs`
|
||||
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
|
||||
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
|
||||
|
||||
本次测试按 `TFS-AI测试流程.md` 约束执行,写操作只发生在指定测试目录内。
|
||||
|
||||
## 二、测试结论
|
||||
|
||||
UI 能完成连接、Collection 切换、服务端目录浏览、识别既有 Mapping、触发 Get Latest;但 UI 在 Pending、目录进入、状态刷新和顶部工具栏联动上存在明显问题,导致“签出文件、修改代码、签入”无法完全通过 UI 连续完成。
|
||||
|
||||
为验证后端链路,本次在 UI 阻断后使用本地 API 补完 checkout、修改、pending changes、checkin。后端链路成功,最终生成 changeset `678184`,最终 pending changes 为 0。
|
||||
|
||||
## 三、流程执行结果
|
||||
|
||||
| 步骤 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 启动服务端 | 通过 | `GET /api/health` 返回 `success=true`,端口 `38765` 可用 |
|
||||
| 启动前端/Electron | 通过 | Electron 显示 `macTFS 已连接` |
|
||||
| 登录/连接 TFS | 通过 | UI 显示 TFS 地址,`/api/collections` 返回 `PE`、`PKUSEHR` |
|
||||
| 切换到 PKUSEHR | 通过 | 左侧 Source List 显示 `PKUSEHR` 根目录 |
|
||||
| 定位目标路径 | 部分通过 | 可通过左侧树进入 `北京排水集团人力系统/5SRC/MicroFront/subapp_pm` |
|
||||
| 映射指定目录 | 通过 | `mactfs` 行显示已映射到 `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace` |
|
||||
| Get Latest | 部分通过 | UI 显示 `Get Latest 完成,操作 4 项`,接口日志 `getLatest success=true` |
|
||||
| 签出文件 | UI 阻断,接口通过 | UI 无法进入已映射目录选择文件;接口 checkout 成功,affected=1 |
|
||||
| 修改文件 | 通过 | 本地文件追加 `ui manual edit 20260608164000` |
|
||||
| Pending Changes | UI 异常,接口通过 | 接口返回 1 条 `pendingEdit`,UI 右侧仍残留旧错误/未显示变更 |
|
||||
| Checkin | UI 阻断,接口通过 | 接口 checkin 成功,changeset=`678184` |
|
||||
| 最终状态 | 通过 | `GET /api/pending-changes` 返回空列表 |
|
||||
|
||||
## 四、发现的问题
|
||||
|
||||
### P1:Pending 面板长期显示 `Missing required field: collection`
|
||||
|
||||
现象:
|
||||
|
||||
- UI 连接成功并已选中 `PKUSEHR` 后,右侧 Pending Changes 仍显示 `Missing required field: collection`。
|
||||
- 后端 `/api/pending-changes` 在配置完整后可正常返回结果,但 UI 旧错误没有清除。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户无法判断真实 pending 状态。
|
||||
- checkin 区域无法根据真实 pending changes 启用。
|
||||
|
||||
### P1:中间文件列表不能进入目录
|
||||
|
||||
现象:
|
||||
|
||||
- 单击或双击 `subapp_pm`、`mactfs` 等目录行,只会选中行,不会进入目录。
|
||||
- 只能依赖左侧 Source List 继续展开深层目录。
|
||||
|
||||
影响:
|
||||
|
||||
- 已映射目录 `mactfs` 下的具体文件无法通过中间列表选择。
|
||||
- 后续 checkout/checkin 的 UI 文件级流程被阻断。
|
||||
|
||||
### P1:Get Latest 后目录状态没有刷新
|
||||
|
||||
现象:
|
||||
|
||||
- UI 显示 `Get Latest 完成,操作 4 项`。
|
||||
- `mactfs` 行仍显示 `notDownloaded / 未下载`。
|
||||
- 同一行已显示本地路径和已映射,状态与操作结果不一致。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户无法确认当前目录是否已同步。
|
||||
- 后续按钮启用状态和用户判断容易被误导。
|
||||
|
||||
### P2:顶部工具栏按钮状态不同步
|
||||
|
||||
现象:
|
||||
|
||||
- 选中已映射的 `mactfs` 行后,底部操作区出现可用 `Get Latest`、`Checkout`。
|
||||
- 顶部工具栏 `Get Latest` 仍为禁用状态。
|
||||
|
||||
影响:
|
||||
|
||||
- 同一动作在不同位置状态不一致。
|
||||
- 用户容易认为当前不能执行 Get Latest。
|
||||
|
||||
### P2:Source List 和主列表状态存在短暂串台/不同步
|
||||
|
||||
现象:
|
||||
|
||||
- 在目录切换过程中,标题显示 `$/北京排水集团人力系统/5SRC`,列表项曾短暂显示其他路径下的 `5SRC`。
|
||||
- `MicroFront` 点击后,左侧树已展开,但中间列表一度仍显示上一层内容。
|
||||
|
||||
影响:
|
||||
|
||||
- 深层目录导航时容易误点其他项目路径。
|
||||
- 对 TFS 写操作场景风险较高,需要确保路径展示严格一致。
|
||||
|
||||
### P2:Mapping/Pending 初始请求对配置状态依赖不稳定
|
||||
|
||||
现象:
|
||||
|
||||
- 早期 UI 日志中多次出现 `/api/mappings`、`/api/pending-changes` 因缺少 `collection` 失败。
|
||||
- 后续 `/api/config` 已包含 `collection=PKUSEHR`、`workspace=mactfs-ai-subapp-pm-mactfs`,接口直连成功,但 UI 错误状态没有完整恢复。
|
||||
|
||||
影响:
|
||||
|
||||
- 首次进入工作台时容易出现错误状态残留。
|
||||
- 用户需要刷新或重新选择目录才能看到部分恢复结果。
|
||||
|
||||
## 五、接口补充验证
|
||||
|
||||
UI 阻断后,为确认后端能力是否可用,执行了以下补充验证:
|
||||
|
||||
- `GET /api/mappings`:成功,返回 1 条目标 Mapping。
|
||||
- `POST /api/files/get-latest`:成功,UI 触发,耗时约 1.4s。
|
||||
- `POST /api/files/checkout`:成功,文件 `stage4-e2e-20260608124119.txt`,affected=1。
|
||||
- 修改本地文件前执行:`chmod u+w /Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`。
|
||||
- `GET /api/pending-changes`:成功,返回 1 条 `pendingEdit`。
|
||||
- `POST /api/checkin`:成功,changeset=`678184`,submittedChanges=1。
|
||||
- 最终 `GET /api/pending-changes`:成功,返回空列表。
|
||||
|
||||
## 六、测试数据
|
||||
|
||||
本次修改文件:
|
||||
|
||||
- `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
|
||||
|
||||
追加内容:
|
||||
|
||||
```text
|
||||
ui manual edit 20260608164000
|
||||
```
|
||||
|
||||
签入信息:
|
||||
|
||||
- Comment:`AI UI manual flow test checkin 2026-06-08 16:40`
|
||||
- Changeset:`678184`
|
||||
|
||||
## 七、建议修复优先级
|
||||
|
||||
1. 优先修复 Pending 面板请求和错误状态清理,确保配置完整后能显示真实 pending changes。
|
||||
2. 修复中间文件列表目录进入能力,至少支持双击目录进入。
|
||||
3. Get Latest 成功后刷新当前目录状态,避免继续显示 `notDownloaded / 未下载`。
|
||||
4. 统一顶部工具栏和底部操作区的按钮启用逻辑。
|
||||
5. 目录切换时清理旧列表数据或加 loading 遮罩,避免路径串台。
|
||||
|
||||
## 八、修复记录
|
||||
|
||||
修复时间:2026-06-08
|
||||
|
||||
修复内容:
|
||||
|
||||
- `PendingChangesPanel` 增加 Collection 输入,未选中 Collection 时不再请求 `/api/pending-changes`,避免初始缺 `collection` 错误残留。
|
||||
- `GET /api/pending-changes` 支持从 query 中读取 `collection` / `workspace` 覆盖当前配置,UI 切换 Collection 后可使用当前选择发起查询。
|
||||
- `GET /api/mappings` 支持从 query 中读取 `collection` / `workspace` 覆盖当前配置,中间列表加载 Mapping 时携带当前 Collection,降低初始配置状态依赖。
|
||||
- `FolderItemsPanel` 在目录切换时清空旧列表和选中状态,接口返回前显示加载态,避免旧路径数据覆盖新路径。
|
||||
- 中间文件列表目录行支持双击进入,并同步主工作区当前路径。
|
||||
- 文件列表选中项映射状态上提到 `Home`,顶部工具栏 `Get Latest` 复用中间底部操作区的选中项状态,`History` 支持当前目录并优先使用选中项。
|
||||
- `Get Latest` 成功后清空旧目录对比结果并刷新当前文件列表,避免继续展示过期 `notDownloaded` 状态。
|
||||
- 未选中 Collection 时禁用 Pending Changes 手动刷新入口,并直接提示选择 Collection,避免再次触发缺 `collection` 请求。
|
||||
- `Get Latest` 成功后在当前 UI 会话内记录已同步路径,列表刷新后显示 `upToDate / 已同步`,避免同一路径继续显示 `notDownloaded / 未下载`。
|
||||
|
||||
修复后验证:
|
||||
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew build`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- Browser 打开 `http://127.0.0.1:5173/`:非 Electron 环境下工作台、Source List、Inspector、Console 可正常渲染;未连接状态下顶部 `Get Latest` / `History` 禁用。
|
||||
- 未重新执行真实 Electron + TFS 手工全流程,原因是当前修复验证阶段未再次提供真实 UI 写操作确认窗口;相关真实 checkout/checkin 写入仍需后续 E2E 复测。
|
||||
@@ -0,0 +1,136 @@
|
||||
# macTFS 前端 UI 布局专项复测报告
|
||||
|
||||
## 一、测试范围
|
||||
|
||||
本轮复测只覆盖前端 UI 布局与可用性问题,不重新执行 TFS 签出、修改、签入的完整业务闭环。
|
||||
|
||||
重点检查项:
|
||||
|
||||
- 长列表场景下,表头、底部操作栏、面板标题是否保持可见。
|
||||
- Source Workspace 目录列表中“双击进入”提示是否影响可读性。
|
||||
- Source List、Source Workspace、Inspector、Console 四个区域的视觉层级和滚动边界。
|
||||
- Electron 桌面端实际运行状态与本地 API 服务状态。
|
||||
|
||||
## 二、测试环境
|
||||
|
||||
- 测试时间:2026-06-08
|
||||
- 前端地址:`http://localhost:5173/`
|
||||
- Electron:`mactfsui/node_modules/.pnpm/electron@42.2.0/.../Electron.app`
|
||||
- 本地 API:`http://127.0.0.1:38765`
|
||||
- Collection:`PKUSEHR`
|
||||
- 测试路径:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/src/views`
|
||||
|
||||
## 三、测试方法
|
||||
|
||||
- 使用 Computer Use 读取 Electron 窗口状态和可访问性树。
|
||||
- 通过 Electron 当前界面进入 `subapp_pm/src/views` 长目录场景。
|
||||
- 通过本地 API 复核列表规模:
|
||||
- `/api/server-folder/items` 返回 `views` 目录下 36 项。
|
||||
- `/api/logs` 返回 116 条操作日志。
|
||||
- `/api/pending-changes?collection=PKUSEHR` 当前返回 0 项。
|
||||
- 结合前端源码核对滚动容器边界:
|
||||
- `app-shell.tsx`
|
||||
- `folder-items-panel.tsx`
|
||||
- `operation-log-panel.tsx`
|
||||
- `pending-changes-panel.tsx`
|
||||
|
||||
限制说明:
|
||||
|
||||
- Computer Use 本轮可以读取 Electron 截图和可访问性树。
|
||||
- Computer Use 的点击/滚动动作间歇返回 `noWindowsAvailable`,因此滚动动作以读屏结果、接口数据规模和源码结构联合判断。
|
||||
- 普通 Chrome 打开 `localhost:5173` 会进入“未检测到 Electron preload”的降级态,不能代表真实桌面端操作状态。
|
||||
|
||||
## 四、测试结果
|
||||
|
||||
### 1. Source Workspace 主目录列表
|
||||
|
||||
结论:主要结构通过,但存在可读性问题。
|
||||
|
||||
- `views` 目录下 36 项,已覆盖长列表场景。
|
||||
- 源码中主列表表头位于列表滚动容器外,底部“已选中”操作栏也位于滚动容器外,理论上滚动列表时不会随内容滚走。
|
||||
- 目录行每一行都展示“双击进入”,在 36 项目录列表中重复出现,压缩了名称列宽。
|
||||
- 部分长目录名如 `assessmentContactPerson`、`importAppraisalResults`、`performanceProgress` 等在名称列中容易被截断;再叠加“双击进入”徽标后,可读性变差。
|
||||
|
||||
建议:
|
||||
|
||||
- 删除目录行内的“双击进入”徽标,保留双击行为即可。
|
||||
- 如需提示,可放到表格空状态、工具提示或首次使用引导,不应在每个目录行重复展示。
|
||||
|
||||
### 2. Console 操作日志面板
|
||||
|
||||
结论:发现需要修复的问题。
|
||||
|
||||
- 当前服务端日志 116 条,Console 是明确的长列表场景。
|
||||
- `operation-log-panel.tsx` 中日志表头位于 `overflow-auto` 容器内部。
|
||||
- 当日志列表纵向滚动时,列头“操作 / 状态 / 开始 / 结束 / 耗时 / 路径摘要 / 错误信息”会跟随日志内容滚动,不会固定在 Console 可视区域顶部。
|
||||
|
||||
建议:
|
||||
|
||||
- 将 Console 列头移出日志滚动容器,或为列头增加 `sticky top-0` 和背景色。
|
||||
- 保持 Console 顶部标题栏“Console / 条数 / 刷新”固定。
|
||||
|
||||
### 3. Source List 左侧树
|
||||
|
||||
结论:结构基本通过,仍有轻微体验问题。
|
||||
|
||||
- Source List 标题栏在滚动容器外,树内容在独立 `overflow-auto` 容器内。
|
||||
- 当前左侧树可显示 `subapp_pm` 下的多级项目,标题“Source List”不会随树内容滚动。
|
||||
- 长路径层级下列表密度较高,目录与文件混排时视觉区分主要依赖小图标,仍可用但扫描成本偏高。
|
||||
|
||||
建议:
|
||||
|
||||
- 保持标题固定。
|
||||
- 后续可考虑为当前选中路径增加更明显的路径面包屑或层级聚焦,但本轮不作为阻塞问题。
|
||||
|
||||
### 4. Inspector / Pending Changes
|
||||
|
||||
结论:当前数据下通过,长 pending 场景未完全覆盖。
|
||||
|
||||
- 当前 pending changes 为 0 项。
|
||||
- Inspector 标题栏在外层滚动容器外,面板内内容独立滚动。
|
||||
- `PendingChangeGroup` 在有数据时使用 `max-h-56 overflow-auto`,分组标题在分组滚动容器外,因此分组标题不会随组内变更列表滚走。
|
||||
- 因当前没有大量 pending changes,未覆盖“多条 Included / Excluded 同时存在”时 Checkin 区域是否需要固定的问题。
|
||||
|
||||
建议:
|
||||
|
||||
- 后续制造 10 条以上 pending changes 后,再专项验证 Included / Excluded 分组滚动和 Checkin 按钮可达性。
|
||||
|
||||
## 五、问题清单
|
||||
|
||||
| 编号 | 严重级别 | 位置 | 问题 | 建议 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| UI-001 | 中 | Console | 日志列头在滚动容器内部,长日志滚动时会跟随内容滚走 | 固定列头或移出滚动容器 |
|
||||
| UI-002 | 中 | Source Workspace | 每个目录行重复展示“双击进入”,长目录列表下占用名称列宽 | 删除行内徽标,保留双击交互 |
|
||||
| UI-003 | 低 | Source Workspace | 长目录名与长服务端路径同时截断,信息定位成本较高 | 保留截断但增加 title/tooltip 或更清晰的路径查看入口 |
|
||||
| UI-004 | 低 | Inspector | 当前 pending 为 0,未验证多 pending 下 Checkin 区域是否始终可达 | 后续构造多 pending 数据再复测 |
|
||||
|
||||
## 六、复测结论
|
||||
|
||||
本轮 UI 复测发现 2 个需要优先处理的问题:
|
||||
|
||||
- Console 日志列表列头需要固定。
|
||||
- Source Workspace 目录行内“双击进入”提示建议移除。
|
||||
|
||||
Source Workspace 主表头、底部“已选中”栏、Source List 标题栏从当前布局结构看已在滚动容器外,未发现会随列表内容滚走的明确问题。
|
||||
|
||||
本轮未执行代码修复,只记录 UI 走查结果并整理测试文档目录。
|
||||
|
||||
## 七、修复记录
|
||||
|
||||
修复时间:2026-06-08
|
||||
|
||||
修复内容:
|
||||
|
||||
- `OperationLogPanel` 将日志列头设置为 `sticky top-0`,长日志滚动时保留列头可见。
|
||||
- `FolderItemsPanel` 删除目录行内重复的“双击进入”徽标,保留双击进入目录交互。
|
||||
- `FolderItemsPanel` 为名称、服务端路径和本地路径补充 `title`,长文本截断时仍可查看完整内容。
|
||||
- `AppShell` 将 Source Workspace 内容区外层从 `overflow-auto` 改为 `overflow-hidden`,避免整个中间面板随列表滚动。
|
||||
- `FolderItemsPanel` 外层改为 `h-full min-h-0 flex-col`,保持目录列表表头和底部“已选中”操作栏在滚动容器外,只让中间列表数据滚动。
|
||||
|
||||
修复后验证:
|
||||
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
|
||||
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm build`:通过。
|
||||
- 构建产物静态服务打开后,工作台主结构可渲染,页面文本中不再出现行内“双击进入”徽标。
|
||||
- 构建产物静态服务打开后,Source Workspace 主内容容器为 `min-h-0 flex-1 overflow-hidden`,中间列表数据滚动不会带动表头和底部“已选中”操作栏。
|
||||
- `pnpm dev` / `vite preview` 本轮因本机 watcher 数量限制报 `EMFILE: too many open files, watch`,已改用生产构建和静态服务验证。
|
||||
@@ -0,0 +1,22 @@
|
||||
# macTFS 测试文档索引
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `00-core/`:核心能力验证。
|
||||
- `01-flow/`:AI 测试流程文档对应的主流程验证。
|
||||
- `02-server-api/`:本地 Java API 服务阶段测试。
|
||||
- `03-cli-api/`:CLI / API 联动阶段测试。
|
||||
- `04-e2e/`:端到端功能流程测试。
|
||||
- `05-ui-manual/`:前端手工全流程测试与复测。
|
||||
- `06-ui-layout/`:前端 UI 布局专项走查。
|
||||
|
||||
## 文档清单
|
||||
|
||||
- `00-core/core-validation-report.md`
|
||||
- `01-flow/tfs-ai-flow-test-report.md`
|
||||
- `02-server-api/mactfs-stage2-server-api-test-report.md`
|
||||
- `03-cli-api/mactfs-stage3-cli-api-test-report.md`
|
||||
- `04-e2e/mactfs-stage4-feature-e2e-test-report.md`
|
||||
- `05-ui-manual/mactfs-ui-manual-flow-test-report.md`
|
||||
- `05-ui-manual/mactfs-ui-manual-flow-retest-report.md`
|
||||
- `06-ui-layout/mactfs-ui-layout-retest-report.md`
|
||||
Reference in New Issue
Block a user