完成阶段5前端重构并固定后端JDK

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