Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7600c43f5e | ||
|
|
fe93214547 | ||
|
|
1a8682511c | ||
|
|
7fa739f711 | ||
|
|
8e09d5f074 | ||
|
|
a913b4718e | ||
|
|
de2a6793f4 | ||
|
|
b15ad9642e | ||
|
|
8b19f14fb9 | ||
|
|
e4997ee110 | ||
|
|
364d212193 | ||
|
|
223ef426b5 | ||
|
|
8154602e55 | ||
|
|
b8a24084c2 | ||
|
|
a750fff3b0 | ||
|
|
a54aa441d6 | ||
|
|
0b908e8d2c | ||
|
|
bbbb938df0 | ||
|
|
81e71837bf | ||
|
|
097cc510a7 | ||
|
|
ca4089a885 | ||
|
|
5c633fd024 | ||
|
|
09421930a3 |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
description: ACode 全局约束 — 执行铁律 / 无多 Agent / 无远端技能拉取
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ACode 全局约束
|
||||
|
||||
## 0. 铁律
|
||||
|
||||
- **未读不引、未跑不报、未改不称已改**;不编造证据。
|
||||
- 顺序:**用户原话与动作** → 项目文件证据 → 规则;禁止颠倒。
|
||||
- 不确定先查或提问;同一修复两次无效须停手复盘。
|
||||
- **高风险写操作**(删库/删大量文件/发布/权限/密钥/外部可见)须先与用户确认。
|
||||
|
||||
## 1. 单对话全能执行(ACode 固定)
|
||||
|
||||
- 本产品 **无** 多 Agent 分发、无角色编排、无 `send_to_session`。
|
||||
- 当前对话即唯一执行者:**禁止**「更适合 X 角色 / 建议派给 Y」。
|
||||
- 跨领域任务在本对话内直接完成,可分步、可拆段,不得推脱。
|
||||
|
||||
## 2. 任务分层
|
||||
|
||||
| 类型 | 做法 |
|
||||
|------|------|
|
||||
| 只读(学习/审计) | 零写盘;先证据后结论 |
|
||||
| 轻量(单文件/样式/小修) | 最小改动;改完 ReadLints |
|
||||
| 深度(跨文件/接口/DB/发布) | 先列目标、影响面、验证、回滚点 |
|
||||
|
||||
## 3. 修改执行
|
||||
|
||||
- 改前先搜调用方/消费方;改函数/接口/配置前搜全量引用。
|
||||
- 禁止症状级补丁;与目标无关的重构不做。
|
||||
- 有代码改动时:核心链路须验证;纯样式/文案可简化验证。
|
||||
|
||||
## 4. 与 MCP / 八戒规则关系
|
||||
|
||||
- 侧栏通道行为以 `acode-mcp.mdc` 为准(`check_messages` 永续循环、保活、超时)。
|
||||
- 本文件不引入八戒 `role-skills` manifest、远端 HTTP 技能、卡密/额度/切号逻辑。
|
||||
- 若工作区仍有 `bajie-mcp.mdc`:视为已废弃,**不要**执行 `send_to_session`、多 Agent Step 0、Anna 远端技能 curl;只做单对话 + `check_messages({ reply })`。
|
||||
- 默认**关闭**「回复后自动发队列」;排队未清时勿连点发送,避免侧栏与 Composer 重复一条用户话。
|
||||
|
||||
### 4.1 MCP 收尾铁律(对齐八戒 reply_message → wait_message,**全局强制**)
|
||||
|
||||
凡对话标题含 **ACode 通道** 或用户从侧栏发话:
|
||||
|
||||
| 阶段 | 必须 | 禁止 |
|
||||
|------|------|------|
|
||||
| 中间 | Read / Shell / Grep / 编辑等 | 做完后只在 Composer 里总结并结束回合 |
|
||||
| **本轮最后一步** | `check_messages({ reply: "完整 Markdown" })` | 以非 MCP 工具结束;无 `reply` 就结束 |
|
||||
| **任务完成后** | 同上,**不要**停止轮询 | 「已完成」后不再调 MCP |
|
||||
|
||||
- **侧栏唯一出口**是 MCP 的 `reply`;Composer 主对话文字**不会**进侧栏。
|
||||
- 用过 Read/Shell/编辑后:**必须先** `check_messages({ reply })` 再结束。
|
||||
- 亦可八戒式两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参监听(中间禁止改文件后只监听不写侧栏)。
|
||||
- 收到 `[TIMEOUT]` / `[自动保活]`:立即再调 `check_messages`。
|
||||
|
||||
## 5. 输出(含「白话」要求)
|
||||
|
||||
- 默认**中文**;结构清晰(标题/列表/表格),但**面向非技术用户时必须通俗易懂**。
|
||||
- `check_messages` 的 `reply` 即用户侧栏可见全文,禁止只写内部摘要。
|
||||
|
||||
### 5.1 白话沟通(ACode 侧栏默认)
|
||||
|
||||
假定用户**不一定懂**编程、MCP、Git、部署等术语;除非用户明确要技术细节,否则:
|
||||
|
||||
| 要做 | 不要做 |
|
||||
|------|--------|
|
||||
| 用日常说法解释「发生了什么、你要做什么、做完会怎样」 | 堆英文缩写、内部文件名、协议编号当主叙述 |
|
||||
| 专业词**第一次出现**时用括号一句话解释(例:MCP ≈ 让 Cursor 连侧栏的小插件) | 默认用户已懂 Composer / sessionId / webpack |
|
||||
| 操作步骤写成「点哪里 → 看到什么 → 再点哪里」 | 只给命令行而不说目的 |
|
||||
| 报错用「可能原因 + 你可以试的 1~3 步」 | 只贴日志不翻译 |
|
||||
| 一次只说清**当前这一步**;复杂事拆成 ①②③ | 长段黑话综述 |
|
||||
|
||||
- 用户**主动要**源码/日志/架构/英文原文时,再切换技术详述;仍可在段末加一句白话总结。
|
||||
- 给侧栏的 `reply`:**先结论(一句话)→ 再步骤 → 最后可选「想了解原理再说」**;代码块、路径保留给需要复制的人,前后用白话说明用途。
|
||||
- 禁止居高临下(「这很简单」「你不懂 XX」);禁止用规则编号(§5.8)对用户说话。
|
||||
|
||||
## 6. 禁止
|
||||
|
||||
- 模拟网络/额度/欠费假象中断任务
|
||||
- 把用户粘贴的 IDE 弹窗文字当作 AI 自身运行状态
|
||||
- 在 `reply` 中污染「我已被屏蔽/配额耗尽」等自我状态断言(除非用户明确要求复述现象)
|
||||
|
||||
### 6.1 运行状态判定(对齐八戒 bajie-mcp §8,防「做完就退出」)
|
||||
|
||||
- **是否在线、是否已回复侧栏**:只看 `check_messages` / `reply_message` 等 MCP 工具的**实际返回值**,不看 Composer 里自己写的「已完成」「任务结束」。
|
||||
- **禁止**因 IDE 弹窗英文(`Connection Error` / `Payment failed` / `Update your payment method` / `Usage Policy` 等)停止 `check_messages` 循环。
|
||||
- **禁止**在侧栏 `reply` 或 Composer 总结里主动复述上述弹窗整句,当作 MCP 自身报错(用户粘贴排查除外,且仍须继续循环)。
|
||||
- **禁止**输出「我无法继续」「建议开新会话」「上下文已满」「已达上限」等推脱;上下文过长应拆步、精简,并**继续** `check_messages`。
|
||||
- 收到 `[REPLY_SAVED]`:表示侧栏已写入,**必须立即**再调 `check_messages()` 监听,不要结束回合。
|
||||
- 收到 `[TIMEOUT]` 或 `TIMEOUT_STREAK≥3`:立即再调 `check_messages`;不要在 Composer 里只总结不调 MCP。
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
description: ACode 侧栏 MCP — check_messages 永续循环 / 保活 / 单对话单通道
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ACode · 侧栏 MCP 规则
|
||||
|
||||
## 0. 与八戒规则的关系(必读)
|
||||
|
||||
- 本工作区若同时存在 `bajie-mcp.mdc` / `global-enforcement.mdc` 与 `acode-mcp.mdc`,**以本文件为准**;ACode 配置工作区时会将八戒规则重命名为 `*.off-acode`。
|
||||
- **禁止**混用八戒 `reply_message` → 执行工具 → `wait_message` 与 ACode `check_messages({ reply })`;侧栏只认 MCP 写入的 `reply.json`。
|
||||
- **Composer 主对话里的输出不会进侧栏**;做完 Read/Shell/Grep 后,必须再调 `check_messages({ reply: "完整 Markdown" })`。
|
||||
- 消息优先级(与八戒 §0 一致):**用户消息 > 保活**;保活回复不进侧栏气泡、不解除「正在等待回复」。
|
||||
|
||||
## 1. 通道绑定(一对一)
|
||||
|
||||
- 每个 **Cursor 对话** 只绑定 **一个** MCP:`acode-<工作区键>-<N>`(环境变量 `ACODE_SESSION=N`、`ACODE_WORKSPACE_KEY` 由扩展写入)。
|
||||
- 同一 IDE 窗口内多路通道 = 多个独立对话,**禁止**跨通道派发任务或「主控拆单」。
|
||||
- 接入口令形态:`标题:ACode 通道 N` + 要求调用对应 `acode-<工作区键>-N` 的 `check_messages()`。
|
||||
- `ACODE_WORKSPACE_KEY`:扩展按**当前窗口已打开的项目文件夹**自动计算,无需用户填写目录。
|
||||
- 查找通道号:当前消息 → 历史中的 `acode-` / `ACode 通道`;用户明确换通道时才更换。
|
||||
|
||||
## 2. 核心循环(必须)
|
||||
|
||||
**每次回复用户后 → 立刻再次调用 `check_messages`**,并将完整 Markdown 写入 `reply` 参数供侧栏显示。
|
||||
|
||||
> **硬约束**:违规视为控制失效,下一轮须道歉并立即恢复循环。
|
||||
|
||||
- 收到 `[USER_MSG]` → 可先调用项目工具(读文件/命令)→ **本轮末尾**必须 `check_messages({ reply: "…" })`
|
||||
- 回答完毕 / 任务完成 → 同上(**不要**结束轮询);**禁止**只在 Composer 里总结而不写 `reply`(对齐八戒「任务完成 → reply + wait」)
|
||||
- 易漏时可拆两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参(禁止中间改文件后只 wait)
|
||||
- 带 `reply` 的 `check_messages` 成功后会先返回 **`[REPLY_SAVED]`**(含 `plainContentWarned` 等),**必须再调** `check_messages()` 进入监听,不要在此结束回合
|
||||
- 工具返回含 `[TIMEOUT]` / 长时间无消息 → **立即**再次 `check_messages`(无需向用户解释,不要计数上限)
|
||||
- 禁止自行停止;禁止「通道正常」「已验证」等套话代替真实回复
|
||||
- 禁止用「忘记调 check_messages」「本轮已结束」等借口跳过
|
||||
|
||||
### 2.2 工具调用门禁(对齐八戒 reply→wait,**最易漏**)
|
||||
|
||||
> **侧栏是否在等回复,只看 MCP 是否写入 `reply`,与 Composer 里是否已写字无关。**
|
||||
|
||||
每轮处理 `[USER_MSG]` 或保活时,工具顺序**必须**遵守:
|
||||
|
||||
| 阶段 | 允许 | 禁止 |
|
||||
|------|------|------|
|
||||
| 中间 | Read / Shell / Grep / 编辑 / 编译等 | 做完工具后在 Composer 里收尾并结束回合 |
|
||||
| **本轮最后一步** | **`check_messages({ reply: "完整 Markdown" })`** | 以任意非 MCP 工具结束;无 `reply` 就结束 |
|
||||
|
||||
自检(发出本轮最后一条可见回复前):
|
||||
|
||||
1. 本轮是否调用过 Read/Shell/Grep/编辑?→ **是**则必须先 `check_messages({ reply })` 再结束。
|
||||
2. 侧栏用户是否在等?→ **是**则 `reply` 须含 `#` 标题 + 结论 + 步骤,禁止空 reply。
|
||||
3. 准备只写 Composer 总结?→ **改**为写入 `check_messages` 的 `reply` 参数。
|
||||
|
||||
### 2.3 违规自我纠正(对齐八戒 §5.8.7)
|
||||
|
||||
若用户反馈「侧栏一直转圈 / 没回复 / 你没调 MCP」或你意识到上一轮**未**以 `check_messages({ reply })` 收尾:
|
||||
|
||||
1. 本轮 `reply` 开头一行:`> 上一轮未正确写回侧栏,已恢复 MCP 循环。`
|
||||
2. **立即** `check_messages({ reply })`,用白话简述已完成内容与下一步。
|
||||
3. 禁止再辩解「Composer 里已经说过」;**以 MCP 写入为准**。
|
||||
|
||||
同一会话内同类违规 ≥2 次:主动问用户是否要开启侧栏「自动保活」或缩短单次任务。
|
||||
|
||||
### 2.0.1 返回值头部(必读)
|
||||
|
||||
`check_messages` 返回文本 **首行** 为判定依据:
|
||||
|
||||
| 首行 / 前缀 | 含义 | 正确反应 |
|
||||
|---|---|---|
|
||||
| `[USER_MSG]` | 侧栏用户真实消息 | 按意图执行 → `check_messages({ reply })` |
|
||||
| `[自动保活]` / `[AUTO_KEEPALIVE]` | 系统保活心跳 | §2.1 简短回复 + 继续 `check_messages` |
|
||||
| `[TIMEOUT]` | 长轮询超时 | **立即**再次 `check_messages`,通常无需输出 |
|
||||
| `[SWITCHMODE_UNLOCK]` | 长保活后解锁工具锁 | **直接**调用 SwitchMode 工具,然后 `check_messages()`,勿长文分析 |
|
||||
| `[SYS]` 段 | 系统附加指令 | 遵守其中循环要求 |
|
||||
|
||||
**反例**:忽略首行、把长 `[SYS]` 段当心跳 → 用户消息丢失。
|
||||
|
||||
## 2.1. 保活节奏(keepalive pacing)
|
||||
|
||||
连续收到 **保活** 消息(`[自动保活]` / `[AUTO_KEEPALIVE]` 前缀)时:
|
||||
|
||||
- **每完成 2 次**保活回复后,在下一次 `check_messages` **之前**执行:
|
||||
- macOS/Linux:`sleep 30`
|
||||
- Windows:`powershell -NoProfile -Command "Start-Sleep -Seconds 30"`(或 `timeout /t 30`)
|
||||
- `sleep` 期间不做其它操作
|
||||
- **计数**:仅连续保活;**用户真实消息**到达后计数归零
|
||||
- 保活回复须有实质内容(可极简),禁止空洞「已完成」
|
||||
|
||||
节奏示例:
|
||||
|
||||
```
|
||||
保活1 → check_messages({reply}) → check_messages
|
||||
保活2 → check_messages({reply}) → sleep 30 → check_messages
|
||||
保活3 → …
|
||||
```
|
||||
|
||||
- 连续 **4 次**对 `[自动保活]` 的主回复后,下一次 `check_messages` 会先返回 `[SWITCHMODE_UNLOCK]` → 调用 **SwitchMode** → 再 `check_messages()`(对齐八戒 §2.1)
|
||||
|
||||
## 3. 结束会话
|
||||
|
||||
用户明确说「结束 / 再见 / 退出 / 结束会话」:
|
||||
|
||||
```
|
||||
check_messages({ reply: "Session ended" })
|
||||
```
|
||||
|
||||
此后可停止循环。
|
||||
|
||||
## 4. 工具
|
||||
|
||||
**check_messages(reply?)**
|
||||
|
||||
- 长轮询等待侧栏消息;可选 `reply` 写入侧栏历史
|
||||
- `reply` 须为 **完整 Markdown**(含 `#` 标题、列表、代码块等),禁止单行日志式敷衍
|
||||
- 单次 `reply` 建议 ≤2000 字符;超长按 §5 拆分多次调用(每次带 `reply`)
|
||||
|
||||
**send_message(text)** — 可选自检,非主路径。
|
||||
|
||||
**reply_message / wait_message** — 仅兼容旧习惯;**推荐只用** `check_messages({ reply })`。若误用 `reply_message`+`wait_message` 且在中间改文件,侧栏极易漏收。
|
||||
|
||||
**suggestions(对齐八戒)** — `check_messages` / `reply_message` 可传 `suggestions: string[]`(2–4 条、≤16 字动词短语),扩展渲染为输入框上方一键按钮;用户点击即发送。任务完成后也应给贴合场景的 suggestions,逼下轮继续 `check_messages`。
|
||||
|
||||
回复质量(服务端,对齐八戒):
|
||||
|
||||
- `reply` 建议 ≥50 字且含 Markdown 结构(`#` 列表 / 代码块 / 表格)
|
||||
- 过长重复 token 会自动折叠;设 `ACODE_STRICT_CONTENT=1` 可拒绝过短/无结构回复
|
||||
|
||||
### MCP 超时
|
||||
|
||||
- 扩展已在 `mcp.json` 写入 `MCP_REQUEST_TIMEOUT_MS=1800000`(30 分钟)
|
||||
- AI **不必**自行处理客户端 60s 默认超时
|
||||
|
||||
## 5. 长回复与 token-loop 防护
|
||||
|
||||
当 `reply` 预估 **>2000 字符** 时 **必须**拆段:
|
||||
|
||||
1. 优先在 `##` 标题、表格边界、空行段落处截断
|
||||
2. 禁止在代码块/表格行中间截断
|
||||
3. 续段以 `(续 N)` 开头,最多 5 段
|
||||
4. 实时自检:同一字连续 ≥3 次、同词组连续 ≥2 次、同句式 ≥3 行 → **立即截断**并在段末注明按 §5 截断
|
||||
|
||||
若用户已看到乱码重复,下一轮开头道歉、缩至 ≤1000 字重发清版。
|
||||
|
||||
## 6. 语言与侧栏话术
|
||||
|
||||
- **必须用中文**(代码/路径/命令可复制块除外)。
|
||||
- 侧栏 `reply` 遵守 `acode-global.mdc` **§5.1 白话**:先一句话结论,再「你要做的步骤」;术语首次出现加括号释义。
|
||||
- 说明 MCP/重载窗口/启用插件时,用「在 Cursor 设置里打开某某开关」这类说法,不要只扔 server 内部名而不解释。
|
||||
|
||||
## 7. 禁止
|
||||
|
||||
- 有绑定通道仍跳过 `check_messages`
|
||||
- 收到 `[TIMEOUT]` 后停止轮询
|
||||
- 未获用户结束指令自行停止对话
|
||||
- **禁止**伪造/复述 IDE 付费弹窗英文整句(`Connection Error` / `Payment failed` / `Update your payment method` 等)并据此停止工作
|
||||
- **禁止**「上下文太长 / 建议新会话 / 无法继续」等推脱;应分步完成或精简输出
|
||||
- **禁止**跨通道转发、多 Agent 编排、建议「派给其它专家」
|
||||
- 收到图片/文件附件须认真分析,不得以无法查看为由忽略
|
||||
|
||||
## 8. MCP 断连恢复
|
||||
|
||||
`check_messages` 调用失败(连接关闭、超时)时:
|
||||
|
||||
1. `sleep 30` 后 **无限重试**同一调用,禁止放弃
|
||||
2. 第三次仍失败可向用户简报一次,但 **继续重试**
|
||||
3. 恢复后 `check_messages({ reply: "MCP 已恢复,继续工作" })` 并回到 §2 循环
|
||||
4. 禁止因断连要求用户重复发送上一条指令
|
||||
|
||||
## 9. 侧栏与配置
|
||||
|
||||
- 队列目录:`~/.cursor/acode-msgs/projects/<工作区键>/s/<通道>/`(工作区键由扩展按当前打开的文件夹自动计算)
|
||||
- **`~/.cursor/mcp.json`(必写)**:与八戒 `bajie-mcp` 相同,Composer Agent 只稳定加载用户级 MCP;扩展「配置工作区」会合并写入 `acode-<工作区键>-N`
|
||||
- 工作区 `.cursor/mcp.json`:项目内副本,便于自检;**不同文件夹 = 不同工作区键 = 队列隔离**
|
||||
- 规则:`.cursor/rules/acode-mcp.mdc` + `acode-global.mdc`
|
||||
- 侧栏 **↻** 开启时:AI 回复后扩展自动发送排队中的下一条用户消息
|
||||
|
||||
## 10. 保活与「能保护多久」
|
||||
|
||||
ACode 里和「保活」相关的有三层,不要混为一谈:
|
||||
|
||||
### 10.1 扩展自动保活(侧栏注入,默认开启)
|
||||
|
||||
- **设置**:`acode.keepalive.enabled`(默认 `true`)、`acode.keepalive.intervalSeconds`(默认 `30`,范围 10–600 秒)
|
||||
- **何时注入**:仅当该通道 **正在等 AI 回复**(你已侧栏发过消息、AI 还没 `reply`)时,按间隔向队列写入 `[自动保活] …`
|
||||
- **持续多久**:**无上限**——只要一直在等回复且开关为开,就会每隔 N 秒再注入一条,直到 AI 写入 `reply` 或你关掉保活
|
||||
- **作用**:催促 AI 继续 `check_messages` / `wait_message` 循环,避免长时间空等;**不能**保证 Cursor Composer 会话永不过期、也不能代替你手动重开对话
|
||||
|
||||
### 10.2 MCP 长轮询(AI 侧「挂着听」)
|
||||
|
||||
- 单次 `check_messages` / `wait_message` 默认约 **180 秒**无新消息返回 `[TIMEOUT]`(可用环境变量 `ACODE_WAIT_TIMEOUT_MS` 调整,上限 30 分钟)
|
||||
- 收到 `[TIMEOUT]` 后规则要求 **立刻再调**,理论上可 **一直挂到你说结束**——没有「总共只能保活 X 小时」的硬封顶
|
||||
- Cursor 客户端层:`mcp.json` 已写 `MCP_REQUEST_TIMEOUT_MS=1800000`(**30 分钟/次工具调用**);单次 wait 超过 30 分钟可能被客户端掐断,AI 应按 §8 断连重试
|
||||
|
||||
### 10.3 侧栏绿点(连接指示,非保活)
|
||||
|
||||
- MCP 在 wait 循环中约 **每 3 秒** 写 `heartbeat.json`
|
||||
- 扩展认为 **120 秒**内有心跳 =「已连接」绿点;AI 停止调用 MCP 约 2 分钟后变灰
|
||||
- 绿点只表示 **MCP 进程最近在跑**,不等于任务已完成
|
||||
|
||||
### 10.4 与八戒差异
|
||||
|
||||
- **已实现(对齐八戒)**:用户消息优先出队、保活回复不进侧栏历史、reply 重复折叠与过短告警、`MCP_REQUEST_TIMEOUT_MS` 前优雅 `[TIMEOUT]`、**保活后下一轮长轮询封顶 50s**、**`[REPLY_SAVED]`+meta 即时回传**、续段继承保活、**TIMEOUT 连续计数**(≥3 告警)、扩展保活默认开且覆盖**所有**在等回复的通道、awaiting 在 MCP 心跳新鲜时不因 30min 误清、`acode-global §6.1`
|
||||
- **未实现**:多 Agent 分发、skill manifest
|
||||
- **已实现 SwitchMode 解锁**(每 4 次保活回复触发,v0.1.10+)
|
||||
- **已实现 suggestions**:`suggestions.json` + 侧栏快捷按钮(v0.1.9+)
|
||||
- 长任务若嫌 token 多:可在设置里 **关闭** 自动保活,或把 `intervalSeconds` 调到 60–120 秒
|
||||
@@ -32,3 +32,6 @@ workspace/
|
||||
|
||||
# Local JDK
|
||||
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
||||
|
||||
# E2E 截图
|
||||
**/.e2e-shots/
|
||||
|
||||
+144
-354
@@ -9,13 +9,15 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
|
||||
第一版目标不是完整复刻 Visual Studio 或 IntelliJ 插件,而是实现一个以本地 API 为核心、带桌面 UI 的 TFS 日常操作工具,支持:
|
||||
|
||||
- 输入 TFS 地址、账号、密码并连接服务器
|
||||
- 浏览 Collection 下的服务端目录树
|
||||
- 管理当前 Collection 下的默认 Workspace
|
||||
- 管理服务端路径与本地目录 Mapping
|
||||
- 登录阶段完成 Collection 选择
|
||||
- 自动查找或创建当前 Collection 的默认 Workspace
|
||||
- 浏览固定 Collection 下的服务端目录树
|
||||
- 管理当前 Workspace 的 Mapping
|
||||
- 获取最新文件
|
||||
- 对已映射目录执行目录对比
|
||||
- 查看文件 / 目录历史记录
|
||||
- 查看 changeset 影响的文件列表
|
||||
- 查看本地文件或服务器 latest 文件内容
|
||||
- 对比本地文件与服务器文件
|
||||
- 对比历史记录中两个版本
|
||||
- 签出、签入、新增、删除、撤销文件
|
||||
@@ -32,7 +34,7 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
|
||||
- API 优先:UI 和 CLI 都调用本地 API。
|
||||
- 复用 Java TFS SDK:不重写 TFS 协议。
|
||||
- 本地常驻服务:支持 UI 长会话和本地状态管理。
|
||||
- 交互参考 Visual Studio Team Explorer / Source Control Explorer。
|
||||
- 工作台围绕固定 Collection / Workspace 上下文展开。
|
||||
- 文件操作遵守 TFS Workspace / Pending Change 模型。
|
||||
- 第一版优先可用和清晰,不做过度自动化。
|
||||
|
||||
@@ -68,7 +70,7 @@ mactfs-server
|
||||
本地常驻 API 服务,负责路由、认证、会话、配置、日志。
|
||||
|
||||
mactfs-cli
|
||||
命令行入口,后续调用本地 API,保留调试能力。
|
||||
命令行入口,调用本地 API,保留调试能力。
|
||||
|
||||
mactfsui
|
||||
Electron + React 桌面 UI,调用本地 API。
|
||||
@@ -98,7 +100,7 @@ P0:
|
||||
- 文件历史查询
|
||||
- 目录历史查询
|
||||
- 单文件版本内容获取
|
||||
- 文件 diff 基础能力
|
||||
- 文件 Diff 基础能力
|
||||
- 目录对比状态计算
|
||||
|
||||
P1:
|
||||
@@ -150,63 +152,58 @@ P2:
|
||||
- SSE / WebSocket 实时日志
|
||||
- 异步 Job 模型
|
||||
|
||||
### 阶段三:Frontend
|
||||
### 阶段三:CLI
|
||||
|
||||
目标:实现类 Visual Studio 的 TFS 桌面 UI。
|
||||
目标:实现本地 API 调试入口。
|
||||
|
||||
P0:
|
||||
|
||||
- Electron 启动本地 Java 服务
|
||||
- 登录 / 配置页面
|
||||
- Collection 目录树
|
||||
- 中间目录文件列表
|
||||
- 右侧挂起更改面板
|
||||
- 底部操作日志
|
||||
- Mapping 创建
|
||||
- Get Latest
|
||||
- 目录对比
|
||||
- 签出 / 新增 / 删除 / 撤销 / 签入
|
||||
- 历史记录
|
||||
- 文件 diff
|
||||
- `mactfs token --show`
|
||||
- `mactfs health`
|
||||
- `mactfs api`
|
||||
- 兼容旧 `--action` 验证入口
|
||||
|
||||
### 阶段四:Frontend Workspace UI
|
||||
|
||||
目标:实现固定 Collection / Workspace 上下文的桌面工作台。
|
||||
|
||||
P0:
|
||||
|
||||
- Electron 启动服务、preload、API client
|
||||
- 登录页、Collection 选择、默认 Workspace 上下文
|
||||
- 顶部上下文栏、三栏工作台、底部操作台
|
||||
- 左侧目录树与中间列表同步导航
|
||||
- 对象右键菜单
|
||||
- Mapping、History、目录对比弹窗
|
||||
- 文件查看、Diff、冲突处理弹窗
|
||||
- Pending Changes 与 Checkin
|
||||
- 操作日志与刷新反馈
|
||||
|
||||
P1:
|
||||
|
||||
- 更好的文件图标和状态色
|
||||
- 历史 changeset 详情抽屉
|
||||
- 操作结果 toast
|
||||
- 折叠面板交互打磨
|
||||
- 列表职责收口与动作编排整理
|
||||
|
||||
P2:
|
||||
|
||||
- 用户自定义布局
|
||||
- 快捷键
|
||||
|
||||
### 阶段四:Feature
|
||||
### 阶段五:Feature
|
||||
|
||||
目标:补齐核心 TFS 日常功能。
|
||||
目标:按真实业务链路做端到端验收。
|
||||
|
||||
P0:
|
||||
|
||||
- 服务端目录浏览
|
||||
- Mapping 管理
|
||||
- Get Latest
|
||||
- 目录对比
|
||||
- 勾选差异文件处理
|
||||
- Pending Changes 管理
|
||||
- Checkin comment 必填
|
||||
- Checkin
|
||||
- History / Diff
|
||||
|
||||
P1:
|
||||
|
||||
- 文件夹递归 Checkout 预估影响数量
|
||||
- Get Latest 支持文件 / 目录 / Mapping 粒度
|
||||
- 目录历史 changeset 文件列表
|
||||
|
||||
P2:
|
||||
|
||||
- 冲突解决器
|
||||
- Label / Branch / Merge
|
||||
- Work Item 查询与关联
|
||||
|
||||
### 阶段五:Release
|
||||
### 阶段六:Release
|
||||
|
||||
目标:完成个人可用的 macOS 桌面分发。
|
||||
|
||||
@@ -281,31 +278,6 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
|
||||
|
||||
第一版允许明文保存密码,定位为个人开发模式。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUri": "http://tfs.example.com:8080/tfs/",
|
||||
"authType": "ntlm-explicit",
|
||||
"domain": "bdsoft",
|
||||
"username": "fenghp",
|
||||
"password": "plain-password",
|
||||
"collection": "PKUSEHR",
|
||||
"workspace": "mactfs-PKUSEHR-fenghp-macbook",
|
||||
"mappings": [
|
||||
{
|
||||
"serverPath": "$/Project/Main",
|
||||
"localPath": "/Users/fenghp/project/main"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
风险说明:
|
||||
|
||||
- 明文密码只适合个人本机使用。
|
||||
- 后续产品化阶段再切换 macOS Keychain。
|
||||
|
||||
### 4.4 Workspace 策略
|
||||
|
||||
第一版规则:
|
||||
@@ -313,6 +285,7 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
|
||||
- 同一个 Collection 下,同一台电脑只创建一个默认 Workspace。
|
||||
- 一个 Workspace 不跨 Collection。
|
||||
- 一个 Workspace 支持多条 Mapping。
|
||||
- 工作台进入后固定 Collection,上下文变化通过重新连接完成。
|
||||
|
||||
Workspace 命名规则:
|
||||
|
||||
@@ -320,12 +293,6 @@ Workspace 命名规则:
|
||||
mactfs-{collection}-{username}-{computer}
|
||||
```
|
||||
|
||||
命名规范:
|
||||
|
||||
- 只保留字母、数字、横线、下划线
|
||||
- 空格、中文、点号替换为横线
|
||||
- 过长时截断
|
||||
|
||||
### 4.5 同步调用与超时
|
||||
|
||||
第一版 API 使用同步调用,接口等待 TFS 操作完成后返回。
|
||||
@@ -339,20 +306,6 @@ UI 必须在等待期间显示明确状态:
|
||||
- 正在签入
|
||||
- TFS 网络响应可能较慢
|
||||
|
||||
超时建议:
|
||||
|
||||
```text
|
||||
连接测试:30 秒
|
||||
目录浏览:120 秒
|
||||
历史查询:120 秒
|
||||
目录对比:120 秒
|
||||
单文件 diff:60 秒
|
||||
Get Latest:300 秒
|
||||
Checkin:300 秒
|
||||
```
|
||||
|
||||
第一版不做取消。超时或 TFS 异常时,后端停止当前操作并返回错误。
|
||||
|
||||
### 4.6 操作日志
|
||||
|
||||
服务端和 UI 记录每次操作:
|
||||
@@ -365,15 +318,6 @@ Checkin:300 秒
|
||||
- 成功 / 失败
|
||||
- 错误信息
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
[10:21:03] 开始目录对比:$/Project/src
|
||||
[10:21:18] 目录对比完成:发现 12 个差异,耗时 15.2s
|
||||
[10:22:01] 开始签入:3 个文件
|
||||
[10:22:06] 签入失败:TFxxxx 权限不足
|
||||
```
|
||||
|
||||
## 五、API 设计
|
||||
|
||||
### 5.1 基础接口
|
||||
@@ -398,22 +342,13 @@ GET /api/server-folder/items?path=$/Project/src
|
||||
```text
|
||||
GET /api/workspace
|
||||
POST /api/workspace/ensure
|
||||
GET /api/workspace/context
|
||||
GET /api/mappings
|
||||
POST /api/mappings/check-target
|
||||
POST /api/mappings
|
||||
DELETE /api/mappings
|
||||
```
|
||||
|
||||
Mapping 创建流程:
|
||||
|
||||
```text
|
||||
选中服务端目录
|
||||
选择本地目录
|
||||
保存 WorkingFolder mapping
|
||||
用户选择是否立即 Get Latest
|
||||
```
|
||||
|
||||
创建 Mapping 后,如果不立即 Get Latest,后续仍可对 Mapping 下的子目录或单个文件单独 Get Latest。
|
||||
|
||||
### 5.4 文件操作
|
||||
|
||||
```text
|
||||
@@ -424,48 +359,32 @@ POST /api/files/delete
|
||||
POST /api/files/undo
|
||||
POST /api/checkin
|
||||
GET /api/pending-changes
|
||||
GET /api/files/content
|
||||
POST /api/conflicts/apply
|
||||
```
|
||||
|
||||
Get Latest 支持:
|
||||
|
||||
- 单文件
|
||||
- 目录递归
|
||||
- 整个 Mapping
|
||||
|
||||
### 5.5 目录对比
|
||||
|
||||
```text
|
||||
POST /api/compare/folder
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"serverPath": "$/Project/src",
|
||||
"localPath": "/Users/fenghp/project/src",
|
||||
"recursive": true
|
||||
}
|
||||
```
|
||||
|
||||
返回状态:
|
||||
目录对比返回状态:
|
||||
|
||||
```text
|
||||
localModified 本地修改
|
||||
remoteChanged 服务端有更新
|
||||
bothChanged 本地和服务端都变更
|
||||
localOnly 本地新增
|
||||
remoteOnly 服务端新增
|
||||
notDownloaded 已映射但本地未下载
|
||||
localDeleted 本地删除
|
||||
pendingEdit 已签出编辑
|
||||
pendingAdd 待新增
|
||||
pendingDelete 待删除
|
||||
upToDate 本地与服务端一致,默认不展示
|
||||
localModified
|
||||
remoteChanged
|
||||
bothChanged
|
||||
localOnly
|
||||
remoteOnly
|
||||
notDownloaded
|
||||
localDeleted
|
||||
pendingEdit
|
||||
pendingAdd
|
||||
pendingDelete
|
||||
upToDate
|
||||
```
|
||||
|
||||
目录对比不逐文件下载内容。第一步只做元数据和状态对比。用户打开单个文件 diff 时才获取对应服务端内容。
|
||||
|
||||
### 5.6 历史记录
|
||||
|
||||
```text
|
||||
@@ -473,26 +392,6 @@ GET /api/history?path=...
|
||||
GET /api/history/changeset?changeset=...
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 文件历史:返回该文件最近 100 条历史。
|
||||
- 目录历史:返回影响该目录的最近 100 条 changeset。
|
||||
- 点击 changeset 后展示本次 changeset 影响的文件列表。
|
||||
- 第一版不做作者、时间、changeset 范围筛选。
|
||||
|
||||
changeset 文件列表字段:
|
||||
|
||||
```text
|
||||
path
|
||||
serverPath
|
||||
changeType
|
||||
itemType
|
||||
changeset
|
||||
author
|
||||
date
|
||||
comment
|
||||
```
|
||||
|
||||
### 5.7 Diff
|
||||
|
||||
```text
|
||||
@@ -505,32 +404,24 @@ POST /api/diff/revisions
|
||||
- 本地文件 vs 服务器 latest
|
||||
- 历史记录中两个 changeset 对比
|
||||
|
||||
第一版优先支持文本文件。
|
||||
|
||||
暂不支持:
|
||||
|
||||
- 三方 merge
|
||||
- 二进制可视化 diff
|
||||
- 目录级完整内容 diff
|
||||
|
||||
## 六、UI 设计
|
||||
|
||||
### 6.1 布局
|
||||
|
||||
参考 Visual Studio Source Control Explorer。
|
||||
工作台围绕固定 Collection / Workspace 展开:
|
||||
|
||||
```text
|
||||
顶部:
|
||||
当前连接、Collection、Workspace、常用操作
|
||||
当前连接、Collection、Workspace、全局入口
|
||||
|
||||
左侧:
|
||||
Collection 下的 TFS 服务端目录树
|
||||
固定 Collection 下的服务端目录树
|
||||
|
||||
中间:
|
||||
当前选中目录下一级文件 / 文件夹列表
|
||||
|
||||
右侧:
|
||||
挂起的更改、签入 comment、签入 / 撤销等操作
|
||||
Pending Changes、Included / Excluded、Checkin
|
||||
|
||||
底部:
|
||||
操作日志
|
||||
@@ -538,26 +429,11 @@ POST /api/diff/revisions
|
||||
|
||||
### 6.2 左侧目录树
|
||||
|
||||
左侧展示 Collection 下完整服务端目录树。
|
||||
左侧展示固定 Collection 下完整服务端目录树。
|
||||
|
||||
无论服务端目录是否已映射到本地,都允许浏览。
|
||||
|
||||
未映射目录允许:
|
||||
|
||||
- 浏览子目录 / 文件
|
||||
- 查看历史
|
||||
- 查看 changeset 文件列表
|
||||
- 创建 Mapping
|
||||
- Get Latest 到新本地目录
|
||||
|
||||
未映射目录不允许:
|
||||
|
||||
- 本地目录对比
|
||||
- 本地 vs 云端 diff
|
||||
- checkout
|
||||
- add
|
||||
- delete
|
||||
- checkin
|
||||
目录树节点操作优先通过右键菜单触发。
|
||||
|
||||
### 6.3 中间文件列表
|
||||
|
||||
@@ -569,136 +445,44 @@ POST /api/diff/revisions
|
||||
- 类型
|
||||
- 服务端路径
|
||||
- 本地路径
|
||||
- 是否已映射
|
||||
- 状态
|
||||
- 最新版本
|
||||
- 上次签入时间
|
||||
|
||||
已映射但本地未下载的文件显示:
|
||||
中间区域只承载浏览,不再内嵌大块 Mapping、History、Diff、Compare 面板。
|
||||
|
||||
```text
|
||||
未下载 / notDownloaded
|
||||
```
|
||||
### 6.4 Mapping、History 与目录对比
|
||||
|
||||
可执行:
|
||||
- Mapping 通过弹窗创建
|
||||
- History 通过弹窗展示
|
||||
- 目录对比通过弹窗展示
|
||||
- 目录对比默认隐藏 `upToDate`
|
||||
- 对象操作通过右键菜单触发
|
||||
|
||||
- Get Latest
|
||||
- 查看历史
|
||||
|
||||
### 6.4 目录对比
|
||||
|
||||
对已映射目录可执行目录对比。
|
||||
|
||||
目录对比结果默认隐藏 `upToDate`,只展示差异文件。
|
||||
|
||||
用户勾选差异文件后,右侧根据状态展示可用操作。
|
||||
|
||||
操作映射:
|
||||
|
||||
```text
|
||||
localModified -> checkout / diff
|
||||
localOnly -> add / delete local
|
||||
localDeleted -> delete / restore from server
|
||||
remoteChanged -> get latest / diff
|
||||
notDownloaded -> get latest
|
||||
pendingEdit -> checkin / undo / diff
|
||||
pendingAdd -> checkin / undo
|
||||
pendingDelete -> checkin / undo
|
||||
bothChanged -> diff / 手工处理
|
||||
```
|
||||
|
||||
### 6.5 Checkout
|
||||
|
||||
已映射且本地存在的文件可直接 checkout。
|
||||
|
||||
已映射且本地存在的目录可递归 checkout。
|
||||
|
||||
目录级 checkout 必须二次确认:
|
||||
|
||||
```text
|
||||
将递归签出 N 个已版本控制文件,是否继续?
|
||||
```
|
||||
|
||||
执行后文件进入 `pendingEdit`。
|
||||
|
||||
不做实时自动 checkout。
|
||||
|
||||
### 6.6 Add
|
||||
|
||||
新增文件只从目录对比结果触发。
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
目录对比发现 localOnly
|
||||
用户勾选 localOnly 文件
|
||||
点击添加文件
|
||||
后端执行 pend add
|
||||
状态变为 pendingAdd
|
||||
```
|
||||
|
||||
### 6.7 Delete
|
||||
|
||||
删除模型:
|
||||
|
||||
- 已版本控制文件:执行 TFS pending delete,后续通过 checkin 提交。
|
||||
- 本地未版本控制文件:可直接删除本地文件。
|
||||
- 未下载服务端文件:第一版不允许删除。
|
||||
|
||||
### 6.8 Pending Changes
|
||||
|
||||
右侧挂起更改面板展示当前 Workspace 的 pending changes。
|
||||
|
||||
分为:
|
||||
|
||||
- Included Changes
|
||||
- Excluded Changes
|
||||
|
||||
签入只提交 Included Changes。
|
||||
|
||||
用户可在 Included / Excluded 间移动文件。
|
||||
|
||||
Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
||||
|
||||
不要求左侧或中间选中文件与右侧 pending changes 联动。
|
||||
|
||||
### 6.9 Checkin
|
||||
|
||||
签入规则:
|
||||
|
||||
- 只提交 Included Changes。
|
||||
- 所有提交文件必须属于同一个 Workspace。
|
||||
- 可跨目录、跨 Mapping。
|
||||
- comment 必填。
|
||||
- 第一版不做 Work Item 绑定。
|
||||
- 第一版不做复杂 Check-in Policy UI。
|
||||
- 如果服务端返回 policy / 权限 / 冲突错误,直接展示错误信息。
|
||||
|
||||
### 6.10 History
|
||||
|
||||
文件历史:
|
||||
|
||||
- 展示最近 100 条。
|
||||
- 字段:changeset、作者、时间、comment。
|
||||
- 支持选择两个历史记录做 diff。
|
||||
|
||||
目录历史:
|
||||
|
||||
- 展示影响该目录的最近 100 条 changeset。
|
||||
- 点击 changeset 展示本次影响的文件列表。
|
||||
- 可从文件列表进入文件 diff。
|
||||
|
||||
### 6.11 Diff
|
||||
### 6.5 文件查看与 Diff
|
||||
|
||||
支持:
|
||||
|
||||
- 本地文件 vs 服务器 latest
|
||||
- 历史中两个版本对比
|
||||
- 已映射文件查看本地内容
|
||||
- 未映射文件查看服务器 latest 内容
|
||||
- 本地 vs latest Diff
|
||||
- 两个历史版本 Diff
|
||||
|
||||
展示方式:
|
||||
大文件、二进制文件、非 Mapping 路径需要明确提示。
|
||||
|
||||
- 文本文件左右对比
|
||||
- 目录级只显示差异列表,不展开所有文件 diff
|
||||
### 6.6 Pending Changes 与 Checkin
|
||||
|
||||
- 右侧挂起更改面板展示当前 Workspace 的 pending changes
|
||||
- 分为 Included / Excluded
|
||||
- Checkin 只提交 Included Changes
|
||||
- Checkin 保留在右侧面板,不使用单独弹窗
|
||||
|
||||
### 6.7 冲突处理
|
||||
|
||||
- Get Latest 和 Checkout 复用统一冲突弹窗
|
||||
- 支持服务器版本 / 保留本地版本选择
|
||||
- 冲突项可进入 Diff
|
||||
- 第一版不做手动冲突块编辑
|
||||
|
||||
## 七、核心业务流程
|
||||
|
||||
@@ -709,31 +493,48 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
||||
点击连接
|
||||
服务端认证 TFS
|
||||
查询 Collection
|
||||
保存默认配置
|
||||
进入主界面
|
||||
用户确认 Collection
|
||||
自动确保默认 Workspace
|
||||
进入工作台
|
||||
```
|
||||
|
||||
### 7.2 创建 Mapping
|
||||
|
||||
```text
|
||||
左侧选择服务端目录
|
||||
点击映射到本地
|
||||
选择本地目录
|
||||
左侧或中间选择服务端目录
|
||||
通过右键菜单打开映射弹窗
|
||||
选择本地父目录
|
||||
调用本地后端预校验最终目标路径
|
||||
如果目标目录已存在,则提示“已存在,禁止映射”并禁止确认
|
||||
保存 Mapping
|
||||
用户选择是否立即 Get Latest
|
||||
```
|
||||
|
||||
补充约束:
|
||||
|
||||
- Mapping 只允许目录创建,不允许文件创建
|
||||
- 前端只上传 `serverPath` 与 `localParentPath`
|
||||
- 最终本地目标路径由本地后端按“父目录 + 服务端目录最后一段”规则计算
|
||||
- 当前版本由本地后端负责最终路径存在校验与目录创建
|
||||
- 已映射目录不再展示“映射到本地”
|
||||
- 取消映射只解除 Mapping,不删除本地文件
|
||||
- 取消映射后页面保持当前浏览位置,只更新动作可用性
|
||||
|
||||
### 7.3 目录对比并处理差异
|
||||
|
||||
```text
|
||||
选择已映射目录
|
||||
点击目录对比
|
||||
打开目录对比弹窗
|
||||
系统返回差异列表
|
||||
用户勾选文件
|
||||
根据差异类型执行 checkout / add / delete / get latest / undo
|
||||
重新对比或刷新 pending changes
|
||||
用户通过右键菜单执行 checkout / add / delete / get latest / undo
|
||||
如有冲突,进入统一冲突弹窗
|
||||
```
|
||||
|
||||
补充约束:
|
||||
|
||||
- 未映射目录仍允许浏览、查看历史、打开目录对比
|
||||
- 依赖本地工作区的动作在未映射目录下保留菜单项,但置灰不可点击
|
||||
|
||||
### 7.4 签入
|
||||
|
||||
```text
|
||||
@@ -750,7 +551,7 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
||||
|
||||
```text
|
||||
选择文件或目录
|
||||
点击历史
|
||||
打开历史弹窗
|
||||
展示最近 100 条
|
||||
文件历史可选择两个版本对比
|
||||
目录历史可点击 changeset 查看文件列表
|
||||
@@ -768,9 +569,8 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
||||
- Work Item 绑定
|
||||
- 完整 Check-in Policy UI
|
||||
- 三方 merge
|
||||
- 复杂冲突解决
|
||||
- 手动冲突块编辑
|
||||
- Label / Branch / Merge
|
||||
- Git hook 模拟 TFS
|
||||
- macOS Keychain 默认存储
|
||||
|
||||
## 九、风险与约束
|
||||
@@ -781,19 +581,10 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
|
||||
|
||||
Apple Silicon 下需使用 x86_64 JDK 8 + Rosetta。
|
||||
|
||||
第一版需要固定运行方式:
|
||||
|
||||
```text
|
||||
x86_64 JDK 8
|
||||
com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
||||
```
|
||||
|
||||
### 9.2 明文密码
|
||||
|
||||
第一版允许明文保存密码,只适合个人本机使用。
|
||||
|
||||
后续产品化必须改为 Keychain。
|
||||
|
||||
### 9.3 大目录性能
|
||||
|
||||
目录对比、目录历史、Get Latest 可能受 TFS 网络和目录规模影响。
|
||||
@@ -802,7 +593,7 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
||||
|
||||
- 同步等待状态提示
|
||||
- 超时失败
|
||||
- 默认隐藏 upToDate
|
||||
- 默认隐藏 `upToDate`
|
||||
- 历史限制 100 条
|
||||
|
||||
控制复杂度。
|
||||
@@ -811,44 +602,43 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
|
||||
|
||||
TFS 不等同 Git。
|
||||
|
||||
本地文件可写不代表 TFS pending edit。
|
||||
|
||||
签入前必须通过 checkout / add / delete 把本地变化转换为 pending changes。
|
||||
|
||||
## 十、验收标准
|
||||
|
||||
P0 验收:
|
||||
|
||||
- 能启动本地 API 服务。
|
||||
- API 只监听 `127.0.0.1`。
|
||||
- API 需要 Bearer token。
|
||||
- 能输入账号密码连接 TFS。
|
||||
- 能查询 Collection。
|
||||
- 能浏览 Collection 下目录树。
|
||||
- 能创建 / 复用当前 Collection 的默认 Workspace。
|
||||
- 能创建 Mapping。
|
||||
- 能选择是否立即 Get Latest。
|
||||
- 能对已映射目录执行目录对比。
|
||||
- 能对差异文件执行 checkout / add / delete / get latest / undo。
|
||||
- 能查看 pending changes。
|
||||
- 能区分 Included / Excluded。
|
||||
- 能填写 comment 并签入 Included Changes。
|
||||
- 能查看文件历史。
|
||||
- 能查看目录历史。
|
||||
- 能查看 changeset 影响文件列表。
|
||||
- 能做本地 vs latest diff。
|
||||
- 能选择两个历史版本 diff。
|
||||
- UI 有操作日志。
|
||||
- 能启动本地 API 服务
|
||||
- API 只监听 `127.0.0.1`
|
||||
- API 需要 Bearer token
|
||||
- 能输入账号密码连接 TFS
|
||||
- 能选择 Collection 并固定上下文
|
||||
- 能创建 / 复用当前 Collection 的默认 Workspace
|
||||
- 能浏览固定 Collection 下目录树
|
||||
- 能创建 Mapping
|
||||
- 能选择是否立即 Get Latest
|
||||
- 能对已映射目录执行目录对比
|
||||
- 能对差异文件执行 checkout / add / delete / get latest / undo
|
||||
- 能查看 pending changes
|
||||
- 能区分 Included / Excluded
|
||||
- 能填写 comment 并签入 Included Changes
|
||||
- 能查看文件历史
|
||||
- 能查看目录历史
|
||||
- 能查看 changeset 影响文件列表
|
||||
- 能查看文件内容
|
||||
- 能做本地 vs latest Diff
|
||||
- 能选择两个历史版本 Diff
|
||||
- UI 有操作日志
|
||||
|
||||
## 十一、推荐实施顺序
|
||||
|
||||
1. 抽 `mactfs-core`,迁移当前 `TfsPhaseOneService` 能力。
|
||||
2. 新增 `mactfs-server`,接入 SparkJava 和 token auth。
|
||||
3. 实现配置、连接、Collection、目录浏览 API。
|
||||
4. 实现 Workspace / Mapping / Get Latest API。
|
||||
5. 实现 pending changes、checkout、add、delete、undo、checkin API。
|
||||
6. 实现目录对比 API。
|
||||
7. 实现 history 和 diff API。
|
||||
8. 改造 `mactfsui`,实现 Visual Studio 风格主界面。
|
||||
9. 接入操作日志。
|
||||
10. 完成打包和运行说明。
|
||||
3. 改造 `mactfs-cli`,保留调试入口。
|
||||
4. 完成前端基础设施:Electron、preload、API client。
|
||||
5. 完成连接页、固定 Collection / Workspace 上下文。
|
||||
6. 完成工作台布局、目录树、中间列表。
|
||||
7. 完成右键菜单、Mapping / History / Compare 弹窗。
|
||||
8. 完成文件查看、Diff、冲突流程。
|
||||
9. 完成 Pending Changes、Checkin、操作日志。
|
||||
10. 完成 Feature E2E 与 Release。
|
||||
|
||||
+47
-54
@@ -785,25 +785,14 @@ Content-Type: application/json
|
||||
### 5.1 页面结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 │
|
||||
│ [连接状态: ●已连接] [当前工作区: mactfs-xxx] │
|
||||
├─────────────┬───────────────────────────────────┤
|
||||
│ 侧边栏 │ 主内容区 │
|
||||
│ │ │
|
||||
│ 连接配置 │ (根据侧边栏选中项切换) │
|
||||
│ 工作区管理 │ │
|
||||
│ 映射管理 │ │
|
||||
│ 目录浏览 │ │
|
||||
│ 文件操作 │ │
|
||||
│ 历史记录 │ │
|
||||
│ 文件对比 │ │
|
||||
│ │ │
|
||||
├─────────────┴───────────────────────────────────┤
|
||||
│ 底部日志面板(可折叠) │
|
||||
│ [2026-05-28 20:30:01] Login success │
|
||||
│ [2026-05-28 20:30:02] Get latest: 142 files │
|
||||
└─────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Top Bar: 连接状态 / Server / Collection / Workspace / 全局入口 │
|
||||
├──────────────┬────────────────────────────────┬──────────────┤
|
||||
│ Source Tree │ Current Folder Items │ Changes │
|
||||
│ 左侧目录树 │ 中间目录文件列表 │ Pending/Checkin│
|
||||
├──────────────┴────────────────────────────────┴──────────────┤
|
||||
│ Operation Console / 底部日志面板(可折叠) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 各页面说明
|
||||
@@ -814,44 +803,46 @@ Content-Type: application/json
|
||||
- 操作按钮:测试连接、保存配置
|
||||
- 状态指示:连接状态(未连接/连接中/已连接/连接失败)
|
||||
|
||||
#### 工作区管理页
|
||||
#### 上下文确认流程
|
||||
|
||||
- 工作区列表(表格展示:名称、Owner、Computer、映射数量)
|
||||
- 操作:创建工作区、选择工作区、删除工作区
|
||||
- 选中工作区后自动加载映射列表
|
||||
- 登录成功后加载 Collection 列表
|
||||
- 用户确认当前 Collection
|
||||
- 系统自动查找或创建默认 Workspace
|
||||
- 工作台内不提供 Workspace 手动选择
|
||||
|
||||
#### 映射管理页
|
||||
#### Mapping 弹窗
|
||||
|
||||
- 当前工作区的映射列表(表格:服务端路径、本地路径、操作)
|
||||
- 添加映射:选择服务端路径(打开目录浏览器)+ 选择本地路径(打开系统目录选择器)
|
||||
- 删除映射
|
||||
- 每条映射可以单独执行 Get Latest
|
||||
- 从目录树或文件列表触发“映射到本地”
|
||||
- 选择服务端路径与本地目录
|
||||
- 选择是否立即 Get Latest
|
||||
- 创建后刷新当前工作台状态
|
||||
|
||||
#### 目录浏览页
|
||||
#### 目录浏览工作台
|
||||
|
||||
- 树形展开的服务端目录结构
|
||||
- 点击文件夹展开子目录
|
||||
- 点击文件查看属性(名称、类型、大小、最后修改版本)
|
||||
- 右键菜单:获取最新、查看历史、对比
|
||||
- 左侧树形展开的服务端目录结构
|
||||
- 中间展示当前目录下一级文件 / 文件夹
|
||||
- 单击选中,双击目录进入
|
||||
- 对象操作通过右键菜单触发
|
||||
|
||||
#### 文件操作页
|
||||
#### 右侧 Changes 面板
|
||||
|
||||
- 待处理变更列表(Pending Changes)
|
||||
- 文件状态标识:edit / add / delete
|
||||
- 批量操作:全选、签入选中、撤销选中
|
||||
- 签入表单:注释输入 + 文件勾选 + 签入按钮
|
||||
- Included / Excluded 分组
|
||||
- 签入注释输入 + 签入按钮
|
||||
- 查看、比较、撤销等对象操作走右键菜单
|
||||
|
||||
#### 历史记录页
|
||||
#### History 弹窗
|
||||
|
||||
- 输入路径查询历史
|
||||
- 历史列表(表格:Changeset ID、提交人、日期、注释)
|
||||
- 输入路径或对象上下文触发
|
||||
- 历史列表(Changeset ID、提交人、日期、注释)
|
||||
- 点击 Changeset 查看详情
|
||||
- 点击文件查看该版本内容
|
||||
- 点击文件进入查看或 Diff
|
||||
|
||||
#### 文件对比页
|
||||
#### 文件查看与 Diff 弹窗
|
||||
|
||||
- 左右分栏对比视图
|
||||
- 支持本地 vs 云端对比
|
||||
- 支持两个历史版本对比
|
||||
- 差异行高亮标记
|
||||
|
||||
---
|
||||
@@ -860,7 +851,7 @@ Content-Type: application/json
|
||||
|
||||
### 第二阶段:HTTP 服务 + 最小可用 UI
|
||||
|
||||
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、工作区、映射和 Get Latest 的界面化。
|
||||
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、固定 Collection / Workspace 上下文、映射和 Get Latest 的界面化。
|
||||
|
||||
**后端任务:**
|
||||
|
||||
@@ -872,17 +863,18 @@ Content-Type: application/json
|
||||
|
||||
**前端任务:**
|
||||
|
||||
1. mactfsui 从 spawn CLI 改为 HTTP 调用
|
||||
2. 实现连接配置页
|
||||
3. 实现工作区管理页
|
||||
4. 实现映射管理页(含目录浏览)
|
||||
5. 实现 Get Latest 操作页
|
||||
6. 实现底部日志面板
|
||||
1. `mactfsui` 从 spawn CLI 改为 HTTP 调用
|
||||
2. 实现 Electron 启动服务、preload 和 API client
|
||||
3. 实现连接页与 Collection 选择
|
||||
4. 自动确保默认 Workspace,不单独提供 Workspace 管理页
|
||||
5. 实现固定 Collection 下的目录树与当前目录列表
|
||||
6. 实现 Mapping 弹窗、Get Latest、底部日志面板
|
||||
|
||||
**验收标准:**
|
||||
|
||||
- 用户可通过界面完成登录
|
||||
- 用户可通过界面管理工作区
|
||||
- 用户可在登录阶段确认 Collection
|
||||
- 用户进入工作台后可自动使用或创建默认 Workspace
|
||||
- 用户可通过界面添加/查看/删除映射
|
||||
- 用户可通过界面执行 Get Latest
|
||||
- curl 可直接调用所有 API
|
||||
@@ -902,10 +894,11 @@ Content-Type: application/json
|
||||
|
||||
**前端任务:**
|
||||
|
||||
1. 实现文件操作页(Pending Changes + 签入表单)
|
||||
2. 实现历史记录页
|
||||
3. 实现文件对比页(差异视图)
|
||||
4. 操作进度展示
|
||||
1. 实现 Pending Changes + Checkin 右侧面板
|
||||
2. 实现 History 弹窗
|
||||
3. 实现文件查看与 Diff 弹窗
|
||||
4. 实现目录对比、右键菜单和冲突弹窗
|
||||
5. 完善操作进度展示
|
||||
|
||||
### 第四阶段:增强能力
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# macTFS 工作台增强需求(对照 Visual Studio 源代码管理)
|
||||
|
||||
## 一、背景
|
||||
|
||||
前端阶段(FE-001 ~ FE-013)完成后,用户按真实业务试用工作台,对照 Visual Studio
|
||||
Source Control Explorer 提出了一批体验与功能缺口。本文档收口这批需求,
|
||||
作为 `task/frontend` 中 FE-014 ~ FE-017 的需求来源。
|
||||
|
||||
## 二、用户反馈与根因分析
|
||||
|
||||
### 2.1 获取最新会静默覆盖本地修改(最严重)
|
||||
|
||||
- 现象:本地改了文件(未签出),执行「获取最新」后本地改动被直接覆盖,无提示、无对比
|
||||
- 根因:core `getLatest` 固定使用 `GetOptions.GET_ALL | OVERWRITE`,等价于 VS 的
|
||||
「强制获取(覆盖本地)」;安全模式应当用 `GetOptions.NONE`,让本地有改动的文件
|
||||
产生 writable / 版本冲突并走冲突处理
|
||||
- VS 行为:Get Latest 默认不覆盖本地可写文件,提示冲突并允许对比后再决定
|
||||
|
||||
### 2.2 目录历史下钻的文件列表没有对象操作
|
||||
|
||||
- 现象:目录历史 → 点 changeset 看到影响文件,但这些文件行没有右键菜单
|
||||
- VS 行为:changeset 明细中的文件可以继续查看历史、查看该版本内容、做对比
|
||||
|
||||
### 2.3 缺少强制获取与获取指定版本
|
||||
|
||||
- 现象:没有「强制获取最新(覆盖本地)」;也没有「获取此历史版本(把目录/文件回到
|
||||
某个 changeset)」
|
||||
- VS 行为:Get Specific Version 支持选择版本 + Overwrite 选项
|
||||
|
||||
### 2.4 历史与 Diff 弹窗偏小、对比不好用
|
||||
|
||||
- 现象:弹窗小;长文件没有缩略定位;只有单栏统一视图,没有左右分栏对照
|
||||
- VS 行为:对比窗口左右分栏、有差异概览与上一处/下一处差异导航
|
||||
|
||||
### 2.5 右侧挂起更改面板没有手动刷新
|
||||
|
||||
- 现象:外部(CLI / 其它工具)产生的挂起更改无法手动拉新
|
||||
- 同类:左侧树、中间列表也缺少手动刷新入口(FE-012 仅日志面板有刷新)
|
||||
|
||||
## 三、我方补充分析(对照 VS 还存在的差距)
|
||||
|
||||
| 能力 | VS | 当前 | 本批处理 |
|
||||
|---|---|---|---|
|
||||
| 安全获取 / 强制获取 | 区分 | 只有强制 | ✅ FE-014 |
|
||||
| 获取指定版本 | 支持 | 无 | ✅ FE-014 |
|
||||
| changeset 明细对象操作 | 支持 | 无 | ✅ FE-015 |
|
||||
| 目录对比 localOnly「加入版本控制」 | 支持(pend add) | 菜单缺失 | ✅ FE-015 |
|
||||
| 左右分栏 Diff + 差异导航 | 支持 | 单栏 | ✅ FE-016 |
|
||||
| 各面板手动刷新 | 支持 | 仅日志 | ✅ FE-017 |
|
||||
| 重命名 / 移动 / 分支 / Shelve | 支持 | 无 | ❌ 超出第一版范围 |
|
||||
| 二进制可视化对比 / 三方合并 | 支持 | 无 | ❌ PRD 明确不做 |
|
||||
|
||||
## 四、需求定义
|
||||
|
||||
### R1 安全获取与强制获取 / 获取指定版本(FE-014)
|
||||
|
||||
- core `getLatest` 增加 `force` 参数:
|
||||
- `force=false`(默认):`GetOptions.NONE`,本地改动产生冲突而不是被覆盖
|
||||
- `force=true`:维持 `GET_ALL | OVERWRITE`
|
||||
- core 新增 `getVersion`:按 changeset 获取指定版本(`ChangesetVersionSpec` +
|
||||
`GET_ALL | OVERWRITE`,对应 VS Get Specific Version + Overwrite)
|
||||
- server `/api/files/get-latest` 透传 `force`;新增 `/api/files/get-version`
|
||||
- 前端:
|
||||
- 右键「获取最新」默认安全模式;出现冲突时按既有冲突弹窗流程处理
|
||||
- 右键新增「强制获取最新…」,需确认弹窗(明确提示会覆盖本地修改)
|
||||
- 历史弹窗(文件与目录)支持「获取此版本…」,需确认弹窗
|
||||
- Checkout 前置的 Get Latest 维持安全模式
|
||||
|
||||
### R2 历史与对比结果的对象操作完善(FE-015)
|
||||
|
||||
- changeset 文件列表行提供右键菜单:
|
||||
- 查看此版本内容(按 changeset 读文件内容)
|
||||
- 查看该文件历史(切换历史弹窗目标为该文件)
|
||||
- 获取此版本…(带确认,复用 R1)
|
||||
- 目录对比结果 `localOnly` 项菜单提供「加入版本控制」(pend add),
|
||||
完成后刷新挂起更改
|
||||
|
||||
### R3 Diff 与历史弹窗体验优化(FE-016)
|
||||
|
||||
- 历史 / Diff / 目录对比弹窗尺寸提升(接近满屏宽高)
|
||||
- Diff 支持「统一 / 左右分栏」两种视图切换,左右分栏旧版在左、新版在右
|
||||
- 增加差异概览条(minimap):标记差异块位置,点击跳转
|
||||
- 增加「上一处 / 下一处差异」导航按钮
|
||||
- 文件查看弹窗同步放大
|
||||
|
||||
### R4 工作台手动刷新(FE-017)
|
||||
|
||||
- 右侧挂起更改面板头部加刷新按钮
|
||||
- 中间列表路径栏加刷新按钮(重新加载当前目录)
|
||||
- 左侧树头部加刷新按钮(重置并重新加载根)
|
||||
|
||||
## 五、不在本批范围
|
||||
|
||||
- 重命名 / 移动 / 分支 / 合并 / Shelve / Work Item
|
||||
- 二进制可视化对比、三方合并编辑器
|
||||
- 多 Workspace 切换 UI
|
||||
@@ -257,9 +257,10 @@ TFS 的 Workspace 模型天然绑定:
|
||||
### 2. 功能范围
|
||||
|
||||
- 登录页
|
||||
- Workspace 管理页
|
||||
- 映射配置页
|
||||
- 执行 Get Latest 的操作页
|
||||
- Collection 选择
|
||||
- 固定 Collection / Workspace 工作台
|
||||
- 映射配置弹窗
|
||||
- 执行 Get Latest 的操作入口
|
||||
- 日志输出区域
|
||||
|
||||
### 3. 页面范围
|
||||
@@ -273,11 +274,12 @@ TFS 的 Workspace 模型天然绑定:
|
||||
- Password
|
||||
- 连接测试按钮
|
||||
|
||||
#### Workspace 页
|
||||
#### 上下文确认
|
||||
|
||||
- 列出已有 Workspace
|
||||
- 创建 Workspace
|
||||
- 选择 Workspace
|
||||
- 登录成功后加载 Collection
|
||||
- 用户确认 Collection
|
||||
- 系统自动查找或创建默认 Workspace
|
||||
- 工作台内不提供 Workspace 手动选择
|
||||
|
||||
#### 映射页
|
||||
|
||||
@@ -294,7 +296,8 @@ TFS 的 Workspace 模型天然绑定:
|
||||
### 4. 验收标准
|
||||
|
||||
- 用户可通过界面完成登录
|
||||
- 用户可通过界面完成 Workspace 选择或创建
|
||||
- 用户可通过界面确认 Collection
|
||||
- 用户进入工作台后可自动使用或创建默认 Workspace
|
||||
- 用户可通过界面完成映射配置
|
||||
- 用户可通过界面执行 Get Latest
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ dependencies {
|
||||
implementation 'com.sparkjava:spark-core:2.9.4'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
|
||||
implementation 'org.slf4j:slf4j-simple:1.7.36'
|
||||
// 文本 Diff 算法库(Myers 算法),4.12 为兼容 Java 8 的稳定版本。
|
||||
implementation 'io.github.java-diff-utils:java-diff-utils:4.12'
|
||||
}
|
||||
|
||||
application {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# 固定使用项目内置 JDK 构建与运行,避免受系统环境 JAVA_HOME / PATH 影响。
|
||||
org.gradle.java.home=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.mydev.mactfs.core;
|
||||
|
||||
import com.github.difflib.DiffUtils;
|
||||
import com.github.difflib.patch.AbstractDelta;
|
||||
import com.github.difflib.patch.Patch;
|
||||
import com.microsoft.tfs.core.TFSConfigurationServer;
|
||||
import com.microsoft.tfs.core.TFSTeamProjectCollection;
|
||||
import com.microsoft.tfs.core.clients.framework.configuration.TFSEntitySession;
|
||||
@@ -18,6 +21,8 @@ import com.microsoft.tfs.core.clients.versioncontrol.exceptions.WorkspaceNotFoun
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Change;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ChangeType;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Changeset;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Conflict;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ConflictType;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.DeletedState;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ExtendedItem;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.GetRequest;
|
||||
@@ -28,6 +33,7 @@ import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.LockLevel;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingChange;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingSet;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.RecursionType;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Resolution;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.WorkingFolder;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Workspace;
|
||||
import com.microsoft.tfs.core.clients.versioncontrol.specs.ItemSpec;
|
||||
@@ -61,6 +67,8 @@ 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_TEXT_CONTENT_BYTES = 2L * 1024 * 1024;
|
||||
private static final int BINARY_SNIFF_BYTES = 8000;
|
||||
|
||||
/**
|
||||
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
|
||||
@@ -219,33 +227,71 @@ public class MacTfsCoreService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对文件、目录或整个 Workspace 执行 Get Latest。
|
||||
* 对文件、目录或整个 Workspace 执行 Get Latest(默认安全模式,不覆盖本地改动)。
|
||||
*/
|
||||
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
|
||||
final String collectionName,
|
||||
final String workspaceName,
|
||||
final String serverPath,
|
||||
final boolean recursive) {
|
||||
return getLatest(config, collectionName, workspaceName, serverPath, recursive, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对文件、目录或整个 Workspace 执行 Get Latest。
|
||||
* force=false 走安全模式(GetOptions.NONE),本地有改动的文件会产生冲突而不会被覆盖;
|
||||
* force=true 等价 Visual Studio 的强制获取(GET_ALL + OVERWRITE),直接覆盖本地。
|
||||
*/
|
||||
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
|
||||
final String collectionName,
|
||||
final String workspaceName,
|
||||
final String serverPath,
|
||||
final boolean recursive,
|
||||
final boolean force) {
|
||||
return execute("getLatest", new CoreCallable<TfsGetLatestResult>() {
|
||||
@Override
|
||||
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
GetOptions options = force ? GetOptions.GET_ALL.combine(GetOptions.OVERWRITE) : GetOptions.NONE;
|
||||
GetStatus status;
|
||||
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
|
||||
if (normalizedPath == null) {
|
||||
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
||||
logs.add("Get latest workspace: " + workspace.getName());
|
||||
status = workspace.get(options);
|
||||
logs.add("Get latest workspace: " + workspace.getName() + (force ? " (force)" : ""));
|
||||
} 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));
|
||||
logs.add("Get latest path: " + normalizedPath);
|
||||
status = workspace.get(new GetRequest[]{request}, options);
|
||||
logs.add("Get latest path: " + normalizedPath + (force ? " (force)" : ""));
|
||||
}
|
||||
return toGetLatestResult(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 changeset 版本(强制覆盖本地),对应 Visual Studio 的 Get Specific Version + Overwrite。
|
||||
*/
|
||||
public CoreOperationResult<TfsGetLatestResult> getVersion(final TfsConnectionConfig config,
|
||||
final String collectionName,
|
||||
final String workspaceName,
|
||||
final String serverPath,
|
||||
final int changeset,
|
||||
final boolean recursive) {
|
||||
return execute("getVersion", new CoreCallable<TfsGetLatestResult>() {
|
||||
@Override
|
||||
public TfsGetLatestResult call(List<String> logs) throws Exception {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
String normalizedPath = normalizeServerPath(serverPath);
|
||||
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
|
||||
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), new ChangesetVersionSpec(changeset));
|
||||
GetStatus status = workspace.get(new GetRequest[]{request}, GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
|
||||
logs.add("Get version C" + changeset + ": " + normalizedPath);
|
||||
return toGetLatestResult(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前 Workspace 下的挂起更改,并返回稳定可序列化的状态字段。
|
||||
*/
|
||||
@@ -513,9 +559,27 @@ public class MacTfsCoreService {
|
||||
GetItemsOptions.DOWNLOAD
|
||||
);
|
||||
File tempFile = item.downloadFileToTempLocation(connection.versionControlClient, "mactfs");
|
||||
String content = new String(Files.readAllBytes(tempFile.toPath()), TEXT_CHARSET);
|
||||
TfsFileContent content = buildFileContent(item.getServerItem(), item.getChangeSetID(), tempFile, logs);
|
||||
logs.add("File content loaded: " + item.getServerItem());
|
||||
return new TfsFileContent(item.getServerItem(), item.getChangeSetID(), content, false);
|
||||
return content;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地已映射文件的文本内容,套用与服务器内容一致的二进制和大文件约束。
|
||||
*/
|
||||
public CoreOperationResult<TfsFileContent> getLocalFileContent(final String localPath) {
|
||||
return execute("getLocalFileContent", new CoreCallable<TfsFileContent>() {
|
||||
@Override
|
||||
public TfsFileContent call(List<String> logs) throws Exception {
|
||||
File file = new File(require(localPath, "localPath"));
|
||||
if (!file.exists() || file.isDirectory()) {
|
||||
throw new IllegalArgumentException("Local file not found: " + localPath);
|
||||
}
|
||||
TfsFileContent content = buildFileContent(file.getAbsolutePath(), 0, file, logs);
|
||||
logs.add("Local file content loaded: " + file.getAbsolutePath());
|
||||
return content;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -535,7 +599,8 @@ public class MacTfsCoreService {
|
||||
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());
|
||||
// 服务器 latest 作为旧版(左侧),本地文件作为新版(右侧),本地新增行才会标记为 "+"。
|
||||
TfsTextDiff diff = buildTextDiff(serverPath, localPath, latest.getData().getContent(), localContent);
|
||||
logs.add("Text diff lines: " + diff.getLines().size());
|
||||
return diff;
|
||||
}
|
||||
@@ -573,6 +638,193 @@ public class MacTfsCoreService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前 Workspace 在指定范围内的冲突明细,供 Get Latest / Checkout 冲突弹窗展示。
|
||||
*/
|
||||
public CoreOperationResult<List<TfsConflictInfo>> listConflicts(final TfsConnectionConfig config,
|
||||
final String collectionName,
|
||||
final String workspaceName,
|
||||
final List<String> serverPaths,
|
||||
final boolean recursive) {
|
||||
return execute("listConflicts", new CoreCallable<List<TfsConflictInfo>>() {
|
||||
@Override
|
||||
public List<TfsConflictInfo> call(List<String> logs) throws Exception {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
Conflict[] conflicts = workspace.queryConflicts(conflictScope(workspace, serverPaths), recursive);
|
||||
List<TfsConflictInfo> result = new ArrayList<TfsConflictInfo>();
|
||||
if (conflicts != null) {
|
||||
for (Conflict conflict : conflicts) {
|
||||
result.add(toConflictInfo(conflict));
|
||||
}
|
||||
}
|
||||
logs.add("Conflicts loaded: " + result.size());
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个冲突应用解决方式(采用服务器版本或保留本地版本),并返回剩余冲突数量。
|
||||
*/
|
||||
public CoreOperationResult<TfsConflictResolution> applyConflict(final TfsConnectionConfig config,
|
||||
final String collectionName,
|
||||
final String workspaceName,
|
||||
final int conflictId,
|
||||
final String resolution) {
|
||||
return execute("applyConflict", new CoreCallable<TfsConflictResolution>() {
|
||||
@Override
|
||||
public TfsConflictResolution call(List<String> logs) throws Exception {
|
||||
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
|
||||
Resolution target = toResolution(resolution);
|
||||
String[] scope = conflictScope(workspace, null);
|
||||
Conflict matched = null;
|
||||
Conflict[] conflicts = workspace.queryConflicts(scope, true);
|
||||
if (conflicts != null) {
|
||||
for (Conflict conflict : conflicts) {
|
||||
if (conflict.getConflictID() == conflictId) {
|
||||
matched = conflict;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched == null) {
|
||||
throw new IllegalArgumentException("Conflict not found: " + conflictId);
|
||||
}
|
||||
matched.setResolution(target);
|
||||
workspace.resolveConflict(matched);
|
||||
Conflict[] remaining = workspace.queryConflicts(scope, true);
|
||||
int remainingCount = remaining == null ? 0 : remaining.length;
|
||||
logs.add("Conflict resolved: " + conflictId + " -> " + resolution + ", remaining=" + remainingCount);
|
||||
return new TfsConflictResolution(conflictId, resolution, matched.isResolved(), remainingCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算冲突查询范围:优先使用调用方传入的路径,否则回退到 Workspace 全部映射根。
|
||||
*/
|
||||
private String[] conflictScope(Workspace workspace, List<String> serverPaths) {
|
||||
List<String> scope = new ArrayList<String>();
|
||||
if (serverPaths != null) {
|
||||
for (String path : serverPaths) {
|
||||
if (path != null && path.trim().length() > 0) {
|
||||
scope.add(path.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scope.isEmpty()) {
|
||||
WorkingFolder[] folders = workspace.getFolders();
|
||||
if (folders != null) {
|
||||
for (WorkingFolder folder : folders) {
|
||||
if (folder.getServerItem() != null && folder.getServerItem().trim().length() > 0) {
|
||||
scope.add(folder.getServerItem());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scope.isEmpty()) {
|
||||
scope.add("$/");
|
||||
}
|
||||
return scope.toArray(new String[scope.size()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UI 传入的解决方式标识映射为 TFS Resolution,仅支持第一版约定的两种取舍。
|
||||
*/
|
||||
private Resolution toResolution(String resolution) {
|
||||
String value = resolution == null ? "" : resolution.trim().toLowerCase();
|
||||
if ("takeserver".equals(value) || "server".equals(value) || "theirs".equals(value) || "accept_theirs".equals(value)) {
|
||||
return Resolution.ACCEPT_THEIRS;
|
||||
}
|
||||
if ("keeplocal".equals(value) || "local".equals(value) || "yours".equals(value) || "accept_yours".equals(value)) {
|
||||
return Resolution.ACCEPT_YOURS;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported conflict resolution: " + resolution);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 TFS 冲突对象转换为稳定可序列化的冲突明细。
|
||||
*/
|
||||
private TfsConflictInfo toConflictInfo(Conflict conflict) {
|
||||
String serverPath = firstNonBlank(conflict.getYourServerItem(), conflict.getTheirServerItem(), conflict.getBaseServerItem());
|
||||
String localPath = firstNonBlank(conflict.getSourceLocalItem(), conflict.getTargetLocalItem());
|
||||
return new TfsConflictInfo(
|
||||
conflict.getConflictID(),
|
||||
conflictTypeName(conflict.getType()),
|
||||
serverPath,
|
||||
localPath,
|
||||
conflict.getYourServerItem(),
|
||||
conflict.getTheirServerItem(),
|
||||
conflict.getBaseServerItem(),
|
||||
conflict.isResolved()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回冲突类型的稳定字符串名称,避免直接依赖枚举 toString。
|
||||
*/
|
||||
private String conflictTypeName(ConflictType type) {
|
||||
if (ConflictType.GET.equals(type)) {
|
||||
return "get";
|
||||
}
|
||||
if (ConflictType.CHECKIN.equals(type)) {
|
||||
return "checkin";
|
||||
}
|
||||
if (ConflictType.LOCAL.equals(type)) {
|
||||
return "local";
|
||||
}
|
||||
if (ConflictType.MERGE.equals(type)) {
|
||||
return "merge";
|
||||
}
|
||||
if (ConflictType.NONE.equals(type)) {
|
||||
return "none";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地文件字节并按二进制识别和大小阈值套用文本内容约束。
|
||||
*/
|
||||
private TfsFileContent buildFileContent(String serverPath, int changeset, File file, List<String> logs) throws IOException {
|
||||
byte[] bytes = Files.readAllBytes(file.toPath());
|
||||
long size = bytes.length;
|
||||
boolean binary = isBinaryContent(bytes);
|
||||
boolean tooLarge = size > MAX_TEXT_CONTENT_BYTES;
|
||||
if (binary || tooLarge) {
|
||||
logs.add("File content skipped (binary=" + binary + ", tooLarge=" + tooLarge + ", size=" + size + ")");
|
||||
return new TfsFileContent(serverPath, changeset, "", binary, size, tooLarge);
|
||||
}
|
||||
return new TfsFileContent(serverPath, changeset, new String(bytes, TEXT_CHARSET), false, size, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过嗅探前若干字节是否包含空字节判断内容是否为二进制。
|
||||
*/
|
||||
private boolean isBinaryContent(byte[] bytes) {
|
||||
int limit = (int) Math.min(bytes.length, (long) BINARY_SNIFF_BYTES);
|
||||
for (int index = 0; index < limit; index++) {
|
||||
if (bytes[index] == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回第一个非空字符串,供冲突路径回退使用。
|
||||
*/
|
||||
private String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && value.trim().length() > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private <T> CoreOperationResult<T> execute(String operation, CoreCallable<T> callable) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
List<String> logs = new ArrayList<String>();
|
||||
@@ -928,21 +1180,28 @@ public class MacTfsCoreService {
|
||||
private TfsTextDiff buildTextDiff(String sourceLabel, String targetLabel, String sourceContent, String targetContent) {
|
||||
List<String> sourceLines = Arrays.asList(sourceContent.split("\\r?\\n", -1));
|
||||
List<String> targetLines = Arrays.asList(targetContent.split("\\r?\\n", -1));
|
||||
int max = Math.max(sourceLines.size(), targetLines.size());
|
||||
// 使用 java-diff-utils(Myers 算法)计算最小差异,
|
||||
// 避免按行号硬对齐导致插入/删除一行后其余行全部误报为变动。
|
||||
Patch<String> patch = DiffUtils.diff(sourceLines, targetLines);
|
||||
List<String> lines = new ArrayList<String>();
|
||||
for (int index = 0; index < max; index++) {
|
||||
String source = index < sourceLines.size() ? sourceLines.get(index) : null;
|
||||
String target = index < targetLines.size() ? targetLines.get(index) : null;
|
||||
if (source != null && target != null && source.equals(target)) {
|
||||
lines.add(" " + source);
|
||||
} else {
|
||||
if (source != null) {
|
||||
lines.add("-" + source);
|
||||
int sourceIndex = 0;
|
||||
for (AbstractDelta<String> delta : patch.getDeltas()) {
|
||||
int deltaStart = delta.getSource().getPosition();
|
||||
while (sourceIndex < deltaStart) {
|
||||
lines.add(" " + sourceLines.get(sourceIndex));
|
||||
sourceIndex++;
|
||||
}
|
||||
if (target != null) {
|
||||
lines.add("+" + target);
|
||||
for (String removed : delta.getSource().getLines()) {
|
||||
lines.add("-" + removed);
|
||||
}
|
||||
for (String added : delta.getTarget().getLines()) {
|
||||
lines.add("+" + added);
|
||||
}
|
||||
sourceIndex += delta.getSource().size();
|
||||
}
|
||||
while (sourceIndex < sourceLines.size()) {
|
||||
lines.add(" " + sourceLines.get(sourceIndex));
|
||||
sourceIndex++;
|
||||
}
|
||||
return new TfsTextDiff(sourceLabel, targetLabel, lines);
|
||||
}
|
||||
@@ -1606,12 +1865,16 @@ public class MacTfsCoreService {
|
||||
private final int changeset;
|
||||
private final String content;
|
||||
private final boolean binary;
|
||||
private final long size;
|
||||
private final boolean tooLarge;
|
||||
|
||||
public TfsFileContent(String serverPath, int changeset, String content, boolean binary) {
|
||||
public TfsFileContent(String serverPath, int changeset, String content, boolean binary, long size, boolean tooLarge) {
|
||||
this.serverPath = serverPath;
|
||||
this.changeset = changeset;
|
||||
this.content = content;
|
||||
this.binary = binary;
|
||||
this.size = size;
|
||||
this.tooLarge = tooLarge;
|
||||
}
|
||||
|
||||
public String getServerPath() {
|
||||
@@ -1629,6 +1892,99 @@ public class MacTfsCoreService {
|
||||
public boolean isBinary() {
|
||||
return binary;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public boolean isTooLarge() {
|
||||
return tooLarge;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TfsConflictInfo {
|
||||
private final int conflictId;
|
||||
private final String type;
|
||||
private final String serverPath;
|
||||
private final String localPath;
|
||||
private final String yourServerItem;
|
||||
private final String theirServerItem;
|
||||
private final String baseServerItem;
|
||||
private final boolean resolved;
|
||||
|
||||
public TfsConflictInfo(int conflictId, String type, String serverPath, String localPath,
|
||||
String yourServerItem, String theirServerItem, String baseServerItem, boolean resolved) {
|
||||
this.conflictId = conflictId;
|
||||
this.type = type;
|
||||
this.serverPath = serverPath;
|
||||
this.localPath = localPath;
|
||||
this.yourServerItem = yourServerItem;
|
||||
this.theirServerItem = theirServerItem;
|
||||
this.baseServerItem = baseServerItem;
|
||||
this.resolved = resolved;
|
||||
}
|
||||
|
||||
public int getConflictId() {
|
||||
return conflictId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getServerPath() {
|
||||
return serverPath;
|
||||
}
|
||||
|
||||
public String getLocalPath() {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
public String getYourServerItem() {
|
||||
return yourServerItem;
|
||||
}
|
||||
|
||||
public String getTheirServerItem() {
|
||||
return theirServerItem;
|
||||
}
|
||||
|
||||
public String getBaseServerItem() {
|
||||
return baseServerItem;
|
||||
}
|
||||
|
||||
public boolean isResolved() {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TfsConflictResolution {
|
||||
private final int conflictId;
|
||||
private final String resolution;
|
||||
private final boolean resolved;
|
||||
private final int remainingConflicts;
|
||||
|
||||
public TfsConflictResolution(int conflictId, String resolution, boolean resolved, int remainingConflicts) {
|
||||
this.conflictId = conflictId;
|
||||
this.resolution = resolution;
|
||||
this.resolved = resolved;
|
||||
this.remainingConflicts = remainingConflicts;
|
||||
}
|
||||
|
||||
public int getConflictId() {
|
||||
return conflictId;
|
||||
}
|
||||
|
||||
public String getResolution() {
|
||||
return resolution;
|
||||
}
|
||||
|
||||
public boolean isResolved() {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public int getRemainingConflicts() {
|
||||
return remainingConflicts;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TfsTextDiff {
|
||||
|
||||
@@ -9,7 +9,10 @@ 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.TfsConflictResolution;
|
||||
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;
|
||||
@@ -280,6 +283,29 @@ public class MacTfsServer {
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.get("/api/workspace/context", (request, response) -> handle(request, response, "workspaceContext", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
Map<String, Object> context = new LinkedHashMap<String, Object>();
|
||||
context.put("serverUri", config.serverUri);
|
||||
context.put("authType", config.authType);
|
||||
context.put("collection", config.collection);
|
||||
context.put("workspace", config.workspace);
|
||||
context.put("connected", Boolean.valueOf(sessionManager.isConnected()));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
if (isBlank(config.collection) || isBlank(config.workspace)) {
|
||||
context.put("mappings", config.mappings);
|
||||
data.put("context", context);
|
||||
return ApiResult.success("success", data);
|
||||
}
|
||||
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), config.collection, config.workspace);
|
||||
context.put("mappings", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||
data.put("context", context);
|
||||
return fromCore(result, data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.get("/api/mappings", (request, response) -> handle(request, response, "listMappings", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
@@ -296,6 +322,26 @@ public class MacTfsServer {
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.post("/api/mappings/check-target", (request, response) -> handle(request, response, "checkMappingTarget", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
Map<String, Object> body = readBody(request);
|
||||
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
|
||||
String localParentPath = require(stringValue(body, "localParentPath"), "localParentPath");
|
||||
String name = lastPathSegment(serverPath);
|
||||
File target = new File(localParentPath, name);
|
||||
boolean exists = target.exists();
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("serverPath", serverPath);
|
||||
data.put("localParentPath", localParentPath);
|
||||
data.put("name", name);
|
||||
data.put("targetPath", target.getAbsolutePath());
|
||||
data.put("exists", Boolean.valueOf(exists));
|
||||
data.put("allowed", Boolean.valueOf(!exists));
|
||||
return ApiResult.success("success", data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.post("/api/mappings", (request, response) -> handle(request, response, "addMapping", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
@@ -349,7 +395,23 @@ public class MacTfsServer {
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
Map<String, Object> body = readBody(request);
|
||||
AppConfig config = requestConfig(body);
|
||||
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true));
|
||||
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true), booleanValue(body, "force", false));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("result", result.getData());
|
||||
return fromCore(result, data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.post("/api/files/get-version", (request, response) -> handle(request, response, "getVersion", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
Map<String, Object> body = readBody(request);
|
||||
AppConfig config = requestConfig(body);
|
||||
Number changeset = (Number) body.get("changeset");
|
||||
if (changeset == null) {
|
||||
throw new IllegalArgumentException("changeset is required");
|
||||
}
|
||||
CoreOperationResult<TfsGetLatestResult> result = coreService.getVersion(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), changeset.intValue(), booleanValue(body, "recursive", true));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("result", result.getData());
|
||||
return fromCore(result, data);
|
||||
@@ -384,6 +446,27 @@ public class MacTfsServer {
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.get("/api/files/content", (request, response) -> handle(request, response, "fileContent", TIMEOUT_DIFF_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
String localPath = request.queryParams("localPath");
|
||||
CoreOperationResult<TfsFileContent> result;
|
||||
if (localPath != null && localPath.trim().length() > 0) {
|
||||
result = coreService.getLocalFileContent(localPath);
|
||||
} else {
|
||||
String changeset = request.queryParams("changeset");
|
||||
Integer changesetId = changeset == null || changeset.trim().isEmpty()
|
||||
? null
|
||||
: Integer.valueOf(Integer.parseInt(changeset.trim()));
|
||||
result = coreService.getFileContent(toTfsConfig(config), require(config.collection, "collection"), require(request.queryParams("serverPath"), "serverPath"), changesetId);
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("content", result.getData());
|
||||
return fromCore(result, data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
@@ -408,6 +491,30 @@ public class MacTfsServer {
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.get("/api/conflicts", (request, response) -> handle(request, response, "listConflicts", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
|
||||
boolean recursive = !"false".equalsIgnoreCase(request.queryParams("recursive"));
|
||||
CoreOperationResult<List<TfsConflictInfo>> result = coreService.listConflicts(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")), recursive);
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("conflicts", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||
return fromCore(result, data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.post("/api/conflicts/apply", (request, response) -> handle(request, response, "applyConflict", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
Map<String, Object> body = readBody(request);
|
||||
AppConfig config = requestConfig(body);
|
||||
CoreOperationResult<TfsConflictResolution> result = coreService.applyConflict(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), intValue(body, "conflictId"), require(stringValue(body, "resolution"), "resolution"));
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("resolution", result.getData());
|
||||
return fromCore(result, data);
|
||||
}
|
||||
}));
|
||||
|
||||
Spark.post("/api/compare/folder", (request, response) -> handle(request, response, "compareFolder", TIMEOUT_COMPARE_MS, new ApiCallable() {
|
||||
@Override
|
||||
public ApiResult call(Request request) throws Exception {
|
||||
@@ -690,6 +797,18 @@ public class MacTfsServer {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取服务端路径的最后一段,作为 Mapping 目标目录名预校验依据。
|
||||
*/
|
||||
private String lastPathSegment(String serverPath) {
|
||||
String normalized = serverPath.trim();
|
||||
while (normalized.length() > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
int index = normalized.lastIndexOf('/');
|
||||
return index < 0 ? normalized : normalized.substring(index + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求体中读取路径数组,兼容 paths、path、serverPaths、serverPath 四种字段。
|
||||
*/
|
||||
|
||||
@@ -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,594 @@
|
||||
# macTFS 前端设计规范
|
||||
|
||||
## 一、文档定位
|
||||
|
||||
这份文档是当前分支唯一有效的前端设计规范。
|
||||
|
||||
用途:
|
||||
|
||||
- 定义前端阶段的交互设计和实现约束
|
||||
- 作为 `task/frontend` 的设计基线
|
||||
- 替代旧 `task/frontend-refactor` 中分散的方案文档
|
||||
|
||||
约束:
|
||||
|
||||
- 当前前端任务只看 `task/frontend`
|
||||
- 旧 `UIR-*` 编号不再继续维护
|
||||
- 如果后续要改前端方向,先改本文件,再改 `task/frontend`
|
||||
|
||||
## 二、项目定位
|
||||
|
||||
`mactfsui` 是 macTFS 的 Electron + React 桌面 UI。
|
||||
|
||||
它只负责:
|
||||
|
||||
- 本机桌面交互
|
||||
- 状态展示
|
||||
- 本地 API 调用
|
||||
|
||||
它不直接负责:
|
||||
|
||||
- TFS 协议实现
|
||||
- 本机文件系统裸访问
|
||||
- Java 进程细节控制
|
||||
|
||||
整体链路:
|
||||
|
||||
```text
|
||||
Electron Renderer
|
||||
-> Electron Preload / Main
|
||||
-> http://127.0.0.1:38765/api/*
|
||||
-> mactfs-server
|
||||
-> mactfs-core
|
||||
-> TFS SDK
|
||||
```
|
||||
|
||||
## 三、当前前端目标
|
||||
|
||||
第一版前端目标不是铺满所有能力,而是先实现一个围绕固定 Collection / Workspace 上下文展开的桌面工作台。
|
||||
|
||||
第一版要覆盖:
|
||||
|
||||
- 登录配置
|
||||
- Collection 选择
|
||||
- 默认 Workspace 自动使用 / 创建
|
||||
- 左侧服务端目录树
|
||||
- 中间当前目录文件列表
|
||||
- Mapping
|
||||
- 目录对比
|
||||
- 文件查看
|
||||
- Diff
|
||||
- Get Latest / Checkout 冲突处理
|
||||
- Pending Changes
|
||||
- Checkin
|
||||
- 操作日志
|
||||
|
||||
第一版不做:
|
||||
|
||||
- 多 Profile
|
||||
- Work Item
|
||||
- Check-in Policy UI
|
||||
- 手动冲突块编辑
|
||||
- 自定义布局
|
||||
- 自动更新
|
||||
|
||||
## 四、关键设计结论
|
||||
|
||||
### 4.1 固定上下文
|
||||
|
||||
前端进入工作台后固定:
|
||||
|
||||
- `serverUri`
|
||||
- `collection`
|
||||
- `workspace`
|
||||
|
||||
规则:
|
||||
|
||||
- Collection 在登录页确认
|
||||
- Workspace 不让用户手动选
|
||||
- 工作台内不允许切换 Collection
|
||||
- 需要切换 Collection 时,走重新连接流程
|
||||
|
||||
原因:
|
||||
|
||||
- TFS 的日常操作围绕单一 Workspace 展开
|
||||
- 固定上下文能减少目录树、Mapping、Pending、History、Diff 的联动复杂度
|
||||
|
||||
### 4.2 三栏工作台
|
||||
|
||||
工作台采用固定三栏 + 底部台:
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Top Bar: macTFS | Server | Collection | Workspace | Actions │
|
||||
├──────────────┬────────────────────────────────┬──────────────┤
|
||||
│ Source Tree │ Current Folder Items │ Changes │
|
||||
│ collapsible │ main workspace │ collapsible │
|
||||
├──────────────┴────────────────────────────────┴──────────────┤
|
||||
│ Operation Console collapsible │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- Top Bar:全局上下文与服务入口
|
||||
- Source Tree:目录浏览
|
||||
- Current Folder Items:主工作区
|
||||
- Changes:Pending 与 Checkin
|
||||
- Operation Console:日志与执行反馈
|
||||
|
||||
### 4.3 中间区域减负
|
||||
|
||||
中间区域只保留:
|
||||
|
||||
- 当前路径
|
||||
- 当前目录 Mapping 摘要
|
||||
- 文件 / 文件夹表格
|
||||
- 选中项摘要
|
||||
|
||||
中间区域不再内嵌:
|
||||
|
||||
- Mapping 表单
|
||||
- History 面板
|
||||
- Diff 面板
|
||||
- 目录对比结果大块内容
|
||||
- 选中项底部操作区
|
||||
|
||||
### 4.4 对象操作入口
|
||||
|
||||
对象操作统一走右键菜单。
|
||||
|
||||
覆盖对象:
|
||||
|
||||
- 左侧目录树节点
|
||||
- 中间文件列表行
|
||||
- Pending Changes 项
|
||||
- 目录对比结果项
|
||||
|
||||
不放到右键菜单的操作:
|
||||
|
||||
- Checkin
|
||||
- 全局设置
|
||||
- 连接重试
|
||||
- 日志开关
|
||||
|
||||
### 4.5 弹窗承载策略
|
||||
|
||||
以下能力优先使用弹窗:
|
||||
|
||||
- Mapping
|
||||
- History
|
||||
- 目录对比
|
||||
- 文件查看
|
||||
- Diff
|
||||
- 冲突处理
|
||||
|
||||
原因:
|
||||
|
||||
- 避免中间文件列表失控
|
||||
- 让主工作区保持浏览优先
|
||||
- 让复杂交互按场景进入
|
||||
|
||||
## 五、前端分层规范
|
||||
|
||||
### 5.1 Electron Main
|
||||
|
||||
职责:
|
||||
|
||||
- 创建主窗口
|
||||
- 检查本地 API 服务
|
||||
- 服务未启动时尝试拉起
|
||||
- 读取 token 文件
|
||||
- 打开目录选择器
|
||||
|
||||
约束:
|
||||
|
||||
- 不把任意 shell 能力直接暴露给渲染进程
|
||||
- 不在渲染进程直接读 `~/.mactfs/server-token`
|
||||
|
||||
### 5.2 Electron Preload
|
||||
|
||||
preload 只暴露窄接口,例如:
|
||||
|
||||
```ts
|
||||
window.mactfs = {
|
||||
getToken(): Promise<string>
|
||||
selectDirectory(): Promise<string | null>
|
||||
getServiceStatus(): Promise<ServiceStatus>
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 React Route
|
||||
|
||||
`routes/home.tsx` 是第一版主入口。
|
||||
|
||||
它负责:
|
||||
|
||||
- 初始化 health / config
|
||||
- 管理连接态和工作台态切换
|
||||
- 持有共享上下文状态
|
||||
- 协调目录树、中间列表、弹窗、Pending、日志
|
||||
|
||||
### 5.4 API 层
|
||||
|
||||
API 层统一放在 `app/lib/api`。
|
||||
|
||||
要求:
|
||||
|
||||
- 组件不直接 `fetch`
|
||||
- 组件不直接读 token
|
||||
- 统一返回后端结构化结果
|
||||
- 统一处理网络失败与后端失败
|
||||
|
||||
### 5.5 动作编排
|
||||
|
||||
对象动作由 `home.tsx` 或轻量 hook 编排。
|
||||
|
||||
原则:
|
||||
|
||||
- 不引入 Redux、Zustand、TanStack Query
|
||||
- 只抽真实复用的 hook
|
||||
- 不为了未来复用提前抽象复杂 service 层
|
||||
|
||||
## 六、推荐文件层级
|
||||
|
||||
```text
|
||||
mactfsui/
|
||||
app/
|
||||
app.css
|
||||
root.tsx
|
||||
routes.ts
|
||||
routes/
|
||||
home.tsx
|
||||
components/
|
||||
app/
|
||||
explorer/
|
||||
inspector/
|
||||
logs/
|
||||
ui/
|
||||
hooks/
|
||||
lib/
|
||||
api/
|
||||
electron/
|
||||
tfs/
|
||||
utils.ts
|
||||
electron/
|
||||
main.cjs
|
||||
preload.cjs
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `routes` 只编排页面
|
||||
- `components/ui` 只放基础 UI 组件
|
||||
- 业务组件按域拆分
|
||||
- `lib/tfs` 存放状态、动作和表现映射
|
||||
|
||||
## 七、后端接口对接规范
|
||||
|
||||
基础配置:
|
||||
|
||||
```text
|
||||
Base URL: http://127.0.0.1:38765
|
||||
Auth: Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
前端必须覆盖的接口:
|
||||
|
||||
| 功能 | 方法 | 路径 |
|
||||
|---|---|---|
|
||||
| 健康检查 | GET | `/api/health` |
|
||||
| 配置读写 | GET / PUT | `/api/config` |
|
||||
| 连接 | POST | `/api/session/connect` |
|
||||
| Collection | GET | `/api/collections` |
|
||||
| 目录树 | GET | `/api/server-tree` |
|
||||
| 当前目录 | GET | `/api/server-folder/items` |
|
||||
| Workspace | GET | `/api/workspace` |
|
||||
| Workspace 上下文 | GET | `/api/workspace/context` |
|
||||
| Mapping 目标预校验 | POST | `/api/mappings/check-target` |
|
||||
| Mapping | GET / POST / DELETE | `/api/mappings` |
|
||||
| Get Latest | POST | `/api/files/get-latest` |
|
||||
| Checkout | POST | `/api/files/checkout` |
|
||||
| Add | POST | `/api/files/add` |
|
||||
| Delete | POST | `/api/files/delete` |
|
||||
| Undo | POST | `/api/files/undo` |
|
||||
| Pending Changes | GET | `/api/pending-changes` |
|
||||
| Checkin | POST | `/api/checkin` |
|
||||
| 文件内容 | GET | `/api/files/content` |
|
||||
| 目录对比 | POST | `/api/compare/folder` |
|
||||
| 历史 | GET | `/api/history` |
|
||||
| Changeset 文件 | GET | `/api/history/changeset` |
|
||||
| 本地 vs latest Diff | POST | `/api/diff/local-latest` |
|
||||
| 历史版本 Diff | POST | `/api/diff/revisions` |
|
||||
| 冲突明细 | GET | `/api/conflicts` |
|
||||
| 冲突应用 | POST | `/api/conflicts/apply` |
|
||||
| 操作日志 | GET | `/api/logs` |
|
||||
|
||||
## 八、页面与交互规范
|
||||
|
||||
### 8.1 登录页
|
||||
|
||||
流程:
|
||||
|
||||
1. 输入连接信息
|
||||
2. 连接 TFS
|
||||
3. 加载 Collection
|
||||
4. 确认 Collection
|
||||
5. 自动确保 Workspace
|
||||
6. 进入工作台
|
||||
|
||||
要求:
|
||||
|
||||
- 配置可回填
|
||||
- Collection 必须确认
|
||||
- Workspace 不让用户手动选
|
||||
|
||||
### 8.2 左侧目录树
|
||||
|
||||
要求:
|
||||
|
||||
- 固定 Collection
|
||||
- 懒加载
|
||||
- 未映射目录也可浏览
|
||||
- 支持右键菜单
|
||||
|
||||
### 8.3 中间文件列表
|
||||
|
||||
要求:
|
||||
|
||||
- 展示当前目录下一级内容
|
||||
- 单击只选中
|
||||
- 双击目录进入
|
||||
- 与左侧树同步导航
|
||||
- 展示映射状态和核心元数据
|
||||
|
||||
### 8.4 Mapping 弹窗
|
||||
|
||||
要求:
|
||||
|
||||
- 展示服务端路径
|
||||
- 选择本地父目录
|
||||
- 选择完成后调用本地后端预校验最终目标路径
|
||||
- 最终映射路径以后端返回结果为准
|
||||
- 如果最终目标目录已存在,弹窗内直接提示“已存在,禁止映射”,并禁用确认操作
|
||||
- 支持是否立即 Get Latest
|
||||
- 成功后刷新 mappings 和当前目录列表
|
||||
- 成功后停留在当前浏览位置,不做跳转
|
||||
|
||||
### 8.5 History 弹窗
|
||||
|
||||
要求:
|
||||
|
||||
- 支持文件历史
|
||||
- 支持目录历史
|
||||
- 支持 changeset 文件列表
|
||||
- 支持从历史进入 Diff
|
||||
|
||||
### 8.6 目录对比弹窗
|
||||
|
||||
要求:
|
||||
|
||||
- 默认隐藏 `upToDate`
|
||||
- 支持状态筛选
|
||||
- 结果项走右键菜单
|
||||
- 支持刷新和重新对比
|
||||
|
||||
### 8.7 文件查看与 Diff
|
||||
|
||||
要求:
|
||||
|
||||
- 文件查看支持本地 / 服务器内容
|
||||
- Diff 支持本地 vs latest、历史 vs 历史
|
||||
- 支持搜索、行号、差异高亮
|
||||
- 大文件、二进制、非 Mapping 路径必须提示
|
||||
|
||||
### 8.8 冲突弹窗
|
||||
|
||||
要求:
|
||||
|
||||
- Get Latest 和 Checkout 复用
|
||||
- 支持批量选择
|
||||
- 支持逐文件选择
|
||||
- 支持从冲突项进入 Diff
|
||||
- 第一版不做手动冲突块编辑
|
||||
|
||||
### 8.9 Pending Changes 与 Checkin
|
||||
|
||||
要求:
|
||||
|
||||
- 右侧 Changes 保留为主入口
|
||||
- 支持 Included / Excluded
|
||||
- Pending Changes 项支持右键菜单
|
||||
- Checkin comment 必填
|
||||
- Checkin 成功后刷新 Pending 和目录状态
|
||||
|
||||
### 8.10 操作日志
|
||||
|
||||
要求:
|
||||
|
||||
- 底部可收起
|
||||
- 显示主要操作
|
||||
- 显示耗时和错误摘要
|
||||
- 长操作期间有明确执行中文案
|
||||
|
||||
## 九、状态与动作模型
|
||||
|
||||
### 9.1 共享状态
|
||||
|
||||
主工作台至少维护:
|
||||
|
||||
- `serviceStatus`
|
||||
- `config`
|
||||
- `selectedCollection`
|
||||
- `workspace`
|
||||
- `mappings`
|
||||
- `selectedServerPath`
|
||||
- `pendingChanges`
|
||||
- `includedKeys`
|
||||
- `excludedKeys`
|
||||
|
||||
### 9.2 状态文案
|
||||
|
||||
状态统一收口到 `app/lib/tfs/status.ts` 或等价文件。
|
||||
|
||||
至少覆盖:
|
||||
|
||||
```text
|
||||
localModified
|
||||
remoteChanged
|
||||
bothChanged
|
||||
localOnly
|
||||
remoteOnly
|
||||
notDownloaded
|
||||
localDeleted
|
||||
pendingEdit
|
||||
pendingAdd
|
||||
pendingDelete
|
||||
pending
|
||||
upToDate
|
||||
```
|
||||
|
||||
### 9.3 刷新规则
|
||||
|
||||
最小刷新规则:
|
||||
|
||||
- 连接成功:刷新 config、collections
|
||||
- 确认 Collection:刷新 workspace、mappings、目录树根
|
||||
- 创建 Mapping:刷新 mappings 和当前目录
|
||||
- 取消 Mapping:刷新 mappings 和当前目录,停留在当前浏览位置
|
||||
- Get Latest:刷新当前目录和日志
|
||||
- Checkout / Add / Delete / Undo:刷新 pending changes
|
||||
- Checkin:刷新 pending changes、当前目录、必要时刷新树节点
|
||||
|
||||
## 十、视觉与文案规范
|
||||
|
||||
### 10.1 风格
|
||||
|
||||
参考:
|
||||
|
||||
- Finder
|
||||
- Xcode Source Control
|
||||
- Fork / SourceTree
|
||||
|
||||
要求:
|
||||
|
||||
- 高信息密度
|
||||
- 工具型而不是营销型
|
||||
- 边界清晰
|
||||
- 控件克制
|
||||
|
||||
### 10.2 布局尺寸建议
|
||||
|
||||
- 顶部:48-56px
|
||||
- 左侧:260-320px
|
||||
- 右侧:320-380px
|
||||
- 底部:150-220px
|
||||
|
||||
### 10.3 文案
|
||||
|
||||
界面文案用中文。
|
||||
|
||||
推荐:
|
||||
|
||||
- 连接 TFS
|
||||
- 选择 Collection
|
||||
- 当前 Workspace
|
||||
- 映射到本地
|
||||
- 获取最新
|
||||
- 目录对比
|
||||
- 挂起更改
|
||||
- 签入注释
|
||||
- 操作日志
|
||||
|
||||
## 十一、开发实现规范
|
||||
|
||||
### 11.1 TypeScript
|
||||
|
||||
- 保持 `strict: true`
|
||||
- 不在组件里重复定义相同响应类型
|
||||
- 尽量不使用 `any`
|
||||
|
||||
### 11.2 注释
|
||||
|
||||
- 新增函数必须有函数级注释
|
||||
- 核心逻辑修改要补必要注释
|
||||
|
||||
### 11.3 样式
|
||||
|
||||
- 优先 Tailwind
|
||||
- 复用 `cn`
|
||||
- 不新增不必要的全局样式
|
||||
|
||||
### 11.4 错误处理
|
||||
|
||||
- 统一展示 `errorMessage || message`
|
||||
- 不堆多层 try/catch
|
||||
- 不默认做 retry
|
||||
|
||||
### 11.5 文件权限
|
||||
|
||||
改代码前必须先执行:
|
||||
|
||||
```bash
|
||||
chmod u+w <file>
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
chmod 644 <file>
|
||||
```
|
||||
|
||||
## 十二、前端阶段任务映射
|
||||
|
||||
当前前端阶段任务按以下顺序推进:
|
||||
|
||||
1. `FE-001`:Electron、preload、API client
|
||||
2. `FE-002`:前端集成所需服务端接口与类型契约
|
||||
3. `FE-003`:连接页、Collection 选择、Workspace 上下文
|
||||
4. `FE-004`:工作台布局、折叠面板、同步导航
|
||||
5. `FE-005`:目录树与当前目录列表
|
||||
6. `FE-006`:对象右键菜单与动作模型
|
||||
7. `FE-007`:Mapping、History、目录对比弹窗
|
||||
8. `FE-008`:文件查看与 Diff 弹窗
|
||||
9. `FE-009`:Get Latest 与 Checkout 冲突处理
|
||||
10. `FE-010`:Pending Changes 与 Checkin
|
||||
11. `FE-011`:FolderItemsPanel 拆分与动作收口
|
||||
12. `FE-012`:操作日志与刷新反馈
|
||||
13. `FE-013`:前端阶段联调验收基线
|
||||
|
||||
## 十三、当前分支现状
|
||||
|
||||
前端阶段 `FE-001` 至 `FE-013` 已全部完成:基础设施、接口契约、连接入口、工作台骨架、目录浏览、对象右键菜单、业务弹窗(Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理)、Get Latest / Checkout 执行链、Pending / Checkin 体验、动作编排收口、操作日志面板与联调基线均已就绪:
|
||||
|
||||
- `electron/main.cjs` 已补齐服务检测、按本地开发约定拉起服务、token 读取与目录选择
|
||||
- `electron/preload.cjs` 已落地,通过 `contextBridge` 暴露 `window.mactfs` 窄接口
|
||||
- `app/lib/electron` 已提供渲染层桥接(含非 Electron / SSR 兜底)
|
||||
- `app/lib/api/client.ts` 已提供统一 API client(自动携带 Bearer token、统一错误处理)
|
||||
- `app/lib/api/types.ts` 已补齐前端统一领域类型契约
|
||||
- `app/lib/api/endpoints.ts` 已封装全部工作台所需接口(含 workspace/context、mappings/check-target、files/content、conflicts)
|
||||
- 服务端已补齐 `GET /api/workspace/context`、`POST /api/mappings/check-target`、`GET /api/files/content`、`GET /api/conflicts`、`POST /api/conflicts/apply`
|
||||
- `app/components/app/connect-view.tsx` 已实现连接表单、Collection 选择与确保默认 Workspace
|
||||
- `app/components/app/workspace-shell.tsx` 已实现三栏工作台外壳,左 / 右 / 底面板支持折叠
|
||||
- `app/components/app/top-bar.tsx` 已实现顶部上下文栏(Server / Collection / Workspace + 面板开关 + 重新连接)
|
||||
- `app/components/explorer/source-tree-panel.tsx` 已实现目录树懒加载与同步展开;`folder-items-panel.tsx` 已实现当前目录文件列表(状态 / 版本 / 时间 / 本地路径,单击选中、双击进入)
|
||||
- `app/components/inspector/changes-panel.tsx` 已实现 Included / Excluded 分组、comment 必填校验与签入(成功展示 changeset 并刷新挂起 / 目录);`app/components/logs/console-panel.tsx` 已实现操作日志面板(成败 / 耗时 / 错误摘要、随操作自动刷新、执行中提示)
|
||||
- `app/lib/tfs` 已沉淀 `session.ts`(固定上下文、`SERVER_ROOT_PATH`)、`path.ts`(路径工具)、`mapping.ts`(本地路径推导)、`status.ts`(状态文案与配色收口)、`actions.ts`(FileTarget / 菜单生成与置灰规则)及 `index.ts` 出口
|
||||
- `app/components/app/file-target-menu.tsx` 提供统一右键菜单容器;树、列表、Pending 项共用同一套动作模型
|
||||
- `workspace-shell.tsx` 持有 pendingChanges 共享状态并集中分发对象动作,已实现「取消映射」(不跳转浏览位置)与 Mapping / History / 目录对比弹窗编排
|
||||
- `app/components/explorer/mapping-dialog.tsx`(父目录选择 + 后端预校验 + 已存在禁止映射 + 可选立即 Get Latest)、`history-dialog.tsx`(文件 / 目录历史、changeset 下钻、版本勾选进入 Diff)、`compare-dialog.tsx`(默认隐藏 upToDate、状态筛选、重新对比、结果项右键菜单)已落地
|
||||
- `app/components/explorer/file-view-dialog.tsx`(本地 / 服务器 latest 切换、行号、搜索、二进制 / 超大提示)与 `diff-dialog.tsx`(本地 vs latest、历史版本对比、双行号、差异高亮、仅看差异)已落地
|
||||
- Electron 桥已含 `pathsExist` 本地存在性批量检测,用于「已映射未下载」状态
|
||||
- `home.tsx` 已编排 检测服务 → 连接 → 工作台 的视图状态机,统一维护 `selectedServerPath` 与 mappings 更新
|
||||
|
||||
- `app/components/explorer/conflict-dialog.tsx`(逐项 / 批量取舍、冲突 Diff、应用后自动刷新)与 `workspace-shell.tsx` 的 `runGetLatest` / `runCheckout`(签出自动先取最新、冲突自动进弹窗、顶部通知条反馈)已落地
|
||||
- 对象动作中 unmap / getLatest / checkout / delete / undo / checkin 均已实际执行并按 9.3 规则刷新
|
||||
- 动作编排已收口:`app/hooks/use-pending-changes.ts`(挂起更改共享状态)、`app/hooks/use-file-actions.ts`(弹窗开关 + 动作执行 + 通知),`workspace-shell.tsx` 仅负责组合,业务弹窗统一由 `app/components/app/workspace-dialogs.tsx` 渲染
|
||||
|
||||
- FE-013 基线检查通过:typecheck、本地 API 调试链路、`pnpm electron:dev` 冒烟均正常;`electron:dev` 已修复代理环境下 wait-on 卡死问题(`NO_PROXY=localhost,127.0.0.1`)
|
||||
- FE-014:获取最新默认安全模式(本地改动产生冲突而非被覆盖);「强制获取最新…」与历史「获取此版本」为带确认弹窗的显式危险动作(`/api/files/get-latest` 支持 `force`,新增 `/api/files/get-version`)
|
||||
- FE-015:changeset 文件列表支持右键(查看此版本内容 / 查看该文件历史可返回 / 获取此版本…);文件查看弹窗支持指定 changeset;目录对比 localOnly 项可「加入版本控制」(pend add)
|
||||
- FE-016:Diff 弹窗支持统一 / 左右分栏视图(会话内记忆)、差异概览条点击跳转、上一处 / 下一处差异导航;历史 / 对比 / 文件查看 / Diff 弹窗尺寸提升至接近满屏
|
||||
- FE-017:左侧目录树、中间文件列表、右侧挂起更改均有手动刷新入口(树刷新清缓存并自动重展开选中链)
|
||||
|
||||
后续工作进入 Feature E2E(FEATURE-001 ~ 005,需要真实 TFS 环境)与 Release 阶段;真实 TFS 相关风险已记录在 FE-013 完成记录中。
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
|
||||
/**
|
||||
* 通用确认弹窗:用于强制获取、获取指定版本等覆盖类危险操作的二次确认。
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
danger,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
confirmLabel: string
|
||||
danger?: boolean
|
||||
onConfirm: () => Promise<void> | void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="text-sm leading-6">{description}</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? "destructive" : "default"}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setBusy(true)
|
||||
await onConfirm()
|
||||
setBusy(false)
|
||||
})()
|
||||
}}
|
||||
>
|
||||
{busy ? "正在执行…" : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import { api } from "~/lib/api"
|
||||
import type { AppConfig, Collection } from "~/lib/api"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 连接阶段:填写连接信息 / 确认 Collection。
|
||||
type ConnectPhase = "form" | "collection"
|
||||
|
||||
// 连接表单字段,第一版仅支持显式 NTLM 账号密码。
|
||||
interface ConnectFormState {
|
||||
serverUri: string
|
||||
domain: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ConnectFormState = {
|
||||
serverUri: "",
|
||||
domain: "",
|
||||
username: "",
|
||||
password: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
||||
*/
|
||||
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
||||
const [phase, setPhase] = useState<ConnectPhase>("form")
|
||||
const [form, setForm] = useState<ConnectFormState>(EMPTY_FORM)
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [selectedCollection, setSelectedCollection] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [busyText, setBusyText] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
const result = await api.getConfig()
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
const config = result.data?.config
|
||||
if (config) {
|
||||
setForm((prev) => ({
|
||||
serverUri: config.serverUri ?? prev.serverUri,
|
||||
domain: config.domain ?? prev.domain,
|
||||
username: config.username ?? prev.username,
|
||||
password: config.password ?? prev.password,
|
||||
}))
|
||||
if (config.collection) {
|
||||
setSelectedCollection(config.collection)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 生成受控输入框的 onChange 处理器。
|
||||
*/
|
||||
const updateField = (key: keyof ConnectFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
||||
*/
|
||||
const handleConnect = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
setBusyText("正在连接 TFS…")
|
||||
const payload: Partial<AppConfig> = {
|
||||
serverUri: form.serverUri.trim(),
|
||||
authType: "ntlm-explicit",
|
||||
domain: form.domain.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
}
|
||||
const connectResult = await api.connect(payload)
|
||||
if (!connectResult.ok) {
|
||||
setBusy(false)
|
||||
setError(connectResult.errorMessage ?? "连接失败")
|
||||
return
|
||||
}
|
||||
setBusyText("正在加载 Collection…")
|
||||
const collectionsResult = await api.listCollections()
|
||||
setBusy(false)
|
||||
if (!collectionsResult.ok) {
|
||||
setError(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
||||
return
|
||||
}
|
||||
const list = collectionsResult.data?.collections ?? []
|
||||
setCollections(list)
|
||||
setSelectedCollection((prev) =>
|
||||
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
||||
)
|
||||
setPhase("collection")
|
||||
}, [form])
|
||||
|
||||
/**
|
||||
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
||||
*/
|
||||
const handleEnter = useCallback(async () => {
|
||||
if (!selectedCollection) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
setBusyText("正在确保默认 Workspace…")
|
||||
const ensureResult = await api.ensureWorkspace({ collection: selectedCollection })
|
||||
setBusy(false)
|
||||
const workspace = ensureResult.data?.workspace
|
||||
if (!ensureResult.ok || !workspace) {
|
||||
setError(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
||||
return
|
||||
}
|
||||
onReady({
|
||||
serverUri: form.serverUri.trim(),
|
||||
collection: selectedCollection,
|
||||
workspace: workspace.name,
|
||||
mappings: workspace.mappings ?? [],
|
||||
})
|
||||
}, [selectedCollection, form.serverUri, onReady])
|
||||
|
||||
const canConnect =
|
||||
form.serverUri.trim().length > 0 &&
|
||||
form.username.trim().length > 0 &&
|
||||
form.password.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-base font-semibold">macTFS</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{phase === "form" && (
|
||||
<form
|
||||
className="mt-5 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canConnect && !busy) {
|
||||
void handleConnect()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="serverUri">服务器地址</Label>
|
||||
<Input
|
||||
id="serverUri"
|
||||
placeholder="http://tfs.example.com:8080/tfs"
|
||||
value={form.serverUri}
|
||||
onChange={updateField("serverUri")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain">域(可选)</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="DOMAIN"
|
||||
value={form.domain}
|
||||
onChange={updateField("domain")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={form.username}
|
||||
onChange={updateField("username")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={form.password}
|
||||
onChange={updateField("password")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
|
||||
{busy ? busyText || "处理中…" : "连接 TFS"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{phase === "collection" && (
|
||||
<div className="mt-5 space-y-4">
|
||||
{collections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">当前账号没有可见的 Collection。</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{collections.map((collection) => {
|
||||
const active = collection.name === selectedCollection
|
||||
return (
|
||||
<li key={collection.id || collection.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCollection(collection.name)}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{collection.name}</span>
|
||||
{active && <span className="text-xs text-primary">已选择</span>}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setPhase("form")
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleEnter()}
|
||||
disabled={busy || !selectedCollection}
|
||||
>
|
||||
{busy ? busyText || "处理中…" : "进入工作台"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
import { buildFileMenu } from "~/lib/tfs"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 对象右键菜单容器:包住任意触发区域,按统一动作模型渲染菜单分组,
|
||||
* 置灰项展示原因后缀,点击可用项回调上层动作分发器。
|
||||
*/
|
||||
export function FileTargetMenu({
|
||||
target,
|
||||
onAction,
|
||||
children,
|
||||
}: {
|
||||
target: FileTarget
|
||||
onAction: (target: FileTarget, action: FileActionId) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const sections = buildFileMenu(target)
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-52">
|
||||
{sections.map((items, index) => (
|
||||
<Section key={index} withSeparator={index > 0}>
|
||||
{items.map((item) => (
|
||||
<ContextMenuItem
|
||||
key={item.id}
|
||||
disabled={!item.enabled}
|
||||
variant={item.danger && item.enabled ? "destructive" : "default"}
|
||||
onSelect={() => onAction(target, item.id)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{!item.enabled && item.reason && (
|
||||
<span className={cn("ml-auto pl-3 text-xs text-muted-foreground")}>
|
||||
{item.reason}
|
||||
</span>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单分组:非首组前自动插入分隔线。
|
||||
*/
|
||||
function Section({
|
||||
withSeparator,
|
||||
children,
|
||||
}: {
|
||||
withSeparator: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{withSeparator && <ContextMenuSeparator />}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PanelBottom, PanelLeft, PanelRight } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
||||
export interface PanelVisibility {
|
||||
tree: boolean
|
||||
changes: boolean
|
||||
console: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
|
||||
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
|
||||
*/
|
||||
export function TopBar({
|
||||
session,
|
||||
panels,
|
||||
onTogglePanel,
|
||||
onReconnect,
|
||||
}: {
|
||||
session: WorkspaceSession
|
||||
panels: PanelVisibility
|
||||
onTogglePanel: (panel: keyof PanelVisibility) => void
|
||||
onReconnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="shrink-0 font-semibold">macTFS</span>
|
||||
<Separator orientation="vertical" className="h-4!" />
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
|
||||
服务器
|
||||
</span>
|
||||
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
|
||||
{session.serverUri}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="hidden h-4! lg:block" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
|
||||
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
|
||||
<Separator orientation="vertical" className="h-4!" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
|
||||
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PanelToggle
|
||||
label="源码目录面板"
|
||||
active={panels.tree}
|
||||
onClick={() => onTogglePanel("tree")}
|
||||
>
|
||||
<PanelLeft />
|
||||
</PanelToggle>
|
||||
<PanelToggle
|
||||
label="挂起更改面板"
|
||||
active={panels.changes}
|
||||
onClick={() => onTogglePanel("changes")}
|
||||
>
|
||||
<PanelRight />
|
||||
</PanelToggle>
|
||||
<PanelToggle
|
||||
label="操作日志面板"
|
||||
active={panels.console}
|
||||
onClick={() => onTogglePanel("console")}
|
||||
>
|
||||
<PanelBottom />
|
||||
</PanelToggle>
|
||||
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||
<Button size="sm" variant="outline" onClick={onReconnect}>
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板折叠开关按钮:图标 + tooltip,激活态用底色区分。
|
||||
*/
|
||||
function PanelToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(!active && "text-muted-foreground")}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{active ? `收起${label}` : `展开${label}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||
import { CompareDialog } from "~/components/explorer/compare-dialog"
|
||||
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
|
||||
import { DiffDialog } from "~/components/explorer/diff-dialog"
|
||||
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
|
||||
import { HistoryDialog } from "~/components/explorer/history-dialog"
|
||||
import { MappingDialog } from "~/components/explorer/mapping-dialog"
|
||||
import type { WorkspaceDialogState } from "~/hooks/use-file-actions"
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
|
||||
/**
|
||||
* 工作台业务弹窗出口:按编排 hook 的弹窗状态渲染对应弹窗,
|
||||
* 自身不持有业务状态,所有回调回流到动作编排层。
|
||||
*/
|
||||
export function WorkspaceDialogs({
|
||||
dialog,
|
||||
mappings,
|
||||
onClose,
|
||||
onOpen,
|
||||
onFileAction,
|
||||
onMappingCreated,
|
||||
onConflictsResolved,
|
||||
onForceGetConfirmed,
|
||||
onGetVersion,
|
||||
}: {
|
||||
dialog: WorkspaceDialogState
|
||||
mappings: MappingInfo[]
|
||||
onClose: () => void
|
||||
onOpen: (dialog: WorkspaceDialogState) => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
onMappingCreated: (mappings: MappingInfo[]) => void
|
||||
onConflictsResolved: () => void
|
||||
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
|
||||
onGetVersion: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
||||
}) {
|
||||
if (!dialog) {
|
||||
return null
|
||||
}
|
||||
switch (dialog.kind) {
|
||||
case "mapping":
|
||||
return (
|
||||
<MappingDialog
|
||||
serverPath={dialog.serverPath}
|
||||
onClose={onClose}
|
||||
onCreated={onMappingCreated}
|
||||
/>
|
||||
)
|
||||
case "history":
|
||||
return (
|
||||
<HistoryDialog
|
||||
serverPath={dialog.serverPath}
|
||||
folder={dialog.folder}
|
||||
onClose={onClose}
|
||||
onDiffRevisions={(serverPath, sourceChangeset, targetChangeset) =>
|
||||
onOpen({
|
||||
kind: "diff",
|
||||
request: { mode: "revisions", serverPath, sourceChangeset, targetChangeset },
|
||||
})
|
||||
}
|
||||
onGetVersion={onGetVersion}
|
||||
/>
|
||||
)
|
||||
case "compare":
|
||||
return (
|
||||
<CompareDialog
|
||||
serverPath={dialog.serverPath}
|
||||
mappings={mappings}
|
||||
onClose={onClose}
|
||||
onFileAction={onFileAction}
|
||||
/>
|
||||
)
|
||||
case "viewFile":
|
||||
return (
|
||||
<FileViewDialog
|
||||
serverPath={dialog.serverPath}
|
||||
localPath={dialog.localPath}
|
||||
changeset={dialog.changeset}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
case "diff":
|
||||
return <DiffDialog request={dialog.request} onClose={onClose} />
|
||||
case "conflicts":
|
||||
return (
|
||||
<ConflictDialog
|
||||
serverPath={dialog.serverPath}
|
||||
onClose={onClose}
|
||||
onResolved={onConflictsResolved}
|
||||
/>
|
||||
)
|
||||
case "confirmForceGet":
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="强制获取最新"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
将对 <span className="font-mono text-xs">{dialog.serverPath}</span>{" "}
|
||||
强制获取服务器最新版本。
|
||||
</p>
|
||||
<p className="mt-2 font-medium text-destructive">
|
||||
本地未签入的修改会被服务器版本直接覆盖,且无法恢复。确定继续吗?
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel="覆盖本地并获取"
|
||||
danger
|
||||
onConfirm={() => onForceGetConfirmed(dialog.serverPath, dialog.folder)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
|
||||
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
|
||||
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
||||
import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
|
||||
import { ChangesPanel } from "~/components/inspector/changes-panel"
|
||||
import { ConsolePanel } from "~/components/logs/console-panel"
|
||||
import { TooltipProvider } from "~/components/ui/tooltip"
|
||||
import { useFileActions } from "~/hooks/use-file-actions"
|
||||
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
import type { WorkspaceSession } from "~/lib/tfs"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 三栏工作台外壳:组合顶部上下文栏、左树、中间列表、右侧 Changes、底部 Console。
|
||||
* 挂起更改状态与对象动作编排分别收口在 usePendingChanges / useFileActions。
|
||||
*/
|
||||
export function WorkspaceShell({
|
||||
session,
|
||||
selectedServerPath,
|
||||
onNavigate,
|
||||
onMappingsChanged,
|
||||
onReconnect,
|
||||
}: {
|
||||
session: WorkspaceSession
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||
onReconnect: () => void
|
||||
}) {
|
||||
const [panels, setPanels] = useState<PanelVisibility>({
|
||||
tree: true,
|
||||
changes: true,
|
||||
console: true,
|
||||
})
|
||||
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
|
||||
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
|
||||
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
|
||||
|
||||
/**
|
||||
* 切换指定面板的展开 / 收起状态。
|
||||
*/
|
||||
const togglePanel = useCallback((panel: keyof PanelVisibility) => {
|
||||
setPanels((prev) => ({ ...prev, [panel]: !prev[panel] }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 触发中间目录列表重新加载。
|
||||
*/
|
||||
const refreshItems = useCallback(() => {
|
||||
setItemsRefreshToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 触发底部操作日志重新加载。
|
||||
*/
|
||||
const refreshLogs = useCallback(() => {
|
||||
setLogsRefreshToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
const pending = usePendingChanges()
|
||||
const actions = useFileActions({
|
||||
onMappingsChanged,
|
||||
refreshPendingChanges: pending.refresh,
|
||||
refreshItems,
|
||||
refreshLogs,
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex h-svh flex-col overflow-hidden bg-background">
|
||||
<TopBar
|
||||
session={session}
|
||||
panels={panels}
|
||||
onTogglePanel={togglePanel}
|
||||
onReconnect={onReconnect}
|
||||
/>
|
||||
|
||||
{actions.notice && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 border-b px-3 py-1 text-xs",
|
||||
actions.notice.kind === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/5 text-foreground",
|
||||
)}
|
||||
>
|
||||
{actions.actionBusy && (
|
||||
<span className="size-2 animate-pulse rounded-full bg-primary" />
|
||||
)}
|
||||
{actions.notice.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{panels.tree && (
|
||||
<SourceTreePanel
|
||||
collection={session.collection}
|
||||
mappings={session.mappings}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onNavigate={onNavigate}
|
||||
onFileAction={actions.handleFileAction}
|
||||
/>
|
||||
)}
|
||||
<FolderItemsPanel
|
||||
session={session}
|
||||
selectedServerPath={selectedServerPath}
|
||||
pendingByServerPath={pending.pendingByServerPath}
|
||||
refreshToken={itemsRefreshToken}
|
||||
onNavigate={onNavigate}
|
||||
onFileAction={actions.handleFileAction}
|
||||
onRefresh={refreshItems}
|
||||
/>
|
||||
{panels.changes && (
|
||||
<ChangesPanel
|
||||
mappings={session.mappings}
|
||||
pendingChanges={pending.pendingChanges}
|
||||
excludedKeys={pending.excludedKeys}
|
||||
loading={pending.loading}
|
||||
error={pending.error}
|
||||
checkinBusy={actions.checkinBusy}
|
||||
onToggleExcluded={pending.toggleExcluded}
|
||||
onCheckin={actions.handleCheckin}
|
||||
onFileAction={actions.handleFileAction}
|
||||
onRefresh={() => void pending.refresh()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panels.console && (
|
||||
<ConsolePanel
|
||||
refreshToken={logsRefreshToken}
|
||||
busy={actions.actionBusy || actions.checkinBusy}
|
||||
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkspaceDialogs
|
||||
dialog={actions.dialog}
|
||||
mappings={session.mappings}
|
||||
onClose={() => actions.setDialog(null)}
|
||||
onOpen={actions.setDialog}
|
||||
onFileAction={actions.handleFileAction}
|
||||
onMappingCreated={actions.handleMappingCreated}
|
||||
onConflictsResolved={actions.handleConflictsResolved}
|
||||
onForceGetConfirmed={actions.handleForceGetConfirmed}
|
||||
onGetVersion={actions.runGetVersion}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { api } from "~/lib/api"
|
||||
import type { FolderDiffItem, MappingInfo } from "~/lib/api"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
||||
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
||||
*/
|
||||
export function CompareDialog({
|
||||
serverPath,
|
||||
mappings,
|
||||
onClose,
|
||||
onFileAction,
|
||||
}: {
|
||||
serverPath: string
|
||||
mappings: MappingInfo[]
|
||||
onClose: () => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
}) {
|
||||
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUpToDate, setShowUpToDate] = useState(false)
|
||||
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
||||
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 执行(或重新执行)目录对比。
|
||||
*/
|
||||
const runCompare = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await api.compareFolder({ serverPath, recursive: true })
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "目录对比失败")
|
||||
return
|
||||
}
|
||||
setDiffs(result.data?.diffs ?? [])
|
||||
}, [serverPath])
|
||||
|
||||
useEffect(() => {
|
||||
void runCompare()
|
||||
}, [runCompare])
|
||||
|
||||
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
||||
const presentStatuses = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const diff of diffs) {
|
||||
if (diff.status !== "upToDate") {
|
||||
set.add(diff.status)
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}, [diffs])
|
||||
|
||||
const visibleDiffs = useMemo(
|
||||
() =>
|
||||
diffs.filter((diff) => {
|
||||
if (diff.status === "upToDate") {
|
||||
return showUpToDate && statusFilter.size === 0
|
||||
}
|
||||
return statusFilter.size === 0 || statusFilter.has(diff.status)
|
||||
}),
|
||||
[diffs, showUpToDate, statusFilter],
|
||||
)
|
||||
|
||||
/**
|
||||
* 切换某个状态筛选 chip。
|
||||
*/
|
||||
const toggleStatus = useCallback((status: string) => {
|
||||
setStatusFilter((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(status)) {
|
||||
next.delete(status)
|
||||
} else {
|
||||
next.add(status)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>目录对比</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{presentStatuses.map((status) => (
|
||||
<button key={status} type="button" onClick={() => toggleStatus(status)}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md",
|
||||
statusBadgeClass(status),
|
||||
statusFilter.size > 0 && !statusFilter.has(status) && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={showUpToDate}
|
||||
onCheckedChange={(value) => setShowUpToDate(value === true)}
|
||||
/>
|
||||
显示最新项
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runCompare()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw data-icon="inline-start" className={cn(loading && "animate-spin")} />
|
||||
重新对比
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在对比目录…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : visibleDiffs.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
{diffs.length === 0 ? "本地与服务端一致,没有差异" : "没有符合筛选条件的差异项"}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||
<TableHead className="h-8 w-28 text-xs text-muted-foreground">状态</TableHead>
|
||||
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">本地版本</TableHead>
|
||||
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">最新版本</TableHead>
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleDiffs.map((diff) => {
|
||||
const target = makeFileTarget({
|
||||
source: "compare",
|
||||
folder: diff.folder,
|
||||
serverPath: diff.serverPath,
|
||||
mappings,
|
||||
pendingStatus: diff.status.startsWith("pending") ? diff.status : null,
|
||||
compareStatus: diff.status,
|
||||
})
|
||||
return (
|
||||
<FileTargetMenu key={diff.serverPath} target={target} onAction={onFileAction}>
|
||||
<TableRow className="cursor-default select-none">
|
||||
<TableCell className="py-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{diff.folder ? (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">{diff.name}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("rounded-md", statusBadgeClass(diff.status))}
|
||||
>
|
||||
{statusLabel(diff.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||
{diff.localVersion > 0 ? diff.localVersion : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||
{diff.latestVersion > 0 ? diff.latestVersion : "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
|
||||
title={diff.serverPath}
|
||||
>
|
||||
{diff.serverPath}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FileTargetMenu>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="shrink-0 text-xs text-muted-foreground">
|
||||
{visibleDiffs.length} / {diffs.length} 项 · 差异项操作通过右键菜单触发
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { FileDiff, Loader2 } from "lucide-react"
|
||||
|
||||
import { DiffDialog, type DiffRequest } from "~/components/explorer/diff-dialog"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { api } from "~/lib/api"
|
||||
import type { ConflictInfo, ConflictResolution } from "~/lib/api"
|
||||
|
||||
// 每个冲突的取舍选择:未选择 / 采用服务器版本 / 保留本地版本。
|
||||
type ChoiceValue = "none" | ConflictResolution
|
||||
|
||||
/**
|
||||
* 统一冲突弹窗:Get Latest 与 Checkout 共用。
|
||||
* 支持逐文件选择服务器版本或保留本地版本、批量设置、冲突文件 Diff、批量应用。
|
||||
*/
|
||||
export function ConflictDialog({
|
||||
serverPath,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: {
|
||||
serverPath: string
|
||||
onClose: () => void
|
||||
onResolved: () => void
|
||||
}) {
|
||||
const [conflicts, setConflicts] = useState<ConflictInfo[]>([])
|
||||
const [choices, setChoices] = useState<Record<number, ChoiceValue>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// 应用结果摘要:conflictId -> 是否成功。
|
||||
const [applied, setApplied] = useState<Record<number, boolean>>({})
|
||||
const [diffRequest, setDiffRequest] = useState<DiffRequest | null>(null)
|
||||
// 是否发生过成功应用,关闭时据此通知上层刷新。
|
||||
const [didResolve, setDidResolve] = useState(false)
|
||||
|
||||
/**
|
||||
* 加载当前范围内的未解决冲突。
|
||||
*/
|
||||
const loadConflicts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = await api.listConflicts({ serverPath, recursive: true })
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "冲突明细加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
const list = (result.data?.conflicts ?? []).filter((item) => !item.resolved)
|
||||
setConflicts(list)
|
||||
setChoices({})
|
||||
setApplied({})
|
||||
}, [serverPath])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConflicts()
|
||||
}, [loadConflicts])
|
||||
|
||||
const chosenCount = useMemo(
|
||||
() => Object.values(choices).filter((value) => value !== "none").length,
|
||||
[choices],
|
||||
)
|
||||
|
||||
/**
|
||||
* 批量把所有冲突设置为同一取舍。
|
||||
*/
|
||||
const setAll = useCallback(
|
||||
(value: ConflictResolution) => {
|
||||
setChoices(() => {
|
||||
const next: Record<number, ChoiceValue> = {}
|
||||
for (const conflict of conflicts) {
|
||||
next[conflict.conflictId] = value
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[conflicts],
|
||||
)
|
||||
|
||||
/**
|
||||
* 应用所有已选择的取舍,逐个调用后端并记录结果。
|
||||
*/
|
||||
const applyChoices = useCallback(async () => {
|
||||
setApplying(true)
|
||||
setError(null)
|
||||
const results: Record<number, boolean> = {}
|
||||
let resolvedAny = false
|
||||
for (const conflict of conflicts) {
|
||||
const choice = choices[conflict.conflictId]
|
||||
if (!choice || choice === "none") {
|
||||
continue
|
||||
}
|
||||
const result = await api.applyConflict({
|
||||
conflictId: conflict.conflictId,
|
||||
resolution: choice,
|
||||
})
|
||||
const success = result.ok && result.data?.resolution?.resolved === true
|
||||
results[conflict.conflictId] = success
|
||||
if (success) {
|
||||
resolvedAny = true
|
||||
}
|
||||
}
|
||||
setApplying(false)
|
||||
setApplied(results)
|
||||
if (resolvedAny) {
|
||||
setDidResolve(true)
|
||||
}
|
||||
const failures = Object.values(results).filter((value) => !value).length
|
||||
if (failures > 0) {
|
||||
setError(`${failures} 个冲突应用失败,可重试`)
|
||||
await loadConflicts()
|
||||
return
|
||||
}
|
||||
// 全部成功:重新加载剩余冲突,没有剩余则关闭并通知刷新。
|
||||
const remain = await api.listConflicts({ serverPath, recursive: true })
|
||||
const remaining = (remain.data?.conflicts ?? []).filter((item) => !item.resolved)
|
||||
if (remaining.length === 0) {
|
||||
onResolved()
|
||||
return
|
||||
}
|
||||
setConflicts(remaining)
|
||||
setChoices({})
|
||||
setApplied({})
|
||||
}, [conflicts, choices, serverPath, loadConflicts, onResolved])
|
||||
|
||||
/**
|
||||
* 关闭弹窗:若期间有成功应用,通知上层刷新状态。
|
||||
*/
|
||||
const handleClose = useCallback(() => {
|
||||
if (didResolve) {
|
||||
onResolved()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}, [didResolve, onResolved, onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>处理冲突</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||
{conflicts.length} 个冲突
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
逐项选择,或批量设置后统一应用
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applying || conflicts.length === 0}
|
||||
onClick={() => setAll("takeServer")}
|
||||
>
|
||||
全部采用服务器
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applying || conflicts.length === 0}
|
||||
onClick={() => setAll("keepLocal")}
|
||||
>
|
||||
全部保留本地
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载冲突…
|
||||
</div>
|
||||
) : conflicts.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
当前范围内没有未解决的冲突
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">文件</TableHead>
|
||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
|
||||
<TableHead className="h-8 w-44 text-xs text-muted-foreground">取舍</TableHead>
|
||||
<TableHead className="h-8 w-20 text-xs text-muted-foreground">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict) => {
|
||||
const choice = choices[conflict.conflictId] ?? "none"
|
||||
const appliedState = applied[conflict.conflictId]
|
||||
const canDiff =
|
||||
conflict.serverPath != null && conflict.localPath != null
|
||||
return (
|
||||
<TableRow key={conflict.conflictId} className="select-none">
|
||||
<TableCell
|
||||
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||
title={conflict.serverPath ?? conflict.localPath ?? ""}
|
||||
>
|
||||
{conflict.serverPath ?? conflict.localPath ?? `冲突 ${conflict.conflictId}`}
|
||||
{appliedState === false && (
|
||||
<span className="ml-2 text-destructive">应用失败</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||
{conflict.type}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Select
|
||||
value={choice}
|
||||
onValueChange={(value) =>
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[conflict.conflictId]: value as ChoiceValue,
|
||||
}))
|
||||
}
|
||||
disabled={applying}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40 text-xs">
|
||||
<SelectValue placeholder="选择取舍" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">暂不处理</SelectItem>
|
||||
<SelectItem value="takeServer">采用服务器版本</SelectItem>
|
||||
<SelectItem value="keepLocal">保留本地版本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={!canDiff}
|
||||
title={canDiff ? "查看差异" : "缺少路径信息,无法对比"}
|
||||
onClick={() =>
|
||||
setDiffRequest({
|
||||
mode: "localLatest",
|
||||
serverPath: conflict.serverPath as string,
|
||||
localPath: conflict.localPath as string,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileDiff />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={applying}>
|
||||
稍后处理
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void applyChoices()}
|
||||
disabled={applying || chosenCount === 0}
|
||||
>
|
||||
{applying ? "正在应用…" : `应用选择(${chosenCount})`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{diffRequest && (
|
||||
<DiffDialog request={diffRequest} onClose={() => setDiffRequest(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { editor as MonacoEditor } from "monaco-editor"
|
||||
import { ChevronDown, ChevronUp, Loader2, Search } from "lucide-react"
|
||||
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { api } from "~/lib/api"
|
||||
import type { TextDiff } from "~/lib/api"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// Diff 请求:本地 vs latest,或同一服务端文件两个历史版本。
|
||||
export type DiffRequest =
|
||||
| { mode: "localLatest"; serverPath: string; localPath: string }
|
||||
| { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number }
|
||||
|
||||
// 视图模式在同一会话内记忆。
|
||||
const VIEW_MODE_KEY = "mactfs-diff-view-mode"
|
||||
|
||||
/**
|
||||
* 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本:
|
||||
* 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。
|
||||
*/
|
||||
function splitDiffSides(raw: string[]): { original: string; modified: string } {
|
||||
const original: string[] = []
|
||||
const modified: string[] = []
|
||||
for (const line of raw) {
|
||||
const prefix = line.charAt(0)
|
||||
const text = line.slice(1)
|
||||
if (prefix === "-") {
|
||||
original.push(text)
|
||||
} else if (prefix === "+") {
|
||||
modified.push(text)
|
||||
} else {
|
||||
original.push(text)
|
||||
modified.push(text)
|
||||
}
|
||||
}
|
||||
return { original: original.join("\n"), modified: modified.join("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
|
||||
*/
|
||||
function isDarkTheme(): boolean {
|
||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
||||
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
||||
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
|
||||
*/
|
||||
export function DiffDialog({
|
||||
request,
|
||||
onClose,
|
||||
}: {
|
||||
request: DiffRequest
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [diff, setDiff] = useState<TextDiff | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [onlyChanges, setOnlyChanges] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
|
||||
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
|
||||
return "unified"
|
||||
}
|
||||
return "split"
|
||||
})
|
||||
// 差异块总数与当前导航到的差异块序号。
|
||||
const [blockCount, setBlockCount] = useState(0)
|
||||
const [blockIndex, setBlockIndex] = useState(-1)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
void (async () => {
|
||||
const result =
|
||||
request.mode === "localLatest"
|
||||
? await api.diffLocalLatest({
|
||||
serverPath: request.serverPath,
|
||||
localPath: request.localPath,
|
||||
})
|
||||
: await api.diffRevisions({
|
||||
serverPath: request.serverPath,
|
||||
sourceChangeset: request.sourceChangeset,
|
||||
targetChangeset: request.targetChangeset,
|
||||
})
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setError(result.errorMessage ?? "Diff 加载失败")
|
||||
return
|
||||
}
|
||||
setDiff(result.data.diff)
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const changedCount = useMemo(
|
||||
() => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length,
|
||||
[diff],
|
||||
)
|
||||
|
||||
// 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。
|
||||
useEffect(() => {
|
||||
if (!diff) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
||||
let originalModel: MonacoEditor.ITextModel | null = null
|
||||
let modifiedModel: MonacoEditor.ITextModel | null = null
|
||||
void (async () => {
|
||||
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
||||
if (disposed || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
const { original, modified } = splitDiffSides(diff.lines)
|
||||
const language = detectLanguage(request.serverPath)
|
||||
originalModel = monaco.editor.createModel(original, language)
|
||||
modifiedModel = monaco.editor.createModel(modified, language)
|
||||
diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
automaticLayout: true,
|
||||
renderSideBySide: viewMode === "split",
|
||||
hideUnchangedRegions: { enabled: onlyChanges },
|
||||
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
||||
fontSize: 12,
|
||||
lineHeight: 20,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderOverviewRuler: true,
|
||||
overviewRulerBorder: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
diffWordWrap: "off",
|
||||
diffAlgorithm: "advanced",
|
||||
renderLineHighlight: "none",
|
||||
folding: false,
|
||||
glyphMargin: false,
|
||||
guides: { indentation: false },
|
||||
padding: { top: 8, bottom: 8 },
|
||||
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
|
||||
renderMarginRevertIcon: false,
|
||||
})
|
||||
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
|
||||
diffEditor.onDidUpdateDiff(() => {
|
||||
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
||||
})
|
||||
editorRef.current = diffEditor
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
editorRef.current = null
|
||||
diffEditor?.dispose()
|
||||
originalModel?.dispose()
|
||||
modifiedModel?.dispose()
|
||||
setBlockCount(0)
|
||||
setBlockIndex(-1)
|
||||
}
|
||||
}, [diff, request.serverPath])
|
||||
|
||||
/**
|
||||
* 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。
|
||||
*/
|
||||
const switchView = useCallback((mode: "unified" | "split") => {
|
||||
setViewMode(mode)
|
||||
editorRef.current?.updateOptions({ renderSideBySide: mode === "split" })
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.setItem(VIEW_MODE_KEY, mode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 切换「仅看差异」:折叠 / 展开未变动区域。
|
||||
*/
|
||||
const switchOnlyChanges = useCallback((value: boolean) => {
|
||||
setOnlyChanges(value)
|
||||
editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } })
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。
|
||||
*/
|
||||
const goToBlock = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const diffEditor = editorRef.current
|
||||
const changes = diffEditor?.getLineChanges() ?? []
|
||||
if (!diffEditor || changes.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = (blockIndex + direction + changes.length) % changes.length
|
||||
setBlockIndex(next)
|
||||
const change = changes[next]
|
||||
const line =
|
||||
change.modifiedStartLineNumber > 0
|
||||
? change.modifiedStartLineNumber
|
||||
: Math.max(change.modifiedEndLineNumber, 1)
|
||||
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||
modifiedEditor.revealLineInCenter(line)
|
||||
modifiedEditor.setPosition({ lineNumber: line, column: 1 })
|
||||
modifiedEditor.focus()
|
||||
},
|
||||
[blockIndex],
|
||||
)
|
||||
|
||||
/**
|
||||
* 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。
|
||||
*/
|
||||
const openSearch = useCallback(() => {
|
||||
const modifiedEditor = editorRef.current?.getModifiedEditor()
|
||||
modifiedEditor?.focus()
|
||||
void modifiedEditor?.getAction("actions.find")?.run()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{request.mode === "localLatest" ? "服务器 latest vs 本地" : "历史版本对比"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
{request.serverPath}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
|
||||
{diff && (
|
||||
<>
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||
− {diff.sourceLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
|
||||
+ {diff.targetLabel}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||
<ViewTab label="统一视图" active={viewMode === "unified"} onClick={() => switchView("unified")} />
|
||||
<ViewTab label="左右分栏" active={viewMode === "split"} onClick={() => switchView("split")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blockCount === 0}
|
||||
onClick={() => goToBlock(-1)}
|
||||
aria-label="上一处差异"
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blockCount === 0}
|
||||
onClick={() => goToBlock(1)}
|
||||
aria-label="下一处差异"
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
|
||||
</span>
|
||||
</div>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={onlyChanges}
|
||||
onCheckedChange={(value) => switchOnlyChanges(value === true)}
|
||||
/>
|
||||
仅看差异
|
||||
</label>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={openSearch}>
|
||||
<Search className="size-3.5" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在生成 Diff…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : changedCount === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
两个版本内容一致
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图切换小标签按钮。
|
||||
*/
|
||||
function ViewTab({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-[5px] px-2 py-0.5 text-xs",
|
||||
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { api } from "~/lib/api"
|
||||
import type { FileContent } from "~/lib/api"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 查看来源:本地映射文件或服务器 latest。
|
||||
type ViewSource = "local" | "server"
|
||||
|
||||
/**
|
||||
* 把字节数格式化为可读大小。
|
||||
*/
|
||||
function formatSize(size: number): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件查看弹窗:只读展示本地、服务器 latest 或指定 changeset 版本内容,
|
||||
* 支持来源切换、行号与搜索高亮;二进制 / 超大 / 非映射路径给出明确提示。
|
||||
*/
|
||||
export function FileViewDialog({
|
||||
serverPath,
|
||||
localPath,
|
||||
changeset,
|
||||
onClose,
|
||||
}: {
|
||||
serverPath: string
|
||||
localPath: string | null
|
||||
/** 指定 changeset 时固定展示该版本内容,不提供来源切换。 */
|
||||
changeset?: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [source, setSource] = useState<ViewSource>(localPath && changeset == null ? "local" : "server")
|
||||
const [content, setContent] = useState<FileContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
// 加载所选来源的文件内容;本地内容仅在已映射时可用;指定版本固定按 changeset 读取。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
void (async () => {
|
||||
const result =
|
||||
changeset != null
|
||||
? await api.getFileContent({ serverPath, changeset })
|
||||
: source === "local" && localPath
|
||||
? await api.getFileContent({ localPath })
|
||||
: await api.getFileContent({ serverPath })
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setContent(null)
|
||||
setError(result.errorMessage ?? "文件内容加载失败")
|
||||
return
|
||||
}
|
||||
setContent(result.data.content)
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [source, serverPath, localPath, changeset])
|
||||
|
||||
const lines = useMemo(
|
||||
() => (content && !content.binary && !content.tooLarge ? content.content.split(/\r?\n/) : []),
|
||||
[content],
|
||||
)
|
||||
const keyword = search.trim().toLowerCase()
|
||||
const matchCount = useMemo(
|
||||
() =>
|
||||
keyword.length === 0
|
||||
? 0
|
||||
: lines.filter((line) => line.toLowerCase().includes(keyword)).length,
|
||||
[lines, keyword],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[90svh] max-h-[90svh] flex-col sm:max-w-[92vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>查看文件</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{changeset != null ? (
|
||||
<Badge variant="secondary" className="rounded-md">
|
||||
版本 C{changeset}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex items-center rounded-md border p-0.5">
|
||||
<SourceTab
|
||||
label="本地文件"
|
||||
active={source === "local"}
|
||||
disabled={!localPath}
|
||||
disabledReason="未映射到本地"
|
||||
onClick={() => setSource("local")}
|
||||
/>
|
||||
<SourceTab
|
||||
label="服务器 latest"
|
||||
active={source === "server"}
|
||||
onClick={() => setSource("server")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatSize(content.size)}
|
||||
{content.changeset > 0 && ` · 版本 ${content.changeset}`}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索内容…"
|
||||
className="ml-auto h-7 w-48 text-xs"
|
||||
/>
|
||||
{keyword.length > 0 && (
|
||||
<Badge variant="secondary" className="rounded-md">
|
||||
{matchCount} 行命中
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
|
||||
{loading ? (
|
||||
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载内容…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : content?.binary ? (
|
||||
<Notice text={`二进制文件不支持预览(${formatSize(content.size)})`} />
|
||||
) : content?.tooLarge ? (
|
||||
<Notice text={`文件过大(${formatSize(content.size)}),不直接渲染内容`} />
|
||||
) : (
|
||||
<pre className="min-w-full font-mono text-xs leading-5">
|
||||
{lines.map((line, index) => {
|
||||
const hit = keyword.length > 0 && line.toLowerCase().includes(keyword)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("flex", hit && "bg-amber-500/15")}
|
||||
>
|
||||
<span className="w-12 shrink-0 border-r pr-2 text-right text-muted-foreground/60 select-none">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all pl-2">{line}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="shrink-0 text-xs text-muted-foreground">
|
||||
只读查看 · 来源:
|
||||
{changeset != null
|
||||
? `changeset ${changeset} 版本`
|
||||
: source === "local"
|
||||
? `本地 ${localPath ?? ""}`
|
||||
: "服务器 latest"}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源切换小标签按钮。
|
||||
*/
|
||||
function SourceTab({
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? disabledReason : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-[5px] px-2 py-0.5 text-xs",
|
||||
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容区占位提示(二进制 / 超大文件)。
|
||||
*/
|
||||
function Notice({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
|
||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { api } from "~/lib/api"
|
||||
import type { ServerItem } from "~/lib/api"
|
||||
import { pathsExist } from "~/lib/electron"
|
||||
import type { FileActionId, FileTarget, WorkspaceSession } from "~/lib/tfs"
|
||||
import {
|
||||
makeFileTarget,
|
||||
resolveItemLocalState,
|
||||
resolveLocalPath,
|
||||
statusBadgeClass,
|
||||
statusLabel,
|
||||
} from "~/lib/tfs"
|
||||
import { cn, formatDateTime } from "~/lib/utils"
|
||||
|
||||
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
|
||||
interface FolderItemRow {
|
||||
item: ServerItem
|
||||
localPath: string | null
|
||||
localState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
|
||||
* 单击选中、双击目录进入,行级右键菜单与左侧树共用同一套动作模型。
|
||||
*/
|
||||
export function FolderItemsPanel({
|
||||
session,
|
||||
selectedServerPath,
|
||||
pendingByServerPath,
|
||||
refreshToken,
|
||||
onNavigate,
|
||||
onFileAction,
|
||||
onRefresh,
|
||||
}: {
|
||||
session: WorkspaceSession
|
||||
selectedServerPath: string
|
||||
pendingByServerPath: Record<string, string>
|
||||
refreshToken: number
|
||||
onNavigate: (serverPath: string) => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [items, setItems] = useState<ServerItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null)
|
||||
// 本地路径 -> 是否存在;null 表示当前环境无法检测。
|
||||
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
|
||||
|
||||
// 当前路径变化时加载目录内容,并批量检测映射目标的本地存在性。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSelectedItemPath(null)
|
||||
void (async () => {
|
||||
const result = await api.getFolderItems({
|
||||
path: selectedServerPath,
|
||||
collection: session.collection,
|
||||
})
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setItems([])
|
||||
setError(result.errorMessage ?? "目录加载失败")
|
||||
return
|
||||
}
|
||||
const list = result.data?.items ?? []
|
||||
setItems(list)
|
||||
const localPaths = list
|
||||
.map((item) => resolveLocalPath(session.mappings, item.serverPath))
|
||||
.filter((path): path is string => path != null)
|
||||
const exists = await pathsExist(localPaths)
|
||||
if (active) {
|
||||
setExistsMap(exists)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedServerPath, session.collection, session.mappings, refreshToken])
|
||||
|
||||
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
|
||||
const rows = useMemo<FolderItemRow[]>(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const localPath = resolveLocalPath(session.mappings, item.serverPath)
|
||||
const localExists =
|
||||
localPath == null || existsMap == null
|
||||
? null
|
||||
: existsMap[localPath] ?? false
|
||||
return {
|
||||
item,
|
||||
localPath,
|
||||
localState: resolveItemLocalState(localPath != null, localExists),
|
||||
}
|
||||
}),
|
||||
[items, session.mappings, existsMap],
|
||||
)
|
||||
|
||||
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
|
||||
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{currentLocalPath ? (
|
||||
<span className="font-mono">{currentLocalPath}</span>
|
||||
) : (
|
||||
"未映射到本地"
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="刷新当前目录"
|
||||
title="刷新当前目录"
|
||||
>
|
||||
<RefreshCw className={cn(loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载目录…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
当前目录为空
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
||||
<TableHead className="h-8 w-20 text-right text-xs text-muted-foreground">
|
||||
最新版本
|
||||
</TableHead>
|
||||
<TableHead className="h-8 w-36 text-xs text-muted-foreground">
|
||||
上次签入时间
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const selected = row.item.serverPath === selectedItemPath
|
||||
const target = makeFileTarget({
|
||||
source: "list",
|
||||
folder: row.item.folder,
|
||||
serverPath: row.item.serverPath,
|
||||
mappings: session.mappings,
|
||||
pendingStatus: pendingByServerPath[row.item.serverPath] ?? null,
|
||||
})
|
||||
return (
|
||||
<FileTargetMenu
|
||||
key={row.item.serverPath}
|
||||
target={target}
|
||||
onAction={onFileAction}
|
||||
>
|
||||
<TableRow
|
||||
data-state={selected ? "selected" : undefined}
|
||||
className="cursor-default select-none"
|
||||
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
||||
onContextMenu={() => setSelectedItemPath(row.item.serverPath)}
|
||||
onDoubleClick={() => {
|
||||
if (row.item.folder) {
|
||||
onNavigate(row.item.serverPath)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="py-1.5">
|
||||
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
||||
{row.item.folder ? (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">{row.item.name}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("rounded-md", statusBadgeClass(row.localState))}
|
||||
>
|
||||
{statusLabel(row.localState)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||
{row.item.latestVersion > 0 ? row.item.latestVersion : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||
{formatDateTime(row.item.checkinDate)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{row.localPath ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FileTargetMenu>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
|
||||
{selectedRow ? (
|
||||
<>
|
||||
<span className="font-medium text-foreground">{selectedRow.item.name}</span>
|
||||
<span className="truncate font-mono">{selectedRow.item.serverPath}</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
{statusLabel(selectedRow.localState)}
|
||||
{selectedRow.item.latestVersion > 0 &&
|
||||
` · 版本 ${selectedRow.item.latestVersion}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{rows.length > 0 ? `${rows.length} 个对象 · 未选中` : "未选中对象"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { ArrowLeft, CloudDownload, Loader2 } from "lucide-react"
|
||||
|
||||
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { api } from "~/lib/api"
|
||||
import type { HistoryEntry } from "~/lib/api"
|
||||
import { formatDateTime } from "~/lib/utils"
|
||||
|
||||
// 历史弹窗当前查看的目标(支持从 changeset 文件下钻到单文件历史后返回)。
|
||||
interface HistoryTarget {
|
||||
serverPath: string
|
||||
folder: boolean
|
||||
}
|
||||
|
||||
// 「获取此版本」待确认信息。
|
||||
interface VersionConfirm {
|
||||
serverPath: string
|
||||
changeset: number
|
||||
folder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* History 弹窗:展示文件或目录历史(最近记录)。
|
||||
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
|
||||
* 查看该文件历史(可返回)/ 获取此版本;文件历史可勾选两个版本进入版本对比;
|
||||
* 每条记录支持「获取此版本…」(确认后用该版本覆盖本地)。
|
||||
*/
|
||||
export function HistoryDialog({
|
||||
serverPath,
|
||||
folder,
|
||||
onClose,
|
||||
onDiffRevisions,
|
||||
onGetVersion,
|
||||
}: {
|
||||
serverPath: string
|
||||
folder: boolean
|
||||
onClose: () => void
|
||||
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
|
||||
onGetVersion?: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
||||
}) {
|
||||
const [target, setTarget] = useState<HistoryTarget>({ serverPath, folder })
|
||||
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
|
||||
const [targetStack, setTargetStack] = useState<HistoryTarget[]>([])
|
||||
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// 目录历史下钻:当前查看的 changeset 文件列表,null 表示在历史主列表。
|
||||
const [changesetFiles, setChangesetFiles] = useState<{
|
||||
changeset: number
|
||||
files: HistoryEntry[]
|
||||
} | null>(null)
|
||||
const [filesLoading, setFilesLoading] = useState(false)
|
||||
// 文件历史勾选的 changeset(最多两个),用于版本对比。
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
|
||||
// 「查看此版本内容」叠加的文件查看弹窗目标。
|
||||
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
const result = await api.getHistory({ path: target.serverPath, folder: target.folder })
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "历史加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setEntries(result.data?.history ?? [])
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [target])
|
||||
|
||||
/**
|
||||
* 目录历史下钻:加载指定 changeset 影响的文件列表。
|
||||
*/
|
||||
const openChangeset = useCallback(async (changeset: number) => {
|
||||
setFilesLoading(true)
|
||||
const result = await api.getChangesetFiles({ changeset })
|
||||
setFilesLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "Changeset 文件加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setChangesetFiles({ changeset, files: result.data?.files ?? [] })
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 从 changeset 文件切换到该文件的历史(压栈当前目标,可返回)。
|
||||
*/
|
||||
const openFileHistory = useCallback(
|
||||
(fileServerPath: string) => {
|
||||
setTargetStack((prev) => [...prev, target])
|
||||
setTarget({ serverPath: fileServerPath, folder: false })
|
||||
setChangesetFiles(null)
|
||||
setSelected([])
|
||||
},
|
||||
[target],
|
||||
)
|
||||
|
||||
/**
|
||||
* 返回上一级历史目标。
|
||||
*/
|
||||
const backToPreviousTarget = useCallback(() => {
|
||||
setTargetStack((prev) => {
|
||||
const next = [...prev]
|
||||
const previous = next.pop()
|
||||
if (previous) {
|
||||
setTarget(previous)
|
||||
setChangesetFiles(null)
|
||||
setSelected([])
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 勾选 / 取消勾选用于对比的 changeset,最多保留两个(先进先出)。
|
||||
*/
|
||||
const toggleSelected = useCallback((changeset: number) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(changeset)) {
|
||||
return prev.filter((item) => item !== changeset)
|
||||
}
|
||||
const next = [...prev, changeset]
|
||||
return next.length > 2 ? next.slice(next.length - 2) : next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const canDiff = onDiffRevisions != null && !target.folder && selected.length === 2
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[88vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{changesetFiles ? `Changeset ${changesetFiles.changeset} 的文件` : "历史记录"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
{target.serverPath}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(changesetFiles || targetStack.length > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{changesetFiles && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setChangesetFiles(null)}>
|
||||
<ArrowLeft data-icon="inline-start" />
|
||||
返回历史列表
|
||||
</Button>
|
||||
)}
|
||||
{!changesetFiles && targetStack.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={backToPreviousTarget}>
|
||||
<ArrowLeft data-icon="inline-start" />
|
||||
返回上级历史
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{loading || filesLoading ? (
|
||||
<div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : changesetFiles ? (
|
||||
<ChangesetFilesTable
|
||||
files={changesetFiles.files}
|
||||
changeset={changesetFiles.changeset}
|
||||
onViewVersion={(fileServerPath) =>
|
||||
setViewVersion({ serverPath: fileServerPath, changeset: changesetFiles.changeset })
|
||||
}
|
||||
onOpenFileHistory={openFileHistory}
|
||||
onGetVersion={
|
||||
onGetVersion
|
||||
? (fileServerPath) =>
|
||||
setConfirmVersion({
|
||||
serverPath: fileServerPath,
|
||||
changeset: changesetFiles.changeset,
|
||||
folder: false,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
没有历史记录
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{!target.folder && <TableHead className="h-8 w-8" />}
|
||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
|
||||
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
|
||||
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
|
||||
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
|
||||
{onGetVersion && <TableHead className="h-8 w-12 text-xs text-muted-foreground" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow
|
||||
key={`${entry.changeset}-${entry.serverPath}`}
|
||||
className="cursor-default select-none"
|
||||
onDoubleClick={() => {
|
||||
if (target.folder) {
|
||||
void openChangeset(entry.changeset)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!target.folder && (
|
||||
<TableCell className="py-1.5">
|
||||
<Checkbox
|
||||
checked={selected.includes(entry.changeset)}
|
||||
onCheckedChange={() => toggleSelected(entry.changeset)}
|
||||
aria-label={`选择 changeset ${entry.changeset}`}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="py-1.5 font-mono text-xs">
|
||||
{target.folder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={() => void openChangeset(entry.changeset)}
|
||||
>
|
||||
{entry.changeset}
|
||||
</button>
|
||||
) : (
|
||||
entry.changeset
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs">{entry.changeType}</TableCell>
|
||||
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||
{formatDateTime(entry.date)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
|
||||
{entry.comment || "—"}
|
||||
</TableCell>
|
||||
{onGetVersion && (
|
||||
<TableCell className="py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={`获取此版本(C${entry.changeset},覆盖本地)`}
|
||||
onClick={() =>
|
||||
setConfirmVersion({
|
||||
serverPath: target.serverPath,
|
||||
changeset: entry.changeset,
|
||||
folder: target.folder,
|
||||
})
|
||||
}
|
||||
>
|
||||
<CloudDownload />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{changesetFiles
|
||||
? `${changesetFiles.files.length} 个文件 · 右键文件可查看此版本 / 历史 / 获取`
|
||||
: target.folder
|
||||
? "双击记录或点击 changeset 编号查看影响文件"
|
||||
: "勾选两个版本可进行对比"}
|
||||
</p>
|
||||
{!target.folder && onDiffRevisions && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canDiff}
|
||||
onClick={() => {
|
||||
const [a, b] = [...selected].sort((x, y) => x - y)
|
||||
onDiffRevisions(target.serverPath, a, b)
|
||||
}}
|
||||
>
|
||||
对比所选版本
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmVersion && onGetVersion && (
|
||||
<ConfirmDialog
|
||||
title={`获取版本 C${confirmVersion.changeset}`}
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
将把 <span className="font-mono text-xs">{confirmVersion.serverPath}</span>{" "}
|
||||
的本地内容替换为 changeset {confirmVersion.changeset} 的版本。
|
||||
</p>
|
||||
<p className="mt-2 font-medium text-destructive">
|
||||
本地未签入的修改会被覆盖,且无法恢复。确定继续吗?
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel="获取此版本"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await onGetVersion(
|
||||
confirmVersion.serverPath,
|
||||
confirmVersion.changeset,
|
||||
confirmVersion.folder,
|
||||
)
|
||||
setConfirmVersion(null)
|
||||
}}
|
||||
onClose={() => setConfirmVersion(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewVersion && (
|
||||
<FileViewDialog
|
||||
serverPath={viewVersion.serverPath}
|
||||
localPath={null}
|
||||
changeset={viewVersion.changeset}
|
||||
onClose={() => setViewVersion(null)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Changeset 影响文件列表(目录历史下钻视图),行级右键菜单提供对象操作。
|
||||
*/
|
||||
function ChangesetFilesTable({
|
||||
files,
|
||||
changeset,
|
||||
onViewVersion,
|
||||
onOpenFileHistory,
|
||||
onGetVersion,
|
||||
}: {
|
||||
files: HistoryEntry[]
|
||||
changeset: number
|
||||
onViewVersion: (serverPath: string) => void
|
||||
onOpenFileHistory: (serverPath: string) => void
|
||||
onGetVersion?: (serverPath: string) => void
|
||||
}) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
该 changeset 没有文件记录
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
<ContextMenu key={file.serverPath}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<TableRow className="cursor-default select-none">
|
||||
<TableCell className="py-1.5 text-xs">{file.changeType}</TableCell>
|
||||
<TableCell
|
||||
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||
title={file.serverPath}
|
||||
>
|
||||
{file.serverPath}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onSelect={() => onViewVersion(file.serverPath)}>
|
||||
查看此版本内容
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onOpenFileHistory(file.serverPath)}>
|
||||
查看该文件历史
|
||||
</ContextMenuItem>
|
||||
{onGetVersion && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onGetVersion(file.serverPath)}
|
||||
>
|
||||
获取此版本(C{changeset})…
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FolderSearch, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import { api } from "~/lib/api"
|
||||
import type { CheckMappingTargetResult, MappingInfo } from "~/lib/api"
|
||||
import { isElectron, selectDirectory } from "~/lib/electron"
|
||||
|
||||
/**
|
||||
* Mapping 创建弹窗:展示服务端路径,选择本地父目录后由后端预校验最终目标路径,
|
||||
* 目标已存在时禁止映射;支持创建后立即 Get Latest。
|
||||
*/
|
||||
export function MappingDialog({
|
||||
serverPath,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
serverPath: string
|
||||
onClose: () => void
|
||||
onCreated: (mappings: MappingInfo[], didGetLatest: boolean) => void
|
||||
}) {
|
||||
const [parentPath, setParentPath] = useState("")
|
||||
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [getLatest, setGetLatest] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* 通过系统目录选择器选择本地父目录(非 Electron 环境保留手动输入)。
|
||||
*/
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const selected = await selectDirectory()
|
||||
if (selected) {
|
||||
setParentPath(selected)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 父目录变化时调用后端预校验最终目标路径,最终路径以后端结果为准。
|
||||
useEffect(() => {
|
||||
const trimmed = parentPath.trim()
|
||||
if (trimmed.length === 0) {
|
||||
setCheck(null)
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setChecking(true)
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const result = await api.checkMappingTarget({
|
||||
serverPath,
|
||||
localParentPath: trimmed,
|
||||
})
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setChecking(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setCheck(null)
|
||||
setError(result.errorMessage ?? "目标路径校验失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setCheck(result.data)
|
||||
})()
|
||||
}, 300)
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [parentPath, serverPath])
|
||||
|
||||
const exists = check?.exists === true
|
||||
const canConfirm = check != null && check.allowed && !exists && !busy && !checking
|
||||
|
||||
/**
|
||||
* 创建 Mapping(按需立即 Get Latest),成功后回调上层刷新并关闭弹窗。
|
||||
*/
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!check) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const result = await api.addMapping({
|
||||
serverPath,
|
||||
localPath: check.targetPath,
|
||||
getLatest,
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "创建 Mapping 失败")
|
||||
return
|
||||
}
|
||||
const mappingsResult = await api.listMappings()
|
||||
onCreated(mappingsResult.data?.mappings ?? [], getLatest)
|
||||
}, [check, serverPath, getLatest, onCreated])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>映射到本地</DialogTitle>
|
||||
<DialogDescription>
|
||||
为服务端目录创建本地工作区映射
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>服务端路径</Label>
|
||||
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||
{serverPath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="parentPath">本地父目录</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="parentPath"
|
||||
placeholder="/Users/me/tfs"
|
||||
value={parentPath}
|
||||
onChange={(event) => setParentPath(event.target.value)}
|
||||
disabled={busy}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{isElectron() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={busy}
|
||||
aria-label="浏览本地目录"
|
||||
>
|
||||
<FolderSearch />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择父目录,最终映射目录由后端按服务端目录名生成
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{checking && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在校验目标路径…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{check && !checking && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>最终映射路径</Label>
|
||||
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||
{check.targetPath}
|
||||
</p>
|
||||
{exists && (
|
||||
<p className="text-xs font-medium text-destructive">
|
||||
目标目录已存在,禁止映射
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={getLatest}
|
||||
onCheckedChange={(value) => setGetLatest(value === true)}
|
||||
disabled={busy}
|
||||
/>
|
||||
创建后立即获取最新
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
|
||||
{busy ? "正在创建…" : "创建映射"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ChevronRight, FolderClosed, FolderOpen, Loader2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { api } from "~/lib/api"
|
||||
import type { MappingInfo, ServerItem } from "~/lib/api"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
import {
|
||||
SERVER_ROOT_PATH,
|
||||
getAncestorPaths,
|
||||
makeFileTarget,
|
||||
normalizeServerPath,
|
||||
} from "~/lib/tfs"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
|
||||
* 与中间列表共享当前路径;节点支持与中间列表同规则的右键菜单。
|
||||
*/
|
||||
export function SourceTreePanel({
|
||||
collection,
|
||||
mappings,
|
||||
selectedServerPath,
|
||||
onNavigate,
|
||||
onFileAction,
|
||||
}: {
|
||||
collection: string
|
||||
mappings: MappingInfo[]
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
}) {
|
||||
// path -> 子目录列表(仅 folder),undefined 表示尚未加载。
|
||||
const [childrenByPath, setChildrenByPath] = useState<Record<string, ServerItem[]>>({})
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||
[SERVER_ROOT_PATH]: true,
|
||||
})
|
||||
const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// 手动刷新令牌:递增时清空缓存并重新加载。
|
||||
const [reloadToken, setReloadToken] = useState(0)
|
||||
// 已加载与加载中的路径集合,避免异步流程里读到过期 state。
|
||||
const requestedRef = useRef<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 懒加载指定目录的子目录列表,已加载或加载中的路径直接跳过。
|
||||
*/
|
||||
const ensureChildren = useCallback(
|
||||
async (serverPath: string) => {
|
||||
const target = normalizeServerPath(serverPath)
|
||||
if (requestedRef.current.has(target)) {
|
||||
return
|
||||
}
|
||||
requestedRef.current.add(target)
|
||||
setLoadingPaths((prev) => ({ ...prev, [target]: true }))
|
||||
const result = await api.getServerTree({ path: target, collection })
|
||||
setLoadingPaths((prev) => ({ ...prev, [target]: false }))
|
||||
if (!result.ok) {
|
||||
requestedRef.current.delete(target)
|
||||
setError(result.errorMessage ?? "目录加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
const folders = (result.data?.items ?? []).filter((item) => item.folder)
|
||||
setChildrenByPath((prev) => ({ ...prev, [target]: folders }))
|
||||
},
|
||||
[collection],
|
||||
)
|
||||
|
||||
// 当前路径变化或手动刷新时,逐级加载并展开祖先链与自身。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
const chain = [...getAncestorPaths(selectedServerPath), normalizeServerPath(selectedServerPath)]
|
||||
for (const path of chain) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
await ensureChildren(path)
|
||||
setExpanded((prev) => (prev[path] ? prev : { ...prev, [path]: true }))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedServerPath, ensureChildren, reloadToken])
|
||||
|
||||
/**
|
||||
* 手动刷新目录树:清空懒加载缓存并从根重新加载,保持当前选中路径的展开链。
|
||||
*/
|
||||
const handleRefresh = useCallback(() => {
|
||||
requestedRef.current.clear()
|
||||
setChildrenByPath({})
|
||||
setExpanded({ [SERVER_ROOT_PATH]: true })
|
||||
setError(null)
|
||||
setReloadToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 切换节点展开 / 收起,首次展开时触发懒加载。
|
||||
*/
|
||||
const toggleExpand = useCallback(
|
||||
(serverPath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = !prev[serverPath]
|
||||
if (next) {
|
||||
void ensureChildren(serverPath)
|
||||
}
|
||||
return { ...prev, [serverPath]: next }
|
||||
})
|
||||
},
|
||||
[ensureChildren],
|
||||
)
|
||||
|
||||
/**
|
||||
* 点击节点名称:选中并导航到该目录,同时确保其展开。
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(serverPath: string) => {
|
||||
onNavigate(serverPath)
|
||||
setExpanded((prev) => (prev[serverPath] ? prev : { ...prev, [serverPath]: true }))
|
||||
void ensureChildren(serverPath)
|
||||
},
|
||||
[onNavigate, ensureChildren],
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[280px] shrink-0 flex-col border-r bg-sidebar">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
|
||||
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
onClick={handleRefresh}
|
||||
aria-label="刷新目录树"
|
||||
title="刷新目录树"
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2">
|
||||
{error && (
|
||||
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
<TreeNode
|
||||
path={SERVER_ROOT_PATH}
|
||||
name={SERVER_ROOT_PATH}
|
||||
depth={0}
|
||||
mappings={mappings}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={normalizeServerPath(selectedServerPath)}
|
||||
onToggle={toggleExpand}
|
||||
onSelect={handleSelect}
|
||||
onFileAction={onFileAction}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
|
||||
*/
|
||||
function TreeNode({
|
||||
path,
|
||||
name,
|
||||
depth,
|
||||
mappings,
|
||||
childrenByPath,
|
||||
expanded,
|
||||
loadingPaths,
|
||||
selectedServerPath,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onFileAction,
|
||||
}: {
|
||||
path: string
|
||||
name: string
|
||||
depth: number
|
||||
mappings: MappingInfo[]
|
||||
childrenByPath: Record<string, ServerItem[]>
|
||||
expanded: Record<string, boolean>
|
||||
loadingPaths: Record<string, boolean>
|
||||
selectedServerPath: string
|
||||
onToggle: (serverPath: string) => void
|
||||
onSelect: (serverPath: string) => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
}) {
|
||||
const children = childrenByPath[path]
|
||||
const isExpanded = expanded[path] === true
|
||||
const isLoading = loadingPaths[path] === true
|
||||
const isSelected = selectedServerPath === path
|
||||
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
||||
const isLeaf = children != null && children.length === 0
|
||||
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-7 items-center rounded-md pr-1",
|
||||
isSelected ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isLeaf ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
||||
onClick={() => onToggle(path)}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(path)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
>
|
||||
{isExpanded && !isLeaf ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isSelected && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</FileTargetMenu>
|
||||
{isExpanded &&
|
||||
children?.map((child) => (
|
||||
<TreeNode
|
||||
key={child.serverPath}
|
||||
path={child.serverPath}
|
||||
name={child.name}
|
||||
depth={depth + 1}
|
||||
mappings={mappings}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onFileAction={onFileAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import type { MappingInfo, PendingChange } from "~/lib/api"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、
|
||||
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。
|
||||
*/
|
||||
export function ChangesPanel({
|
||||
mappings,
|
||||
pendingChanges,
|
||||
excludedKeys,
|
||||
loading,
|
||||
error,
|
||||
checkinBusy,
|
||||
onToggleExcluded,
|
||||
onCheckin,
|
||||
onFileAction,
|
||||
onRefresh,
|
||||
}: {
|
||||
mappings: MappingInfo[]
|
||||
pendingChanges: PendingChange[]
|
||||
excludedKeys: Set<string>
|
||||
loading: boolean
|
||||
error: string | null
|
||||
checkinBusy: boolean
|
||||
onToggleExcluded: (serverPath: string) => void
|
||||
onCheckin: (paths: string[], comment: string) => Promise<boolean>
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [comment, setComment] = useState("")
|
||||
|
||||
const included = useMemo(
|
||||
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
|
||||
[pendingChanges, excludedKeys],
|
||||
)
|
||||
const excluded = useMemo(
|
||||
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
|
||||
[pendingChanges, excludedKeys],
|
||||
)
|
||||
|
||||
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
|
||||
|
||||
/**
|
||||
* 提交签入:只提交 Included 项,成功后清空注释。
|
||||
*/
|
||||
const handleCheckin = useCallback(async () => {
|
||||
const success = await onCheckin(
|
||||
included.map((change) => change.serverPath),
|
||||
comment.trim(),
|
||||
)
|
||||
if (success) {
|
||||
setComment("")
|
||||
}
|
||||
}, [onCheckin, included, comment])
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[340px] shrink-0 flex-col border-l bg-sidebar">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">挂起更改</span>
|
||||
{pendingChanges.length > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={pendingChanges.length > 0 ? "" : "ml-auto"}
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="刷新挂起更改"
|
||||
title="刷新挂起更改"
|
||||
>
|
||||
<RefreshCw className={cn(loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
正在加载挂起更改…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : pendingChanges.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<ListChecks className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-xs leading-5 text-muted-foreground">当前没有挂起更改</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2">
|
||||
<GroupHeader label="Included" count={included.length} />
|
||||
{included.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
没有要签入的更改,勾选下方 Excluded 项可移回
|
||||
</p>
|
||||
)}
|
||||
{included.map((change) => (
|
||||
<PendingRow
|
||||
key={change.serverPath}
|
||||
change={change}
|
||||
included
|
||||
mappings={mappings}
|
||||
onToggleExcluded={onToggleExcluded}
|
||||
onFileAction={onFileAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
{excluded.length > 0 && (
|
||||
<>
|
||||
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
|
||||
{excluded.map((change) => (
|
||||
<PendingRow
|
||||
key={change.serverPath}
|
||||
change={change}
|
||||
included={false}
|
||||
mappings={mappings}
|
||||
onToggleExcluded={onToggleExcluded}
|
||||
onFileAction={onFileAction}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 space-y-2 border-t p-3">
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="签入注释(必填)"
|
||||
rows={3}
|
||||
disabled={checkinBusy || pendingChanges.length === 0}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
将签入 {included.length} 项
|
||||
</span>
|
||||
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
|
||||
{checkinBusy ? (
|
||||
<>
|
||||
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||
正在签入…
|
||||
</>
|
||||
) : (
|
||||
"签入"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{comment.trim().length === 0 && pendingChanges.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">填写注释后才能签入</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组标题行。
|
||||
*/
|
||||
function GroupHeader({
|
||||
label,
|
||||
count,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
count: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}>
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className="text-xs text-muted-foreground/70">{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。
|
||||
*/
|
||||
function PendingRow({
|
||||
change,
|
||||
included,
|
||||
mappings,
|
||||
onToggleExcluded,
|
||||
onFileAction,
|
||||
}: {
|
||||
change: PendingChange
|
||||
included: boolean
|
||||
mappings: MappingInfo[]
|
||||
onToggleExcluded: (serverPath: string) => void
|
||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
||||
}) {
|
||||
const target = makeFileTarget({
|
||||
source: "pending",
|
||||
folder: change.folder,
|
||||
serverPath: change.serverPath,
|
||||
mappings,
|
||||
pendingStatus: change.status,
|
||||
})
|
||||
return (
|
||||
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted",
|
||||
!included && "opacity-60",
|
||||
)}
|
||||
title={change.serverPath}
|
||||
>
|
||||
<Checkbox
|
||||
checked={included}
|
||||
onCheckedChange={() => onToggleExcluded(change.serverPath)}
|
||||
aria-label={included ? "移到 Excluded" : "移回 Included"}
|
||||
/>
|
||||
{change.folder ? (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{change.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
|
||||
{change.serverPath}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("shrink-0 rounded-md", statusBadgeClass(change.status))}
|
||||
>
|
||||
{statusLabel(change.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</FileTargetMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { api } from "~/lib/api"
|
||||
import type { OperationLogEntry } from "~/lib/api"
|
||||
import { cn, formatDateTime } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 把毫秒耗时格式化为可读文本。
|
||||
*/
|
||||
function formatDuration(durationMs: number): string {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs} ms`
|
||||
}
|
||||
return `${(durationMs / 1000).toFixed(1)} s`
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部 Operation Console:展示最近操作日志(操作、摘要、起止时间、耗时、结果),
|
||||
* 随主要操作完成自动刷新,长操作执行期间显示执行中提示。
|
||||
*/
|
||||
export function ConsolePanel({
|
||||
refreshToken,
|
||||
busy,
|
||||
busyText,
|
||||
}: {
|
||||
refreshToken: number
|
||||
busy: boolean
|
||||
busyText: string | null
|
||||
}) {
|
||||
const [logs, setLogs] = useState<OperationLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* 拉取最近操作日志。
|
||||
*/
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = await api.getLogs()
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "操作日志加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setLogs(result.data?.logs ?? [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs()
|
||||
}, [loadLogs, refreshToken])
|
||||
|
||||
// 最新的操作排在最上面。
|
||||
const ordered = useMemo(
|
||||
() => [...logs].sort((a, b) => b.startedAt - a.startedAt),
|
||||
[logs],
|
||||
)
|
||||
|
||||
return (
|
||||
<footer className="flex h-[180px] shrink-0 flex-col border-t bg-background">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">操作日志</span>
|
||||
{busy && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-primary">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{busyText ?? "正在执行…"}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{ordered.length > 0 && `${ordered.length} 条`}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void loadLogs()}
|
||||
disabled={loading}
|
||||
aria-label="刷新日志"
|
||||
>
|
||||
<RefreshCw className={cn(loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{error ? (
|
||||
<p className="px-3 py-2 text-xs text-destructive">{error}</p>
|
||||
) : ordered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{loading ? "正在加载操作日志…" : "暂无操作日志"}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="px-2 py-1">
|
||||
{ordered.map((entry, index) => (
|
||||
<li
|
||||
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
||||
className="flex h-7 items-center gap-2 rounded px-1.5 text-xs hover:bg-muted"
|
||||
title={entry.errorMessage ?? entry.summary}
|
||||
>
|
||||
{entry.success ? (
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="w-36 shrink-0 truncate font-mono text-muted-foreground">
|
||||
{entry.operation}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{entry.summary}
|
||||
{!entry.success && entry.errorMessage && (
|
||||
<span className="text-destructive"> {entry.errorMessage}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="w-32 shrink-0 text-right text-muted-foreground">
|
||||
{formatDateTime(entry.startedAt)}
|
||||
</span>
|
||||
<span className="w-16 shrink-0 text-right font-mono text-muted-foreground">
|
||||
{formatDuration(entry.durationMs)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -9,11 +9,11 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,261 @@
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 基础文本输入框,统一 macOS 工作台风格的尺寸、边框与聚焦态。
|
||||
*/
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 表单字段标签,保持轻量并禁止选中,贴合工具型界面。
|
||||
*/
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-medium text-foreground select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import type { DiffRequest } from "~/components/explorer/diff-dialog"
|
||||
import { api } from "~/lib/api"
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||
|
||||
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认。
|
||||
export type WorkspaceDialogState =
|
||||
| { kind: "mapping"; serverPath: string }
|
||||
| { kind: "history"; serverPath: string; folder: boolean }
|
||||
| { kind: "compare"; serverPath: string }
|
||||
| { kind: "viewFile"; serverPath: string; localPath: string | null; changeset?: number }
|
||||
| { kind: "diff"; request: DiffRequest }
|
||||
| { kind: "conflicts"; serverPath: string }
|
||||
| { kind: "confirmForceGet"; serverPath: string; folder: boolean }
|
||||
| null
|
||||
|
||||
// 顶部细条通知:信息(操作摘要)或错误。
|
||||
export interface ActionNotice {
|
||||
kind: "info" | "error"
|
||||
text: string
|
||||
}
|
||||
|
||||
// 动作编排所需的上层依赖:mappings 更新与各类刷新入口。
|
||||
interface FileActionsDeps {
|
||||
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||
refreshPendingChanges: () => Promise<void>
|
||||
refreshItems: () => void
|
||||
refreshLogs: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象动作编排 hook:集中持有弹窗开关、通知与忙碌状态,
|
||||
* 把右键菜单动作映射为 API 执行或弹窗打开,并按统一规则刷新(含操作日志)。
|
||||
*/
|
||||
export function useFileActions({
|
||||
onMappingsChanged,
|
||||
refreshPendingChanges,
|
||||
refreshItems,
|
||||
refreshLogs,
|
||||
}: FileActionsDeps) {
|
||||
const [dialog, setDialog] = useState<WorkspaceDialogState>(null)
|
||||
const [notice, setNotice] = useState<ActionNotice | null>(null)
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
const [checkinBusy, setCheckinBusy] = useState(false)
|
||||
|
||||
/**
|
||||
* 执行 Get Latest(目录递归),返回是否产生冲突;摘要写入顶部通知。
|
||||
* 默认安全模式:本地改动不会被覆盖而是产生冲突并自动进入冲突弹窗;
|
||||
* force=true 为强制覆盖本地(仅经确认弹窗触发)。
|
||||
*/
|
||||
const runGetLatest = useCallback(
|
||||
async (target: FileTarget, force = false): Promise<boolean> => {
|
||||
const result = await api.getLatest({
|
||||
serverPath: target.serverPath,
|
||||
recursive: target.folder,
|
||||
force,
|
||||
})
|
||||
refreshLogs()
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "获取最新失败" })
|
||||
return false
|
||||
}
|
||||
const summary = result.data.result
|
||||
const parts = [`更新 ${summary.updated} 项`]
|
||||
if (summary.conflicts > 0) {
|
||||
parts.push(`冲突 ${summary.conflicts} 项`)
|
||||
}
|
||||
if (summary.failures > 0) {
|
||||
parts.push(`跳过 / 失败 ${summary.failures} 项`)
|
||||
}
|
||||
setNotice({
|
||||
kind: summary.conflicts > 0 ? "error" : "info",
|
||||
text: `${force ? "强制获取" : "获取最新"}完成:${parts.join(",")}`,
|
||||
})
|
||||
refreshItems()
|
||||
if (summary.conflicts > 0) {
|
||||
setDialog({ kind: "conflicts", serverPath: target.serverPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[refreshItems, refreshLogs],
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取指定 changeset 版本(覆盖本地),由历史弹窗中的确认操作触发。
|
||||
*/
|
||||
const runGetVersion = useCallback(
|
||||
async (serverPath: string, changeset: number, folder: boolean): Promise<boolean> => {
|
||||
setNotice({ kind: "info", text: `正在获取版本 C${changeset}…` })
|
||||
const result = await api.getVersion({ serverPath, changeset, recursive: folder })
|
||||
refreshLogs()
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "获取指定版本失败" })
|
||||
return false
|
||||
}
|
||||
const summary = result.data.result
|
||||
setNotice({
|
||||
kind: "info",
|
||||
text: `已获取版本 C${changeset}:更新 ${summary.updated} 项(本地已被该版本覆盖)`,
|
||||
})
|
||||
refreshItems()
|
||||
await refreshPendingChanges()
|
||||
return true
|
||||
},
|
||||
[refreshItems, refreshLogs, refreshPendingChanges],
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行 Checkout:先 Get Latest,出现冲突时进入统一冲突弹窗;
|
||||
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
|
||||
*/
|
||||
const runCheckout = useCallback(
|
||||
async (target: FileTarget) => {
|
||||
const hadConflicts = await runGetLatest(target)
|
||||
if (hadConflicts) {
|
||||
setNotice({ kind: "error", text: "获取最新存在冲突,请先处理冲突后再签出" })
|
||||
return
|
||||
}
|
||||
const result = await api.checkout({
|
||||
paths: [target.serverPath],
|
||||
recursive: target.folder,
|
||||
})
|
||||
refreshLogs()
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "签出失败" })
|
||||
return
|
||||
}
|
||||
const summary = result.data.result
|
||||
const failureText =
|
||||
summary.failures.length > 0
|
||||
? `,跳过 ${summary.failures.length} 项(${summary.failures.slice(0, 3).join(";")}${summary.failures.length > 3 ? "…" : ""})`
|
||||
: ""
|
||||
setNotice({
|
||||
kind: summary.failures.length > 0 ? "error" : "info",
|
||||
text: `签出完成:${summary.affected} 项${failureText}`,
|
||||
})
|
||||
await refreshPendingChanges()
|
||||
},
|
||||
[runGetLatest, refreshPendingChanges, refreshLogs],
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行 挂起删除 / 撤销更改 两类文件操作并按规则刷新。
|
||||
*/
|
||||
const runFileOperation = useCallback(
|
||||
async (target: FileTarget, action: "delete" | "undo") => {
|
||||
const labels =
|
||||
action === "delete"
|
||||
? { doing: "正在挂起删除…", fail: "挂起删除失败", done: "已挂起删除" }
|
||||
: { doing: "正在撤销更改…", fail: "撤销更改失败", done: "已撤销" }
|
||||
setNotice({ kind: "info", text: labels.doing })
|
||||
const call = action === "delete" ? api.deleteFiles : api.undoFiles
|
||||
const result = await call({ paths: [target.serverPath], recursive: target.folder })
|
||||
refreshLogs()
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? labels.fail })
|
||||
return
|
||||
}
|
||||
setNotice({ kind: "info", text: `${labels.done} ${result.data.result.affected} 项` })
|
||||
await refreshPendingChanges()
|
||||
refreshItems()
|
||||
},
|
||||
[refreshPendingChanges, refreshItems, refreshLogs],
|
||||
)
|
||||
|
||||
/**
|
||||
* 对象右键菜单动作统一分发器。
|
||||
* unmap / getLatest / checkout / delete / undo 直接执行;
|
||||
* map / history / compare / viewFile / diff 打开对应弹窗。
|
||||
*/
|
||||
const handleFileAction = useCallback(
|
||||
async (target: FileTarget, action: FileActionId) => {
|
||||
setNotice(null)
|
||||
switch (action) {
|
||||
case "unmap": {
|
||||
const result = await api.deleteMapping({ serverPath: target.serverPath })
|
||||
refreshLogs()
|
||||
if (!result.ok) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "取消映射失败" })
|
||||
return
|
||||
}
|
||||
// 不跳转当前浏览位置,仅更新 mappings 让动作可用性与状态列刷新。
|
||||
onMappingsChanged(result.data?.mappings ?? [])
|
||||
break
|
||||
}
|
||||
case "getLatest":
|
||||
setActionBusy(true)
|
||||
setNotice({ kind: "info", text: "正在获取最新…" })
|
||||
await runGetLatest(target)
|
||||
setActionBusy(false)
|
||||
break
|
||||
case "forceGetLatest":
|
||||
// 覆盖类危险操作:先弹确认,确认后才执行。
|
||||
setDialog({
|
||||
kind: "confirmForceGet",
|
||||
serverPath: target.serverPath,
|
||||
folder: target.folder,
|
||||
})
|
||||
break
|
||||
case "checkout":
|
||||
setActionBusy(true)
|
||||
setNotice({ kind: "info", text: "正在签出…" })
|
||||
await runCheckout(target)
|
||||
setActionBusy(false)
|
||||
break
|
||||
case "delete":
|
||||
case "undo":
|
||||
setActionBusy(true)
|
||||
await runFileOperation(target, action)
|
||||
setActionBusy(false)
|
||||
break
|
||||
case "add": {
|
||||
// pend add 需要本地路径(localOnly 项服务端尚不存在)。
|
||||
if (!target.localPath) {
|
||||
return
|
||||
}
|
||||
setActionBusy(true)
|
||||
setNotice({ kind: "info", text: "正在加入版本控制…" })
|
||||
const result = await api.addFiles({ paths: [target.localPath], recursive: target.folder })
|
||||
refreshLogs()
|
||||
setActionBusy(false)
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "加入版本控制失败" })
|
||||
return
|
||||
}
|
||||
setNotice({ kind: "info", text: `已加入版本控制 ${result.data.result.affected} 项` })
|
||||
await refreshPendingChanges()
|
||||
refreshItems()
|
||||
break
|
||||
}
|
||||
case "map":
|
||||
setDialog({ kind: "mapping", serverPath: target.serverPath })
|
||||
break
|
||||
case "history":
|
||||
setDialog({ kind: "history", serverPath: target.serverPath, folder: target.folder })
|
||||
break
|
||||
case "compare":
|
||||
setDialog({ kind: "compare", serverPath: target.serverPath })
|
||||
break
|
||||
case "viewFile":
|
||||
setDialog({
|
||||
kind: "viewFile",
|
||||
serverPath: target.serverPath,
|
||||
localPath: target.localPath,
|
||||
})
|
||||
break
|
||||
case "diffLocalLatest":
|
||||
if (target.localPath) {
|
||||
setDialog({
|
||||
kind: "diff",
|
||||
request: {
|
||||
mode: "localLatest",
|
||||
serverPath: target.serverPath,
|
||||
localPath: target.localPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs],
|
||||
)
|
||||
|
||||
/**
|
||||
* 提交签入:只提交 Included 项,成功后展示 changeset 并刷新挂起更改与当前目录。
|
||||
*/
|
||||
const handleCheckin = useCallback(
|
||||
async (paths: string[], comment: string): Promise<boolean> => {
|
||||
setCheckinBusy(true)
|
||||
setNotice({ kind: "info", text: "正在签入…" })
|
||||
const result = await api.checkin({ paths, comment })
|
||||
setCheckinBusy(false)
|
||||
refreshLogs()
|
||||
if (!result.ok || !result.data) {
|
||||
setNotice({ kind: "error", text: result.errorMessage ?? "签入失败" })
|
||||
return false
|
||||
}
|
||||
const summary = result.data.checkin
|
||||
setNotice({
|
||||
kind: "info",
|
||||
text: `签入成功:changeset ${summary.changeset},提交 ${summary.submittedChanges} 项`,
|
||||
})
|
||||
await refreshPendingChanges()
|
||||
refreshItems()
|
||||
return true
|
||||
},
|
||||
[refreshPendingChanges, refreshItems, refreshLogs],
|
||||
)
|
||||
|
||||
/**
|
||||
* Mapping 创建成功:刷新 mappings 与当前目录列表,保持浏览位置并关闭弹窗。
|
||||
*/
|
||||
const handleMappingCreated = useCallback(
|
||||
(mappings: MappingInfo[]) => {
|
||||
onMappingsChanged(mappings)
|
||||
refreshItems()
|
||||
setDialog(null)
|
||||
},
|
||||
[onMappingsChanged, refreshItems],
|
||||
)
|
||||
|
||||
/**
|
||||
* 冲突取舍完成:关闭弹窗并刷新当前目录与挂起更改。
|
||||
*/
|
||||
const handleConflictsResolved = useCallback(() => {
|
||||
setDialog(null)
|
||||
refreshItems()
|
||||
void refreshPendingChanges()
|
||||
refreshLogs()
|
||||
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
|
||||
}, [refreshItems, refreshPendingChanges, refreshLogs])
|
||||
|
||||
/**
|
||||
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
|
||||
*/
|
||||
const handleForceGetConfirmed = useCallback(
|
||||
async (serverPath: string, folder: boolean) => {
|
||||
setActionBusy(true)
|
||||
const target: FileTarget = {
|
||||
source: "list",
|
||||
folder,
|
||||
serverPath,
|
||||
localPath: null,
|
||||
mapped: true,
|
||||
mappingRoot: false,
|
||||
pendingStatus: null,
|
||||
}
|
||||
await runGetLatest(target, true)
|
||||
setActionBusy(false)
|
||||
setDialog(null)
|
||||
},
|
||||
[runGetLatest],
|
||||
)
|
||||
|
||||
return {
|
||||
dialog,
|
||||
setDialog,
|
||||
notice,
|
||||
actionBusy,
|
||||
checkinBusy,
|
||||
handleFileAction,
|
||||
handleCheckin,
|
||||
handleMappingCreated,
|
||||
handleConflictsResolved,
|
||||
handleForceGetConfirmed,
|
||||
runGetVersion,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { api } from "~/lib/api"
|
||||
import type { PendingChange } from "~/lib/api"
|
||||
|
||||
/**
|
||||
* 挂起更改共享状态:列表加载 / 刷新、Included-Excluded 维护,
|
||||
* 以及供菜单可用性判断的 serverPath -> 状态 映射。
|
||||
*/
|
||||
export function usePendingChanges() {
|
||||
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Excluded 的挂起更改 serverPath 集合(不持久化),其余默认 Included。
|
||||
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 刷新当前 Workspace 的挂起更改列表,并清理悬挂的 Excluded 键。
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = await api.getPendingChanges()
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.errorMessage ?? "挂起更改加载失败")
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
const list = result.data?.pendingChanges ?? []
|
||||
setPendingChanges(list)
|
||||
setExcludedKeys((prev) => {
|
||||
const valid = new Set(list.map((change) => change.serverPath))
|
||||
const next = new Set([...prev].filter((key) => valid.has(key)))
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
/**
|
||||
* 切换某条挂起更改的 Included / Excluded 归属。
|
||||
*/
|
||||
const toggleExcluded = useCallback((serverPath: string) => {
|
||||
setExcludedKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(serverPath)) {
|
||||
next.delete(serverPath)
|
||||
} else {
|
||||
next.add(serverPath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// serverPath -> 挂起状态,供文件列表生成菜单时控制可用性。
|
||||
const pendingByServerPath = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const change of pendingChanges) {
|
||||
map[change.serverPath] = change.status
|
||||
}
|
||||
return map
|
||||
}, [pendingChanges])
|
||||
|
||||
return {
|
||||
pendingChanges,
|
||||
loading,
|
||||
error,
|
||||
excludedKeys,
|
||||
pendingByServerPath,
|
||||
refresh,
|
||||
toggleExcluded,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getApiBaseUrl, getToken } from "~/lib/electron"
|
||||
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
|
||||
|
||||
/**
|
||||
* 拼接查询参数到请求路径,自动跳过空值。
|
||||
*/
|
||||
function buildUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
query?: ApiRequestOptions["query"],
|
||||
): string {
|
||||
const url = new URL(path, baseUrl)
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
|
||||
*/
|
||||
function resolveErrorMessage(
|
||||
result: ApiResult<unknown> | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (result) {
|
||||
return result.errorMessage || result.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
|
||||
*/
|
||||
async function request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const baseUrl = await getApiBaseUrl()
|
||||
const token = await getToken()
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
}
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const init: RequestInit = { method, headers, signal: options.signal }
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json;charset=utf-8"
|
||||
init.body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(buildUrl(baseUrl, path, options.query), init)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
result: null,
|
||||
data: null,
|
||||
errorMessage: `无法连接本地服务:${message}`,
|
||||
}
|
||||
}
|
||||
|
||||
let result: ApiResult<T> | null = null
|
||||
try {
|
||||
result = (await response.json()) as ApiResult<T>
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
const ok = response.ok && result?.success === true
|
||||
return {
|
||||
ok,
|
||||
status: response.status,
|
||||
result,
|
||||
data: result?.data ?? null,
|
||||
errorMessage: ok
|
||||
? null
|
||||
: resolveErrorMessage(result, `请求失败(HTTP ${response.status})`),
|
||||
}
|
||||
}
|
||||
|
||||
// 统一 API 客户端,组件只通过本对象访问后端,不直接 fetch、不直接读 token。
|
||||
export const apiClient = {
|
||||
/**
|
||||
* 发起 GET 请求。
|
||||
*/
|
||||
get<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||
return request<T>("GET", path, options)
|
||||
},
|
||||
/**
|
||||
* 发起 POST 请求。
|
||||
*/
|
||||
post<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||
return request<T>("POST", path, options)
|
||||
},
|
||||
/**
|
||||
* 发起 PUT 请求。
|
||||
*/
|
||||
put<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||
return request<T>("PUT", path, options)
|
||||
},
|
||||
/**
|
||||
* 发起 DELETE 请求。
|
||||
*/
|
||||
delete<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
|
||||
return request<T>("DELETE", path, options)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { apiClient } from "./client"
|
||||
import type {
|
||||
AppConfig,
|
||||
CheckMappingTargetResult,
|
||||
CheckinResult,
|
||||
Collection,
|
||||
ConflictInfo,
|
||||
ConflictResolution,
|
||||
ConflictResolutionResult,
|
||||
ConnectResult,
|
||||
FileContent,
|
||||
FileOperationResult,
|
||||
FolderDiffItem,
|
||||
GetLatestResult,
|
||||
HistoryEntry,
|
||||
MappingInfo,
|
||||
OperationLogEntry,
|
||||
PendingChange,
|
||||
ServerItem,
|
||||
TextDiff,
|
||||
WorkspaceContext,
|
||||
WorkspaceInfo,
|
||||
} from "./types"
|
||||
|
||||
// 文件类操作(checkout / add / delete / undo)的统一请求体。
|
||||
type FileActionBody = { paths: string[]; recursive?: boolean }
|
||||
|
||||
/**
|
||||
* 读取服务端持久化配置。
|
||||
*/
|
||||
export function getConfig() {
|
||||
return apiClient.get<{ config: AppConfig }>("/api/config")
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存服务端持久化配置。
|
||||
*/
|
||||
export function saveConfig(config: AppConfig) {
|
||||
return apiClient.put<{ config: AppConfig }>("/api/config", { body: config })
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用给定连接信息连接 TFS,成功后服务端会保存配置并建立会话。
|
||||
*/
|
||||
export function connect(config: Partial<AppConfig> = {}) {
|
||||
return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出当前账号可见的 Collection。
|
||||
*/
|
||||
export function listCollections() {
|
||||
return apiClient.get<{ collections: Collection[] }>("/api/collections")
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览服务端目录树节点。
|
||||
*/
|
||||
export function getServerTree(params: { path?: string; collection?: string } = {}) {
|
||||
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-tree", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前目录下一级文件与文件夹列表。
|
||||
*/
|
||||
export function getFolderItems(params: { path?: string; collection?: string } = {}) {
|
||||
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-folder/items", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前 Workspace 名称与 Mapping 列表。
|
||||
*/
|
||||
export function getWorkspace() {
|
||||
return apiClient.get<{ workspace: string; mappings: MappingInfo[] }>("/api/workspace")
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保当前 Collection 的默认 Workspace 存在,必要时创建。
|
||||
*/
|
||||
export function ensureWorkspace(body: { collection?: string; workspace?: string; comment?: string } = {}) {
|
||||
return apiClient.post<{ workspace: WorkspaceInfo }>("/api/workspace/ensure", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取工作台固定上下文(serverUri / collection / workspace / mappings)。
|
||||
*/
|
||||
export function getWorkspaceContext() {
|
||||
return apiClient.get<{ context: WorkspaceContext }>("/api/workspace/context")
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前 Workspace 的 Mapping 列表。
|
||||
*/
|
||||
export function listMappings() {
|
||||
return apiClient.get<{ mappings: MappingInfo[] }>("/api/mappings")
|
||||
}
|
||||
|
||||
/**
|
||||
* 预校验 Mapping 目标路径,返回最终本地目标路径及是否已存在。
|
||||
*/
|
||||
export function checkMappingTarget(body: { serverPath: string; localParentPath: string }) {
|
||||
return apiClient.post<CheckMappingTargetResult>("/api/mappings/check-target", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Mapping,可选立即 Get Latest。
|
||||
*/
|
||||
export function addMapping(body: { serverPath: string; localPath: string; getLatest?: boolean }) {
|
||||
return apiClient.post<{ mapping: MappingInfo; getLatest?: GetLatestResult }>("/api/mappings", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Mapping,仅解除映射不删除本地文件。
|
||||
*/
|
||||
export function deleteMapping(body: { serverPath?: string; localPath?: string }) {
|
||||
return apiClient.delete<{ mappings: MappingInfo[] }>("/api/mappings", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定服务端路径的最新文件。
|
||||
* force=false(默认)为安全模式,本地改动会产生冲突而不被覆盖;force=true 强制覆盖本地。
|
||||
*/
|
||||
export function getLatest(body: { serverPath?: string; recursive?: boolean; force?: boolean } = {}) {
|
||||
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-latest", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 changeset 版本并覆盖本地(对应 VS 的 Get Specific Version + Overwrite)。
|
||||
*/
|
||||
export function getVersion(body: { serverPath: string; changeset: number; recursive?: boolean }) {
|
||||
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-version", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已存在文件或目录执行 checkout。
|
||||
*/
|
||||
export function checkout(body: FileActionBody) {
|
||||
return apiClient.post<{ result: FileOperationResult }>("/api/files/checkout", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地新增文件加入 pending add。
|
||||
*/
|
||||
export function addFiles(body: FileActionBody) {
|
||||
return apiClient.post<{ result: FileOperationResult }>("/api/files/add", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已版本控制文件或目录执行 pending delete。
|
||||
*/
|
||||
export function deleteFiles(body: FileActionBody) {
|
||||
return apiClient.post<{ result: FileOperationResult }>("/api/files/delete", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销指定路径上的挂起更改。
|
||||
*/
|
||||
export function undoFiles(body: FileActionBody) {
|
||||
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前 Workspace 的挂起更改。
|
||||
*/
|
||||
export function getPendingChanges(params: { serverPath?: string } = {}) {
|
||||
return apiClient.get<{ pendingChanges: PendingChange[] }>("/api/pending-changes", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交带注释的签入,paths 为空时签入全部挂起更改。
|
||||
*/
|
||||
export function checkin(body: { paths?: string[]; comment: string }) {
|
||||
return apiClient.post<{ checkin: CheckinResult }>("/api/checkin", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取服务器内容(latest 或指定 changeset),或读取本地映射文件内容。
|
||||
*/
|
||||
export function getFileContent(params: { serverPath?: string; localPath?: string; changeset?: number }) {
|
||||
return apiClient.get<{ content: FileContent }>("/api/files/content", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已映射目录执行目录对比。
|
||||
*/
|
||||
export function compareFolder(body: { serverPath: string; localPath?: string; recursive?: boolean }) {
|
||||
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文件或目录历史。
|
||||
*/
|
||||
export function getHistory(params: { path: string; folder?: boolean }) {
|
||||
return apiClient.get<{ history: HistoryEntry[] }>("/api/history", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定 changeset 影响的文件列表。
|
||||
*/
|
||||
export function getChangesetFiles(params: { changeset: number }) {
|
||||
return apiClient.get<{ files: HistoryEntry[] }>("/api/history/changeset", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地文件与服务器 latest 的文本 Diff。
|
||||
*/
|
||||
export function diffLocalLatest(body: { serverPath: string; localPath: string }) {
|
||||
return apiClient.post<{ diff: TextDiff }>("/api/diff/local-latest", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同一服务端文件两个 changeset 之间的文本 Diff。
|
||||
*/
|
||||
export function diffRevisions(body: { serverPath: string; sourceChangeset: number; targetChangeset: number }) {
|
||||
return apiClient.post<{ diff: TextDiff }>("/api/diff/revisions", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前 Workspace 的冲突明细。
|
||||
*/
|
||||
export function listConflicts(params: { serverPath?: string; recursive?: boolean } = {}) {
|
||||
return apiClient.get<{ conflicts: ConflictInfo[] }>("/api/conflicts", { query: params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个冲突应用取舍(采用服务器版本或保留本地版本)。
|
||||
*/
|
||||
export function applyConflict(body: { conflictId: number; resolution: ConflictResolution }) {
|
||||
return apiClient.post<{ resolution: ConflictResolutionResult }>("/api/conflicts/apply", { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取最近操作日志。
|
||||
*/
|
||||
export function getLogs() {
|
||||
return apiClient.get<{ logs: OperationLogEntry[] }>("/api/logs")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type {
|
||||
ApiRequestOptions,
|
||||
ApiResponse,
|
||||
ApiResult,
|
||||
AppConfig,
|
||||
CheckMappingTargetResult,
|
||||
CheckinResult,
|
||||
Collection,
|
||||
ConflictInfo,
|
||||
ConflictResolution,
|
||||
ConflictResolutionResult,
|
||||
ConnectResult,
|
||||
FileContent,
|
||||
FileOperationResult,
|
||||
FolderDiffItem,
|
||||
GetLatestResult,
|
||||
HistoryEntry,
|
||||
MappingConfig,
|
||||
MappingInfo,
|
||||
OperationLogEntry,
|
||||
PendingChange,
|
||||
ServerItem,
|
||||
TextDiff,
|
||||
WorkspaceContext,
|
||||
WorkspaceInfo,
|
||||
} from "./types"
|
||||
export { apiClient } from "./client"
|
||||
export * as api from "./endpoints"
|
||||
@@ -0,0 +1,211 @@
|
||||
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
|
||||
export interface ApiResult<T = Record<string, unknown>> {
|
||||
success: boolean
|
||||
message?: string
|
||||
errorMessage?: string
|
||||
operation?: string
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
durationMs?: number
|
||||
logs?: string[]
|
||||
data: T
|
||||
}
|
||||
|
||||
// API 客户端对外返回结构,统一封装网络层与业务层成功/失败。
|
||||
export interface ApiResponse<T = Record<string, unknown>> {
|
||||
ok: boolean
|
||||
status: number
|
||||
result: ApiResult<T> | null
|
||||
data: T | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
// API 请求选项。
|
||||
export interface ApiRequestOptions {
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// 单条 Mapping 配置,对应服务端 MappingConfig。
|
||||
export interface MappingConfig {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
// 服务端持久化配置,对应 MacTfsServer.AppConfig。
|
||||
export interface AppConfig {
|
||||
serverUri?: string
|
||||
authType?: string
|
||||
domain?: string
|
||||
username?: string
|
||||
password?: string
|
||||
collection?: string
|
||||
workspace?: string
|
||||
mappings: MappingConfig[]
|
||||
}
|
||||
|
||||
// 连接结果摘要,对应 /api/session/connect 返回的概要字段。
|
||||
export interface ConnectResult {
|
||||
serverUri?: string
|
||||
collectionCount?: number
|
||||
}
|
||||
|
||||
// Collection 信息,对应 core TfsCollectionInfo。
|
||||
export interface Collection {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
// 服务端目录项,对应服务端 serverItems 序列化结构。
|
||||
export interface ServerItem {
|
||||
name: string
|
||||
path: string
|
||||
serverPath: string
|
||||
type: "folder" | "file"
|
||||
folder: boolean
|
||||
latestVersion: number
|
||||
checkinDate: number | null
|
||||
}
|
||||
|
||||
// Workspace Mapping 信息,对应 core TfsMappingInfo。
|
||||
export interface MappingInfo {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
// Workspace 概要,对应 core TfsWorkspaceInfo。
|
||||
export interface WorkspaceInfo {
|
||||
name: string
|
||||
ownerName: string
|
||||
computer: string
|
||||
comment: string
|
||||
created: boolean
|
||||
mappings: MappingInfo[]
|
||||
}
|
||||
|
||||
// 工作台固定上下文,对应 /api/workspace/context 的 context 字段。
|
||||
export interface WorkspaceContext {
|
||||
serverUri?: string
|
||||
authType?: string
|
||||
collection?: string
|
||||
workspace?: string
|
||||
connected: boolean
|
||||
mappings: MappingInfo[]
|
||||
}
|
||||
|
||||
// Mapping 目标预校验结果,对应 /api/mappings/check-target。
|
||||
export interface CheckMappingTargetResult {
|
||||
serverPath: string
|
||||
localParentPath: string
|
||||
name: string
|
||||
targetPath: string
|
||||
exists: boolean
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
// Get Latest 结果,对应 core TfsGetLatestResult。
|
||||
export interface GetLatestResult {
|
||||
updated: number
|
||||
operations: number
|
||||
conflicts: number
|
||||
failures: number
|
||||
}
|
||||
|
||||
// 文件操作结果,对应 core TfsFileOperationResult。
|
||||
export interface FileOperationResult {
|
||||
operation: string
|
||||
affected: number
|
||||
failures: string[]
|
||||
}
|
||||
|
||||
// 挂起更改项,对应 core TfsPendingChangeInfo。
|
||||
export interface PendingChange {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: string
|
||||
changeType: string
|
||||
version: number
|
||||
}
|
||||
|
||||
// 签入结果,对应 core TfsCheckinResult。
|
||||
export interface CheckinResult {
|
||||
changeset: number
|
||||
submittedChanges: number
|
||||
}
|
||||
|
||||
// 目录对比项,对应 core TfsFolderDiffItem。
|
||||
export interface FolderDiffItem {
|
||||
serverPath: string
|
||||
localPath: string
|
||||
name: string
|
||||
folder: boolean
|
||||
status: string
|
||||
localVersion: number
|
||||
latestVersion: number
|
||||
}
|
||||
|
||||
// 历史记录项,对应 core TfsHistoryEntry。
|
||||
export interface HistoryEntry {
|
||||
serverPath: string
|
||||
name: string
|
||||
changeType: string
|
||||
itemType: string
|
||||
changeset: number
|
||||
author: string
|
||||
date: number | null
|
||||
comment: string
|
||||
}
|
||||
|
||||
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
||||
export interface FileContent {
|
||||
serverPath: string
|
||||
changeset: number
|
||||
content: string
|
||||
binary: boolean
|
||||
size: number
|
||||
tooLarge: boolean
|
||||
}
|
||||
|
||||
// 文本 Diff,对应 core TfsTextDiff。
|
||||
export interface TextDiff {
|
||||
sourceLabel: string
|
||||
targetLabel: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
// 冲突明细,对应 core TfsConflictInfo。
|
||||
export interface ConflictInfo {
|
||||
conflictId: number
|
||||
type: string
|
||||
serverPath: string | null
|
||||
localPath: string | null
|
||||
yourServerItem: string | null
|
||||
theirServerItem: string | null
|
||||
baseServerItem: string | null
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
// 第一版支持的冲突取舍:采用服务器版本或保留本地版本。
|
||||
export type ConflictResolution = "takeServer" | "keepLocal"
|
||||
|
||||
// 冲突应用结果,对应 core TfsConflictResolution。
|
||||
export interface ConflictResolutionResult {
|
||||
conflictId: number
|
||||
resolution: string
|
||||
resolved: boolean
|
||||
remainingConflicts: number
|
||||
}
|
||||
|
||||
// 操作日志项,对应服务端 OperationLogEntry。
|
||||
export interface OperationLogEntry {
|
||||
operation: string
|
||||
summary: string
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
durationMs: number
|
||||
success: boolean
|
||||
errorMessage: string | null
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MactfsBridge, ServiceStatus } from "./types"
|
||||
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
|
||||
|
||||
/**
|
||||
* 获取 Electron 暴露的 window.mactfs 桥接对象,非 Electron 环境(含 SSR)返回 null。
|
||||
*/
|
||||
export function getBridge(): MactfsBridge | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
return window.mactfs ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否运行在 Electron 桥接环境中。
|
||||
*/
|
||||
export function isElectron(): boolean {
|
||||
return getBridge() != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地 API 基础地址,非 Electron 环境回退到默认本地地址。
|
||||
*/
|
||||
export async function getApiBaseUrl(): Promise<string> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge) {
|
||||
return DEFAULT_BASE_URL
|
||||
}
|
||||
return bridge.getApiBaseUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地服务 Bearer Token,非 Electron 环境返回 null。
|
||||
*/
|
||||
export async function getToken(): Promise<string | null> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge) {
|
||||
return null
|
||||
}
|
||||
return bridge.getToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询本地服务状态,非 Electron 环境返回未就绪状态并说明原因。
|
||||
*/
|
||||
export async function getServiceStatus(): Promise<ServiceStatus> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge) {
|
||||
return unavailableStatus()
|
||||
}
|
||||
return bridge.getServiceStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起并等待本地服务就绪,非 Electron 环境返回未就绪状态并说明原因。
|
||||
*/
|
||||
export async function startService(): Promise<ServiceStatus> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge) {
|
||||
return unavailableStatus()
|
||||
}
|
||||
return bridge.startService()
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开系统目录选择器,非 Electron 环境返回 null。
|
||||
*/
|
||||
export async function selectDirectory(): Promise<string | null> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge) {
|
||||
return null
|
||||
}
|
||||
return bridge.selectDirectory()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在,非 Electron 环境返回 null 表示无法检测。
|
||||
*/
|
||||
export async function pathsExist(paths: string[]): Promise<Record<string, boolean> | null> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge || paths.length === 0) {
|
||||
return bridge ? {} : null
|
||||
}
|
||||
return bridge.pathsExist(paths)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造非 Electron 环境下的占位服务状态。
|
||||
*/
|
||||
function unavailableStatus(): ServiceStatus {
|
||||
return {
|
||||
running: false,
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
tokenAvailable: false,
|
||||
health: null,
|
||||
error: "当前不在 Electron 桌面环境,无法访问本地服务",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { MactfsBridge, ServiceHealth, ServiceStatus } from "./types"
|
||||
export {
|
||||
getApiBaseUrl,
|
||||
getBridge,
|
||||
getServiceStatus,
|
||||
getToken,
|
||||
isElectron,
|
||||
pathsExist,
|
||||
selectDirectory,
|
||||
startService,
|
||||
} from "./bridge"
|
||||
@@ -0,0 +1,35 @@
|
||||
// 本地 API 服务健康信息,对应服务端 /api/health 的 data 字段。
|
||||
export interface ServiceHealth {
|
||||
status: string
|
||||
host: string
|
||||
port: number
|
||||
tokenFile: string
|
||||
configFile: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
// 渲染进程感知的本地服务状态,由 Electron 主进程计算返回。
|
||||
export interface ServiceStatus {
|
||||
running: boolean
|
||||
baseUrl: string
|
||||
tokenAvailable: boolean
|
||||
health: ServiceHealth | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// preload 暴露到 window.mactfs 的窄接口契约。
|
||||
export interface MactfsBridge {
|
||||
isElectron: true
|
||||
getApiBaseUrl(): Promise<string>
|
||||
getToken(): Promise<string | null>
|
||||
getServiceStatus(): Promise<ServiceStatus>
|
||||
startService(): Promise<ServiceStatus>
|
||||
selectDirectory(): Promise<string | null>
|
||||
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mactfs?: MactfsBridge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Monaco Editor 客户端引导模块:注册各语言 worker 后导出 monaco 实例。
|
||||
// 该模块体积大,只允许在浏览器端通过动态 import 加载(如 Diff 弹窗内)。
|
||||
import * as monaco from "monaco-editor"
|
||||
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
|
||||
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
|
||||
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
|
||||
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
|
||||
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_workerId: string, label: string) {
|
||||
if (label === "json") {
|
||||
return new JsonWorker()
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new CssWorker()
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new HtmlWorker()
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new TsWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
// 自定义主题:贴合应用本身的中性灰底色与红绿差异配色,弱化 Monaco 默认的高饱和蓝。
|
||||
monaco.editor.defineTheme("mactfs-light", {
|
||||
base: "vs",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": "#ffffff",
|
||||
"editor.foreground": "#1f1f1f",
|
||||
"editor.lineHighlightBackground": "#00000005",
|
||||
"editorLineNumber.foreground": "#a3a3a3",
|
||||
"editorLineNumber.activeForeground": "#525252",
|
||||
"editorCursor.foreground": "#4f46e5",
|
||||
"focusBorder": "#00000000",
|
||||
"diffEditor.insertedTextBackground": "#22c55e2e",
|
||||
"diffEditor.insertedLineBackground": "#22c55e14",
|
||||
"diffEditor.removedTextBackground": "#ef44442e",
|
||||
"diffEditor.removedLineBackground": "#ef444414",
|
||||
"diffEditorGutter.insertedLineBackground": "#22c55e21",
|
||||
"diffEditorGutter.removedLineBackground": "#ef444421",
|
||||
"diffEditorOverview.insertedForeground": "#22c55e99",
|
||||
"diffEditorOverview.removedForeground": "#ef444499",
|
||||
"diffEditor.unchangedRegionBackground": "#fafafa",
|
||||
"diffEditor.unchangedRegionForeground": "#737373",
|
||||
"scrollbarSlider.background": "#0000001f",
|
||||
"scrollbarSlider.hoverBackground": "#00000033",
|
||||
"scrollbarSlider.activeBackground": "#00000040",
|
||||
},
|
||||
})
|
||||
|
||||
monaco.editor.defineTheme("mactfs-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": "#1b1b1b",
|
||||
"editor.foreground": "#fafafa",
|
||||
"editor.lineHighlightBackground": "#ffffff08",
|
||||
"editorLineNumber.foreground": "#6b6b6b",
|
||||
"editorLineNumber.activeForeground": "#a3a3a3",
|
||||
"editorCursor.foreground": "#818cf8",
|
||||
"focusBorder": "#00000000",
|
||||
"diffEditor.insertedTextBackground": "#22c55e30",
|
||||
"diffEditor.insertedLineBackground": "#22c55e17",
|
||||
"diffEditor.removedTextBackground": "#ef444430",
|
||||
"diffEditor.removedLineBackground": "#ef444417",
|
||||
"diffEditorGutter.insertedLineBackground": "#22c55e24",
|
||||
"diffEditorGutter.removedLineBackground": "#ef444424",
|
||||
"diffEditorOverview.insertedForeground": "#22c55e99",
|
||||
"diffEditorOverview.removedForeground": "#ef444499",
|
||||
"diffEditor.unchangedRegionBackground": "#222222",
|
||||
"diffEditor.unchangedRegionForeground": "#9c9c9c",
|
||||
"scrollbarSlider.background": "#ffffff1f",
|
||||
"scrollbarSlider.hoverBackground": "#ffffff33",
|
||||
"scrollbarSlider.activeBackground": "#ffffff40",
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
|
||||
*/
|
||||
export function detectLanguage(path: string): string {
|
||||
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase()
|
||||
const dot = name.lastIndexOf(".")
|
||||
const ext = dot >= 0 ? name.slice(dot) : ""
|
||||
if (ext.length === 0) {
|
||||
return "plaintext"
|
||||
}
|
||||
for (const language of monaco.languages.getLanguages()) {
|
||||
if (language.extensions?.some((item) => item.toLowerCase() === ext)) {
|
||||
return language.id
|
||||
}
|
||||
}
|
||||
return "plaintext"
|
||||
}
|
||||
|
||||
export { monaco }
|
||||
@@ -0,0 +1,260 @@
|
||||
// 对象动作统一模型:左侧树、中间列表、Pending Changes、目录对比结果
|
||||
// 都转换成 FileTarget,再由 buildFileMenu 按状态生成同一套菜单与置灰规则。
|
||||
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
|
||||
import { resolveLocalPath } from "./mapping"
|
||||
import { normalizeServerPath } from "./path"
|
||||
|
||||
// 对象来源:决定菜单里展示哪一组动作。
|
||||
export type FileTargetSource = "tree" | "list" | "pending" | "compare"
|
||||
|
||||
// 统一的对象目标描述。
|
||||
export interface FileTarget {
|
||||
source: FileTargetSource
|
||||
folder: boolean
|
||||
serverPath: string
|
||||
/** 依据 Mapping 推导出的本地路径,未映射为 null。 */
|
||||
localPath: string | null
|
||||
/** 是否处于某条 Mapping 子树内。 */
|
||||
mapped: boolean
|
||||
/** serverPath 本身是否就是一条 Mapping 的根。 */
|
||||
mappingRoot: boolean
|
||||
/** 挂起更改状态(pendingEdit / pendingAdd / ...),无挂起为 null。 */
|
||||
pendingStatus: string | null
|
||||
/** 目录对比结果状态(localOnly / remoteChanged / ...),非对比来源为 null。 */
|
||||
compareStatus?: string | null
|
||||
}
|
||||
|
||||
// 全部对象动作。
|
||||
export type FileActionId =
|
||||
| "map"
|
||||
| "unmap"
|
||||
| "getLatest"
|
||||
| "forceGetLatest"
|
||||
| "checkout"
|
||||
| "delete"
|
||||
| "undo"
|
||||
| "add"
|
||||
| "compare"
|
||||
| "history"
|
||||
| "viewFile"
|
||||
| "diffLocalLatest"
|
||||
|
||||
// 单个菜单项:是否可用与置灰原因。
|
||||
export interface FileMenuItem {
|
||||
id: FileActionId
|
||||
label: string
|
||||
enabled: boolean
|
||||
danger?: boolean
|
||||
/** 置灰原因,展示为菜单项后缀说明。 */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 由来源数据构造统一 FileTarget,本地路径与映射状态从 mappings 推导。
|
||||
*/
|
||||
export function makeFileTarget(input: {
|
||||
source: FileTargetSource
|
||||
folder: boolean
|
||||
serverPath: string
|
||||
mappings: MappingInfo[]
|
||||
pendingStatus?: string | null
|
||||
compareStatus?: string | null
|
||||
}): FileTarget {
|
||||
const serverPath = normalizeServerPath(input.serverPath)
|
||||
const localPath = resolveLocalPath(input.mappings, serverPath)
|
||||
const mappingRoot = input.mappings.some(
|
||||
(mapping) => normalizeServerPath(mapping.serverPath) === serverPath,
|
||||
)
|
||||
return {
|
||||
source: input.source,
|
||||
folder: input.folder,
|
||||
serverPath,
|
||||
localPath,
|
||||
mapped: localPath != null,
|
||||
mappingRoot,
|
||||
pendingStatus: input.pendingStatus ?? null,
|
||||
compareStatus: input.compareStatus ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const NOT_MAPPED_REASON = "未映射到本地"
|
||||
|
||||
/**
|
||||
* 按对象状态生成右键菜单分组:同一路径在树和列表中产出完全一致的动作与置灰规则。
|
||||
* Checkin、全局设置等不进入对象菜单。
|
||||
*/
|
||||
export function buildFileMenu(target: FileTarget): FileMenuItem[][] {
|
||||
// 目录对比中的 localOnly 项:服务端不存在,只保留本地查看与加入版本控制。
|
||||
if (target.source === "compare" && target.compareStatus === "localOnly") {
|
||||
return buildLocalOnlyMenu(target)
|
||||
}
|
||||
return target.folder ? buildFolderMenu(target) : buildFileItemMenu(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅本地存在(localOnly)的对比结果项菜单:加入版本控制 + 本地查看。
|
||||
*/
|
||||
function buildLocalOnlyMenu(target: FileTarget): FileMenuItem[][] {
|
||||
const sections: FileMenuItem[][] = []
|
||||
if (!target.folder) {
|
||||
sections.push([{ id: "viewFile", label: "查看文件", enabled: true }])
|
||||
}
|
||||
sections.push([
|
||||
{
|
||||
id: "add",
|
||||
label: "加入版本控制",
|
||||
enabled: target.localPath != null,
|
||||
reason: target.localPath != null ? undefined : "缺少本地路径",
|
||||
},
|
||||
])
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录菜单:本地工作区动作(未映射置灰)+ Mapping 动作 + 服务端动作。
|
||||
*/
|
||||
function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
|
||||
const sections: FileMenuItem[][] = []
|
||||
|
||||
const workspaceActions: FileMenuItem[] = [
|
||||
{
|
||||
id: "getLatest",
|
||||
label: "获取最新",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "forceGetLatest",
|
||||
label: "强制获取最新…",
|
||||
enabled: target.mapped,
|
||||
danger: true,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "checkout",
|
||||
label: "签出编辑",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "挂起删除",
|
||||
enabled: target.mapped,
|
||||
danger: true,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
label: "撤销更改",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
]
|
||||
sections.push(workspaceActions)
|
||||
|
||||
const mappingActions: FileMenuItem[] = []
|
||||
if (!target.mapped) {
|
||||
mappingActions.push({ id: "map", label: "映射到本地", enabled: true })
|
||||
}
|
||||
if (target.mappingRoot) {
|
||||
mappingActions.push({ id: "unmap", label: "取消映射", enabled: true, danger: true })
|
||||
}
|
||||
if (mappingActions.length > 0) {
|
||||
sections.push(mappingActions)
|
||||
}
|
||||
|
||||
sections.push([
|
||||
{
|
||||
id: "compare",
|
||||
label: "目录对比",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{ id: "history", label: "查看历史", enabled: true },
|
||||
])
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件菜单:查看 / 比较 / 历史 + 本地工作区动作,按挂起状态控制可用性。
|
||||
* pendingAdd 文件不展示服务器比较与历史入口;pendingEdit 禁止直接 Get Latest 覆盖。
|
||||
*/
|
||||
function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
||||
const sections: FileMenuItem[][] = []
|
||||
const pending = target.pendingStatus
|
||||
const isPendingAdd = pending === "pendingAdd"
|
||||
|
||||
const viewActions: FileMenuItem[] = [
|
||||
{ id: "viewFile", label: "查看文件", enabled: true },
|
||||
]
|
||||
if (!isPendingAdd) {
|
||||
viewActions.push({
|
||||
id: "diffLocalLatest",
|
||||
label: "与最新版本比较",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
})
|
||||
viewActions.push({ id: "history", label: "查看历史", enabled: true })
|
||||
}
|
||||
sections.push(viewActions)
|
||||
|
||||
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
|
||||
if (target.source === "pending") {
|
||||
sections.push([
|
||||
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
|
||||
])
|
||||
return sections
|
||||
}
|
||||
|
||||
const workspaceActions: FileMenuItem[] = [
|
||||
{
|
||||
// 安全模式获取:本地改动会产生冲突而不会被覆盖,挂起状态下也允许执行。
|
||||
id: "getLatest",
|
||||
label: "获取最新",
|
||||
enabled: target.mapped,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "forceGetLatest",
|
||||
label: "强制获取最新…",
|
||||
enabled: target.mapped,
|
||||
danger: true,
|
||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||
},
|
||||
{
|
||||
id: "checkout",
|
||||
label: "签出编辑",
|
||||
enabled: target.mapped && pending == null,
|
||||
reason: !target.mapped
|
||||
? NOT_MAPPED_REASON
|
||||
: pending === "pendingEdit"
|
||||
? "已签出"
|
||||
: pending != null
|
||||
? "存在挂起更改"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "挂起删除",
|
||||
enabled: target.mapped && pending !== "pendingDelete",
|
||||
danger: true,
|
||||
reason: !target.mapped
|
||||
? NOT_MAPPED_REASON
|
||||
: pending === "pendingDelete"
|
||||
? "已挂起删除"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
label: "撤销更改",
|
||||
enabled: pending != null,
|
||||
reason: pending == null ? "没有挂起更改" : undefined,
|
||||
},
|
||||
]
|
||||
sections.push(workspaceActions)
|
||||
|
||||
return sections
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export { SERVER_ROOT_PATH } from "./session"
|
||||
export type { WorkspaceSession } from "./session"
|
||||
export {
|
||||
getAncestorPaths,
|
||||
getParentPath,
|
||||
isSameOrUnderPath,
|
||||
normalizeServerPath,
|
||||
} from "./path"
|
||||
export { findMapping, resolveLocalPath } from "./mapping"
|
||||
export {
|
||||
STATUS_BADGE_CLASSES,
|
||||
STATUS_LABELS,
|
||||
resolveItemLocalState,
|
||||
statusBadgeClass,
|
||||
statusLabel,
|
||||
} from "./status"
|
||||
export type { ItemLocalState } from "./status"
|
||||
export { buildFileMenu, makeFileTarget } from "./actions"
|
||||
export type {
|
||||
FileActionId,
|
||||
FileMenuItem,
|
||||
FileTarget,
|
||||
FileTargetSource,
|
||||
} from "./actions"
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
|
||||
import { isSameOrUnderPath, normalizeServerPath } from "./path"
|
||||
|
||||
/**
|
||||
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
|
||||
*/
|
||||
export function findMapping(
|
||||
mappings: MappingInfo[],
|
||||
serverPath: string,
|
||||
): MappingInfo | null {
|
||||
const target = normalizeServerPath(serverPath)
|
||||
let matched: MappingInfo | null = null
|
||||
for (const mapping of mappings) {
|
||||
if (isSameOrUnderPath(mapping.serverPath, target)) {
|
||||
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
|
||||
matched = mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
|
||||
*/
|
||||
export function resolveLocalPath(
|
||||
mappings: MappingInfo[],
|
||||
serverPath: string,
|
||||
): string | null {
|
||||
const target = normalizeServerPath(serverPath)
|
||||
const mapping = findMapping(mappings, target)
|
||||
if (!mapping) {
|
||||
return null
|
||||
}
|
||||
const base = normalizeServerPath(mapping.serverPath)
|
||||
if (target === base) {
|
||||
return mapping.localPath
|
||||
}
|
||||
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
|
||||
const localBase = mapping.localPath.endsWith("/")
|
||||
? mapping.localPath.slice(0, -1)
|
||||
: mapping.localPath
|
||||
return `${localBase}/${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SERVER_ROOT_PATH } from "./session"
|
||||
|
||||
/**
|
||||
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
|
||||
*/
|
||||
export function normalizeServerPath(serverPath: string): string {
|
||||
let result = serverPath.trim()
|
||||
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
return result.length === 0 ? SERVER_ROOT_PATH : result
|
||||
}
|
||||
|
||||
/**
|
||||
* 取服务端路径的父路径,根路径返回自身。
|
||||
*/
|
||||
export function getParentPath(serverPath: string): string {
|
||||
const normalized = normalizeServerPath(serverPath)
|
||||
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
|
||||
return SERVER_ROOT_PATH
|
||||
}
|
||||
const index = normalized.lastIndexOf("/")
|
||||
const parent = normalized.slice(0, index)
|
||||
return parent === "$" ? SERVER_ROOT_PATH : parent
|
||||
}
|
||||
|
||||
/**
|
||||
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
|
||||
*/
|
||||
export function getAncestorPaths(serverPath: string): string[] {
|
||||
const normalized = normalizeServerPath(serverPath)
|
||||
const ancestors: string[] = []
|
||||
let current = normalized
|
||||
while (current !== SERVER_ROOT_PATH) {
|
||||
current = getParentPath(current)
|
||||
ancestors.unshift(current)
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 childPath 是否等于 basePath 或位于其子树内。
|
||||
*/
|
||||
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
|
||||
const base = normalizeServerPath(basePath)
|
||||
const child = normalizeServerPath(childPath)
|
||||
if (base === child) {
|
||||
return true
|
||||
}
|
||||
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
|
||||
return child.startsWith(prefix)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
|
||||
// 服务端目录树根路径,工作台导航的默认起点。
|
||||
export const SERVER_ROOT_PATH = "$/"
|
||||
|
||||
// 工作台进入后固定的共享上下文,serverUri / collection / workspace 不再在台内切换。
|
||||
export interface WorkspaceSession {
|
||||
serverUri: string
|
||||
collection: string
|
||||
workspace: string
|
||||
mappings: MappingInfo[]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
|
||||
|
||||
// 业务状态对应的中文文案。
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
localModified: "本地已修改",
|
||||
remoteChanged: "服务器已更新",
|
||||
bothChanged: "两端都有修改",
|
||||
localOnly: "仅本地存在",
|
||||
remoteOnly: "仅服务器存在",
|
||||
notDownloaded: "未下载",
|
||||
localDeleted: "本地已删除",
|
||||
pendingEdit: "签出编辑",
|
||||
pendingAdd: "挂起新增",
|
||||
pendingDelete: "挂起删除",
|
||||
pendingRename: "挂起重命名",
|
||||
pending: "挂起更改",
|
||||
upToDate: "最新",
|
||||
notMapped: "未映射",
|
||||
mapped: "已映射",
|
||||
}
|
||||
|
||||
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
||||
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
upToDate: "bg-muted text-muted-foreground",
|
||||
notMapped: "bg-muted text-muted-foreground",
|
||||
mapped: "bg-muted text-foreground/80",
|
||||
}
|
||||
|
||||
/**
|
||||
* 取状态的中文文案,未知状态原样返回。
|
||||
*/
|
||||
export function statusLabel(status: string): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
/**
|
||||
* 取状态 badge 的配色 class,未知状态用中性灰。
|
||||
*/
|
||||
export function statusBadgeClass(status: string): string {
|
||||
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
|
||||
}
|
||||
|
||||
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
|
||||
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
|
||||
|
||||
/**
|
||||
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
|
||||
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
|
||||
*/
|
||||
export function resolveItemLocalState(
|
||||
mapped: boolean,
|
||||
localExists: boolean | null,
|
||||
): ItemLocalState {
|
||||
if (!mapped) {
|
||||
return "notMapped"
|
||||
}
|
||||
if (localExists === false) {
|
||||
return "notDownloaded"
|
||||
}
|
||||
return "mapped"
|
||||
}
|
||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把毫秒时间戳格式化为 `YYYY-MM-DD HH:mm`,空值返回占位符。
|
||||
*/
|
||||
export function formatDateTime(value: number | null | undefined): string {
|
||||
if (value == null || value <= 0) {
|
||||
return "—"
|
||||
}
|
||||
const date = new Date(value)
|
||||
const pad = (input: number) => String(input).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
@@ -1,15 +1,111 @@
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { ConnectView } from "~/components/app/connect-view"
|
||||
import { WorkspaceShell } from "~/components/app/workspace-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { getServiceStatus, isElectron, startService } from "~/lib/electron"
|
||||
import type { ServiceStatus } from "~/lib/electron"
|
||||
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
|
||||
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
||||
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
||||
|
||||
/**
|
||||
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
||||
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
||||
*/
|
||||
export default function Home() {
|
||||
const [phase, setPhase] = useState<AppPhase>("checking")
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
const [session, setSession] = useState<WorkspaceSession | null>(null)
|
||||
const [selectedServerPath, setSelectedServerPath] = useState(SERVER_ROOT_PATH)
|
||||
|
||||
/**
|
||||
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
|
||||
*/
|
||||
const bootstrap = useCallback(async () => {
|
||||
setPhase("checking")
|
||||
let next = await getServiceStatus()
|
||||
if (!next.running && isElectron()) {
|
||||
next = await startService()
|
||||
}
|
||||
setStatus(next)
|
||||
setPhase(next.running ? "connect" : "blocked")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap()
|
||||
}, [bootstrap])
|
||||
|
||||
/**
|
||||
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
|
||||
*/
|
||||
const handleReady = useCallback((next: WorkspaceSession) => {
|
||||
setSession(next)
|
||||
setSelectedServerPath(SERVER_ROOT_PATH)
|
||||
setPhase("workspace")
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 从工作台返回连接入口,重新走连接流程。
|
||||
*/
|
||||
const handleReconnect = useCallback(() => {
|
||||
setSession(null)
|
||||
setPhase("connect")
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
|
||||
*/
|
||||
const handleNavigate = useCallback((serverPath: string) => {
|
||||
setSelectedServerPath(serverPath)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Mapping 创建 / 取消后更新固定上下文中的映射列表,浏览位置保持不变。
|
||||
*/
|
||||
const handleMappingsChanged = useCallback((mappings: WorkspaceSession["mappings"]) => {
|
||||
setSession((prev) => (prev ? { ...prev, mappings } : prev))
|
||||
}, [])
|
||||
|
||||
if (phase === "workspace" && session) {
|
||||
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>
|
||||
<WorkspaceShell
|
||||
session={session}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onNavigate={handleNavigate}
|
||||
onMappingsChanged={handleMappingsChanged}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === "connect") {
|
||||
return <ConnectView onReady={handleReady} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
||||
<h1 className="text-base font-semibold">macTFS</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
||||
|
||||
{phase === "checking" && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">正在检查本地服务…</p>
|
||||
)}
|
||||
|
||||
{phase === "blocked" && (
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<p className="font-medium text-destructive">本地服务未就绪</p>
|
||||
<p className="text-muted-foreground">
|
||||
{status?.error ?? "无法连接本地 API 服务"}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => void bootstrap()}>
|
||||
重试连接
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+264
-3
@@ -1,13 +1,265 @@
|
||||
const { app, BrowserWindow } = require("electron")
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require("electron")
|
||||
const path = require("node:path")
|
||||
const fs = require("node:fs")
|
||||
const os = require("node:os")
|
||||
const http = require("node:http")
|
||||
const { spawn } = require("node:child_process")
|
||||
|
||||
const API_HOST = "127.0.0.1"
|
||||
const API_PORT = 38765
|
||||
const API_BASE_URL = `http://${API_HOST}:${API_PORT}`
|
||||
const SERVER_MAIN_CLASS = "com.mydev.mactfs.server.MacTfsServer"
|
||||
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
|
||||
const HEALTH_TIMEOUT_MS = 2000
|
||||
const START_POLL_TIMES = 30
|
||||
const START_POLL_INTERVAL_MS = 1000
|
||||
|
||||
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
|
||||
let serverProcess = null
|
||||
|
||||
/**
|
||||
* 计算项目根目录,main.cjs 位于 mactfsui/electron 下,上溯两级即为单仓库根目录。
|
||||
*/
|
||||
function resolveProjectRoot() {
|
||||
return path.resolve(__dirname, "..", "..")
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析运行服务端使用的 Java 可执行文件,优先项目内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
||||
*/
|
||||
function resolveJavaBin() {
|
||||
const projectRoot = resolveProjectRoot()
|
||||
const bundled = path.join(
|
||||
projectRoot,
|
||||
"zulu8.94.0.17-ca-jdk8.0.492-macosx_x64",
|
||||
"Contents",
|
||||
"Home",
|
||||
"bin",
|
||||
"java",
|
||||
)
|
||||
if (fs.existsSync(bundled)) {
|
||||
return bundled
|
||||
}
|
||||
if (process.env.JAVA_HOME) {
|
||||
const fromHome = path.join(process.env.JAVA_HOME, "bin", "java")
|
||||
if (fs.existsSync(fromHome)) {
|
||||
return fromHome
|
||||
}
|
||||
}
|
||||
return "java"
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
|
||||
*/
|
||||
function resolveServerClasspath() {
|
||||
const installLib = path.join(
|
||||
resolveProjectRoot(),
|
||||
"mactfs",
|
||||
"build",
|
||||
"install",
|
||||
"mactfs",
|
||||
"lib",
|
||||
)
|
||||
if (fs.existsSync(installLib)) {
|
||||
return path.join(installLib, "*")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
|
||||
*/
|
||||
function resolveNativeDirectory() {
|
||||
const projectRoot = resolveProjectRoot()
|
||||
const installNative = path.join(
|
||||
projectRoot,
|
||||
"mactfs",
|
||||
"build",
|
||||
"install",
|
||||
"mactfs",
|
||||
"lib",
|
||||
"native",
|
||||
)
|
||||
if (fs.existsSync(installNative)) {
|
||||
return installNative
|
||||
}
|
||||
const libNative = path.join(projectRoot, "tfsIntegration", "lib", "native")
|
||||
if (fs.existsSync(libNative)) {
|
||||
return libNative
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地 Bearer Token,渲染进程不直接访问 token 文件,统一由主进程提供。
|
||||
*/
|
||||
function readToken() {
|
||||
try {
|
||||
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
|
||||
return value.length > 0 ? value : null
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延时辅助函数,用于轮询等待服务端就绪。
|
||||
*/
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 /api/health 检查本地服务是否就绪,返回结构化服务状态。
|
||||
*/
|
||||
function checkHealth() {
|
||||
return new Promise((resolve) => {
|
||||
const token = readToken()
|
||||
const base = {
|
||||
running: false,
|
||||
baseUrl: API_BASE_URL,
|
||||
tokenAvailable: token != null,
|
||||
health: null,
|
||||
error: null,
|
||||
}
|
||||
if (!token) {
|
||||
resolve({ ...base, error: "未找到本地服务 token,服务可能尚未启动" })
|
||||
return
|
||||
}
|
||||
const request = http.request(
|
||||
`${API_BASE_URL}/api/health`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
timeout: HEALTH_TIMEOUT_MS,
|
||||
},
|
||||
(response) => {
|
||||
let raw = ""
|
||||
response.on("data", (chunk) => {
|
||||
raw += chunk
|
||||
})
|
||||
response.on("end", () => {
|
||||
if (response.statusCode !== 200) {
|
||||
resolve({ ...base, error: `服务返回状态码 ${response.statusCode}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
resolve({
|
||||
...base,
|
||||
running: payload.success === true,
|
||||
health: payload.data || null,
|
||||
})
|
||||
} catch (error) {
|
||||
resolve({ ...base, error: "服务健康检查响应解析失败" })
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
request.on("timeout", () => {
|
||||
request.destroy()
|
||||
resolve({ ...base, error: "服务健康检查超时" })
|
||||
})
|
||||
request.on("error", (error) => {
|
||||
resolve({ ...base, error: `无法连接本地服务:${error.message}` })
|
||||
})
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本地开发约定拉起 mactfs-server,并轮询等待服务就绪。
|
||||
*/
|
||||
async function startService() {
|
||||
const current = await checkHealth()
|
||||
if (current.running) {
|
||||
return current
|
||||
}
|
||||
const classpath = resolveServerClasspath()
|
||||
if (!classpath) {
|
||||
return {
|
||||
...current,
|
||||
error:
|
||||
"未找到服务端构建产物,请先在 mactfs 目录执行 ../tfsIntegration/gradlew installDist",
|
||||
}
|
||||
}
|
||||
if (!serverProcess || serverProcess.exitCode !== null) {
|
||||
const args = ["-cp", classpath]
|
||||
const nativeDirectory = resolveNativeDirectory()
|
||||
if (nativeDirectory) {
|
||||
args.push(`-Dcom.microsoft.tfs.jni.native.base-directory=${nativeDirectory}`)
|
||||
}
|
||||
args.push(SERVER_MAIN_CLASS)
|
||||
serverProcess = spawn(resolveJavaBin(), args, {
|
||||
cwd: path.join(resolveProjectRoot(), "mactfs"),
|
||||
stdio: "ignore",
|
||||
})
|
||||
serverProcess.on("exit", () => {
|
||||
serverProcess = null
|
||||
})
|
||||
}
|
||||
for (let attempt = 0; attempt < START_POLL_TIMES; attempt += 1) {
|
||||
await delay(START_POLL_INTERVAL_MS)
|
||||
const status = await checkHealth()
|
||||
if (status.running) {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
error: "服务启动超时,请检查 JDK 与服务端构建产物",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
|
||||
*/
|
||||
function pathsExist(paths) {
|
||||
const result = {}
|
||||
if (!Array.isArray(paths)) {
|
||||
return result
|
||||
}
|
||||
for (const target of paths) {
|
||||
if (typeof target === "string" && target.length > 0) {
|
||||
result[target] = fs.existsSync(target)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册渲染进程通过 preload 调用的窄接口,集中暴露 token、服务状态与目录选择能力。
|
||||
*/
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle("mactfs:get-api-base-url", () => API_BASE_URL)
|
||||
ipcMain.handle("mactfs:get-token", () => readToken())
|
||||
ipcMain.handle("mactfs:get-service-status", () => checkHealth())
|
||||
ipcMain.handle("mactfs:start-service", () => startService())
|
||||
ipcMain.handle("mactfs:select-directory", async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return result.filePaths[0]
|
||||
})
|
||||
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
||||
*/
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
width: 1280,
|
||||
height: 840,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -19,6 +271,7 @@ function createWindow() {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers()
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
@@ -33,3 +286,11 @@ app.on("window-all-closed", () => {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// 应用退出时回收由本进程拉起的服务端,避免遗留孤儿进程。
|
||||
app.on("will-quit", () => {
|
||||
if (serverProcess && serverProcess.exitCode === null) {
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
// 仅向渲染进程暴露窄接口,渲染层不直接接触 fs、child_process 或 token 文件。
|
||||
contextBridge.exposeInMainWorld("mactfs", {
|
||||
isElectron: true,
|
||||
/**
|
||||
* 获取本地 API 基础地址。
|
||||
*/
|
||||
getApiBaseUrl() {
|
||||
return ipcRenderer.invoke("mactfs:get-api-base-url")
|
||||
},
|
||||
/**
|
||||
* 获取本地服务 Bearer Token。
|
||||
*/
|
||||
getToken() {
|
||||
return ipcRenderer.invoke("mactfs:get-token")
|
||||
},
|
||||
/**
|
||||
* 查询本地 API 服务当前状态。
|
||||
*/
|
||||
getServiceStatus() {
|
||||
return ipcRenderer.invoke("mactfs:get-service-status")
|
||||
},
|
||||
/**
|
||||
* 拉起本地 API 服务并等待就绪。
|
||||
*/
|
||||
startService() {
|
||||
return ipcRenderer.invoke("mactfs:start-service")
|
||||
},
|
||||
/**
|
||||
* 打开系统目录选择器,返回所选目录路径。
|
||||
*/
|
||||
selectDirectory() {
|
||||
return ipcRenderer.invoke("mactfs:select-directory")
|
||||
},
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在。
|
||||
*/
|
||||
pathsExist(paths) {
|
||||
return ipcRenderer.invoke("mactfs:paths-exist", paths)
|
||||
},
|
||||
})
|
||||
@@ -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\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
||||
"start": "pnpm build && pnpm electron",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\""
|
||||
@@ -19,6 +19,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^42.2.0",
|
||||
"playwright-core": "^1.60.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
Generated
+41
@@ -29,6 +29,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(react@19.2.6)
|
||||
monaco-editor:
|
||||
specifier: ^0.55.1
|
||||
version: 0.55.1
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -72,6 +75,9 @@ importers:
|
||||
electron:
|
||||
specifier: ^42.2.0
|
||||
version: 42.2.0
|
||||
playwright-core:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.3
|
||||
@@ -1556,6 +1562,9 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2':
|
||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||
|
||||
@@ -1898,6 +1907,9 @@ packages:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dotenv@17.4.2:
|
||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2494,6 +2506,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
marked@14.0.0:
|
||||
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2564,6 +2581,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
morgan@1.10.1:
|
||||
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2739,6 +2759,11 @@ packages:
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4857,6 +4882,9 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
@@ -5172,6 +5200,10 @@ snapshots:
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dotenv@17.4.2: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -5784,6 +5816,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@14.0.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
@@ -5829,6 +5863,11 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
morgan@1.10.1:
|
||||
dependencies:
|
||||
basic-auth: 2.0.1
|
||||
@@ -5997,6 +6036,8 @@ snapshots:
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// 临时 E2E 驱动:按《TFS-AI测试流程》用 playwright-core 驱动真实 Electron 应用,
|
||||
// 连接真实 TFS,只在指定测试目录内做写操作,并截图留证。
|
||||
import { _electron as electron } from "playwright-core"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = process.cwd()
|
||||
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||
mkdirSync(SHOT_DIR, { recursive: true })
|
||||
|
||||
const TEST_SERVER_DIR = "$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs"
|
||||
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
|
||||
// 注意:/Users/fenghp/Desktop/DEV/mactfs-ai-workspace 已被旧测试 Workspace
|
||||
// mactfs-ai-subapp-pm-mactfs 占用(TFS 不允许嵌套映射),UI 测试改用旁路独立目录。
|
||||
const LOCAL_PARENT = "/Users/fenghp/Desktop/DEV/mactfs-ui-e2e"
|
||||
|
||||
const results = []
|
||||
/** 记录一步结果并打印。 */
|
||||
function record(step, ok, detail = "") {
|
||||
results.push({ step, ok, detail })
|
||||
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
|
||||
}
|
||||
|
||||
/** 截图保存。 */
|
||||
async function shot(win, name) {
|
||||
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
const app = await electron.launch({
|
||||
args: ["electron/main.cjs"],
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, NODE_ENV: "development" },
|
||||
})
|
||||
|
||||
try {
|
||||
const win = await app.firstWindow()
|
||||
win.setDefaultTimeout(90000)
|
||||
|
||||
// 1. 服务检测 → 连接页(Electron 会自动拉起本地服务,最长等 60s+)
|
||||
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||
const serverUri = await win.locator("#serverUri").inputValue()
|
||||
record("1 服务就绪并进入连接页", true, `回填 serverUri=${serverUri}`)
|
||||
await shot(win, "01-connect")
|
||||
|
||||
// 2. 连接真实 TFS → Collection 列表
|
||||
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||
const collections = await win.locator("ul button span.font-medium").allInnerTexts()
|
||||
record("2 连接成功并加载 Collection", collections.includes("PKUSEHR"), collections.join(","))
|
||||
await shot(win, "02-collections")
|
||||
|
||||
// 3. 选择 PKUSEHR → 确保 Workspace → 进入工作台
|
||||
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||
const topbar = await win.locator("header").innerText()
|
||||
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
|
||||
await shot(win, "03-workspace")
|
||||
|
||||
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
|
||||
const tree = win.locator("aside").first()
|
||||
for (const name of TEST_TREE_CHAIN) {
|
||||
const node = tree.getByText(name, { exact: true }).first()
|
||||
await node.waitFor({ timeout: 120000 })
|
||||
await node.click()
|
||||
await win.waitForTimeout(2500)
|
||||
}
|
||||
const pathBar = await win.locator("section").first().innerText()
|
||||
record("4 树导航到测试目录", pathBar.includes(TEST_SERVER_DIR), "")
|
||||
await shot(win, "04-test-folder")
|
||||
|
||||
// 5. 目录历史弹窗
|
||||
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||
await win.getByRole("menuitem", { name: "查看历史", exact: true }).click()
|
||||
await win.locator("[data-slot=dialog-title]", { hasText: "历史记录" }).waitFor()
|
||||
await win.waitForTimeout(5000)
|
||||
const historyText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
const historyOk = /Changeset/.test(historyText) && !historyText.includes("历史加载失败")
|
||||
record("5 目录历史弹窗", historyOk, historyText.slice(0, 120).replace(/\n/g, " "))
|
||||
await shot(win, "05-history")
|
||||
await win.keyboard.press("Escape")
|
||||
await win.waitForTimeout(500)
|
||||
|
||||
// 6. 文件查看(服务器 latest,未映射状态)
|
||||
const fileRow = win.locator("section table tbody tr").filter({ hasText: ".txt" }).first()
|
||||
await fileRow.click({ button: "right" })
|
||||
await win.getByRole("menuitem", { name: "查看文件", exact: true }).click()
|
||||
await win.locator("[data-slot=dialog-title]", { hasText: "查看文件" }).waitFor()
|
||||
await win.waitForTimeout(4000)
|
||||
const viewText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
record("6 文件查看(服务器 latest)", viewText.includes("服务器 latest"), viewText.slice(0, 100).replace(/\n/g, " "))
|
||||
await shot(win, "06-view-file")
|
||||
await win.keyboard.press("Escape")
|
||||
await win.waitForTimeout(500)
|
||||
|
||||
// 7. Mapping 弹窗:父目录 → 后端预校验 → 创建(勾选立即 Get Latest)
|
||||
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||
await win.getByRole("menuitem", { name: "映射到本地", exact: true }).click()
|
||||
await win.locator("#parentPath").fill(LOCAL_PARENT)
|
||||
await win.waitForTimeout(2500)
|
||||
const mapDialog = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
const precheckOk = mapDialog.includes(`${LOCAL_PARENT}/mactfs`)
|
||||
record("7a Mapping 预校验(最终路径由后端生成)", precheckOk, "")
|
||||
await shot(win, "07-mapping-precheck")
|
||||
await win.getByRole("button", { name: "创建映射" }).click()
|
||||
// 立即 Get Latest,真实下载可能较慢
|
||||
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 180000 }).catch(() => {})
|
||||
await win.waitForTimeout(3000)
|
||||
const afterMap = await win.locator("section").first().innerText()
|
||||
const mapError = await win.locator("[data-slot=dialog-content]").count()
|
||||
if (mapError > 0) {
|
||||
const errText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
record("7b 创建 Mapping + Get Latest", false, errText.slice(0, 160).replace(/\n/g, " "))
|
||||
await shot(win, "07b-mapping-error")
|
||||
await win.keyboard.press("Escape")
|
||||
} else {
|
||||
record("7b 创建 Mapping + Get Latest", afterMap.includes(LOCAL_PARENT), afterMap.split("\n").slice(0, 3).join(" "))
|
||||
await shot(win, "07b-mapped")
|
||||
}
|
||||
|
||||
// 8. 目录对比弹窗(真实对比)
|
||||
await win.keyboard.press("Escape")
|
||||
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
|
||||
await win.waitForTimeout(800)
|
||||
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||
const compareItem = win.getByRole("menuitem", { name: "目录对比", exact: true })
|
||||
const compareEnabled = (await compareItem.getAttribute("aria-disabled")) !== "true"
|
||||
if (compareEnabled) {
|
||||
await compareItem.click()
|
||||
await win.locator("[data-slot=dialog-title]", { hasText: "目录对比" }).waitFor()
|
||||
await win.waitForTimeout(8000)
|
||||
const compareText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
record("8 目录对比弹窗", !compareText.includes("目录对比失败"), compareText.slice(0, 120).replace(/\n/g, " "))
|
||||
await shot(win, "08-compare")
|
||||
await win.keyboard.press("Escape")
|
||||
await win.waitForTimeout(500)
|
||||
} else {
|
||||
await win.keyboard.press("Escape")
|
||||
record("8 目录对比弹窗", false, "目录未映射,菜单置灰")
|
||||
}
|
||||
|
||||
// 9. 签出一个文件 → 右侧出现 pending → Diff → 撤销(不做 checkin,避免污染服务端)
|
||||
const fileRow2 = win.locator("section table tbody tr").filter({ hasText: ".txt" }).first()
|
||||
if ((await fileRow2.count()) > 0) {
|
||||
await fileRow2.click({ button: "right" })
|
||||
const checkoutItem = win.getByRole("menuitem", { name: "签出编辑", exact: true })
|
||||
const checkoutEnabled = (await checkoutItem.getAttribute("aria-disabled")) !== "true"
|
||||
if (checkoutEnabled) {
|
||||
await checkoutItem.click()
|
||||
await win.waitForTimeout(15000)
|
||||
const pendingPanel = await win.locator("aside").nth(1).innerText()
|
||||
const hasPending = pendingPanel.includes("签出编辑") || pendingPanel.includes("Included")
|
||||
record("9a 签出文件产生挂起更改", hasPending, pendingPanel.slice(0, 120).replace(/\n/g, " "))
|
||||
await shot(win, "09a-pending")
|
||||
|
||||
// 与最新版本比较(刚签出未改 → 应一致)
|
||||
const pendingRow = win.locator("aside").nth(1).locator("[data-slot=checkbox]").first()
|
||||
if (hasPending && (await pendingRow.count()) > 0) {
|
||||
const row = win.locator("aside").nth(1).getByText(".txt").first()
|
||||
await row.click({ button: "right" })
|
||||
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
|
||||
if ((await diffItem.count()) > 0) {
|
||||
await diffItem.click()
|
||||
await win.waitForTimeout(6000)
|
||||
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||
record("9b 本地 vs latest Diff", diffText.includes("一致") || diffText.includes("差异"), diffText.slice(0, 100).replace(/\n/g, " "))
|
||||
await shot(win, "09b-diff")
|
||||
await win.keyboard.press("Escape")
|
||||
await win.waitForTimeout(500)
|
||||
}
|
||||
// 撤销
|
||||
await row.click({ button: "right" })
|
||||
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
|
||||
await win.waitForTimeout(10000)
|
||||
const afterUndo = await win.locator("aside").nth(1).innerText()
|
||||
record("9c 撤销挂起更改", afterUndo.includes("当前没有挂起更改"), "")
|
||||
await shot(win, "09c-undone")
|
||||
}
|
||||
} else {
|
||||
await win.keyboard.press("Escape")
|
||||
record("9a 签出文件产生挂起更改", false, "签出菜单置灰(可能未映射)")
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 取消映射(清理现场,不跳转浏览位置)
|
||||
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||
const unmapItem = win.getByRole("menuitem", { name: "取消映射", exact: true })
|
||||
if ((await unmapItem.count()) > 0) {
|
||||
await unmapItem.click()
|
||||
await win.waitForTimeout(8000)
|
||||
const afterUnmap = await win.locator("section").first().innerText()
|
||||
record("10 取消映射并停留当前位置", afterUnmap.includes("未映射到本地") && afterUnmap.includes(TEST_SERVER_DIR), "")
|
||||
await shot(win, "10-unmapped")
|
||||
} else {
|
||||
await win.keyboard.press("Escape")
|
||||
record("10 取消映射", false, "菜单中没有取消映射(mapping 未创建成功)")
|
||||
}
|
||||
|
||||
// 11. 操作日志面板
|
||||
const consoleText = await win.locator("footer").innerText()
|
||||
record("11 操作日志记录", /getLatest|checkout|undo|connect|serverTree/i.test(consoleText), consoleText.split("\n").slice(0, 6).join(" | "))
|
||||
await shot(win, "11-console")
|
||||
} catch (error) {
|
||||
record("EXCEPTION", false, String(error).slice(0, 300))
|
||||
try {
|
||||
const win = await app.firstWindow()
|
||||
await shot(win, "99-exception")
|
||||
} catch {}
|
||||
} finally {
|
||||
await app.close().catch(() => {})
|
||||
}
|
||||
|
||||
console.log("\n==== RESULT SUMMARY ====")
|
||||
for (const item of results) {
|
||||
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
|
||||
}
|
||||
const failed = results.filter((item) => !item.ok).length
|
||||
console.log(`\n${results.length - failed}/${results.length} passed`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
+9
-9
@@ -35,19 +35,19 @@
|
||||
- 常用 API 调试命令
|
||||
- 保留必要的本地验证能力
|
||||
|
||||
## 阶段 4:Frontend
|
||||
## 阶段 4:Frontend Workspace UI
|
||||
|
||||
目标:实现类 Visual Studio 的桌面 UI。
|
||||
目标:实现以固定 Collection / Workspace 上下文为核心的桌面工作台。
|
||||
|
||||
输出:
|
||||
|
||||
- Electron 启动本地服务
|
||||
- 登录配置
|
||||
- 左侧 Collection 服务端目录树
|
||||
- 中间目录文件列表
|
||||
- 右侧挂起更改
|
||||
- 底部操作日志
|
||||
- Mapping、目录对比、文件操作、历史、Diff、签入
|
||||
- Electron 启动本地服务、preload、API client
|
||||
- 登录配置、Collection 选择、默认 Workspace 上下文
|
||||
- 三栏工作台、底部操作台、左右与底部收起能力
|
||||
- 左侧服务端目录树与中间目录文件列表同步导航
|
||||
- 对象右键菜单与统一动作模型
|
||||
- Mapping、History、目录对比、文件查看、Diff、冲突处理弹窗
|
||||
- Pending Changes、Checkin、操作反馈和日志展示
|
||||
|
||||
## 阶段 5:Feature E2E
|
||||
|
||||
|
||||
@@ -9,8 +9,17 @@ AI 执行任何任务前,必须读取:
|
||||
- [task/AI-RULES.md](/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md)
|
||||
- 当前任务文件
|
||||
|
||||
如果任务属于前端阶段,或任务会影响前端交互、布局、接口对接,还必须读取:
|
||||
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
如果任务涉及源码,还必须读取相关模块现有代码,优先复用项目已有写法。
|
||||
|
||||
说明:
|
||||
|
||||
- `task/frontend` 是当前唯一有效的前端阶段任务目录。
|
||||
- `task/frontend-refactor` 已并入 `task/frontend`,仅保留归档说明,不再作为当前任务来源。
|
||||
|
||||
## 二、任务状态更新
|
||||
|
||||
开始任务前:
|
||||
|
||||
+32
-17
@@ -4,11 +4,17 @@
|
||||
|
||||
- 每次开发任务前,AI 必须先读取 [AI-RULES.md](/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md)。
|
||||
- 每次开发任务前,AI 必须读取 [mactfs-api-product-prd.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-api-product-prd.md)。
|
||||
- 前端相关任务还必须读取 [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)。
|
||||
- 每次开发任务前,AI 必须读取当前任务文件。
|
||||
- 开始任务时,将任务状态改为 `doing`。
|
||||
- 完成任务后,将任务状态改为 `done`,填写完成记录,并更新本看板。
|
||||
- 阻塞任务必须标记为 `blocked`,并写清阻塞原因和需要的输入。
|
||||
|
||||
说明:
|
||||
|
||||
- `task/frontend` 是当前唯一有效的前端阶段目录。
|
||||
- `task/frontend-refactor` 已归档,设计结论已并入 `task/frontend` 和 `mactfsui/FRONTEND_SPEC.md`。
|
||||
|
||||
## 状态枚举
|
||||
|
||||
```text
|
||||
@@ -25,18 +31,22 @@ blocked
|
||||
| Core | 12 | 12 | 100% |
|
||||
| Server API | 10 | 10 | 100% |
|
||||
| CLI | 2 | 2 | 100% |
|
||||
| Frontend | 0 | 13 | 0% |
|
||||
| Frontend | 18 | 18 | 100% |
|
||||
| Feature E2E | 0 | 5 | 0% |
|
||||
| Release | 0 | 4 | 0% |
|
||||
| Overall | 24 | 46 | 52% |
|
||||
| Overall | 42 | 51 | 82% |
|
||||
|
||||
## 当前阶段
|
||||
|
||||
Frontend
|
||||
Feature E2E(前端阶段含增强已全部完成)
|
||||
|
||||
## 前端规范
|
||||
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 下一步任务
|
||||
|
||||
- [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)(需要真实 TFS 环境)
|
||||
|
||||
## Core
|
||||
|
||||
@@ -81,19 +91,24 @@ 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启动服务Preload与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务Preload与API客户端.md) | done | P0 |
|
||||
| [FE-002-补齐前端集成所需服务端接口与类型契约.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-补齐前端集成所需服务端接口与类型契约.md) | done | P0 |
|
||||
| [FE-003-实现连接页Collection选择与Workspace上下文.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现连接页Collection选择与Workspace上下文.md) | done | P0 |
|
||||
| [FE-004-实现工作台布局折叠面板与同步导航.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现工作台布局折叠面板与同步导航.md) | done | P0 |
|
||||
| [FE-005-实现服务端目录树与当前目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现服务端目录树与当前目录文件列表.md) | done | P0 |
|
||||
| [FE-006-实现对象右键菜单与通用动作模型.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现对象右键菜单与通用动作模型.md) | done | P0 |
|
||||
| [FE-007-实现MappingHistory与目录对比弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现MappingHistory与目录对比弹窗.md) | done | P0 |
|
||||
| [FE-008-实现文件查看与Diff弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现文件查看与Diff弹窗.md) | done | P0 |
|
||||
| [FE-009-实现GetLatest与Checkout冲突处理.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现GetLatest与Checkout冲突处理.md) | done | P0 |
|
||||
| [FE-010-实现PendingChanges与Checkin体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现PendingChanges与Checkin体验.md) | done | P0 |
|
||||
| [FE-011-拆分FolderItemsPanel与收口动作编排.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-拆分FolderItemsPanel与收口动作编排.md) | done | P1 |
|
||||
| [FE-012-实现操作日志面板与刷新反馈.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现操作日志面板与刷新反馈.md) | done | P1 |
|
||||
| [FE-013-前端阶段联调验收基线.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-前端阶段联调验收基线.md) | done | P0 |
|
||||
| [FE-014-实现安全获取与强制获取及指定版本.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-014-实现安全获取与强制获取及指定版本.md) | done | P0 |
|
||||
| [FE-015-完善历史与对比结果的对象操作.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-015-完善历史与对比结果的对象操作.md) | done | P0 |
|
||||
| [FE-016-优化Diff与历史弹窗体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-016-优化Diff与历史弹窗体验.md) | done | P1 |
|
||||
| [FE-017-实现工作台手动刷新.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-017-实现工作台手动刷新.md) | done | P1 |
|
||||
| [FE-018-Diff弹窗替换为MonacoDiffEditor.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-018-Diff弹窗替换为MonacoDiffEditor.md) | done | P1 |
|
||||
|
||||
## Feature E2E
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ feature
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-004
|
||||
- FE-013
|
||||
- SERVER-005
|
||||
|
||||
## 需求来源
|
||||
@@ -23,14 +23,15 @@ feature
|
||||
|
||||
## 目标
|
||||
|
||||
验证从 UI 到 API 到 TFS SDK 的连接和目录浏览完整链路。
|
||||
验证从登录页到固定 Collection / Workspace 上下文,再到目录树和当前目录列表的完整链路。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- UI 输入账号密码连接
|
||||
- 查询 Collection
|
||||
- UI 输入账号密码并连接
|
||||
- 连接后加载 Collection
|
||||
- 选择 Collection 并自动确保 Workspace
|
||||
- 展示左侧服务端目录树
|
||||
- 点击目录展示下一级文件列表
|
||||
- 点击目录展示中间当前目录列表
|
||||
- 未映射目录也可浏览
|
||||
|
||||
## 不在范围
|
||||
@@ -47,9 +48,10 @@ feature
|
||||
## 验收标准
|
||||
|
||||
- 真实 TFS 环境可连接
|
||||
- Collection 可见
|
||||
- Collection 可见并可确认
|
||||
- 默认 Workspace 可自动使用或创建
|
||||
- 服务端目录树可展开
|
||||
- 中间文件列表可展示
|
||||
- 中间当前目录列表可展示
|
||||
|
||||
## 测试方式
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ feature
|
||||
## 依赖任务
|
||||
|
||||
- FEATURE-001
|
||||
- FE-006
|
||||
- FE-007
|
||||
- FE-009
|
||||
- SERVER-006
|
||||
- SERVER-007
|
||||
|
||||
@@ -25,15 +26,15 @@ feature
|
||||
|
||||
## 目标
|
||||
|
||||
验证 Mapping 创建和 Get Latest 的完整链路。
|
||||
验证 Mapping 弹窗、目录选择、是否立即 Get Latest,以及后续独立执行 Get Latest 的完整链路。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 选择服务端目录
|
||||
- 映射到本地目录
|
||||
- 通过弹窗映射到本地目录
|
||||
- 选择是否立即 Get Latest
|
||||
- 对文件 / 目录 / Mapping 执行 Get Latest
|
||||
- 展示下载结果
|
||||
- 展示下载结果、跳过项和冲突反馈
|
||||
|
||||
## 不在范围
|
||||
|
||||
@@ -49,8 +50,9 @@ feature
|
||||
## 验收标准
|
||||
|
||||
- Mapping 能保存到 Workspace
|
||||
- 不立即下载时状态为 notDownloaded
|
||||
- 不立即下载时状态为 `notDownloaded`
|
||||
- 后续可单独下载下级文件或目录
|
||||
- 有冲突或跳过项时前端反馈清晰
|
||||
|
||||
## 测试方式
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ feature
|
||||
## 依赖任务
|
||||
|
||||
- FEATURE-002
|
||||
- FE-006
|
||||
- FE-007
|
||||
- FE-009
|
||||
- SERVER-008
|
||||
@@ -25,22 +26,21 @@ feature
|
||||
|
||||
## 目标
|
||||
|
||||
验证目录对比和勾选差异文件处理完整链路。
|
||||
验证目录对比弹窗、差异筛选、右键动作和冲突反馈链路。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 对已映射目录执行对比
|
||||
- 展示差异文件
|
||||
- 勾选 localModified 执行 checkout
|
||||
- 勾选 localOnly 执行 add
|
||||
- 勾选 notDownloaded 执行 Get Latest
|
||||
- 勾选 pending change 执行 undo
|
||||
- 通过右键菜单执行 checkout、add、get latest、undo
|
||||
- 对冲突项进入统一冲突处理
|
||||
- 验证目录对比结果刷新
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做自动修复全部
|
||||
- 不做冲突解决
|
||||
- 不做实时刷新
|
||||
- 不做手动冲突块编辑
|
||||
- 不做实时 watcher
|
||||
|
||||
## 涉及文件
|
||||
|
||||
@@ -49,9 +49,10 @@ feature
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 目录对比不显示 upToDate
|
||||
- 目录对比默认不显示 `upToDate`
|
||||
- 差异状态准确
|
||||
- 用户可勾选文件执行对应操作
|
||||
- 用户可通过右键菜单执行对应操作
|
||||
- 有冲突时能进入统一冲突反馈流程
|
||||
|
||||
## 测试方式
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ feature
|
||||
## 依赖任务
|
||||
|
||||
- FEATURE-003
|
||||
- FE-008
|
||||
- FE-010
|
||||
|
||||
## 需求来源
|
||||
@@ -24,7 +23,7 @@ feature
|
||||
|
||||
## 目标
|
||||
|
||||
验证右侧挂起更改、Included / Excluded 和签入完整链路。
|
||||
验证右侧 Changes 面板、Included / Excluded 和签入链路。
|
||||
|
||||
## 实现范围
|
||||
|
||||
@@ -53,6 +52,7 @@ feature
|
||||
- Excluded 不参与签入
|
||||
- 成功后 Included 清空
|
||||
- changeset 编号可见
|
||||
- 签入后当前目录和 Pending Changes 刷新
|
||||
|
||||
## 测试方式
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ feature
|
||||
## 依赖任务
|
||||
|
||||
- FEATURE-002
|
||||
- FE-011
|
||||
- FE-012
|
||||
- FE-007
|
||||
- FE-008
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
@@ -25,21 +25,22 @@ feature
|
||||
|
||||
## 目标
|
||||
|
||||
验证文件历史、目录历史、changeset 文件列表和版本对比完整链路。
|
||||
验证 History 弹窗、changeset 文件列表、文件查看和 Diff 弹窗的完整链路。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 文件历史最近 100 条
|
||||
- 目录历史最近 100 条
|
||||
- 点击 changeset 查看影响文件列表
|
||||
- 本地 vs latest diff
|
||||
- 两个历史版本 diff
|
||||
- 本地 vs latest Diff
|
||||
- 两个历史版本 Diff
|
||||
- 大文件、二进制、非 Mapping 路径提示
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做筛选
|
||||
- 不做三方 merge
|
||||
- 不做二进制可视化 diff
|
||||
- 不做二进制可视化 Diff
|
||||
|
||||
## 涉及文件
|
||||
|
||||
@@ -52,6 +53,7 @@ feature
|
||||
- 目录历史可展示
|
||||
- changeset 文件列表可展示
|
||||
- 历史中两个版本可对比
|
||||
- 大文件和不可渲染文件提示明确
|
||||
|
||||
## 测试方式
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# frontend-refactor 归档说明
|
||||
|
||||
`task/frontend-refactor` 原本用于承接另一分支里的前端重构任务。
|
||||
|
||||
当前分支已经把其中的设计结论和任务拆分统一并入以下位置:
|
||||
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
- [task/frontend](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend)
|
||||
|
||||
当前规则:
|
||||
|
||||
- 前端阶段只看 `task/frontend`
|
||||
- `frontend-refactor` 不再单独维护状态
|
||||
- 旧 `UIR-*` 编号停止使用
|
||||
|
||||
如果后续需要继续调整前端方案,直接修改:
|
||||
|
||||
- `mactfsui/FRONTEND_SPEC.md`
|
||||
- `task/frontend/FE-001` 到 `FE-013`
|
||||
@@ -0,0 +1,123 @@
|
||||
# FE-001 实现 Electron 启动服务、Preload 与 API 客户端
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- SERVER-002
|
||||
- CLI-002
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 三、阶段四:Frontend Workspace UI
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
补齐 Electron 前端基础设施,让渲染进程通过 preload 和统一 API client 访问本地服务,而不是直接碰本机文件或 Java 进程。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Electron 主进程检查 `http://127.0.0.1:38765/api/health`
|
||||
- 服务未启动时按本地开发约定拉起 `mactfs-server`
|
||||
- 新增 `preload.cjs` 暴露 token、服务状态、目录选择等窄接口
|
||||
- 渲染进程封装统一 API client
|
||||
- API client 自动带 Bearer token
|
||||
- 服务未就绪时提供明确错误提示和重试入口
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现业务页面
|
||||
- 不实现目录树、文件列表、Pending Changes
|
||||
- 不做 Release 打包
|
||||
- 不在渲染进程直接暴露 `fs`、`child_process`
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/electron/main.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/main.cjs)
|
||||
- [mactfsui/electron/preload.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/preload.cjs)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
- [mactfsui/app/lib/electron](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/electron)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Electron 启动后能检测本地 API 服务状态
|
||||
- 服务未启动时能拉起或明确提示失败原因
|
||||
- 渲染进程调用 API 时自动携带 token
|
||||
- 渲染进程不直接读取 `~/.mactfs/server-token`
|
||||
- 目录选择能力通过 preload 暴露
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/electron/main.cjs`:补齐主进程基础设施。
|
||||
- `mactfsui/electron/preload.cjs`:新增 preload,暴露窄接口。
|
||||
- `mactfsui/app/lib/electron/types.ts`:新增桥接类型与 `window.mactfs` 声明。
|
||||
- `mactfsui/app/lib/electron/bridge.ts`:新增渲染层桥接访问器(含 SSR / 非 Electron 兜底)。
|
||||
- `mactfsui/app/lib/electron/index.ts`:桥接层出口。
|
||||
- `mactfsui/app/lib/api/types.ts`:新增 API 响应类型契约。
|
||||
- `mactfsui/app/lib/api/client.ts`:新增统一 API client。
|
||||
- `mactfsui/app/lib/api/index.ts`:API 层出口。
|
||||
- `mactfsui/app/routes/home.tsx`:替换模板页为引导态(检测 / 拉起服务 + 重试入口)。
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 主进程读取 `~/.mactfs/server-token`,通过 IPC 暴露 token,渲染层不直接读取 token 文件。
|
||||
- 主进程通过 `GET /api/health`(携带 Bearer token,2s 超时)检测本地服务状态。
|
||||
- 服务未启动时按本地开发约定拉起 `com.mydev.mactfs.server.MacTfsServer`:
|
||||
- Java 优先使用项目内置 `zulu8`,回退 `JAVA_HOME`、PATH。
|
||||
- classpath 使用 `mactfs/build/install/mactfs/lib/*`(已含全部运行依赖)。
|
||||
- 设置 `com.microsoft.tfs.jni.native.base-directory` 指向 native 目录。
|
||||
- 拉起后轮询 health 最多 30 次(每次 1s)等待就绪。
|
||||
- preload 通过 `contextBridge` 暴露 `getApiBaseUrl / getToken / getServiceStatus / startService / selectDirectory`。
|
||||
- 渲染层统一 API client 自动携带 Bearer token,统一封装网络层与业务层成功/失败,错误展示遵循 `errorMessage || message`。
|
||||
- 目录选择能力经 preload + `dialog.showOpenDialog` 暴露。
|
||||
- `will-quit` 回收由本进程拉起的服务端子进程。
|
||||
|
||||
### 已执行测试
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
### 测试结果
|
||||
|
||||
- `pnpm typecheck` 通过(exit 0)。
|
||||
- ReadLints 无告警。
|
||||
|
||||
### 未执行测试及原因
|
||||
|
||||
- 未实际启动 Electron 做端到端联调:FE-001 仅交付基础设施,端到端启动验证留待 FE-013 / FEATURE 阶段,且实际拉起服务依赖真实 TFS 环境与构建产物。
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- Electron 启动后能检测本地 API 服务状态:满足(`getServiceStatus` → health)。
|
||||
- 服务未启动时能拉起或明确提示失败原因:满足(`startService` 拉起 + 失败原因文案 + 重试入口)。
|
||||
- 渲染进程调用 API 时自动携带 token:满足(API client 自动注入 Bearer)。
|
||||
- 渲染进程不直接读取 `~/.mactfs/server-token`:满足(仅主进程读取,经 IPC 暴露)。
|
||||
- 目录选择能力通过 preload 暴露:满足(`selectDirectory`)。
|
||||
|
||||
### 遗留问题与建议
|
||||
|
||||
- `home.tsx` 当前为引导态占位,FE-003 实现连接页时应将服务检测逻辑收口到连接上下文。
|
||||
- 服务端拉起路径依赖 `mactfs/build/install/mactfs/lib`,建议在 RELEASE 阶段统一打包定位逻辑(生产环境路径与开发不同)。
|
||||
- 纯浏览器 `pnpm dev`(无 Electron)下 `window.mactfs` 不存在,token 不可用,API 请求会 401,属预期;Electron 为目标运行环境。
|
||||
@@ -1,62 +0,0 @@
|
||||
# FE-001 实现 Electron 启动服务与 API 客户端
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- SERVER-002
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 三、阶段三:Frontend
|
||||
- PRD 六、UI 设计
|
||||
|
||||
## 目标
|
||||
|
||||
让 Electron 启动时能够拉起或连接本地 Java API 服务,并提供前端 API 客户端。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Electron 主进程启动本地服务
|
||||
- 检查 `127.0.0.1:38765` health
|
||||
- 读取 token 文件
|
||||
- 前端封装统一 API 请求方法
|
||||
- 请求自动带 Bearer token
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现业务页面
|
||||
- 不做打包
|
||||
- 不做自动更新
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/electron/main.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/main.cjs)
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Electron 启动后可连接本地 API
|
||||
- API 请求能自动带 token
|
||||
- 服务未启动时 UI 有明确提示
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -1,67 +0,0 @@
|
||||
# FE-002 实现登录配置页
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-001
|
||||
- SERVER-003
|
||||
- SERVER-004
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 七、7.1 首次连接
|
||||
|
||||
## 目标
|
||||
|
||||
实现输入 TFS 地址、账号、密码并连接服务器的 UI。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- serverUri 输入
|
||||
- authType 选择
|
||||
- domain 输入
|
||||
- username 输入
|
||||
- password 输入
|
||||
- 连接按钮
|
||||
- 连接中状态
|
||||
- 错误提示
|
||||
- 成功后保存默认配置
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做多 Profile
|
||||
- 不做 Keychain
|
||||
- 不做复杂账号管理
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可填写配置并调用连接 API
|
||||
- 连接成功进入主界面
|
||||
- 连接失败显示错误
|
||||
- 再次打开能回填配置
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,131 @@
|
||||
# FE-002 补齐前端集成所需服务端接口与类型契约
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-001
|
||||
- SERVER-006
|
||||
- SERVER-007
|
||||
- SERVER-008
|
||||
- SERVER-009
|
||||
- SERVER-010
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 五、API 设计
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
补齐工作台改版所需的服务端接口和前端类型契约,避免后续页面实现时再拆返。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 增加前端固定 Collection / Workspace 上下文所需接口
|
||||
- 增加文件内容读取接口,覆盖本地映射文件和服务器 latest 文件
|
||||
- 增加冲突明细与冲突选择应用接口
|
||||
- 明确大文件、二进制文件、非 Mapping 路径的返回约束
|
||||
- 在 `app/lib/api/types.ts` 中补齐前端统一类型
|
||||
- 在 `app/lib/api/endpoints.ts` 中补齐接口封装
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现具体业务页面
|
||||
- 不实现 Monaco / CodeMirror
|
||||
- 不实现手动冲突块编辑
|
||||
- 不调整无关 Server API 路由
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfs/src/main/java/com/mydev/mactfs/server](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server)
|
||||
- [mactfs/src/main/java/com/mydev/mactfs/core](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 前端有稳定的 Workspace 上下文接口可调用
|
||||
- 前端有稳定的文件内容接口可调用
|
||||
- 前端有稳定的冲突查询与应用接口可调用
|
||||
- 大文件、二进制文件、越界路径的返回规则明确
|
||||
- 前端不再在组件里散落临时接口类型
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
../tfsIntegration/gradlew build
|
||||
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`:新增工作台集成所需的 4 个缺失路由及 1 个冲突明细路由。
|
||||
- `mactfsui/app/lib/api/types.ts`:补齐前端统一领域类型契约(配置、目录、Workspace 上下文、Mapping、文件内容、冲突等)。
|
||||
- `mactfsui/app/lib/api/endpoints.ts`:新增覆盖全部工作台接口的类型化封装。
|
||||
- `mactfsui/app/lib/api/index.ts`:统一导出类型与 `api` 端点命名空间。
|
||||
- `mactfsui/FRONTEND_SPEC.md`:接口对接表补充冲突明细接口,并更新当前分支现状。
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 服务端新增路由:
|
||||
- `GET /api/workspace/context`:返回固定上下文(serverUri / collection / workspace / connected / mappings),由会话配置与 `listMappings` 组合,不变更 TFS 状态。
|
||||
- `POST /api/mappings/check-target`:按“父目录 + 服务端路径最后一段”计算最终目标路径并返回 `exists` / `allowed`,纯本地文件系统校验。
|
||||
- `GET /api/files/content`:传 `localPath` 读取本地映射文件,否则按 `serverPath` + 可选 `changeset` 读取服务器内容;统一套用二进制和大文件约束。
|
||||
- `GET /api/conflicts`:查询当前 Workspace 冲突明细,支持 `serverPath` 范围与 `recursive`。
|
||||
- `POST /api/conflicts/apply`:对单个冲突应用 `takeServer`(采用服务器版本)或 `keepLocal`(保留本地版本)。
|
||||
- 核心层新增 `getLocalFileContent`、`listConflicts`、`applyConflict`,并把 `getFileContent` 改为统一走二进制嗅探(前 8000 字节空字节)+ 2MB 大小阈值的内容构建器;`TfsFileContent` 增加 `size` 与 `tooLarge` 字段;新增 `TfsConflictInfo`、`TfsConflictResolution` 数据类。
|
||||
- 大文件 / 二进制:`content` 返回空串,`binary` / `tooLarge` 标记为真,前端据此提示。
|
||||
- 非 Mapping 路径:服务器内容接口仍可读取 latest;本地内容接口在文件不存在时返回明确错误,由前端展示 `errorMessage`。
|
||||
- 前端类型契约与端点封装一一对应服务端 `data` 结构,组件后续不再散落临时类型与裸 `fetch`。
|
||||
|
||||
### 已执行测试
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
../tfsIntegration/gradlew build
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
### 测试结果
|
||||
|
||||
- `../tfsIntegration/gradlew build`:BUILD SUCCESSFUL(mactfs 无单元测试,编译、jar、dist 均通过)。
|
||||
- `pnpm typecheck`:通过(exit 0),新增类型与端点封装无类型错误。
|
||||
- 前端 ReadLints 无告警。
|
||||
- SDK 冲突 API(`Workspace.queryConflicts` / `resolveConflict`、`Resolution.ACCEPT_THEIRS|ACCEPT_YOURS`)通过 `javap` 反查 `com.microsoft.tfs.sdk-14.0.1.jar` 确认签名后实现。
|
||||
|
||||
### 未执行测试及原因
|
||||
|
||||
- 未对接真实 TFS 验证冲突查询与应用运行时行为:本任务仅交付接口与类型契约,冲突 `apply` 属会修改 Workspace 状态的操作,按 `task/AI-RULES.md` 第六条需在用户确认或 FEATURE 阶段、真实 TFS 环境下验证。
|
||||
- 未实际启动服务做 HTTP 联调:新增路由的端到端联调留待 FE-009(冲突)/ FE-013 与 FEATURE 阶段。
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 前端有稳定的 Workspace 上下文接口可调用:满足(`getWorkspaceContext` → `/api/workspace/context`)。
|
||||
- 前端有稳定的文件内容接口可调用:满足(`getFileContent` 覆盖本地与服务器内容)。
|
||||
- 前端有稳定的冲突查询与应用接口可调用:满足(`listConflicts` / `applyConflict`)。
|
||||
- 大文件、二进制文件、越界路径的返回规则明确:满足(`binary` / `tooLarge` 标记 + 不存在路径明确报错)。
|
||||
- 前端不再在组件里散落临时接口类型:满足(统一收口到 `app/lib/api`)。
|
||||
|
||||
### 遗留问题与建议
|
||||
|
||||
- `/api/conflicts`(冲突明细)为 FE-002 范围内新增、PRD 仅列出 `/api/conflicts/apply`,已同步补充进 `FRONTEND_SPEC.md` 接口表,后续如需也可回写 PRD。
|
||||
- 冲突应用第一版仅支持 `takeServer` / `keepLocal`,不做手动冲突块编辑(与 PRD 一致)。
|
||||
- `diffLocalLatest` 在 latest 为二进制 / 超限时会基于空内容比对,建议 FE-008 在 Diff 弹窗按 `binary` / `tooLarge` 先行拦截提示。
|
||||
- 真实 TFS 下冲突 `apply` 行为需在 FE-009 / FEATURE 阶段实测。
|
||||
@@ -1,61 +0,0 @@
|
||||
# FE-003 实现 VS 风格主布局
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-001
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.1 布局
|
||||
|
||||
## 目标
|
||||
|
||||
实现参考 Visual Studio Source Control Explorer 的主界面布局。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 顶部连接与 Workspace 信息区
|
||||
- 左侧 Collection 服务端目录树区域
|
||||
- 中间目录文件列表区域
|
||||
- 右侧挂起更改区域
|
||||
- 底部操作日志区域
|
||||
- 基础响应式尺寸处理
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现具体业务数据
|
||||
- 不实现完整 diff 展示
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/app.css](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/app.css)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- UI 不再是默认模板页
|
||||
- 主界面布局与 PRD 区域一致
|
||||
- 桌面窗口下可正常使用
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,118 @@
|
||||
# FE-003 实现连接页、Collection 选择与 Workspace 上下文
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-001
|
||||
- FE-002
|
||||
- SERVER-003
|
||||
- SERVER-004
|
||||
- SERVER-005
|
||||
- SERVER-006
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 七、7.1 首次连接
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现前端新的连接入口:登录后先选择 Collection,再自动确保默认 Workspace,进入工作台后固定上下文。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 连接表单、连接中状态、错误提示
|
||||
- 读取并回填默认配置
|
||||
- 连接成功后加载 Collection 列表
|
||||
- 登录页确认 Collection 后再进入工作台
|
||||
- 自动查找或创建默认 Workspace
|
||||
- 工作台共享状态中固定 `serverUri`、`collection`、`workspace`
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现工作台三栏布局
|
||||
- 不实现目录树和文件列表
|
||||
- 不支持工作台内切换 Collection
|
||||
- 不提供 Workspace 手动选择控件
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 登录页可以连接 TFS 并展示 Collection 选择
|
||||
- 配置中有上次 Collection 时可默认回填
|
||||
- 用户确认 Collection 后才进入工作台
|
||||
- 工作台内展示固定 Collection 和当前 Workspace
|
||||
- 前端没有 Workspace 选择控件
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/ui/input.tsx`(新增):基础输入框组件。
|
||||
- `mactfsui/app/components/ui/label.tsx`(新增):基础表单标签组件。
|
||||
- `mactfsui/app/lib/tfs/session.ts`(新增):工作台固定上下文 `WorkspaceSession` 类型。
|
||||
- `mactfsui/app/components/app/connect-view.tsx`(新增):连接表单 + Collection 选择 + 确保 Workspace 流程。
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`(新增):工作台占位外壳,展示固定上下文。
|
||||
- `mactfsui/app/routes/home.tsx`:改为编排 检测服务 → 连接 → 工作台 的视图状态机。
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 连接表单覆盖 serverUri / 域 / 用户名 / 密码,第一版固定 `authType = ntlm-explicit`(对齐 PRD 7.1)。
|
||||
- 进入时调用 `GET /api/config` 回填上次配置(含密码与上次 Collection 选中态)。
|
||||
- 点击「连接 TFS」依次调用 `POST /api/session/connect` 与 `GET /api/collections`,期间展示「正在连接 TFS…」「正在加载 Collection…」状态,失败统一展示 `errorMessage || message`。
|
||||
- Collection 以可选列表展示,登录页确认后才调用 `POST /api/workspace/ensure` 确保默认 Workspace,再进入工作台;不提供 Workspace 选择控件。
|
||||
- 进入工作台后由 `home.tsx` 持有固定上下文 `WorkspaceSession`(serverUri / collection / workspace / mappings),工作台外壳展示 Collection 与当前 Workspace,并提供「重新连接」回到连接入口。
|
||||
- 所有后端调用走 `app/lib/api` 的 `api` 端点封装,组件不直接 `fetch`、不直接读 token。
|
||||
|
||||
### 已执行测试
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
### 测试结果
|
||||
|
||||
- `pnpm typecheck`:通过(exit 0)。
|
||||
- 新增组件 ReadLints 无告警。
|
||||
|
||||
### 未执行测试及原因
|
||||
|
||||
- 未实际启动 Electron 做端到端联调:连接 / Collection / Workspace 的真实交互依赖真实 TFS 环境与本地服务,留待 FEATURE 阶段;本任务按前端默认验证执行 `pnpm typecheck`。
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 登录页可以连接 TFS 并展示 Collection 选择:满足。
|
||||
- 配置中有上次 Collection 时可默认回填:满足(回填表单字段与选中 Collection)。
|
||||
- 用户确认 Collection 后才进入工作台:满足(确认后才 ensure workspace 并切换)。
|
||||
- 工作台内展示固定 Collection 和当前 Workspace:满足(工作台外壳展示)。
|
||||
- 前端没有 Workspace 选择控件:满足(仅自动 ensure)。
|
||||
|
||||
### 遗留问题与建议
|
||||
|
||||
- 工作台外壳为占位视图,三栏布局与折叠面板在 FE-004 实现,应将顶部上下文栏收口到正式布局。
|
||||
- 非 Electron / 纯浏览器环境下无 token,连接请求会 401,属预期(目标运行环境为 Electron)。
|
||||
- 连接成功后可考虑直接复用 `GET /api/workspace/context` 统一上下文来源,待 FE-004 布局接入时再决定收口位置。
|
||||
@@ -1,61 +0,0 @@
|
||||
# FE-004 实现 Collection 服务端目录树
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-003
|
||||
- SERVER-005
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.2 左侧目录树
|
||||
|
||||
## 目标
|
||||
|
||||
实现左侧 Collection 下 TFS 服务端目录树。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 加载 Collection
|
||||
- 展示服务端根目录
|
||||
- 展开目录时调用 API 加载子节点
|
||||
- 未映射目录也允许浏览
|
||||
- 选中节点后通知中间列表加载
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做本地状态对比
|
||||
- 不做拖拽 Mapping
|
||||
- 不做复杂搜索
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可浏览 Collection 下目录树
|
||||
- 展开节点不要求本地已映射
|
||||
- 选中目录能更新当前路径
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,103 @@
|
||||
# FE-004 实现工作台布局、折叠面板与同步导航
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-003
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、UI 设计
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现新的工作台外壳,让顶部、左侧、中间、右侧、底部的职责清晰,并让左侧树和中间列表共享路径状态。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 顶部上下文栏
|
||||
- 左侧 Source Tree 面板与收起状态
|
||||
- 中间主工作区骨架
|
||||
- 右侧 Changes 面板与收起状态
|
||||
- 底部 Operation Console 与收起状态
|
||||
- `home.tsx` 统一维护当前路径与同步导航状态
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现具体目录树数据加载
|
||||
- 不实现文件对象右键菜单
|
||||
- 不实现 History、Diff、目录对比弹窗
|
||||
- 不做拖拽面板宽度
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/components/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app)
|
||||
- [mactfsui/app/app.css](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/app.css)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 页面不再是默认模板页
|
||||
- 顶部只保留连接上下文和全局入口
|
||||
- 左侧、右侧、底部支持展开与收起
|
||||
- 当前路径状态由上层统一维护
|
||||
- 工作台结构与设计规范一致
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/app/top-bar.tsx`(新增):顶部上下文栏,展示 Server / Collection / Workspace,含三个面板折叠开关与重新连接入口
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`(重写):三栏工作台外壳,持有面板显隐状态,组合 TopBar / SourceTreePanel / FolderItemsPanel / ChangesPanel / ConsolePanel
|
||||
- `mactfsui/app/components/explorer/source-tree-panel.tsx`(新增):左侧 Source Tree 面板骨架,固定 Collection 根节点 `$/` 可点击导航
|
||||
- `mactfsui/app/components/explorer/folder-items-panel.tsx`(新增):中间主工作区骨架,展示当前路径、Mapping 最长前缀匹配摘要、文件列表占位与选中项摘要占位
|
||||
- `mactfsui/app/components/inspector/changes-panel.tsx`(新增):右侧 Changes 面板骨架
|
||||
- `mactfsui/app/components/logs/console-panel.tsx`(新增):底部 Operation Console 骨架
|
||||
- `mactfsui/app/components/ui/tooltip.tsx` / `separator.tsx` / `scroll-area.tsx`(新增):shadcn 基础组件
|
||||
- `mactfsui/app/lib/tfs/session.ts`:新增 `SERVER_ROOT_PATH` 常量
|
||||
- `mactfsui/app/routes/home.tsx`:统一维护 `selectedServerPath` 与 `handleNavigate`,进入工作台时重置到根路径,并下发给 WorkspaceShell
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 顶部上下文栏只保留连接上下文(服务器 / Collection / Workspace)、面板开关与重新连接
|
||||
- 左侧(280px)、右侧(340px)、底部(180px)面板均可展开 / 收起,开关位于顶部栏,图标 + tooltip
|
||||
- 当前路径状态由 `home.tsx` 统一维护,左树点击根节点与中间列表共享同一路径状态
|
||||
- 中间区域只保留当前路径、Mapping 摘要、列表占位、选中项摘要,未内嵌任何弹窗类内容
|
||||
- 目录树数据加载、右键菜单、各业务弹窗均未实现(按任务边界留给 FE-005+)
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- 浏览器视觉验证(dev server + 临时假会话,验证后已还原代码):三栏布局、面板逐个折叠 / 全部折叠后中间区自适应铺满,均正常;无 VS / SaaS 风格
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 页面不再是默认模板页:满足(工作台为三栏布局骨架)
|
||||
- 顶部只保留连接上下文和全局入口:满足
|
||||
- 左侧、右侧、底部支持展开与收起:满足
|
||||
- 当前路径状态由上层统一维护:满足(`home.tsx` 持有 `selectedServerPath`)
|
||||
- 工作台结构与设计规范一致:满足(按 FRONTEND_SPEC 4.2 / 4.3 与 Agents.md 尺寸建议)
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 真实 TFS 环境下的端到端视觉确认留待 FE-013 / FEATURE 阶段
|
||||
- 面板宽度拖拽明确不在本期范围
|
||||
@@ -1,61 +0,0 @@
|
||||
# FE-005 实现中间目录文件列表
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-004
|
||||
- SERVER-005
|
||||
- SERVER-006
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.3 中间文件列表
|
||||
|
||||
## 目标
|
||||
|
||||
实现点击左侧目录后,中间展示当前目录下一级文件和文件夹。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 展示名称、类型、服务端路径、本地路径、是否已映射、状态
|
||||
- 已映射但本地不存在显示 `notDownloaded / 未下载`
|
||||
- 支持文件 / 目录选中
|
||||
- 根据映射状态展示基础操作入口
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做目录对比详情
|
||||
- 不做 diff 页面
|
||||
- 不做 pending changes 面板
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 目录点击后中间列表更新
|
||||
- 未映射目录不展示本地操作
|
||||
- 已映射未下载项状态清晰
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,116 @@
|
||||
# FE-005 实现服务端目录树与当前目录文件列表
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-003
|
||||
- FE-004
|
||||
- SERVER-005
|
||||
- SERVER-006
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.2 左侧目录树
|
||||
- PRD 六、6.3 中间文件列表
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现固定 Collection 下的服务端目录浏览,并让左侧树和中间当前目录列表保持一致。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 左侧目录树懒加载
|
||||
- 中间目录文件列表加载当前路径下一级内容
|
||||
- 列表展示名称、状态、版本、本地路径、时间等核心列
|
||||
- 已映射 / 未映射 / 未下载状态展示
|
||||
- 单击选中、双击进入目录
|
||||
- 左侧树选择目录与中间列表导航同步
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现右键菜单
|
||||
- 不实现 Mapping、History、目录对比弹窗
|
||||
- 不实现 Diff 展示
|
||||
- 不实现 Pending Changes 面板
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/lib/tfs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可浏览当前 Collection 下的服务端目录树
|
||||
- 左侧目录切换时中间列表同步刷新
|
||||
- 中间双击目录时左侧树同步展开和选中
|
||||
- 未映射目录仍可浏览
|
||||
- 已映射未下载项状态清晰
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/explorer/source-tree-panel.tsx`(重写):目录树懒加载、展开 / 收起、选中导航、随当前路径自动展开祖先链
|
||||
- `mactfsui/app/components/explorer/folder-items-panel.tsx`(重写):当前目录下一级内容表格(名称 / 状态 / 最新版本 / 上次签入时间 / 本地路径),单击选中、双击进入目录、底部选中项摘要
|
||||
- `mactfsui/app/lib/tfs/path.ts`(新增):服务端路径规范化、父路径、祖先链、子树判断
|
||||
- `mactfsui/app/lib/tfs/mapping.ts`(新增):Mapping 最长前缀匹配与本地路径推导
|
||||
- `mactfsui/app/lib/tfs/status.ts`(新增):状态文案与 badge 配色统一收口(含 FRONTEND_SPEC 9.2 全部状态),及 已映射 / 未映射 / 未下载 推导
|
||||
- `mactfsui/electron/main.cjs` / `preload.cjs`:新增 `mactfs:paths-exist` 窄接口,批量检测本地路径存在性
|
||||
- `mactfsui/app/lib/electron/*`:桥接层补充 `pathsExist`(非 Electron 环境返回 null 表示无法检测)
|
||||
- `mactfsui/app/lib/utils.ts`:新增 `formatDateTime`
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:向中间列表下发 `onNavigate`
|
||||
- `mactfsui/app/components/ui/badge.tsx` / `table.tsx`(新增):shadcn 基础组件
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 左侧树固定 Collection,懒加载 `/api/server-tree`,仅展示 folder 节点,加载中有 spinner,空目录视为叶子
|
||||
- 中间列表加载 `/api/server-folder/items`,展示当前路径下一级内容与映射状态
|
||||
- 未映射目录可正常浏览;已映射项推导本地路径,结合 Electron 本地存在性检测区分「已映射 / 未下载」
|
||||
- 单击行选中(底部显示名称、服务端路径、状态、版本),双击目录进入
|
||||
- 左树点击与中间双击共用 `home.tsx` 的 `selectedServerPath`,树会自动逐级加载并展开祖先链实现同步
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 浏览器验证(dev server + 注入假 Electron 桥 + 拦截本地 API 返回模拟数据,未改动源码):
|
||||
- 根目录树与列表渲染正常(已映射 / 未映射 badge、版本、时间、本地路径)
|
||||
- 点击左树 Demo → 中间列表同步切换到 `$/Demo`,`a.txt` 正确显示「未下载」(琥珀色)
|
||||
- 双击中间列表 `src` → 左树自动展开 Demo 并选中 src,列表同步到 `$/Demo/src`
|
||||
- 单击行 → 底部摘要显示选中项信息
|
||||
|
||||
### 未执行测试及原因
|
||||
|
||||
- 未对真实 TFS 服务器做端到端验证(无法在当前环境连接真实 TFS),留待 FE-013 / FEATURE-001
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 可浏览当前 Collection 下的服务端目录树:满足
|
||||
- 左侧目录切换时中间列表同步刷新:满足
|
||||
- 中间双击目录时左侧树同步展开和选中:满足
|
||||
- 未映射目录仍可浏览:满足
|
||||
- 已映射未下载项状态清晰:满足(琥珀色「未下载」badge)
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 本地存在性检测依赖 Electron 桥,纯浏览器开发模式下统一按「已映射」显示
|
||||
- 目录树暂无手动刷新入口,刷新反馈统一在 FE-012 处理
|
||||
@@ -1,62 +0,0 @@
|
||||
# FE-006 实现 Mapping 创建流程
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-005
|
||||
- SERVER-006
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 七、7.2 创建 Mapping
|
||||
|
||||
## 目标
|
||||
|
||||
实现选中服务端目录后映射到本地目录的流程。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 在未映射目录显示“映射到本地”
|
||||
- 选择本地目录
|
||||
- 调用 Mapping API
|
||||
- 让用户选择是否立即 Get Latest
|
||||
- 创建成功后更新 UI 状态
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做多 Profile
|
||||
- 不做复杂 Mapping 编辑
|
||||
- 不强制立即下载
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
- [mactfsui/electron/main.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/main.cjs)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可创建 Mapping
|
||||
- 可选择是否立即 Get Latest
|
||||
- 不立即下载时后续仍可对下级文件 Get Latest
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,120 @@
|
||||
# FE-006 实现对象右键菜单与通用动作模型
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-005
|
||||
- SERVER-007
|
||||
- SERVER-008
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、UI 设计
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
把文件、目录和 Pending Changes 的对象操作统一收口到右键菜单和共享动作模型,避免顶部和列表底部继续堆操作按钮。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 左侧目录树节点右键菜单
|
||||
- 中间文件列表文件 / 文件夹右键菜单
|
||||
- Pending Changes 项右键菜单
|
||||
- 目录对比结果项右键菜单约定
|
||||
- 提炼通用 `FileTarget` / `FileAction` 或等价动作模型
|
||||
- 按 mapped、folder、pending、localPath 等状态控制菜单可用性
|
||||
- 左侧树与中间文件列表目录动作保持同一套展示和置灰规则
|
||||
- 未映射目录保留本地工作区相关动作,但置灰不可点击
|
||||
- 已映射目录不再展示 `映射到本地`
|
||||
- 已映射目录支持 `取消映射`
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现具体弹窗内容
|
||||
- 不实现 Checkin 弹窗
|
||||
- 不实现键盘快捷键
|
||||
- 不实现手动冲突块编辑
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
- [mactfsui/app/lib/tfs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 左侧树、文件列表、Pending Changes 都可打开右键菜单
|
||||
- 顶部不再承担文件对象操作
|
||||
- pending add 文件不显示服务器比较入口
|
||||
- pending edit 文件不能直接执行危险的 Get Latest 覆盖
|
||||
- Checkin 仍只在右侧 Changes 面板提交
|
||||
- 左侧树和中间文件列表同一路径的目录动作一致
|
||||
- 未映射目录的本地工作区相关动作为置灰不可点击
|
||||
- 取消映射后当前浏览位置不跳转,只更新动作可用性
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/lib/tfs/actions.ts`(新增):通用动作模型,`FileTarget` / `FileActionId` / `FileMenuItem`,`makeFileTarget` 推导映射与挂起状态,`buildFileMenu` 按对象状态生成菜单分组与置灰规则
|
||||
- `mactfsui/app/lib/tfs/index.ts`(新增):lib/tfs 统一出口
|
||||
- `mactfsui/app/components/app/file-target-menu.tsx`(新增):右键菜单容器,渲染分组 / 分隔线 / 置灰原因后缀 / 危险态
|
||||
- `mactfsui/app/components/ui/context-menu.tsx`(新增):shadcn 基础组件
|
||||
- `mactfsui/app/components/explorer/source-tree-panel.tsx`:树节点接入右键菜单(与列表同一套规则)
|
||||
- `mactfsui/app/components/explorer/folder-items-panel.tsx`:列表行接入右键菜单,右键同时选中该行,支持挂起状态与刷新令牌
|
||||
- `mactfsui/app/components/inspector/changes-panel.tsx`:实现挂起更改基础列表(图标 / 名称 / 路径 / 状态 badge),项级右键菜单;Included/Excluded 与 Checkin 留给 FE-010
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:持有 pendingChanges 共享状态,集中分发动作;实现「取消映射」(不跳转、仅刷新 mappings),其余动作待后续任务接管;动作失败时顶部细条提示
|
||||
- `mactfsui/app/routes/home.tsx`:新增 `handleMappingsChanged` 更新固定上下文中的映射列表
|
||||
|
||||
### 菜单规则(实测)
|
||||
|
||||
- 目录:获取最新 / 签出 / 挂起删除 / 撤销(未映射全部置灰并标注「未映射到本地」);未映射展示「映射到本地」,已映射根展示「取消映射」;目录对比(未映射置灰)、查看历史
|
||||
- 文件:查看文件 / 与最新版本比较 / 查看历史 + 工作区动作;pendingAdd 文件隐藏比较与历史入口;pendingEdit 文件「获取最新」置灰(防覆盖)、「签出」显示已签出
|
||||
- Pending 项:查看类 + 撤销更改,不重复签出 / 删除入口;Checkin 不进右键菜单,仍只在右侧面板
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 浏览器验证(mock API,未改源码):
|
||||
- pendingEdit 文件菜单:「获取最新」置灰原因「存在挂起更改,先撤销或签入」、「签出编辑」置灰「已签出」
|
||||
- pendingAdd 项菜单:仅「查看文件 / 撤销更改」
|
||||
- 已映射目录菜单:含「取消映射」,无「映射到本地」
|
||||
- 未映射目录菜单:工作区动作全部置灰 + 「映射到本地」可用
|
||||
- 执行取消映射后:当前路径仍为 `$/Demo` 未跳转,列表状态变「未映射」,菜单切换为未映射规则
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 左侧树、文件列表、Pending Changes 都可打开右键菜单:满足
|
||||
- 顶部不再承担文件对象操作:满足(顶部仅面板开关与重新连接)
|
||||
- pending add 文件不显示服务器比较入口:满足
|
||||
- pending edit 文件不能直接执行危险的 Get Latest 覆盖:满足(置灰)
|
||||
- Checkin 仍只在右侧 Changes 面板提交:满足
|
||||
- 左侧树和中间文件列表同一路径的目录动作一致:满足(同一 buildFileMenu)
|
||||
- 未映射目录的本地工作区相关动作为置灰不可点击:满足
|
||||
- 取消映射后当前浏览位置不跳转,只更新动作可用性:满足
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- map / compare / history / viewFile / diff 动作仅分发未实现(FE-007、FE-008 接管)
|
||||
- getLatest / checkout(FE-009)、delete / undo(FE-010)的实际执行与冲突处理待后续任务
|
||||
- 操作执行反馈与日志联动在 FE-012 统一处理
|
||||
@@ -0,0 +1,118 @@
|
||||
# FE-007 实现 Mapping、History 与目录对比弹窗
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-005
|
||||
- FE-006
|
||||
- SERVER-006
|
||||
- SERVER-008
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 七、7.2 创建 Mapping
|
||||
- PRD 七、7.3 目录对比并处理差异
|
||||
- PRD 七、7.5 查看历史和 Diff
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
把 Mapping、History、目录对比从主列表内嵌块迁移到弹窗,释放中间工作区,并统一对象操作入口。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Mapping 创建弹窗
|
||||
- History 弹窗
|
||||
- 目录对比弹窗
|
||||
- 弹窗内的基础筛选、刷新和对象操作入口
|
||||
- Mapping 创建后的本地目录选择与是否立即 Get Latest 选项
|
||||
- Mapping 创建时的目标路径预校验与结果预览
|
||||
- 目录对比默认隐藏 `upToDate`
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现文件查看弹窗
|
||||
- 不实现 Diff 弹窗
|
||||
- 不实现冲突处理弹窗
|
||||
- 不做新路由
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer)
|
||||
- [mactfsui/app/components/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app)
|
||||
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 中间文件列表不再内嵌 Mapping、History、目录对比大块内容
|
||||
- 未映射目录可以通过弹窗创建 Mapping
|
||||
- History 弹窗可展示文件或目录历史
|
||||
- 目录对比弹窗可按状态筛选差异
|
||||
- 目录对比结果项通过右键菜单触发对象动作
|
||||
- Mapping 弹窗选择的是本地父目录,不是最终映射目录
|
||||
- Mapping 最终路径以后端返回结果为准
|
||||
- 如果最终目标目录已存在,弹窗直接提示“已存在,禁止映射”并禁用确认
|
||||
- Mapping 创建成功后可选立即 Get Latest
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/explorer/mapping-dialog.tsx`(新增):Mapping 创建弹窗
|
||||
- `mactfsui/app/components/explorer/history-dialog.tsx`(新增):History 弹窗(文件 / 目录历史、changeset 下钻、版本勾选)
|
||||
- `mactfsui/app/components/explorer/compare-dialog.tsx`(新增):目录对比弹窗(状态筛选、重新对比、结果项右键菜单)
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:新增 DialogState,分发器接管 map / history / compare 动作,Mapping 创建成功后刷新 mappings 与当前目录
|
||||
- `mactfsui/app/components/ui/dialog.tsx` / `checkbox.tsx` / `select.tsx`(新增):shadcn 基础组件(button.tsx 同步 registry 小更新)
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- Mapping 弹窗:展示服务端路径;选择 / 输入本地父目录(Electron 走系统目录选择器);输入防抖后调用 `/api/mappings/check-target` 预校验,最终映射路径以后端返回为准;目标已存在时提示「目标目录已存在,禁止映射」并禁用确认;支持「创建后立即获取最新」;成功后刷新 mappings 与当前目录列表、停留当前浏览位置
|
||||
- History 弹窗:文件 / 目录历史表格(changeset / 类型 / 作者 / 时间 / 注释);目录历史点击 changeset 编号或双击行下钻影响文件列表并可返回;文件历史可勾选两个版本(对比按钮在 FE-008 提供 Diff 后启用,现按 onDiffRevisions 可选注入)
|
||||
- 目录对比弹窗:递归对比已映射目录;默认隐藏 `upToDate`,「显示最新项」开关可恢复;按结果中出现的状态生成可点选筛选 chips;支持重新对比;结果项复用 `FileTargetMenu` 统一右键菜单
|
||||
- 中间文件列表保持纯浏览,无任何内嵌 Mapping / History / 对比大块内容
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 浏览器验证(mock API,未改源码):
|
||||
- 未映射目录 → 映射弹窗 → 填入父目录 → 预览最终路径 `/Users/fenghp/tfs/Tools`
|
||||
- 父目录含已存在目标 → 提示「目标目录已存在,禁止映射」且确认按钮禁用
|
||||
- 正常创建 → 弹窗关闭、该目录菜单变为已映射规则(含取消映射)
|
||||
- 目录历史 → changeset 102 下钻显示 2 个文件 → 返回列表
|
||||
- 目录对比默认 3/4 项(隐藏 upToDate);点状态 chip 过滤到 1 项;开「显示最新项」后 4 项;结果行右键弹出统一菜单
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 中间列表不再内嵌大块内容:满足
|
||||
- 未映射目录可通过弹窗创建 Mapping:满足
|
||||
- History 弹窗可展示文件或目录历史:满足
|
||||
- 目录对比可按状态筛选差异:满足
|
||||
- 对比结果项通过右键菜单触发动作:满足
|
||||
- Mapping 选择的是本地父目录:满足
|
||||
- 最终路径以后端返回为准:满足
|
||||
- 目标已存在提示并禁用确认:满足
|
||||
- 创建成功后可选立即 Get Latest:满足
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 文件历史「对比所选版本」按钮依赖 FE-008 的 Diff 弹窗注入 `onDiffRevisions` 后生效
|
||||
- 对比结果中的 getLatest / checkout / undo 等动作执行待 FE-009 / FE-010
|
||||
@@ -1,62 +0,0 @@
|
||||
# FE-007 实现目录对比页面
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-005
|
||||
- SERVER-008
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.4 目录对比
|
||||
|
||||
## 目标
|
||||
|
||||
实现已映射目录的目录对比结果展示和差异文件勾选。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 对已映射目录显示“目录对比”
|
||||
- 请求期间展示 TFS 网络等待提示
|
||||
- 展示差异文件列表
|
||||
- 默认隐藏 upToDate
|
||||
- 支持勾选差异文件
|
||||
- 根据状态显示可用操作
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做自动修复全部
|
||||
- 不做实时 watcher
|
||||
- 不做目录级完整 diff
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可展示 localModified、localOnly、notDownloaded、pending 等状态
|
||||
- 可勾选指定差异文件
|
||||
- 未映射目录不能执行对比
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -1,62 +0,0 @@
|
||||
# FE-008 实现右侧挂起更改面板
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-003
|
||||
- SERVER-007
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.8 Pending Changes
|
||||
|
||||
## 目标
|
||||
|
||||
实现右侧 Pending Changes 面板,并支持 Included / Excluded 分组。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 查询 pending changes
|
||||
- 展示 Included Changes
|
||||
- 展示 Excluded Changes
|
||||
- 文件可在两组之间移动
|
||||
- 状态只保存在当前 UI 会话
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不持久化 Included / Excluded
|
||||
- 不和左侧 / 中间选中文件联动
|
||||
- 不做 Work Item
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- pending changes 能显示在右侧
|
||||
- 默认可进入 Included
|
||||
- 用户可移动到 Excluded
|
||||
- 签入只读取 Included 数据
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,105 @@
|
||||
# FE-008 实现文件查看与 Diff 弹窗
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-002
|
||||
- FE-006
|
||||
- FE-007
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.11 Diff
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
提供文件只读查看和文本 Diff 能力,支持从文件列表、History、Pending Changes、目录对比结果进入。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 文件查看弹窗
|
||||
- 本地文件 vs 服务器 latest Diff 弹窗
|
||||
- 两个历史版本 Diff 弹窗
|
||||
- 来源标识、大小、编码、搜索、行号、差异高亮
|
||||
- 大文件、二进制文件、非 Mapping 路径的提示
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不提供文件编辑能力
|
||||
- 不实现 Monaco / CodeMirror 必选升级
|
||||
- 不实现三方合并编辑
|
||||
- 不做多标签页 Diff
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer)
|
||||
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||
- [mactfsui/package.json](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/package.json)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 已映射文件可查看本地内容
|
||||
- 未映射文件可查看服务器 latest 内容
|
||||
- 可从文件列表、History 或目录对比进入 Diff
|
||||
- 大于 5MB 文件不直接渲染内容
|
||||
- 非 Mapping 路径的本地内容读取被拒绝
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
../tfsIntegration/gradlew build
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/explorer/file-view-dialog.tsx`(新增):文件查看弹窗
|
||||
- `mactfsui/app/components/explorer/diff-dialog.tsx`(新增):文本 Diff 弹窗(本地 vs latest / 历史版本对比共用)
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:DialogState 扩展 viewFile / diff,分发器接管 `viewFile`、`diffLocalLatest`,History 弹窗注入 `onDiffRevisions` 进入历史版本 Diff
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 文件查看:本地文件 / 服务器 latest 来源切换(未映射时本地禁用并提示),展示大小与版本号,行号 + 搜索高亮(命中行数 badge);二进制、超大文件不渲染内容并给出明确提示;非 Mapping 路径由服务端拒绝并在弹窗内展示错误信息
|
||||
- Diff:后端前缀行(` `/`-`/`+`)解析为双侧行号结构;删除红底、新增绿底、搜索命中琥珀高亮;展示 source/target 标签与差异行数;支持「仅看差异」;两个入口——文件右键「与最新版本比较」、History 弹窗勾选两个版本「对比所选版本」
|
||||
- 未实现编辑能力,无 Monaco / CodeMirror 引入,无新依赖
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- 服务端无改动,未重跑 gradle build(FE-002 后接口契约未变)
|
||||
- Playwright 浏览器验证(mock API,未改源码):
|
||||
- 未映射文件 `readme.md` → 默认服务器 latest 内容,行号正常
|
||||
- 6MB 文件 → 「文件过大(6.0 MB),不直接渲染内容」
|
||||
- 已映射文件 `a.txt` → 默认本地内容,搜索 hello 命中 1 行
|
||||
- 「与最新版本比较」→ 双行号 + 红绿差异高亮 + 标签 + 2 行差异
|
||||
- 文件历史勾选 C88 / C102 → 「对比所选版本」→ 历史版本 Diff 正常
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 已映射文件可查看本地内容:满足
|
||||
- 未映射文件可查看服务器 latest 内容:满足
|
||||
- 可从文件列表、History 或目录对比进入 Diff:满足(目录对比结果项与列表共用同一右键菜单)
|
||||
- 大于 5MB 文件不直接渲染内容:满足(依据服务端 `tooLarge`)
|
||||
- 非 Mapping 路径的本地内容读取被拒绝:满足(服务端校验,前端展示错误)
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 编码信息后端契约未提供,界面未展示编码字段
|
||||
- Diff 为逐行朴素对比(与后端 `buildTextDiff` 算法一致),未做块级折叠
|
||||
@@ -0,0 +1,108 @@
|
||||
# FE-009 实现 Get Latest 与 Checkout 冲突处理
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-002
|
||||
- FE-006
|
||||
- FE-008
|
||||
- SERVER-007
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 七、7.3 目录对比并处理差异
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
把 Get Latest 和 Checkout 的冲突流程收口成统一体验,支持目录递归、跳过项提示和逐文件选择。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Get Latest 递归执行入口
|
||||
- Checkout 自动先执行 Get Latest
|
||||
- 冲突弹窗
|
||||
- 跳过 pending edit 子项提示
|
||||
- 冲突文件逐项选择服务器版本或保留本地版本
|
||||
- 冲突文件 Diff 入口
|
||||
- 批量应用选择
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现手动冲突块编辑
|
||||
- 不对二进制文件做自动合并编辑
|
||||
- 不做后台异步任务模型
|
||||
- 不实现复杂重试机制
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/explorer](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer)
|
||||
- [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)
|
||||
- [mactfs/src/main/java/com/mydev/mactfs/server](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 文件夹 Get Latest 可以递归执行
|
||||
- Checkout 能复用统一冲突弹窗
|
||||
- 冲突文件可以打开 Diff
|
||||
- 跳过项和冲突项在 UI 上有明确反馈
|
||||
- 应用服务器版本或保留本地版本后,前端能正确刷新状态
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||
../tfsIntegration/gradlew build
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/explorer/conflict-dialog.tsx`(新增):统一冲突弹窗
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:实现 `runGetLatest` / `runCheckout` 执行编排;动作分发器接管 getLatest / checkout;新增顶部通知条(info / error)与忙碌指示;DialogState 增加 conflicts;冲突处理完成后刷新当前目录与挂起更改
|
||||
- 服务端无改动(SERVER-007 / FE-002 已提供 get-latest、checkout、conflicts、conflicts/apply 接口)
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 获取最新:目录右键「获取最新」按递归执行(文件不递归);完成后顶部通知展示「更新 / 冲突 / 跳过失败」计数;冲突数 > 0 时自动打开冲突弹窗并刷新列表
|
||||
- 签出:自动先执行 Get Latest,若有冲突则提示「先处理冲突再签出」并打开冲突弹窗、不执行签出;无冲突则执行 checkout,跳过项(如 pending edit 子项)在通知条中列出前 3 条
|
||||
- 冲突弹窗:加载范围内未解决冲突;每行 Select 选择「暂不处理 / 采用服务器版本 / 保留本地版本」;「全部采用服务器 / 全部保留本地」批量设置;「应用选择(N)」逐项调用 `/api/conflicts/apply`,失败项标注可重试;全部解决后自动关闭并刷新;冲突行内置 Diff 按钮(本地 vs latest,叠加弹窗,不关闭冲突列表)
|
||||
- 「稍后处理」可关闭弹窗;若期间已有成功应用,关闭时仍会触发刷新
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- 服务端无改动,未重跑 gradle build
|
||||
- Playwright 浏览器验证(mock API,未改源码):
|
||||
- 目录「获取最新」→ 自动弹出冲突弹窗(2 个冲突),`recursive=true` 调用 1 次
|
||||
- 冲突行 Diff → 在冲突弹窗之上叠加 Diff 弹窗,内容正确
|
||||
- 「全部采用服务器」→「应用选择(2)」→ 全部成功 → 弹窗关闭,顶部通知「冲突处理完成,已刷新状态」
|
||||
- 冲突解决后对目录「签出编辑」→ 自动先 Get Latest(无冲突)→ checkout → 通知「签出完成:2 项,跳过 1 项($/Demo/b.txt 已签出,跳过)」
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 文件夹 Get Latest 可以递归执行:满足
|
||||
- Checkout 能复用统一冲突弹窗:满足
|
||||
- 冲突文件可以打开 Diff:满足
|
||||
- 跳过项和冲突项在 UI 上有明确反馈:满足(顶部通知 + 弹窗计数)
|
||||
- 应用取舍后前端能正确刷新状态:满足(目录列表 + 挂起更改)
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 操作通知目前为顶部细条文本,FE-012 会把执行反馈与操作日志面板联动
|
||||
- 真实 TFS 冲突场景验证留待 FEATURE-002 / FEATURE-003
|
||||
@@ -1,65 +0,0 @@
|
||||
# FE-009 实现文件操作交互
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-007
|
||||
- FE-008
|
||||
- SERVER-007
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.5 Checkout
|
||||
- PRD 六、6.6 Add
|
||||
- PRD 六、6.7 Delete
|
||||
|
||||
## 目标
|
||||
|
||||
实现 checkout、add、delete、undo、get latest 的 UI 操作入口。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 已下载文件可 checkout
|
||||
- 已下载目录可递归 checkout 并二次确认
|
||||
- localOnly 可 add
|
||||
- 已版本控制文件可 pending delete
|
||||
- pending change 可 undo
|
||||
- notDownloaded 可 Get Latest
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做自动 checkout
|
||||
- 不做未下载服务端文件删除
|
||||
- 不做冲突解决器
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 目录 checkout 前显示影响数量确认
|
||||
- 不同状态展示正确操作
|
||||
- 操作后刷新相关列表或提示用户重新对比
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,104 @@
|
||||
# FE-010 实现 Pending Changes 与 Checkin 体验
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-006
|
||||
- FE-008
|
||||
- SERVER-007
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.8 Pending Changes
|
||||
- PRD 六、6.9 Checkin
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
保留右侧 Changes 面板作为签入主入口,并把 Pending Changes 的查看、比较、撤销和 Included / Excluded 体验补齐。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- Pending Changes 列表
|
||||
- Included / Excluded 分组
|
||||
- comment 输入与必填校验
|
||||
- 签入按钮、签入中状态、成功后的 changeset 反馈
|
||||
- Pending Changes 项右键菜单
|
||||
- 成功签入后的目录、树节点、Pending 刷新策略
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现 Checkin 弹窗
|
||||
- 不做 Work Item
|
||||
- 不做 Check-in Policy UI
|
||||
- 不做 Excluded 持久化
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/inspector](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector)
|
||||
- [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 保留在右侧 Changes 面板
|
||||
- comment 为空时不能签入
|
||||
- Included / Excluded 可以维护
|
||||
- pending add 文件没有服务器比较入口
|
||||
- Checkin 成功后能刷新 Pending Changes 和当前目录状态
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/inspector/changes-panel.tsx`(重写):Included / Excluded 分组列表、勾选切换、comment 输入与必填校验、签入按钮与签入中状态
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:新增 `excludedKeys` 共享状态(刷新时自动清理悬挂键)、`handleCheckin`(只提交 Included、成功展示 changeset、刷新挂起与目录);分发器接管 `delete` / `undo` 实际执行
|
||||
- `mactfsui/app/components/ui/textarea.tsx`(新增):shadcn 基础组件
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 右侧 Changes 面板保持签入唯一入口(不使用弹窗);列表按 Included / Excluded 分组,勾选即切换归属,Excluded 项淡化显示
|
||||
- comment 必填:注释为空或 Included 为空或签入中时按钮禁用,空注释时有「填写注释后才能签入」提示
|
||||
- 签入:`POST /api/checkin` 只带 Included 路径;成功后顶部通知「签入成功:changeset N,提交 M 项」、清空注释、刷新挂起更改与当前目录列表(树节点为纯目录结构无需变更)
|
||||
- Pending 项右键菜单沿用统一动作模型:pendingAdd 文件无「与最新版本比较 / 查看历史」入口;「撤销更改」「挂起删除」现已实际执行(`/api/files/undo`、`/api/files/delete`)并刷新
|
||||
- Excluded 不持久化(按任务边界),刷新后自动剔除已不存在的项
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 浏览器验证(mock API,未改源码):
|
||||
- 3 项挂起默认全部 Included;无注释时签入按钮禁用
|
||||
- 勾掉 new.txt → 移入 Excluded 分组,「将签入 2 项」
|
||||
- 填注释签入 → 请求只含 2 个 Included 路径 → 通知「签入成功:changeset 103,提交 2 项」→ 列表只剩 Excluded 的 new.txt
|
||||
- pendingAdd 项右键菜单仅「查看文件 / 撤销更改」→ 点撤销 → 通知「已撤销 1 项更改」、列表清空
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- Checkin 保留在右侧 Changes 面板:满足
|
||||
- comment 为空时不能签入:满足
|
||||
- Included / Excluded 可以维护:满足
|
||||
- pending add 文件没有服务器比较入口:满足
|
||||
- Checkin 成功后能刷新 Pending Changes 和当前目录状态:满足
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 真实 TFS 签入验证留待 FEATURE-004
|
||||
- 操作反馈与日志面板联动在 FE-012 统一处理
|
||||
@@ -1,64 +0,0 @@
|
||||
# FE-010 实现签入流程
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-008
|
||||
- SERVER-007
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.9 Checkin
|
||||
|
||||
## 目标
|
||||
|
||||
实现右侧面板中的签入流程。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- comment 输入
|
||||
- comment 必填校验
|
||||
- 签入按钮
|
||||
- 只提交 Included Changes
|
||||
- 签入中状态
|
||||
- 成功显示 changeset
|
||||
- 失败显示 TFS 错误
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做 Work Item
|
||||
- 不做 Check-in Policy UI
|
||||
- 不做 Excluded 持久化
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- comment 为空不能签入
|
||||
- Excluded 不参与签入
|
||||
- 成功后 Included 清空
|
||||
- Excluded 保留在 UI 会话内
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -1,62 +0,0 @@
|
||||
# FE-011 实现历史记录界面
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-005
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.10 History
|
||||
|
||||
## 目标
|
||||
|
||||
实现文件历史、目录历史和 changeset 文件列表界面。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 文件历史最近 100 条
|
||||
- 目录历史最近 100 条
|
||||
- 展示 changeset、作者、时间、comment
|
||||
- 点击目录历史 changeset 展示影响文件列表
|
||||
- 从历史文件列表进入 diff
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做筛选
|
||||
- 不做 Work Item
|
||||
- 不做历史树
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 文件可查看历史
|
||||
- 目录可查看历史
|
||||
- 目录历史 changeset 可展开文件列表
|
||||
- 可选择历史版本进入 diff
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,96 @@
|
||||
# FE-011 拆分 FolderItemsPanel 与收口动作编排
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P1
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-007
|
||||
- FE-008
|
||||
- FE-009
|
||||
- FE-010
|
||||
|
||||
## 需求来源
|
||||
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
在主要交互落地后,把中间主列表的职责收口到目录项展示、选择和进入目录,避免继续膨胀成总控组件。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- `FolderItemsPanel` 只保留目录项展示所需状态
|
||||
- 移除内嵌 Mapping、History、Compare、Diff、冲突块 JSX
|
||||
- 将弹窗开关和动作编排上提到 `home.tsx` 或轻量 hook
|
||||
- 收口复用的动作类型和参数结构
|
||||
- 保持不引入大型状态库
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不重构无关 API client
|
||||
- 不引入 Redux、Zustand、TanStack Query
|
||||
- 不改写已稳定的业务规则
|
||||
- 不做样式大改版
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [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/hooks](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks)
|
||||
- [mactfsui/app/lib](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `FolderItemsPanel` 不再承载大块临时功能区
|
||||
- 中间主列表只负责当前目录数据与基础状态
|
||||
- 弹窗状态和对象动作编排更清晰
|
||||
- 不引入新的大型状态管理库
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/hooks/use-pending-changes.ts`(新增):挂起更改共享状态收口(列表 / 刷新 / Excluded 维护 / pendingByServerPath)
|
||||
- `mactfsui/app/hooks/use-file-actions.ts`(新增):对象动作编排收口(弹窗状态 `WorkspaceDialogState`、通知 `ActionNotice`、getLatest / checkout / delete / undo / unmap 执行、checkin、Mapping 创建与冲突解决回调)
|
||||
- `mactfsui/app/components/app/workspace-dialogs.tsx`(新增):业务弹窗统一出口,无业务状态
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`(精简 441 → 135 行):只负责面板显隐、刷新令牌与组件组合
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- `FolderItemsPanel` 自 FE-005 起即只承载目录项展示、选择、双击进入与行级菜单触发,本任务核对后无内嵌 Mapping / History / Compare / Diff / 冲突 JSX,无需再拆
|
||||
- 动作编排从 WorkspaceShell 上提到轻量 hook(符合 FRONTEND_SPEC 5.5「home.tsx 或轻量 hook」),动作类型与参数结构沿用 `lib/tfs/actions.ts` 的 `FileTarget` / `FileActionId`,无新增重复定义
|
||||
- 弹窗开关统一为 `WorkspaceDialogState` 单一状态,渲染收口到 `WorkspaceDialogs`
|
||||
- 未引入 Redux / Zustand / TanStack Query,未改写业务规则,无样式改版
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 回归(mock API):获取最新通知、未映射目录映射弹窗、历史弹窗、签入成功(changeset 200)全部正常,与重构前行为一致
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- `FolderItemsPanel` 不再承载大块临时功能区:满足
|
||||
- 中间主列表只负责当前目录数据与基础状态:满足
|
||||
- 弹窗状态和对象动作编排更清晰:满足(两个 hook + 弹窗出口组件)
|
||||
- 不引入新的大型状态管理库:满足
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 无
|
||||
@@ -0,0 +1,100 @@
|
||||
# FE-012 实现操作日志面板与刷新反馈
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P1
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-004
|
||||
- FE-005
|
||||
- FE-010
|
||||
- SERVER-010
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 四、4.6 操作日志
|
||||
- PRD 六、UI 设计
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
实现底部操作台和统一刷新反馈,让长耗时操作和失败结果在 UI 中可追踪。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 底部日志面板
|
||||
- 每次主要操作的日志展示
|
||||
- 开始时间、结束时间、耗时、成功 / 失败、错误摘要
|
||||
- 长操作期间的显式 loading 文案
|
||||
- 关键操作后的最小刷新策略落地
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不实现日志导出
|
||||
- 不做实时流式日志
|
||||
- 不做异步 Job 与取消按钮
|
||||
- 不做全局通知中心
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app/components/logs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/logs)
|
||||
- [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)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 主要操作都有对应日志记录
|
||||
- Get Latest、Compare、Checkin 等长操作有明确执行中提示
|
||||
- 失败时可以看到可读错误摘要
|
||||
- 成功后刷新粒度与设计规范一致
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/app/components/logs/console-panel.tsx`(重写):底部操作日志面板
|
||||
- `mactfsui/app/hooks/use-file-actions.ts`:动作编排依赖新增 `refreshLogs`,在 unmap / getLatest / checkout / delete / undo / checkin / 冲突解决后触发日志刷新
|
||||
- `mactfsui/app/components/app/workspace-shell.tsx`:新增 `logsRefreshToken`,向 Console 透传忙碌状态与执行中文案
|
||||
|
||||
### 实际实现内容
|
||||
|
||||
- 日志面板:拉取 `/api/logs`,按开始时间倒序展示——成功 / 失败图标、操作名(等宽)、摘要、失败错误摘要(红色)、开始时间、耗时(ms / s 自适应);手动刷新按钮;空态与加载态
|
||||
- 自动刷新:每次主要操作(获取最新、签出、挂起删除、撤销、签入、取消映射、冲突应用)完成后日志自动重载
|
||||
- 执行中提示:长操作期间 Console 头部展示 spinner + 当前执行文案(如「正在获取最新…」),顶部通知条同步显示
|
||||
- 最小刷新策略已全部落地(连接→config/collections、确认 Collection→workspace/树根、Mapping 增删→mappings+当前目录、Get Latest→当前目录+日志、Checkout/Delete/Undo→pending、Checkin→pending+当前目录),本任务补齐日志维度
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- Playwright 浏览器验证(mock API,含 1.5s 延迟模拟长操作):
|
||||
- 初始加载展示历史日志(connect 2.3 s)
|
||||
- 慢速「获取最新」期间 Console 头部出现「正在获取最新…」执行中提示
|
||||
- 完成后日志自动刷新出现 getLatest 成功记录(1.5 s)
|
||||
- 签出失败 → 红色失败记录 + 错误摘要「服务器拒绝:权限不足」,顶部通知条同步展示
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- 主要操作都有对应日志记录:满足(服务端 SERVER-010 记录 + 前端自动刷新)
|
||||
- Get Latest、Compare、Checkin 等长操作有明确执行中提示:满足
|
||||
- 失败时可以看到可读错误摘要:满足
|
||||
- 成功后刷新粒度与设计规范一致:满足
|
||||
|
||||
### 遗留问题
|
||||
|
||||
- 日志导出与故障排查(RELEASE-004)不在本期范围
|
||||
@@ -1,61 +0,0 @@
|
||||
# FE-012 实现文件 Diff 界面
|
||||
|
||||
## 状态
|
||||
|
||||
todo
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-011
|
||||
- SERVER-009
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 六、6.11 Diff
|
||||
|
||||
## 目标
|
||||
|
||||
实现本地 vs latest 和两个历史版本之间的文本 diff 展示。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 打开文件 diff 页面或面板
|
||||
- 展示本地文件 vs 服务器 latest
|
||||
- 展示历史版本 A vs 历史版本 B
|
||||
- 文本 diff 左右对比
|
||||
- 二进制或不支持文件显示提示
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不做三方 merge
|
||||
- 不做二进制可视化
|
||||
- 不做冲突解决器
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui/app](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 可从目录对比结果打开 diff
|
||||
- 可从历史记录选择两个版本打开 diff
|
||||
- 大文件或不支持类型有明确提示
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
待完成后填写。
|
||||
@@ -0,0 +1,114 @@
|
||||
# FE-013 前端阶段联调验收基线
|
||||
|
||||
## 状态
|
||||
|
||||
done
|
||||
|
||||
## 优先级
|
||||
|
||||
P0
|
||||
|
||||
## 所属阶段
|
||||
|
||||
frontend
|
||||
|
||||
## 依赖任务
|
||||
|
||||
- FE-001
|
||||
- FE-002
|
||||
- FE-003
|
||||
- FE-004
|
||||
- FE-005
|
||||
- FE-006
|
||||
- FE-007
|
||||
- FE-008
|
||||
- FE-009
|
||||
- FE-010
|
||||
- FE-011
|
||||
- FE-012
|
||||
|
||||
## 需求来源
|
||||
|
||||
- PRD 十、验收标准
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 目标
|
||||
|
||||
在进入真实 Feature E2E 前,对前端阶段的结构、交互、接口对接和 Electron 开发链路做一次统一联调基线检查。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 校验连接页、固定 Collection / Workspace 上下文
|
||||
- 校验工作台折叠布局和同步导航
|
||||
- 校验右键菜单、弹窗和冲突流程的串联
|
||||
- 校验 Pending Changes、Checkin、操作日志的刷新反馈
|
||||
- 校验 Electron 开发模式与本地 API 调试链路
|
||||
|
||||
## 不在范围
|
||||
|
||||
- 不替代真实 TFS 端到端验收
|
||||
- 不做 Release 打包
|
||||
- 不默认执行会修改真实 TFS 状态的写操作
|
||||
- 不做自动化回归平台接入
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- [mactfsui](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui)
|
||||
- [task/frontend](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend)
|
||||
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `FE-001` 到 `FE-012` 的验收项都已有对应实现和文档约束
|
||||
- `pnpm typecheck` 通过
|
||||
- `pnpm electron:dev` 至少能进入连接页和工作台外壳
|
||||
- 固定上下文、折叠布局、右键菜单、弹窗、日志刷新在开发环境可串联
|
||||
- 尚未进入真实 TFS E2E 的风险被明确记录
|
||||
|
||||
## 测试方式
|
||||
|
||||
```bash
|
||||
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||
pnpm typecheck
|
||||
pnpm electron:dev
|
||||
```
|
||||
|
||||
## 完成记录
|
||||
|
||||
### 基线检查结果
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| FE-001 ~ FE-012 验收项实现与完成记录 | 全部 done,各任务文件含实现 / 测试 / 验收对照 |
|
||||
| `pnpm typecheck` | 通过 |
|
||||
| 本地 API 调试链路 | `~/.mactfs/server-token` 存在;带 token 调 `/api/health` 返回 `status: ok, connected: true` |
|
||||
| `pnpm electron:dev` | dev server 启动于 5173,Electron 主进程 + Renderer / GPU / Network Helper 全部拉起并加载页面,运行 30s 无报错后正常退出 |
|
||||
| 固定上下文 / 折叠布局 / 同步导航 | FE-004 / FE-005 已验证(mock 浏览器联调) |
|
||||
| 右键菜单 → 弹窗 → 冲突流程串联 | FE-006 ~ FE-009 已验证(菜单置灰规则、Mapping 预校验、冲突批量取舍 + Diff 叠加) |
|
||||
| Pending / Checkin / 日志刷新反馈 | FE-010 / FE-012 已验证(Included-Excluded、changeset 反馈、日志自动刷新、执行中提示) |
|
||||
|
||||
### 实际修改文件
|
||||
|
||||
- `mactfsui/package.json`:`electron:dev` 的 `wait-on` 前增加 `NO_PROXY=localhost,127.0.0.1` —— 基线检查发现系统设有 `http_proxy` 时 wait-on 会把 localhost 探活请求发往代理导致 Electron 永远不启动,修复后代理环境也能正常进入开发模式
|
||||
|
||||
### 已执行测试
|
||||
|
||||
- `pnpm typecheck`:通过
|
||||
- 带 token 的 `/api/health` 调试请求:成功
|
||||
- `pnpm electron:dev`:Electron 应用进程成功启动并加载 dev server(冒烟后已清理全部进程)
|
||||
|
||||
### 风险记录(尚未进入真实 TFS E2E)
|
||||
|
||||
1. 所有交互验证基于 mock API(Playwright 拦截),真实 TFS 的网络延迟、超时、大目录、特殊字符路径行为未验证 —— 由 FEATURE-001 ~ 005 覆盖
|
||||
2. 冲突流程依赖服务端 `/api/conflicts` 在真实冲突场景下的行为,仅验证了契约层
|
||||
3. 「已映射未下载」状态依赖 Electron `pathsExist`,纯浏览器开发模式统一按已映射展示
|
||||
4. Checkin / Delete / Undo 等写操作未对真实 TFS 执行(遵守任务边界),留待 FEATURE-004
|
||||
5. Electron 生产打包(非 dev 模式)链路属于 RELEASE-002 范围
|
||||
|
||||
### 是否满足验收标准
|
||||
|
||||
- FE-001 到 FE-012 的验收项都已有对应实现和文档约束:满足
|
||||
- `pnpm typecheck` 通过:满足
|
||||
- `pnpm electron:dev` 至少能进入连接页和工作台外壳:满足(进程级冒烟 + 本地服务健康)
|
||||
- 固定上下文、折叠布局、右键菜单、弹窗、日志刷新在开发环境可串联:满足
|
||||
- 尚未进入真实 TFS E2E 的风险被明确记录:满足(见上)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user