7 Commits
Author SHA1 Message Date
fenghpandCursor 646665f3a3 refactor: 更新挂起更改处理逻辑与组件交互
- 将 `onToggleExcluded` 方法重命名为 `onSetExcluded`,并更新相关调用,支持批量设置挂起更改的 Included/Excluded 状态。
- 在 `ChangesPanel` 组件中实现目录树结构,支持折叠与三态复选功能,提升用户体验。
- 修改 `Checkbox` 组件以支持不确定状态的显示,增强视觉反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 11:48:49 +08:00
fenghpandCursor 24de23ee61 feat: 增加父进程看护机制与操作来源标识
- 新增 startParentWatchdog 方法,监控宿主进程状态,确保后端在宿主崩溃时自动退出,避免端口占用。
- 在 API 请求中引入 X-MacTFS-Source 请求头,区分来自 MCP(AI)与手动操作的请求,增强操作日志的可追溯性。
- 更新操作日志结构,记录操作来源,支持在前端进行来源过滤展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 14:15:08 +08:00
fenghp 3cf42cdbaf 添加 mcp 说明 2026-06-23 17:49:58 +08:00
fenghpandCursor b03ca2c4d5 fix: 补上 main.cjs 缺失的 MCP_HOST 常量,修复打包版主进程启动崩溃
新增的 MCP_SSE_URL / MCP_HEALTHZ_URL 引用了未定义的 MCP_HOST,
导致主进程模块加载即抛 ReferenceError(开发态未触发,仅安装版暴露)。
补上 const MCP_HOST = "127.0.0.1"。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:25:23 +08:00
fenghpandCursor 03eb61520f build: 将 mactfs-mcp 以 esbuild 单文件打进 .app
- mactfs-mcp 增加 esbuild 与 build:bundle 脚本,产出单文件 dist-bundle/index.cjs
  (免带 node_modules),用 .cjs 规避 type:module 下的 ESM 误判
- mactfsui extraResources 增加 { from: ../mactfs-mcp/dist-bundle, to: mcp },
  dist 链路加入 prepare:mcp(自动 install + build:bundle)
- Electron resolveMcpEntry 打包态改为命中 Resources/mcp/index.cjs
- 同步更新 mactfs-mcp/README 与打包构建文档(移除「MCP 未打进 .app」的旧限制)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:17:00 +08:00
fenghpandCursor 9018a6c90e feat: 顶栏新增 TFS 连接测试与 MCP 状态/日志查看
- 后端新增只读端点 POST /api/session/test:用已存配置真正回连一次 TFS,
  验证服务可达与认证有效,不改动会话/配置状态,返回耗时与 Collection 数
- 顶栏加「测试 TFS 连接」按钮:手动触发,图标按结果变色(绿=正常/红=断开),
  结果走右上角 toast
- Electron 将 MCP 子进程 stdio 由 ignore 改为管道捕获,环形缓冲保留最近 500 行;
  跟踪运行状态(pid/运行时长/重启次数/退出码)并探活 /healthz,新增 get-mcp-status
  / get-mcp-logs 两个 IPC,经 preload 与 lib/electron 暴露
- 顶栏加「MCP 服务状态」入口,新增 McpStatusDialog 展示状态摘要与运行日志
  (打开期间每 2 秒刷新、自动滚到底部)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:05:19 +08:00
fenghpandCursor e9f942e695 feat: 新增 mactfs-mcp 服务(AI 改文件前自动签出/加入)
- mactfs-mcp:通过 SSE 暴露 tfs_checkout / tfs_add,转调本机 Java 后端操作 TFS
- 后端 MacTfsCoreService/MacTfsServer 新增文件状态查询等接口,供 MCP 执行前校验
- Electron 随客户端拉起并回收 MCP 子进程(父死自杀、崩溃自动重拉)
- tfs_checkout 默认行为:新文件自动 add、本地落后自动 get-latest 后再签出
  (可分别用 autoAdd=false / getLatestIfStale=false 关闭;dryRun 无副作用)
- 新增 docs/打包构建与MCP使用指南.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 10:31:31 +08:00
32 changed files with 4043 additions and 109 deletions
+255
View File
@@ -0,0 +1,255 @@
# macTFS 打包构建 & MCP 使用配置指南
本文分两大块:
1. **打包构建**:怎么把整个项目从源码构建成可分发的 macOS 桌面应用(`.app` / `.dmg`)。
2. **MCP 工具**:`mactfs-mcp` 能做什么、怎么在 AI 客户端(Cursor / Claude 等)里配置使用。
> 日常「启动 / 停止 / 重启」服务的操作见根目录 [`服务启停指南.md`](../服务启停指南.md),本文不重复。
---
## 一、项目组成与端口
运行态由三个进程组成,全部只监听本机(`127.0.0.1`):
| 进程 | 是什么 | 监听 |
|---|---|---|
| Java 后端 | `com.mydev.mactfs.server.MacTfsServer`,复用微软 TFS SDK 与 TFS 通信 | `127.0.0.1:38765` |
| Electron 前端 | 桌面界面(开发态由 vite 提供页面 `localhost:5173`) | 窗口 |
| mactfs-mcp | 给 AI 客户端用的 SSE 服务(改文件前自动签出) | `127.0.0.1:38766/sse` |
**进程关系(重点)**:
```
Electron 客户端(你启动的)
├─ 自动拉起 Java 后端 127.0.0.1:38765
└─ 自动拉起 mactfs-mcp 127.0.0.1:38766/sse
退出 Electron → 两个子进程一并回收(孤儿进程会被「父死自杀」看护清掉)
```
- 后端 / MCP 都由 Electron 按需拉起、随之退出,所以**日常只需启动前端**。
- MCP 还会看护父进程 PID,Electron 异常退出时自杀;崩溃后 Electron 会自动重拉(2 秒后)。
- 登录 token 存在 `~/.mactfs/server-token`,三个进程共用同一份。
---
## 二、环境要求
- macOS 12+(Intel 或 Apple Silicon;Apple Silicon 需装 Rosetta 2)
- [pnpm](https://pnpm.io/)(前端 / MCP 包管理)
- Node ≥ 18(开发 MCP 用)
- 首次构建需联网(自动从 Azul 下载 x64 JDK)
- 目标 TFS:TFS 2015 及以下(TFVC)
> ⚠️ **为什么固定 x64 JDK**:TFS SDK 的 JNI 原生库只含 i386/x86_64,没有 arm64 切片。所以服务端 JVM 固定走 x64,在 Apple Silicon 上经 Rosetta 2 运行;Electron 界面本身仍是原生 universal。
---
## 三、从零打包成桌面应用(.app / .dmg)
下面是一套**完整顺序**,第一次构建建议逐步执行;之后只改了某一层就只重跑对应步骤。
### 步骤 1 · 安装前端依赖
```bash
cd mactfsui
pnpm install
```
### 步骤 2 · 准备 x64 JDK(打包用运行时)
```bash
cd mactfsui
pnpm prepare:runtime
```
脚本逻辑(`scripts/prepare-runtime.mjs`):
- 检测仓库根是否已有 `zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/`;没有就从 Azul 官方下载 → 校验 sha256 → 解压到仓库根目录。
- 再把精简后的运行时复制到 `mactfsui/runtime/jdk-x64`(去掉 `src.zip`、`demo`、`man` 等无用部分缩小体积)。
- 打包时这个 `runtime/jdk-x64` 会被复制进 `.app` 的 `Resources/jre-x64`。
> `pnpm dist` 会自动先跑这一步,所以你也可以跳过手动执行;单独跑只是为了提前下载/排错。
### 步骤 3 · 构建 Java 后端
```bash
cd mactfs
../tfsIntegration/gradlew installDist
```
- 产物落在 `mactfs/build/install/mactfs/lib/`(含全部依赖 jar 与 `native/` JNI 库)。
- 打包时这个目录会被复制进 `.app` 的 `Resources/server/lib`。
- **改了 Java 代码必须重新 `installDist`**,只跑 `gradlew build` 不更新这个运行目录。
### 步骤 4 · 构建 MCP
```bash
cd mactfs-mcp
pnpm install
pnpm build # 开发态:tsc 编译,产物在 dist/
pnpm build:bundle # 打包态:esbuild 单文件,产物在 dist-bundle/index.cjs
```
- 开发态 Electron 从 `mactfs-mcp/dist/index.js` 拉起 MCP,所以**改完 MCP 代码必须重新 `pnpm build`**。
- 打包态用 esbuild 单文件 `dist-bundle/index.cjs`(免带 node_modules);`pnpm dist` 已通过 `prepare:mcp` 自动生成它,无需手动跑。
### 步骤 5 · 打包
```bash
cd mactfsui
pnpm dist
```
`pnpm dist` 等价于:`pnpm build`(打包前端页面)→ `pnpm prepare:runtime`(准备 JDK)→ `electron-builder --mac --arm64 --x64`。
**产物输出**:`mactfsui/dist-app/`,生成 arm64 与 x64 两个 DMG:
| 安装包 | 适用机型 |
|---|---|
| `MacTFS-<版本>-arm64.dmg` | Apple Silicon(M 系列) |
| `MacTFS-<版本>-x64.dmg` | Intel |
打包配置见 `mactfsui/package.json` 的 `build` 字段:`appId=com.mydev.mactfs`,`extraResources` 把后端 lib、x64 JRE 与 **MCP 单文件包** 带进 `Resources/`,`identity: null`(不签名)。
### MCP 已随 .app 一起打包
`extraResources` 现包含三项,分别落到 `.app/Contents/Resources/` 下:
| from | to | 内容 |
|---|---|---|
| `../mactfs/build/install/mactfs/lib` | `server/lib` | Java 后端 jar + JNI native |
| `runtime/jdk-x64` | `jre-x64` | x64 JRE |
| `../mactfs-mcp/dist-bundle` | `mcp` | MCP esbuild 单文件 `index.cjs` |
启动时 `resolveMcpEntry()` 打包态命中 `Resources/mcp/index.cjs`,由 Electron 自带 Node 拉起。所以**正式 .app 也自带 MCP**,AI 自动签出在安装版里同样可用(前提是客户端在运行)。
---
## 四、开发模式运行(推荐日常使用)
```bash
cd mactfsui
pnpm electron:dev
```
一条命令同时拉起 vite(`localhost:5173`)+ Electron 窗口,Electron 再按需自动拉起后端与 MCP。
前置:首次需先跑过 **步骤 3(`installDist`)** 和 **步骤 4(MCP `pnpm build`)**,否则后端 / MCP 起不来。
---
## 五、mactfs-mcp 工具:能做什么 & 怎么用
### 5.1 它是什么
一个 SSE(`http://127.0.0.1:38766/sse`)小服务,让支持 MCP 的 AI 客户端在**改 TFS 受控文件之前自动签出**。它自己不直接连 TFS,而是把请求转成调本机 Java 后端的 REST 接口,由后端经微软 SDK 操作 TFS。
```
AI 客户端 ──SSE──▶ mactfs-mcp ──HTTP(REST+Token)──▶ Java 后端(38765) ──▶ TFS
```
> 前提:MacTFS 桌面客户端正在运行(否则 38766 端口不存在,AI 连接会被拒)——这就是「客户端开着才能用」的天然绑定。
### 5.2 在 Cursor 中配置
**默认无需 token**,配置里只写一个 URL 即可(MCP 调后端用的 token 会自动从 `~/.mactfs/server-token` 读取):
```json
{
"mcpServers": {
"mactfs": { "url": "http://127.0.0.1:38766/sse" }
}
}
```
### 5.3 两个工具
#### `tfs_checkout` — 签出(改已有文件前用)
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `paths` | `string[]` | 必填 | 文件本地绝对路径(也兼容 `$/` 服务端路径) |
| `dryRun` | `boolean?` | false | 只校验、不真正签出/加入/拉最新 |
| `getLatestIfStale` | `boolean?` | **true** | 本地落后时自动先获取最新再签出;传 `false` 则仅警告 |
| `autoAdd` | `boolean?` | **true** | 遇到服务端尚不存在的新文件时自动加入版本控制;传 `false` 则仅提示改用 `tfs_add` |
执行前校验流水线:连接 → 是否映射 → 是否已签出 → 是否新文件 → 是否未下载 → 是否落后 → 签出。
**默认行为(已内置)**:
- 新文件 → **自动 `add` 加入版本控制**(除非 `autoAdd=false`)。
- 本地落后 → **自动先 `get-latest` 再签出**(除非 `getLatestIfStale=false`)。
- `dryRun=true` 时只报告「会怎么做」,**不会**真的 add / 拉最新(无副作用)。
#### `tfs_add` — 加入版本控制(新建文件用)
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `paths` | `string[]` | 必填 | 要加入版本控制的文件本地绝对路径 |
| `dryRun` | `boolean?` | false | 只校验、不真正加入 |
每个路径都会返回结构化结果,`action` 取值:`checkedOut / alreadyCheckedOut / added / alreadyAdded / skipped / error`,并附一段人话 `message`。
### 5.4 可选:开启更严格的本机鉴权
默认靠「仅监听 127.0.0.1 + 随客户端绑定」两道保护,AI 端不用带 token。若想防止同机其它程序连本 MCP,给 MCP 进程设 `MACTFS_MCP_REQUIRE_TOKEN=1`,再在配置里带上 token:
```json
{
"mcpServers": {
"mactfs": {
"url": "http://127.0.0.1:38766/sse",
"headers": { "Authorization": "Bearer 这里粘贴 server-token 内容" }
}
}
}
```
也可用查询参数:`"url": "http://127.0.0.1:38766/sse?token=server-token内容"`。
### 5.5 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `MACTFS_API_BASE_URL` | `http://127.0.0.1:38765` | Java 后端地址 |
| `MACTFS_MCP_HOST` / `MACTFS_MCP_PORT` | `127.0.0.1` / `38766` | SSE 监听地址 |
| `MACTFS_TOKEN_FILE` | `~/.mactfs/server-token` | Bearer Token 文件(自动读取) |
| `MACTFS_MCP_REQUIRE_TOKEN` | 关 | 设 `1` 时要求 AI 客户端连接也带 token |
| `MACTFS_PARENT_PID` | 无 | 父进程 pid,设置后启用「父死则自杀」 |
| `MACTFS_MCP_HEALTH_TRIES` / `_INTERVAL_MS` | `60` / `1000` | 启动时等待后端就绪的轮询次数 / 间隔 |
| `MACTFS_MCP_HTTP_TIMEOUT_MS` | `30000` | 调 Java 后端的 HTTP 超时 |
> Electron 自动拉起 MCP 时,会注入 `MACTFS_PARENT_PID`、`MACTFS_API_BASE_URL`、`MACTFS_TOKEN_FILE`、`MACTFS_MCP_PORT`。
---
## 六、验证与排错
### 验证后端
```bash
TOKEN=$(cat ~/.mactfs/server-token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:38765/api/health
# 返回 "success":true 即正常
```
### 验证 MCP
```bash
curl -s http://127.0.0.1:38766/healthz
# 返回 {"ok":true,"name":"mactfs-mcp",...} 即在跑
```
### 常见问题
| 现象 | 原因与处理 |
|---|---|
| MCP 端口 38766 连不上 | MacTFS 客户端没开,或开发态没先 `pnpm build` 过 MCP |
| 改了 MCP 代码不生效 | 忘了在 `mactfs-mcp` 重新 `pnpm build`(Electron 加载的是 `dist/`) |
| 改了 Java 代码不生效 | 忘了 `gradlew installDist` 或没重启后端 |
| 打包后的 .app 里 MCP 不工作 | 确认打包前生成了 `mactfs-mcp/dist-bundle/index.cjs`(`pnpm dist` 会自动跑 `prepare:mcp`) |
| 工具返回 `skipped / notMapped` | 文件不在任何 TFS 映射目录下,本就无需签出,可直接继续 |
| 工具返回 `notConnected` | 后端 38765 没起来,确认 MacTFS 客户端在运行 |
| 接口全部 401 | token 不一致,确认用的是 `~/.mactfs/server-token` 的最新值 |
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
dist-bundle/
*.log
+213
View File
@@ -0,0 +1,213 @@
# mactfs-mcp
让任意支持 MCP 的 AI 客户端(Cursor / Claude / Codex 等)在**修改受 TFS 版本控制的文件之前自动校验并签出**。
本服务暴露 `tfs_checkout`、`tfs_add` 两个工具,背后调用 MacTFS 本地 Java 服务(`127.0.0.1:38765`)完成实际签出(TFVC pendEdit)/ 加入(pendAdd)。
## 两种接入模式
| 模式 | 启动方式 | 适用客户端 | 说明 |
|---|---|---|---|
| **SSE**(默认) | 由 MacTFS 桌面客户端拉起,监听 `127.0.0.1:38766/sse` | **Cursor**(支持填 URL) | 随客户端启停,端口存在才能连 |
| **stdio** | `mactfs-mcp --mcp-stdio`(由 AI 客户端以子进程拉起) | **Codex / Claude 等**(只认「启动一个命令」) | 不占 38766 端口,直连 38765 Java 后端;后端没起时工具返回 notConnected |
> 为什么要两种:Cursor 天生支持「填个网址就连」(SSE),而 Codex / Claude 的配置默认是「给我一个能启动的程序」(stdio),没法直接贴 SSE 地址。stdio 模式就是为后者准备的通用入口。
## 与客户端绑定
本 MCP **由 MacTFS 桌面客户端(Electron)拉起、随客户端退出而回收**:
```
Electron 客户端(运行中)
├─ Java 后端 127.0.0.1:38765
└─ mactfs-mcp 127.0.0.1:38766/sse ← AI 客户端用 URL 连接
└─ HTTP → Java 后端 → TFS
退出客户端 → 两个子进程都被回收 → AI 连接被拒(天然绑定)
```
- 只有客户端开着,`38766` 端口才存在,AI 才能用。
- MCP 还会看护父进程(`MACTFS_PARENT_PID`),客户端异常退出时自杀,避免孤儿进程。
## 工具:`tfs_checkout`
在编辑受 TFS 管控的已有文件前调用。
入参:
| 参数 | 类型 | 说明 |
|---|---|---|
| `paths` | `string[]` | 文件本地绝对路径(也兼容 `$/` 服务端路径) |
| `dryRun` | `boolean?` | 只做执行前校验、不真正签出 |
| `getLatestIfStale` | `boolean?` | 本地落后于服务器时自动先获取最新再签出(默认 false,仅警告) |
执行前校验流水线:连接 → 是否映射 → 是否已签出 → 是否新文件 → 是否未下载 → 是否落后 → 签出。
每个路径返回结构化结果,`action` 为 `checkedOut | alreadyCheckedOut | skipped | error`,并附人话 `message`。
## 在 Cursor 中配置
**默认无需 token**,配置里只写一个 URL 即可(MCP 调后端用的 token 会自动从 `~/.mactfs/server-token` 读取,AI 端不用管):
```json
{
"mcpServers": {
"mactfs": { "url": "http://127.0.0.1:38766/sse" }
}
}
```
> 前提:MacTFS 桌面客户端正在运行(否则 38766 端口不存在,连接会被拒)——这就是「客户端开着才能用」的绑定。
### 可选:开启更严格的本机鉴权
如果想防止同机其它程序连本 MCP,给 MCP 进程设环境变量 `MACTFS_MCP_REQUIRE_TOKEN=1`,再在配置里带上 token:
```json
{
"mcpServers": {
"mactfs": {
"url": "http://127.0.0.1:38766/sse",
"headers": { "Authorization": "Bearer 这里粘贴server-token内容" }
}
}
}
```
也可用查询参数:`"url": "http://127.0.0.1:38766/sse?token=server-token内容"`。
## 在 Codex / Claude 中配置(stdio 模式)
Cursor 支持「填个 URL 直接连」(SSE),而 **Codex / Claude 的配置是「给我一个能启动的命令」**(stdio),没法直接贴 SSE 地址。
为此本 MCP 增加了 `--mcp-stdio` 模式:启动后**不监听 38766 端口、不起 SSE**,直接通过 stdin/stdout 与 AI 客户端通信,背后仍调用 38765 Java 后端。
### 前提(务必先确认)
1. **MacTFS 桌面客户端正在运行**(Java 后端 38765 在跑)。后端没起时工具不会报错崩溃,只会返回 `notConnected`,等后端起来即可正常用。
2. **本机装了 Node 18+**(`node -v` 能输出版本)。stdio 进程由 AI 客户端拉起,需要一个 Node 运行时来跑那个单文件。
- 不想另装 Node?见文末「免装 Node:复用 app 自带 Electron」。
### 第 1 步:确定入口文件的绝对路径
入口就是 esbuild 打出的单文件 `index.cjs`,两种来源任选其一:
| 场景 | 绝对路径(示例,按你机器实际替换) |
|---|---|
| **已安装 app**(推荐) | `/Applications/MacTFS.app/Contents/Resources/mcp/index.cjs` |
| **本仓库开发态** | `/Users/你的用户名/.../mydev/mactfs-mcp/dist-bundle/index.cjs` |
> 开发态若还没生成单文件,先在 `mactfs-mcp/` 下执行 `pnpm build:bundle`(产物在 `dist-bundle/index.cjs`)。
> 也可以直接用 tsc 产物 `dist/index.js`(同样加 `--mcp-stdio`),效果一致。
### 第 2 步:按客户端写配置(把路径换成你的真实路径)
**Codex CLI**(`~/.codex/config.toml`):
```toml
[mcp_servers.mactfs]
command = "node"
args = ["/Applications/MacTFS.app/Contents/Resources/mcp/index.cjs", "--mcp-stdio"]
```
**Claude Desktop**(`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"mactfs": {
"command": "node",
"args": ["/Applications/MacTFS.app/Contents/Resources/mcp/index.cjs", "--mcp-stdio"]
}
}
}
```
**Claude Code**(命令行,一条搞定):
```bash
claude mcp add mactfs -- node /Applications/MacTFS.app/Contents/Resources/mcp/index.cjs --mcp-stdio
```
> 改完配置后**重启对应客户端**(Codex 重开会话 / Claude 退出重进),让它重新读取配置并拉起 MCP。
### 第 3 步:验证是否生效
- Codex / Claude 的工具列表里应能看到 `tfs_checkout`、`tfs_add` 两个工具。
- 想在命令行先自测,可临时手动跑:
```bash
node /Applications/MacTFS.app/Contents/Resources/mcp/index.cjs --mcp-stdio
# 正常会在 stderr 打印:[mactfs-mcp] stdio transport connected
# 然后阻塞等待 stdin 的 JSON-RPC(Ctrl+C 退出即可)
```
### 可选 A:免装 Node —— 复用 app 自带的 Electron 当 Node
安装包里已经带了 Electron,可让它「以 Node 模式」运行,无需另装 Node:
```json
{
"mcpServers": {
"mactfs": {
"command": "/Applications/MacTFS.app/Contents/MacOS/MacTFS",
"args": ["/Applications/MacTFS.app/Contents/Resources/mcp/index.cjs", "--mcp-stdio"],
"env": { "ELECTRON_RUN_AS_NODE": "1" }
}
}
}
```
`ELECTRON_RUN_AS_NODE=1` 让这个 app 二进制不弹窗、纯当 Node 跑脚本。这样**所有路径都在安装包内部,零外部依赖**。
### 可选 B:不另起进程,桥接现成的 SSE 服务
如果只是想复用 app 已经在跑的 SSE(38766),不想再由客户端拉起 stdio 进程,Claude 侧可用桥接:
```bash
npx -y mcp-remote http://127.0.0.1:38766/sse
```
把它作为客户端的 `command`,即可把 SSE 转成 stdio。
### 几点说明(不是问题,但要知道)
- **生命周期**:stdio 进程由 AI 客户端启停,不再随 MacTFS app 开关绑定。但真正干活的是 38765 Java 后端 —— app 没开时工具只返回 `notConnected`,不会误操作,安全。
- **多客户端不冲突**:stdio 模式**不占 38766 端口**,Cursor 的 SSE 与 Codex/Claude 的多个 stdio 进程可同时存在,各自连同一个 38765 后端,互不干扰。
- **鉴权**:stdio 是本机父子进程直连,无需 token;`MACTFS_MCP_REQUIRE_TOKEN` 只对 SSE 连接生效。
## 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `MACTFS_API_BASE_URL` | `http://127.0.0.1:38765` | Java 后端地址 |
| `MACTFS_MCP_HOST` / `MACTFS_MCP_PORT` | `127.0.0.1` / `38766` | SSE 监听地址 |
| `MACTFS_TOKEN_FILE` | `~/.mactfs/server-token` | Bearer Token 文件(MCP 调后端用,自动读取) |
| `MACTFS_MCP_REQUIRE_TOKEN` | 关 | 设 `1` 时要求 AI 客户端连接也带 token |
| `MACTFS_PARENT_PID` | 无 | 父进程 pid,设置后启用「父死则自杀」 |
| `MACTFS_MCP_HEALTH_TRIES` / `_INTERVAL_MS` | `60` / `1000` | 启动时等待后端就绪的轮询 |
## 开发
```bash
pnpm install
pnpm build # 产物在 dist/
pnpm dev # tsx watch 热更新
pnpm typecheck
```
Electron 开发态会自动从 `mactfs-mcp/dist/index.js` 拉起本服务,所以改完记得 `pnpm build`。
## 打包进 .app
打包态用 **esbuild 把整个 MCP 打成单文件**(免带 node_modules),再随 electron-builder 落到 `Resources/mcp/index.cjs`:
```bash
pnpm build:bundle # 产物 dist-bundle/index.cjs(单文件 CJS)
```
`mactfsui` 侧已接好:
- `package.json` 的 `extraResources` 增加 `{ from: "../mactfs-mcp/dist-bundle", to: "mcp" }`,把单文件包复制进 `Resources/mcp/`。
- `pnpm dist` 链路里加了 `prepare:mcp`(自动 `pnpm install` + `build:bundle`),打包前会自动生成最新单文件包。
- Electron 主进程 `resolveMcpEntry()` 打包态命中 `Resources/mcp/index.cjs`,用 Electron 自带 Node(`ELECTRON_RUN_AS_NODE`)拉起。
> 打包态用 `.cjs`(CJS),开发态仍用 `dist/index.js`(tsc 的 ESM 产物 + node_modules),两条路径互不影响。
+34
View File
@@ -0,0 +1,34 @@
{
"name": "mactfs-mcp",
"version": "0.1.0",
"private": true,
"description": "MacTFS MCP 服务:让任意 AI 客户端在改文件前自动校验并签出 TFS 文件。与 MacTFS 桌面客户端绑定运行。",
"type": "module",
"bin": {
"mactfs-mcp": "dist/index.js"
},
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"build:bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --target=node18 --outfile=dist-bundle/index.cjs",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"start:bundle": "node dist-bundle/index.cjs",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.0",
"express": "^4.21.2",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"esbuild": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18"
}
}
+1428
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
const transport = new SSEClientTransport(new URL("http://127.0.0.1:38766/sse"))
const client = new Client({ name: "probe", version: "1.0.0" })
await client.connect(transport)
const tools = await client.listTools()
console.log("TOOLS:", tools.tools.map((t) => t.name).join(", "))
for (const t of tools.tools) {
console.log("-", t.name, "::", JSON.stringify(Object.keys(t.inputSchema?.properties ?? {})))
}
await client.close()
process.exit(0)
+269
View File
@@ -0,0 +1,269 @@
import path from "node:path"
import { tfsClient } from "./tfs-client.js"
import type {
FileStatus,
PendAction,
PendItemResult,
PendReason,
} from "./types.js"
export interface PendOptions {
dryRun?: boolean
/** 本地落后于服务器时是否先自动获取最新再签出。默认 true,传 false 才退回仅警告。 */
getLatestIfStale?: boolean
/** 遇到服务端尚不存在的新文件时是否自动加入版本控制(add)。默认 true,传 false 才退回仅提示。 */
autoAdd?: boolean
}
/**
* 组装单条结果,缺省字段从已知的 FileStatus 回填。
*/
function item(
filePath: string,
serverPath: string | null,
action: PendAction,
reason: PendReason,
message: string,
status?: FileStatus,
): PendItemResult {
return {
path: filePath,
serverPath: serverPath ?? status?.serverPath ?? null,
action,
reason,
mapped: status?.mapped ?? false,
pendingEdit: status?.pendingEdit ?? false,
localVersion: status?.localVersion ?? 0,
latestVersion: status?.latestVersion ?? 0,
upToDate: status?.upToDate ?? false,
message,
}
}
/**
* 连接预检:返回 null 表示后端可用,否则返回给所有路径的统一失败结果。
*/
async function connectionGuard(paths: string[]): Promise<PendItemResult[] | null> {
const health = await tfsClient.health()
if (health.ok) {
return null
}
return paths.map((p) =>
item(
p,
null,
"error",
"notConnected",
`连不上本地 MacTFS 服务(${health.errorMessage ?? "服务未运行"})。请确认 MacTFS 客户端已打开。`,
),
)
}
/** 读取单个本地文件状态,失败时返回 null(调用方据此产出 error 结果)。 */
async function loadStatus(localPath: string): Promise<FileStatus | { error: string }> {
const resp = await tfsClient.fileStatus(localPath)
if (!resp.ok || !resp.data || !resp.data.status) {
return { error: resp.errorMessage ?? "未知错误" }
}
return resp.data.status
}
/**
* 真正执行签出(pendEdit)。checkoutArg 可为本地绝对路径或服务端路径。
*/
async function doCheckout(
checkoutArg: string,
serverPath: string | null,
status?: FileStatus,
): Promise<PendItemResult> {
const resp = await tfsClient.checkout([checkoutArg], false)
if (!resp.ok || !resp.data) {
return item(checkoutArg, serverPath, "error", "error", `签出失败:${resp.errorMessage ?? "未知错误"}`, status)
}
const result = resp.data.result
if (result.failures && result.failures.length > 0) {
return item(checkoutArg, serverPath, "error", "error", `签出未成功:${result.failures.join(";")}`, status)
}
return item(checkoutArg, serverPath, "checkedOut", "ok", `已签出(影响 ${result.affected} 项),可以开始修改。`, status)
}
/**
* 真正执行加入版本控制(pendAdd),只接受本地路径。
*/
async function doAdd(localPath: string, status: FileStatus): Promise<PendItemResult> {
const resp = await tfsClient.add([localPath], false)
if (!resp.ok || !resp.data) {
return item(localPath, status.serverPath, "error", "error", `加入版本控制失败:${resp.errorMessage ?? "未知错误"}`, status)
}
const result = resp.data.result
if (result.failures && result.failures.length > 0) {
return item(localPath, status.serverPath, "error", "error", `加入未成功:${result.failures.join(";")}`, status)
}
return item(localPath, status.serverPath, "added", "ok", `已加入版本控制(影响 ${result.affected} 项),签入后服务器生效。`, status)
}
/**
* 对单个路径跑「签出」校验流水线:
* 映射 → 已签出 → 新文件 → 未下载 → 是否落后 → 真正签出。
*/
async function checkoutOne(raw: string, options: PendOptions): Promise<PendItemResult> {
if (raw.startsWith("$/")) {
if (options.dryRun) {
return item(raw, raw, "skipped", "ok", "服务端路径,dryRun 跳过实际签出。")
}
return doCheckout(raw, raw)
}
const localPath = path.resolve(raw)
const status = await loadStatus(localPath)
if ("error" in status) {
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
}
if (!status.mapped) {
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无需签出。", status)
}
if (status.pendingEdit) {
return item(localPath, status.serverPath, "alreadyCheckedOut", "ok", "文件已处于签出状态,可直接修改。", status)
}
if (!status.serverExists) {
if (options.autoAdd === false) {
return item(localPath, status.serverPath, "skipped", "newFileNeedsAdd", "这是服务端尚不存在的新文件,请改用 tfs_add 加入版本控制。", status)
}
if (!status.exists) {
return item(localPath, status.serverPath, "skipped", "notExist", "这是新文件但本地不存在;请先创建文件再加入版本控制。", status)
}
if (status.pendingChangeType === "pendingAdd") {
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:这是新文件,将自动加入版本控制(dryRun 未真正执行)。", status)
}
return doAdd(localPath, status)
}
if (!status.exists || status.status === "notDownloaded") {
return item(localPath, status.serverPath, "skipped", "notDownloaded", "本地尚未下载该文件,请先获取最新。", status)
}
const stale = !status.upToDate || status.status === "remoteChanged" || status.status === "bothChanged"
const autoGetLatest = options.getLatestIfStale !== false
if (stale && !autoGetLatest) {
return item(
localPath,
status.serverPath,
"skipped",
"stale",
`本地版本(${status.localVersion})落后于服务器最新(${status.latestVersion})。已按 getLatestIfStale=false 跳过;若要在旧版本上修改请自行处理。`,
status,
)
}
if (stale) {
if (options.dryRun) {
return item(
localPath,
status.serverPath,
"skipped",
"ok",
`校验通过:本地落后(${status.localVersion}→${status.latestVersion}),将先自动获取最新再签出(dryRun 未真正执行)。`,
status,
)
}
const gl = await tfsClient.getLatest(status.serverPath ?? undefined, false)
if (!gl.ok) {
return item(localPath, status.serverPath, "error", "stale", `自动获取最新失败:${gl.errorMessage ?? "未知错误"}`, status)
}
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可签出(dryRun 未真正执行)。", status)
}
return doCheckout(localPath, status.serverPath, status)
}
/**
* 对单个路径跑「加入版本控制」校验流水线:
* 映射 → 本地存在 → 已加入 → 是否已受控(应改签出) → 真正 add。
*/
async function addOne(raw: string, options: PendOptions): Promise<PendItemResult> {
const localPath = path.resolve(raw)
const status = await loadStatus(localPath)
if ("error" in status) {
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
}
if (!status.mapped) {
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无法加入版本控制。", status)
}
if (!status.exists) {
return item(localPath, status.serverPath, "skipped", "notExist", "本地文件不存在;请先创建文件再加入版本控制。", status)
}
if (status.pendingChangeType === "pendingAdd") {
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
}
if (status.serverExists) {
return item(localPath, status.serverPath, "skipped", "alreadyTracked", "该文件已在版本控制中;若要修改请用 tfs_checkout 签出,而非加入。", status)
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可加入版本控制(dryRun 未真正执行)。", status)
}
return doAdd(localPath, status)
}
/**
* tfs_checkout 主流程。
*/
export async function runCheckout(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
if (paths.length === 0) {
return []
}
const guard = await connectionGuard(paths)
if (guard) {
return guard
}
const results: PendItemResult[] = []
for (const raw of paths) {
results.push(await checkoutOne(raw, options))
}
return results
}
/**
* tfs_add 主流程。
*/
export async function runAdd(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
if (paths.length === 0) {
return []
}
const guard = await connectionGuard(paths)
if (guard) {
return guard
}
const results: PendItemResult[] = []
for (const raw of paths) {
results.push(await addOne(raw, options))
}
return results
}
const ACTION_TAGS: Record<PendAction, string> = {
checkedOut: "✓ 已签出",
alreadyCheckedOut: "✓ 已签出(无需重复)",
added: "✓ 已加入",
alreadyAdded: "✓ 已加入(无需重复)",
skipped: "• 跳过",
error: "✗ 失败",
}
/**
* 把结构化结果汇总成一段人话 summary,方便 AI 与人快速读懂。
*/
export function summarize(results: PendItemResult[]): string {
if (results.length === 0) {
return "未提供任何文件路径。"
}
return results.map((r) => `${ACTION_TAGS[r.action]} ${r.path} — ${r.message}`).join("\n")
}
+60
View File
@@ -0,0 +1,60 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
/** 读取整数环境变量,非法或缺省时回退默认值。 */
function intEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) ? value : fallback
}
// MacTFS 本地 Java 服务地址,与 Electron / 前端约定一致(127.0.0.1:38765)。
export const TFS_API_BASE_URL =
process.env.MACTFS_API_BASE_URL ?? "http://127.0.0.1:38765"
// MCP SSE 监听地址,AI 客户端通过 http://127.0.0.1:38766/sse 连接。
export const MCP_HOST = process.env.MACTFS_MCP_HOST ?? "127.0.0.1"
export const MCP_PORT = intEnv("MACTFS_MCP_PORT", 38766)
// 本地 Bearer Token 文件,与 Java 服务、Electron 复用同一份。
export const TOKEN_FILE =
process.env.MACTFS_TOKEN_FILE ?? path.join(os.homedir(), ".mactfs", "server-token")
// 启动时等待 Java 后端就绪的最大轮询次数与间隔(毫秒)。
export const HEALTH_WAIT_TRIES = intEnv("MACTFS_MCP_HEALTH_TRIES", 60)
export const HEALTH_WAIT_INTERVAL_MS = intEnv("MACTFS_MCP_HEALTH_INTERVAL_MS", 1000)
// 父进程(Electron)pid:用于「父死则自杀」的看护,避免遗留孤儿进程。
export const PARENT_PID = (() => {
const raw = process.env.MACTFS_PARENT_PID
if (!raw) {
return null
}
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) && value > 0 ? value : null
})()
// HTTP 调用 Java 服务的默认超时(毫秒)。
export const HTTP_TIMEOUT_MS = intEnv("MACTFS_MCP_HTTP_TIMEOUT_MS", 30000)
// 是否要求 AI 客户端连接 SSE 时携带 token。
// 默认关闭:已有「仅监听 127.0.0.1 + 随客户端生命周期绑定」两道保护,AI 端无需配置 token。
// 设 MACTFS_MCP_REQUIRE_TOKEN=1 可开启更严格的本机鉴权。
export const MCP_REQUIRE_TOKEN = (process.env.MACTFS_MCP_REQUIRE_TOKEN ?? "").trim() === "1"
/**
* 读取本地 Bearer Token,文件缺失或为空时返回 null。
* Token 由 Java 服务在 ~/.mactfs/server-token 生成。
*/
export function readToken(): string | null {
try {
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
return value.length > 0 ? value : null
} catch {
return null
}
}
+11
View File
@@ -0,0 +1,11 @@
import { startServer, startStdioServer } from "./server.js"
// --mcp-stdio:以 stdio 模式运行(供 Codex / Claude 等用「命令启动子进程」接入);
// 缺省仍走 SSE 模式(供 Cursor 用 URL 接入、由 MacTFS 桌面客户端拉起)。
const useStdio = process.argv.includes("--mcp-stdio")
const run = useStdio ? startStdioServer : startServer
run().catch((error) => {
process.stderr.write(`[mactfs-mcp] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
process.exit(1)
})
+241
View File
@@ -0,0 +1,241 @@
import express from "express"
import type { Request } from "express"
import { z } from "zod"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
HEALTH_WAIT_INTERVAL_MS,
HEALTH_WAIT_TRIES,
MCP_HOST,
MCP_PORT,
MCP_REQUIRE_TOKEN,
PARENT_PID,
readToken,
} from "./config.js"
import { runAdd, runCheckout, summarize } from "./checkout.js"
import { tfsClient } from "./tfs-client.js"
const SERVER_NAME = "mactfs-mcp"
const SERVER_VERSION = "0.1.0"
/** 简单延时。 */
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** stderr 日志,SSE 走 HTTP,stdout 可用但统一打 stderr 更安全。 */
function log(message: string): void {
process.stderr.write(`[mactfs-mcp] ${message}\n`)
}
/**
* 校验请求是否允许连接。
* 默认不强制 token(已有 127.0.0.1 + 客户端绑定两道保护);
* 仅当 MACTFS_MCP_REQUIRE_TOKEN=1 时,才要求 Authorization: Bearer 或 ?token=。
*/
function authorized(req: Request): boolean {
if (!MCP_REQUIRE_TOKEN) {
return true
}
const expected = readToken()
if (!expected) {
return true
}
const auth = req.headers["authorization"]
if (typeof auth === "string" && auth.startsWith("Bearer ") && auth.slice(7).trim() === expected) {
return true
}
const queryToken = req.query.token
if (typeof queryToken === "string" && queryToken === expected) {
return true
}
return false
}
/**
* 创建一个 MCP server 实例并注册工具。每个 SSE 连接用独立实例,避免跨连接状态串扰。
*/
function createMcpServer(): McpServer {
const mcp = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION })
mcp.registerTool(
"tfs_checkout",
{
title: "TFS 签出文件",
description:
"在修改受 TFS 版本控制的文件之前调用本工具签出(checkout / pendEdit)。" +
"会先做执行前校验:是否在映射目录内、是否已签出、是否为新文件、本地是否落后于服务器最新版本。" +
"凡是即将编辑、且位于 MacTFS 映射目录下的已有文件,都应先调用本工具。" +
"默认行为:遇到服务端尚不存在的新文件会自动加入版本控制(add);本地版本落后时会自动先获取最新再签出。" +
"返回每个文件的处理结果(checkedOut / alreadyCheckedOut / added / alreadyAdded / skipped / error)。",
inputSchema: {
paths: z
.array(z.string())
.min(1)
.describe("要签出的文件本地绝对路径列表(也兼容 $/ 开头的服务端路径)"),
dryRun: z
.boolean()
.optional()
.describe("只做执行前校验、不真正签出/加入/获取最新,用于预检"),
getLatestIfStale: z
.boolean()
.optional()
.describe("当本地版本落后于服务器时,自动先获取最新再签出(默认 true;传 false 则仅警告、不自动获取)"),
autoAdd: z
.boolean()
.optional()
.describe("遇到服务端尚不存在的新文件时,自动加入版本控制(add)(默认 true;传 false 则仅提示改用 tfs_add)"),
},
},
async ({ paths, dryRun, getLatestIfStale, autoAdd }) => {
const results = await runCheckout(paths, { dryRun, getLatestIfStale, autoAdd })
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
return { content: [{ type: "text" as const, text }] }
},
)
mcp.registerTool(
"tfs_add",
{
title: "TFS 加入版本控制",
description:
"为 MacTFS 映射目录下、尚未纳入版本控制的【新建文件】执行加入(add / pendAdd)。" +
"新建文件请在写入磁盘后调用本工具(此时文件已存在于本地)。" +
"会先做执行前校验:是否在映射目录内、本地是否存在、是否已加入、是否其实已受控(已受控应改用 tfs_checkout)。" +
"返回每个文件的处理结果(added / alreadyAdded / skipped / error)。",
inputSchema: {
paths: z
.array(z.string())
.min(1)
.describe("要加入版本控制的文件本地绝对路径列表"),
dryRun: z
.boolean()
.optional()
.describe("只做执行前校验、不真正加入,用于预检"),
},
},
async ({ paths, dryRun }) => {
const results = await runAdd(paths, { dryRun })
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
return { content: [{ type: "text" as const, text }] }
},
)
return mcp
}
/**
* 启动「父进程(Electron)看护」:父进程消失则自杀,避免遗留孤儿。
*/
function startParentWatchdog(): void {
const pid = PARENT_PID
if (pid === null) {
return
}
log(`watching parent pid ${pid}`)
setInterval(() => {
try {
process.kill(pid, 0)
} catch {
log("parent process gone, exiting")
process.exit(0)
}
}, 3000)
}
/**
* 等待 Java 后端 health 就绪;超过上限仍未就绪则降级继续(工具会返回 notConnected)。
*/
async function waitForBackend(): Promise<boolean> {
for (let attempt = 0; attempt < HEALTH_WAIT_TRIES; attempt += 1) {
const health = await tfsClient.health()
if (health.ok) {
return true
}
await delay(HEALTH_WAIT_INTERVAL_MS)
}
return false
}
/**
* 启动 MCP SSE 服务:等待后端、注册路由、监听端口、挂看护。
*/
export async function startServer(): Promise<void> {
startParentWatchdog()
log("waiting for MacTFS backend ...")
const ready = await waitForBackend()
if (ready) {
log("backend ready")
} else {
log("backend not ready after wait, starting in degraded mode")
}
const app = express()
const transports: Record<string, SSEServerTransport> = {}
app.get("/sse", async (req, res) => {
if (!authorized(req)) {
res.status(401).json({ error: "unauthorized" })
return
}
const transport = new SSEServerTransport("/messages", res)
transports[transport.sessionId] = transport
res.on("close", () => {
delete transports[transport.sessionId]
})
const mcp = createMcpServer()
try {
await mcp.connect(transport)
} catch (error) {
log(`sse connect error: ${error instanceof Error ? error.message : String(error)}`)
}
})
app.post("/messages", async (req, res) => {
if (!authorized(req)) {
res.status(401).json({ error: "unauthorized" })
return
}
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : ""
const transport = transports[sessionId]
if (!transport) {
res.status(400).json({ error: "no transport for sessionId" })
return
}
await transport.handlePostMessage(req, res)
})
app.get("/healthz", (_req, res) => {
res.json({ ok: true, name: SERVER_NAME, version: SERVER_VERSION })
})
app.listen(MCP_PORT, MCP_HOST, () => {
log(`SSE listening on http://${MCP_HOST}:${MCP_PORT}/sse`)
})
}
/**
* 启动 MCP stdio 服务:供 Codex / Claude 等「以命令启动子进程」方式接入的通用客户端使用。
*
* 与 SSE 模式的差异:
* - 不监听 38766 端口、不起 express,AI 客户端直接通过 stdin/stdout 与本进程通信。
* - 直连 38765 Java 后端(tfsClient 已走 MACTFS_API_BASE_URL);后端未起时工具会返回 notConnected。
* - 必须尽快 connect,让客户端的 initialize 握手立即完成,故不在启动时阻塞等待后端,
* 仅在后台探测一次后端就绪并打日志(stderr,绝不写 stdout,避免污染 JSON-RPC 通道)。
*/
export async function startStdioServer(): Promise<void> {
startParentWatchdog()
void waitForBackend().then((ready) => {
log(ready ? "backend ready" : "backend not ready, running in degraded mode (tools will report notConnected)")
})
const mcp = createMcpServer()
const transport = new StdioServerTransport()
await mcp.connect(transport)
log("stdio transport connected")
}
+103
View File
@@ -0,0 +1,103 @@
import { HTTP_TIMEOUT_MS, TFS_API_BASE_URL, readToken } from "./config.js"
import type {
ApiResponse,
ApiResult,
FileOperationResult,
FileStatus,
HealthData,
} from "./types.js"
/**
* 发起一次对 Java 服务的请求,自动带 Bearer Token、统一封装结果与错误。
* 任何网络/解析失败都收敛成 { ok:false },绝不抛异常,保证 MCP 不被打断。
*/
async function request<T>(
method: string,
path: string,
options: { query?: Record<string, string | undefined>; body?: unknown } = {},
): Promise<ApiResponse<T>> {
const token = readToken()
const url = new URL(path, TFS_API_BASE_URL)
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value)
}
}
}
const headers: Record<string, string> = {
Accept: "application/json",
// 标记本次请求来自 MCP(AI),供后端操作日志区分「AI 操作 / 手动操作」。
"X-MacTFS-Source": "mcp",
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const init: RequestInit = { method, headers }
if (options.body !== undefined) {
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS)
init.signal = controller.signal
let response: Response
try {
response = await fetch(url, init)
} catch (error) {
clearTimeout(timer)
const message = error instanceof Error ? error.message : String(error)
return { ok: false, status: 0, data: null, errorMessage: `无法连接本地服务:${message}` }
}
clearTimeout(timer)
let result: ApiResult<T> | null = null
try {
result = (await response.json()) as ApiResult<T>
} catch {
result = null
}
const ok = response.ok && result?.success === true
return {
ok,
status: response.status,
data: result?.data ?? null,
errorMessage: ok
? null
: result?.errorMessage || result?.message || `请求失败(HTTP ${response.status})`,
}
}
// 统一的 Java 服务客户端,MCP 只通过本对象访问后端。
export const tfsClient = {
/** 健康检查,返回 { connected } 等服务状态。 */
health() {
return request<HealthData>("GET", "/api/health")
},
/** 查询单个本地文件的 TFS 状态(依赖后端新增的 /api/files/status)。 */
fileStatus(localPath: string) {
return request<{ status: FileStatus }>("GET", "/api/files/status", { query: { localPath } })
},
/** 对给定路径执行签出(pendEdit),paths 可为本地绝对路径或服务端路径。 */
checkout(paths: string[], recursive = false) {
return request<{ result: FileOperationResult }>("POST", "/api/files/checkout", {
body: { paths, recursive },
})
},
/** 将本地新增文件加入 pending add,paths 为本地绝对路径。 */
add(paths: string[], recursive = false) {
return request<{ result: FileOperationResult }>("POST", "/api/files/add", {
body: { paths, recursive },
})
},
/** 获取最新(用于 getLatestIfStale 场景),serverPath 缺省按当前 Workspace 全量。 */
getLatest(serverPath: string | undefined, recursive = false) {
return request<{ result: unknown }>("POST", "/api/files/get-latest", {
body: { serverPath, recursive, force: false },
})
},
}
+77
View File
@@ -0,0 +1,77 @@
// 与 Java 后端 ApiResult 对齐的统一响应外壳。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message?: string
errorMessage?: string
operation?: string
data: T
}
// HTTP 客户端对外返回结构,统一封装网络层与业务层。
export interface ApiResponse<T = Record<string, unknown>> {
ok: boolean
status: number
data: T | null
errorMessage: string | null
}
// /api/health 返回的 data 字段。
export interface HealthData {
status?: string
connected?: boolean
}
// /api/files/status(新增)返回的单文件状态。
export interface FileStatus {
localPath: string
serverPath: string | null
mapped: boolean
exists: boolean
serverExists: boolean
pendingEdit: boolean
pendingChangeType: string | null
localVersion: number
latestVersion: number
upToDate: boolean
status: string
}
// /api/files/checkout 返回的 result 字段。
export interface FileOperationResult {
operation: string
affected: number
failures: string[]
}
// tfs_checkout / tfs_add 对每个路径返回的结构化结果(共用一套结构)。
export type PendAction =
| "checkedOut"
| "alreadyCheckedOut"
| "added"
| "alreadyAdded"
| "skipped"
| "error"
export type PendReason =
| "ok"
| "notConnected"
| "clientNotRunning"
| "notMapped"
| "newFileNeedsAdd"
| "notDownloaded"
| "stale"
| "notExist"
| "alreadyTracked"
| "error"
export interface PendItemResult {
path: string
serverPath: string | null
action: PendAction
reason: PendReason
mapped: boolean
pendingEdit: boolean
localVersion: number
latestVersion: number
upToDate: boolean
message: string
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
@@ -804,6 +804,120 @@ public class MacTfsCoreService {
}); });
} }
/**
* 查询单个本地文件的 TFS 状态(是否映射 / 是否已签出 / 本地与最新版本对比),
* 供 MCP 在 AI 改文件前做执行前校验。整条逻辑复用目录对比的状态判定。
*/
public CoreOperationResult<TfsFileStatus> fileStatus(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String localPath) {
return execute("fileStatus", new CoreCallable<TfsFileStatus>() {
@Override
public TfsFileStatus call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String absolute = new File(require(localPath, "localPath")).getAbsolutePath();
boolean exists = new File(absolute).exists();
String serverPath = mappedServerPathOf(workspace, absolute);
if (serverPath == null) {
logs.add("File not mapped: " + absolute);
return TfsFileStatus.notMapped(absolute, exists);
}
ExtendedItem item = queryExtendedItem(workspace, serverPath);
PendingChange pendingChange = findPendingChange(workspace, serverPath);
String pendingChangeType = pendingChange == null ? null : toPendingStatus(pendingChange);
boolean pendingEdit = pendingChange != null && pendingChange.isEdit();
int localVersion = item == null ? 0 : item.getLocalVersion();
int latestVersion = item == null ? 0 : item.getLatestVersion();
boolean serverExists = latestVersion > 0;
String status;
if (item == null) {
status = exists ? "localOnly" : "notDownloaded";
} else {
status = resolveDiffStatus(item, pendingChange, new File(absolute));
}
boolean upToDate = serverExists && localVersion > 0 && localVersion >= latestVersion;
logs.add("File status: " + serverPath + " local=" + localVersion + " latest=" + latestVersion + " status=" + status);
return new TfsFileStatus(absolute, serverPath, true, exists, serverExists, pendingEdit,
pendingChangeType, localVersion, latestVersion, upToDate, status);
}
});
}
/**
* 将本地绝对路径解析为映射的服务端路径,取最深(最长)匹配的映射根;未命中任何映射返回 null。
*/
private String mappedServerPathOf(Workspace workspace, String absoluteLocalPath) {
WorkingFolder[] folders = workspace.getFolders();
if (folders == null) {
return null;
}
String best = null;
String bestRoot = null;
for (WorkingFolder folder : folders) {
if (folder.getLocalItem() == null) {
continue;
}
String localRoot = new File(folder.getLocalItem()).getAbsolutePath();
boolean underRoot = absoluteLocalPath.equals(localRoot)
|| absoluteLocalPath.startsWith(localRoot + File.separator);
if (!underRoot) {
continue;
}
if (bestRoot != null && localRoot.length() <= bestRoot.length()) {
continue;
}
bestRoot = localRoot;
String serverRoot = normalizeServerPath(folder.getServerItem());
String relative = absoluteLocalPath.substring(localRoot.length()).replace(File.separatorChar, '/');
while (relative.startsWith("/")) {
relative = relative.substring(1);
}
best = relative.isEmpty() ? serverRoot : serverRoot + "/" + relative;
}
return best;
}
/**
* 查询单个服务端路径对应的 ExtendedItem(本地版本 / 最新版本 / 类型),无则返回 null。
*/
private ExtendedItem queryExtendedItem(Workspace workspace, String serverPath) {
ExtendedItem[][] groups = workspace.getExtendedItems(
new ItemSpec[]{new ItemSpec(normalizeServerPath(serverPath), RecursionType.NONE)},
DeletedState.NON_DELETED,
ItemType.ANY,
GetItemsOptions.NONE
);
if (groups == null) {
return null;
}
for (ExtendedItem[] group : groups) {
if (group == null) {
continue;
}
for (ExtendedItem item : group) {
if (item != null) {
return item;
}
}
}
return null;
}
/**
* 查询单个服务端路径上的挂起更改,无则返回 null。
*/
private PendingChange findPendingChange(Workspace workspace, String serverPath) {
PendingChange[] changes = queryPendingChanges(workspace, Arrays.asList(serverPath), false);
String normalized = normalizeServerPath(serverPath).toLowerCase();
for (PendingChange change : changes) {
if (change.getServerItem() != null && change.getServerItem().toLowerCase().equals(normalized)) {
return change;
}
}
return null;
}
/** /**
* 生成本地文件和服务器 latest 的文本 diff。 * 生成本地文件和服务器 latest 的文本 diff。
*/ */
@@ -2189,6 +2303,84 @@ public class MacTfsCoreService {
} }
} }
public static class TfsFileStatus {
private final String localPath;
private final String serverPath;
private final boolean mapped;
private final boolean exists;
private final boolean serverExists;
private final boolean pendingEdit;
private final String pendingChangeType;
private final int localVersion;
private final int latestVersion;
private final boolean upToDate;
private final String status;
public TfsFileStatus(String localPath, String serverPath, boolean mapped, boolean exists, boolean serverExists,
boolean pendingEdit, String pendingChangeType, int localVersion, int latestVersion,
boolean upToDate, String status) {
this.localPath = localPath;
this.serverPath = serverPath;
this.mapped = mapped;
this.exists = exists;
this.serverExists = serverExists;
this.pendingEdit = pendingEdit;
this.pendingChangeType = pendingChangeType;
this.localVersion = localVersion;
this.latestVersion = latestVersion;
this.upToDate = upToDate;
this.status = status;
}
public static TfsFileStatus notMapped(String localPath, boolean exists) {
return new TfsFileStatus(localPath, null, false, exists, false, false, null, 0, 0, false, "notMapped");
}
public String getLocalPath() {
return localPath;
}
public String getServerPath() {
return serverPath;
}
public boolean isMapped() {
return mapped;
}
public boolean isExists() {
return exists;
}
public boolean isServerExists() {
return serverExists;
}
public boolean isPendingEdit() {
return pendingEdit;
}
public String getPendingChangeType() {
return pendingChangeType;
}
public int getLocalVersion() {
return localVersion;
}
public int getLatestVersion() {
return latestVersion;
}
public boolean isUpToDate() {
return upToDate;
}
public String getStatus() {
return status;
}
}
public static class TfsConflictInfo { public static class TfsConflictInfo {
private final int conflictId; private final int conflictId;
private final String type; private final String type;
@@ -14,6 +14,7 @@ import com.mydev.mactfs.core.MacTfsCoreService.TfsConflictResolution;
import com.mydev.mactfs.core.MacTfsCoreService.TfsConnectionConfig; import com.mydev.mactfs.core.MacTfsCoreService.TfsConnectionConfig;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileContent; import com.mydev.mactfs.core.MacTfsCoreService.TfsFileContent;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult; import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileStatus;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem; import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult; import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry; import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
@@ -95,6 +96,64 @@ public class MacTfsServer {
Spark.awaitInitialization(); Spark.awaitInitialization();
System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT); System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath()); System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
startParentWatchdog();
}
/**
* 启动父进程看护:当拉起本服务的宿主(Electron)进程消失时自动退出,
* 避免宿主被强制结束 / 崩溃后遗留「占着 38765 端口」的孤儿后端。
* 依据环境变量 MACTFS_PARENT_PID(由宿主 spawn 时写入);未设置则不启用(如 CLI / 手动启动)。
*/
private void startParentWatchdog() {
String raw = System.getenv("MACTFS_PARENT_PID");
if (raw == null || raw.trim().isEmpty()) {
return;
}
final long parentPid;
try {
parentPid = Long.parseLong(raw.trim());
} catch (NumberFormatException exception) {
return;
}
Thread watchdog = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
Thread.sleep(3000L);
} catch (InterruptedException exception) {
return;
}
if (!isProcessAlive(parentPid)) {
System.out.println("MacTFS parent process " + parentPid + " gone, exiting.");
System.exit(0);
}
}
}
}, "mactfs-parent-watchdog");
watchdog.setDaemon(true);
watchdog.start();
System.out.println("MacTFS watching parent pid " + parentPid);
}
/**
* 判断给定 pid 的进程是否存活:macOS / Linux 上用 `/bin/kill -0 <pid>` 探测,退出码 0 即存活。
* 探测自身出现异常时按「存活」处理,宁可不退也不误杀正在服务的后端。
*/
private boolean isProcessAlive(long pid) {
try {
Process probe = new ProcessBuilder("/bin/kill", "-0", String.valueOf(pid))
.redirectErrorStream(true)
.start();
boolean finished = probe.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
probe.destroyForcibly();
return true;
}
return probe.exitValue() == 0;
} catch (Exception exception) {
return true;
}
} }
/** /**
@@ -150,7 +209,7 @@ public class MacTfsServer {
private void registerFilters() { private void registerFilters() {
Spark.before((request, response) -> { Spark.before((request, response) -> {
response.header("Access-Control-Allow-Origin", "*"); response.header("Access-Control-Allow-Origin", "*");
response.header("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-MacTFS-Source");
response.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); response.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
if ("OPTIONS".equalsIgnoreCase(request.requestMethod())) { if ("OPTIONS".equalsIgnoreCase(request.requestMethod())) {
throw Spark.halt(204, ""); throw Spark.halt(204, "");
@@ -220,6 +279,22 @@ public class MacTfsServer {
} }
})); }));
// 只读连通性探测:用当前会话/已存配置真正回连一次 TFS,验证服务是否仍可达、认证是否仍有效。
// 不修改任何会话或配置状态,供顶栏「连接测试」手动触发;耗时见返回的 coreDurationMs。
Spark.post("/api/session/test", (request, response) -> handle(request, response, "testConnection", TIMEOUT_CONNECT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(readBody(request));
CoreOperationResult<ConnectionSummary> result = coreService.testConnection(toTfsConfig(config));
Map<String, Object> data = new LinkedHashMap<String, Object>();
if (result.getData() != null) {
data.put("serverUri", result.getData().getServerUri());
data.put("collectionCount", Integer.valueOf(result.getData().getCollectionCount()));
}
return fromCore(result, data);
}
}));
Spark.get("/api/collections", (request, response) -> handle(request, response, "listCollections", TIMEOUT_DIRECTORY_MS, new ApiCallable() { Spark.get("/api/collections", (request, response) -> handle(request, response, "listCollections", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
@Override @Override
public ApiResult call(Request request) throws Exception { public ApiResult call(Request request) throws Exception {
@@ -533,6 +608,22 @@ public class MacTfsServer {
} }
})); }));
Spark.get("/api/files/status", (request, response) -> handle(request, response, "fileStatus", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
CoreOperationResult<TfsFileStatus> result = coreService.fileStatus(
toTfsConfig(config),
require(config.collection, "collection"),
require(config.workspace, "workspace"),
require(request.queryParams("localPath"), "localPath")
);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("status", result.getData());
return fromCore(result, data);
}
}));
Spark.get("/api/items/info", (request, response) -> handle(request, response, "itemInfo", TIMEOUT_DEFAULT_MS, new ApiCallable() { Spark.get("/api/items/info", (request, response) -> handle(request, response, "itemInfo", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override @Override
public ApiResult call(Request request) throws Exception { public ApiResult call(Request request) throws Exception {
@@ -708,6 +799,7 @@ public class MacTfsServer {
*/ */
private String handle(Request request, Response response, String operation, long timeoutMs, ApiCallable callable) throws Exception { private String handle(Request request, Response response, String operation, long timeoutMs, ApiCallable callable) throws Exception {
long startedAt = System.currentTimeMillis(); long startedAt = System.currentTimeMillis();
String source = resolveSource(request);
ApiResult result; ApiResult result;
Future<ApiResult> future = executorService.submit(new Callable<ApiResult>() { Future<ApiResult> future = executorService.submit(new Callable<ApiResult>() {
@Override @Override
@@ -731,10 +823,22 @@ public class MacTfsServer {
result.startedAt = startedAt; result.startedAt = startedAt;
result.endedAt = endedAt; result.endedAt = endedAt;
response.status(result.status); response.status(result.status);
logStore.add(operation, summarize(request), startedAt, endedAt, result); logStore.add(operation, summarize(request), startedAt, endedAt, result, source);
return objectMapper.writeValueAsString(result); return objectMapper.writeValueAsString(result);
} }
/**
* 判定本次请求来源:mcp(AI 通过 MCP 调用)或 ui(桌面端手动操作)。
* 依据自定义请求头 X-MacTFS-Source;缺省或未知一律按 ui 处理,兼容未带该头的旧客户端。
*/
private String resolveSource(Request request) {
String raw = request.headers("X-MacTFS-Source");
if (raw != null && "mcp".equalsIgnoreCase(raw.trim())) {
return "mcp";
}
return "ui";
}
/** /**
* 从当前会话或配置文件读取连接配置,并允许请求体覆盖当前操作相关字段。 * 从当前会话或配置文件读取连接配置,并允许请求体覆盖当前操作相关字段。
*/ */
@@ -1259,7 +1363,7 @@ public class MacTfsServer {
/** /**
* 追加一次 API 操作日志,记录开始、结束、耗时和失败原因。 * 追加一次 API 操作日志,记录开始、结束、耗时和失败原因。
*/ */
synchronized void add(String operation, String summary, long startedAt, long endedAt, ApiResult result) { synchronized void add(String operation, String summary, long startedAt, long endedAt, ApiResult result, String source) {
OperationLogEntry entry = new OperationLogEntry(); OperationLogEntry entry = new OperationLogEntry();
entry.operation = operation; entry.operation = operation;
entry.summary = summary; entry.summary = summary;
@@ -1268,6 +1372,7 @@ public class MacTfsServer {
entry.durationMs = endedAt - startedAt; entry.durationMs = endedAt - startedAt;
entry.success = result.success; entry.success = result.success;
entry.errorMessage = result.errorMessage; entry.errorMessage = result.errorMessage;
entry.source = source;
logs.add(entry); logs.add(entry);
while (logs.size() > MAX_LOGS) { while (logs.size() > MAX_LOGS) {
logs.remove(0); logs.remove(0);
@@ -1290,5 +1395,7 @@ public class MacTfsServer {
public long durationMs; public long durationMs;
public boolean success; public boolean success;
public String errorMessage; public String errorMessage;
/** 操作来源:mcp(AI)或 ui(手动)。 */
public String source;
} }
} }
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { RefreshCw } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { ScrollArea } from "~/components/ui/scroll-area"
import { getMcpLogs, getMcpStatus } from "~/lib/electron"
import type { McpLogEntry, McpStatus } from "~/lib/electron"
import { cn } from "~/lib/utils"
// 弹窗打开时的自动刷新间隔(毫秒)。
const REFRESH_INTERVAL_MS = 2000
/**
* 把毫秒时间戳格式化为 HH:mm:ss,供日志行展示。
*/
function formatLogTime(value: number): string {
const date = new Date(value)
const pad = (input: number) => String(input).padStart(2, "0")
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/**
* 把运行时长(毫秒)格式化为 Xs / Xm Ys / Xh Ym。
*/
function formatUptime(ms: number): string {
if (ms <= 0) {
return "—"
}
const totalSeconds = Math.floor(ms / 1000)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
/**
* MCP 服务状态弹窗:展示子进程运行状态(运行中 / healthz 探活 / pid / 运行时长 /
* 重启次数 / 上次退出码)与最近 500 行运行日志。打开期间每 2 秒自动刷新,日志自动滚到底部。
*/
export function McpStatusDialog({ onClose }: { onClose: () => void }) {
const [status, setStatus] = useState<McpStatus | null>(null)
const [logs, setLogs] = useState<McpLogEntry[]>([])
const [loading, setLoading] = useState(true)
const logEndRef = useRef<HTMLDivElement | null>(null)
const refresh = useCallback(async () => {
const [nextStatus, nextLogs] = await Promise.all([getMcpStatus(), getMcpLogs()])
setStatus(nextStatus)
setLogs(nextLogs)
setLoading(false)
}, [])
useEffect(() => {
void refresh()
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS)
return () => clearInterval(timer)
}, [refresh])
// 日志更新后滚到底部,始终看到最新输出。
useEffect(() => {
logEndRef.current?.scrollIntoView({ block: "end" })
}, [logs])
const running = status?.running ?? false
const healthy = status?.healthy ?? false
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>MCP 服务状态</DialogTitle>
<DialogDescription className="font-mono text-xs">
{status?.sseUrl ?? "http://127.0.0.1:38766/sse"}
</DialogDescription>
</DialogHeader>
{/* 状态摘要 */}
<div className="grid shrink-0 grid-cols-2 gap-x-6 gap-y-2 rounded-lg border bg-muted/30 px-4 py-3 text-xs sm:grid-cols-3">
<StatusItem label="进程">
{running ? (
<Badge variant="default">运行中</Badge>
) : (
<Badge variant="destructive">已停止</Badge>
)}
</StatusItem>
<StatusItem label="探活 /healthz">
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-2 rounded-full",
healthy ? "bg-emerald-500" : "bg-red-500",
)}
/>
{healthy ? "可达" : "不可达"}
</span>
</StatusItem>
<StatusItem label="PID">
<span className="font-mono">{status?.pid ?? "—"}</span>
</StatusItem>
<StatusItem label="运行时长">
<span className="font-mono">{formatUptime(status?.uptimeMs ?? 0)}</span>
</StatusItem>
<StatusItem label="重启次数">
<span className="font-mono">{status?.restartCount ?? 0}</span>
</StatusItem>
<StatusItem label="上次退出码">
<span className="font-mono">{status?.lastExitCode ?? "—"}</span>
</StatusItem>
</div>
{status?.lastError && (
<p className="shrink-0 rounded-md bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
{status.lastError}
</p>
)}
{!status?.entryResolved && (
<p className="shrink-0 rounded-md bg-amber-500/10 px-3 py-1.5 text-xs text-amber-600">
未找到 MCP 入口(开发态请先在 mactfs-mcp 执行 pnpm build)。
</p>
)}
{/* 运行日志 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border">
<div className="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
运行日志
</span>
<span className="ml-auto text-xs text-muted-foreground">
{logs.length > 0 && `${logs.length} 行`}
</span>
</div>
<ScrollArea className="min-h-0 flex-1">
{logs.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{loading ? "正在加载日志…" : "暂无日志输出"}
</p>
) : (
<div className="px-3 py-1.5 font-mono text-[11px] leading-relaxed">
{logs.map((entry, index) => (
<div
key={`${entry.ts}-${index}`}
className={cn(
"flex gap-2 whitespace-pre-wrap break-all",
entry.stream === "stderr" ? "text-destructive" : "text-foreground/80",
)}
>
<span className="shrink-0 text-muted-foreground">{formatLogTime(entry.ts)}</span>
<span className="min-w-0">{entry.line}</span>
</div>
))}
<div ref={logEndRef} />
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => void refresh()} disabled={loading}>
<RefreshCw className={cn(loading && "animate-spin")} />
刷新
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/**
* 状态摘要中的单项:上方标签、下方值。
*/
function StatusItem({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="text-foreground">{children}</span>
</div>
)
}
+71 -1
View File
@@ -1,12 +1,18 @@
import { FolderCog, PanelBottom, PanelLeft, PanelRight, Unplug } from "lucide-react" import { Activity, FolderCog, Loader2, PanelBottom, PanelLeft, PanelRight, PlugZap, Unplug } from "lucide-react"
import { useCallback, useState } from "react"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator" import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import { showToast } from "~/components/ui/toaster"
import { api } from "~/lib/api"
import { isMacElectron } from "~/lib/platform" import { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session" import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
// 顶栏 TFS 连接测试的四态:未测 / 测试中 / 正常 / 断开。
type TfsTestStatus = "idle" | "testing" | "ok" | "fail"
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。 // 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility { export interface PanelVisibility {
tree: boolean tree: boolean
@@ -24,15 +30,47 @@ export function TopBar({
onTogglePanel, onTogglePanel,
onReconnect, onReconnect,
onManageWorkspace, onManageWorkspace,
onOpenMcpStatus,
}: { }: {
session: WorkspaceSession session: WorkspaceSession
panels: PanelVisibility panels: PanelVisibility
onTogglePanel: (panel: keyof PanelVisibility) => void onTogglePanel: (panel: keyof PanelVisibility) => void
onReconnect: () => void onReconnect: () => void
onManageWorkspace: () => void onManageWorkspace: () => void
onOpenMcpStatus: () => void
}) { }) {
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。 // 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
const macInset = isMacElectron() const macInset = isMacElectron()
// 连接测试状态:手动触发,真正回连一次 TFS,结果走右上角 toast + 图标变色。
const [tfsStatus, setTfsStatus] = useState<TfsTestStatus>("idle")
const runTfsTest = useCallback(async () => {
setTfsStatus("testing")
showToast({ id: "tfs-test", kind: "loading", text: "正在测试 TFS 连接…" })
const resp = await api.testTfsConnection()
if (resp.ok) {
setTfsStatus("ok")
const ms = resp.data?.coreDurationMs
showToast({
id: "tfs-test",
kind: "success",
text: typeof ms === "number" ? `TFS 连接正常(${ms}ms)` : "TFS 连接正常",
})
} else {
setTfsStatus("fail")
showToast({ id: "tfs-test", kind: "error", text: `TFS 连接失败:${resp.errorMessage ?? "未知错误"}` })
}
}, [])
const tfsTestTip =
tfsStatus === "testing"
? "正在测试 TFS 连接…"
: tfsStatus === "ok"
? "TFS 连接正常,点击重新测试"
: tfsStatus === "fail"
? "TFS 连接异常,点击重新测试"
: "测试 TFS 连接"
return ( return (
<header <header
className={cn( className={cn(
@@ -85,6 +123,38 @@ export function TopBar({
<PanelBottom /> <PanelBottom />
</PanelToggle> </PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" /> <Separator orientation="vertical" className="mx-1 h-4!" />
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
onClick={runTfsTest}
disabled={tfsStatus === "testing"}
aria-label="测试 TFS 连接"
>
{tfsStatus === "testing" ? (
<Loader2 className="animate-spin" />
) : (
<PlugZap
className={cn(
tfsStatus === "ok" && "text-emerald-500",
tfsStatus === "fail" && "text-red-500",
tfsStatus === "idle" && "text-muted-foreground",
)}
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tfsTestTip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onOpenMcpStatus} aria-label="MCP 服务状态">
<Activity />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">MCP 服务状态与日志</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理"> <Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { McpStatusDialog } from "~/components/app/mcp-status-dialog"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar" import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs" import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel" import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
@@ -42,6 +43,8 @@ export function WorkspaceShell({
const [logsRefreshToken, setLogsRefreshToken] = useState(0) const [logsRefreshToken, setLogsRefreshToken] = useState(0)
// 工作区集中管理弹窗显隐。 // 工作区集中管理弹窗显隐。
const [manageOpen, setManageOpen] = useState(false) const [manageOpen, setManageOpen] = useState(false)
// MCP 状态弹窗显隐。
const [mcpOpen, setMcpOpen] = useState(false)
/** /**
* 切换指定面板的展开 / 收起状态。 * 切换指定面板的展开 / 收起状态。
@@ -95,6 +98,7 @@ export function WorkspaceShell({
onTogglePanel={togglePanel} onTogglePanel={togglePanel}
onReconnect={onReconnect} onReconnect={onReconnect}
onManageWorkspace={() => setManageOpen(true)} onManageWorkspace={() => setManageOpen(true)}
onOpenMcpStatus={() => setMcpOpen(true)}
/> />
<div className="flex min-h-0 flex-1 px-2 pb-2"> <div className="flex min-h-0 flex-1 px-2 pb-2">
@@ -135,7 +139,7 @@ export function WorkspaceShell({
loading={pending.loading} loading={pending.loading}
error={pending.error} error={pending.error}
checkinBusy={actions.checkinBusy} checkinBusy={actions.checkinBusy}
onToggleExcluded={pending.toggleExcluded} onSetExcluded={pending.setExcluded}
onCheckin={actions.handleCheckin} onCheckin={actions.handleCheckin}
onFileAction={actions.handleFileAction} onFileAction={actions.handleFileAction}
onRefresh={() => void pending.refresh()} onRefresh={() => void pending.refresh()}
@@ -186,6 +190,8 @@ export function WorkspaceShell({
/> />
)} )}
{mcpOpen && <McpStatusDialog onClose={() => setMcpOpen(false)} />}
<Toaster /> <Toaster />
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react" import { ChevronRight, ListChecks, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge" import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox" import { Checkbox } from "~/components/ui/checkbox"
@@ -9,12 +10,112 @@ import { ScrollArea } from "~/components/ui/scroll-area"
import { Textarea } from "~/components/ui/textarea" import { Textarea } from "~/components/ui/textarea"
import type { MappingInfo, PendingChange } from "~/lib/api" import type { MappingInfo, PendingChange } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs" import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs" import {
SERVER_ROOT_PATH,
makeFileTarget,
normalizeServerPath,
statusBadgeClass,
statusLabel,
} from "~/lib/tfs"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
// 挂起更改目录树节点:目录节点聚合子项,文件/变更目录节点携带 change。
interface ChangeNode {
path: string
name: string
isDir: boolean
change: PendingChange | null
children: Map<string, ChangeNode>
}
/** /**
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、 * 把扁平的挂起更改按 serverPath 还原成目录树。中间目录自动补齐为目录节点,
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。 * 末段挂载对应 change(变更本身是目录时该节点 isDir=true,可继续容纳子项)。
*/
function buildChangeTree(changes: PendingChange[]): ChangeNode {
const root: ChangeNode = {
path: SERVER_ROOT_PATH,
name: SERVER_ROOT_PATH,
isDir: true,
change: null,
children: new Map(),
}
for (const change of changes) {
const normalized = normalizeServerPath(change.serverPath)
const relative =
normalized === SERVER_ROOT_PATH ? "" : normalized.slice(SERVER_ROOT_PATH.length)
const segments = relative.length > 0 ? relative.split("/") : []
let current = root
let currentPath = SERVER_ROOT_PATH
segments.forEach((segment, index) => {
currentPath =
currentPath === SERVER_ROOT_PATH
? `${SERVER_ROOT_PATH}${segment}`
: `${currentPath}/${segment}`
let child = current.children.get(segment)
if (!child) {
child = { path: currentPath, name: segment, isDir: true, change: null, children: new Map() }
current.children.set(segment, child)
}
if (index === segments.length - 1) {
child.change = change
child.isDir = change.folder
}
current = child
})
}
return root
}
/**
* 收集某节点子树内全部挂起更改的 serverPath(含节点自身的 change),
* 用于目录节点的计数与三态复选。
*/
function collectChangePaths(node: ChangeNode): string[] {
const result: string[] = []
const walk = (current: ChangeNode) => {
if (current.change) {
result.push(current.change.serverPath)
}
for (const child of current.children.values()) {
walk(child)
}
}
walk(node)
return result
}
// 目录/文件混排排序:目录在前,名称不区分大小写升序。
function sortNodes(nodes: ChangeNode[]): ChangeNode[] {
return [...nodes].sort((a, b) => {
if (a.isDir !== b.isDir) {
return a.isDir ? -1 : 1
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
})
}
/**
* 路径压缩:把「只有单个子目录且自身无变更」的目录链合并为一行(VS Code 风格),
* 返回最终展示节点与合并后的名称。
*/
function compactDir(node: ChangeNode): { node: ChangeNode; label: string } {
let current = node
const parts = [node.name]
while (current.isDir && !current.change && current.children.size === 1) {
const only = [...current.children.values()][0]
if (!only.isDir) {
break
}
parts.push(only.name)
current = only
}
return { node: current, label: parts.join("/") }
}
/**
* 右侧 Changes 面板:以服务端目录树呈现 Pending Changes,目录节点支持折叠与
* 三态复选(级联子项 Included / Excluded),并作为签入唯一入口提供 comment + Checkin。
*/ */
export function ChangesPanel({ export function ChangesPanel({
mappings, mappings,
@@ -23,7 +124,7 @@ export function ChangesPanel({
loading, loading,
error, error,
checkinBusy, checkinBusy,
onToggleExcluded, onSetExcluded,
onCheckin, onCheckin,
onFileAction, onFileAction,
onRefresh, onRefresh,
@@ -34,24 +135,40 @@ export function ChangesPanel({
loading: boolean loading: boolean
error: string | null error: string | null
checkinBusy: boolean checkinBusy: boolean
onToggleExcluded: (serverPath: string) => void onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onCheckin: (paths: string[], comment: string) => Promise<boolean> onCheckin: (paths: string[], comment: string) => Promise<boolean>
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const [comment, setComment] = useState("") const [comment, setComment] = useState("")
// 已折叠的目录 path 集合;默认全部展开。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const tree = useMemo(() => buildChangeTree(pendingChanges), [pendingChanges])
const rootChildren = useMemo(() => sortNodes([...tree.children.values()]), [tree])
const included = useMemo( const included = useMemo(
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)), () => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys], [pendingChanges, excludedKeys],
) )
const excluded = useMemo(
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
/**
* 折叠 / 展开目录节点。
*/
const toggleCollapsed = useCallback((path: string) => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
/** /**
* 提交签入:只提交 Included 项,成功后清空注释。 * 提交签入:只提交 Included 项,成功后清空注释。
*/ */
@@ -106,38 +223,19 @@ export function ChangesPanel({
) : ( ) : (
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-2"> <div className="p-2">
<GroupHeader label="Included" count={included.length} /> {rootChildren.map((node) => (
{included.length === 0 && ( <ChangeTreeNode
<p className="px-2 py-1.5 text-xs text-muted-foreground"> key={node.path}
没有要签入的更改,勾选下方 Excluded 项可移回 node={node}
</p> depth={0}
)}
{included.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included
mappings={mappings} mappings={mappings}
onToggleExcluded={onToggleExcluded} excludedKeys={excludedKeys}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction} onFileAction={onFileAction}
/> />
))} ))}
{excluded.length > 0 && (
<>
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
{excluded.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included={false}
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
</>
)}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@@ -152,9 +250,7 @@ export function ChangesPanel({
className="resize-none text-sm" className="resize-none text-sm"
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">将签入 {included.length} 项</span>
将签入 {included.length} 项
</span>
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}> <Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
{checkinBusy ? ( {checkinBusy ? (
<> <>
@@ -175,39 +271,155 @@ export function ChangesPanel({
} }
/** /**
* 分组标题行。 * 目录树节点:目录节点渲染折叠箭头 + 三态复选 + 计数并递归子节点;
* 文件 / 变更目录节点渲染单项复选 + 状态徽标,整行支持统一右键菜单。
*/ */
function GroupHeader({ function ChangeTreeNode({
label, node,
count, depth,
className, mappings,
excludedKeys,
collapsed,
onToggleCollapsed,
onSetExcluded,
onFileAction,
}: { }: {
label: string node: ChangeNode
count: number depth: number
className?: string mappings: MappingInfo[]
excludedKeys: Set<string>
collapsed: Set<string>
onToggleCollapsed: (path: string) => void
onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) { }) {
// 纯目录节点(自身无 change)做路径压缩;变更节点保持原样。
const { node: displayNode, label } =
node.isDir && !node.change ? compactDir(node) : { node, label: node.name }
const indent = depth * 12 + 4
const change = displayNode.change
// 叶子:文件,或没有子项的变更目录。
const isLeaf = !displayNode.isDir || displayNode.children.size === 0
if (isLeaf && change) {
return (
<LeafRow
node={displayNode}
label={label}
change={change}
indent={indent}
mappings={mappings}
excluded={excludedKeys.has(change.serverPath)}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction}
/>
)
}
// 目录节点:聚合子树变更,决定三态与计数。
const descendantPaths = collectChangePaths(displayNode)
const excludedCount = descendantPaths.filter((path) => excludedKeys.has(path)).length
const checkboxState: boolean | "indeterminate" =
excludedCount === 0 ? true : excludedCount === descendantPaths.length ? false : "indeterminate"
const expanded = !collapsed.has(displayNode.path)
const children = sortNodes([...displayNode.children.values()])
// 已全部 Included 时点击改为全部排除,否则全部移回 Included。
const handleToggle = () => onSetExcluded(descendantPaths, checkboxState === true)
const header = (
<div
className="flex h-8 cursor-default items-center gap-1.5 rounded-md pr-2 select-none hover:bg-muted"
style={{ paddingLeft: `${indent}px` }}
title={displayNode.path}
>
<button
type="button"
aria-label={expanded ? "收起目录" : "展开目录"}
onClick={() => onToggleCollapsed(displayNode.path)}
className="flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRight
className={cn("size-3.5 transition-transform duration-200", expanded && "rotate-90")}
/>
</button>
<Checkbox
checked={checkboxState}
onCheckedChange={handleToggle}
aria-label={checkboxState === true ? "排除该目录全部更改" : "签入该目录全部更改"}
/>
<FileIcon name={label} folder mapped={false} expanded={expanded} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{label}</span>
{change && (
<Badge
variant="secondary"
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(change.status)}
</Badge>
)}
<span className="shrink-0 text-[11px] text-muted-foreground/70">{descendantPaths.length}</span>
</div>
)
return ( return (
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}> <div>
<span className="text-xs font-medium text-muted-foreground">{label}</span> {change ? (
<span className="text-xs text-muted-foreground/70">{count}</span> <FolderTargetMenu
node={displayNode}
change={change}
mappings={mappings}
onFileAction={onFileAction}
>
{header}
</FolderTargetMenu>
) : (
header
)}
{expanded && children.length > 0 && (
<div>
{children.map((child) => (
<ChangeTreeNode
key={child.path}
node={child}
depth={depth + 1}
mappings={mappings}
excludedKeys={excludedKeys}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction}
/>
))}
</div>
)}
</div> </div>
) )
} }
/** /**
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。 * 文件 / 变更目录叶子行:复选框维护 Included / Excluded,整行支持统一右键菜单。
*/ */
function PendingRow({ function LeafRow({
node,
label,
change, change,
included, indent,
mappings, mappings,
onToggleExcluded, excluded,
onSetExcluded,
onFileAction, onFileAction,
}: { }: {
node: ChangeNode
label: string
change: PendingChange change: PendingChange
included: boolean indent: number
mappings: MappingInfo[] mappings: MappingInfo[]
onToggleExcluded: (serverPath: string) => void excluded: boolean
onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void
}) { }) {
const target = makeFileTarget({ const target = makeFileTarget({
@@ -217,35 +429,27 @@ function PendingRow({
mappings, mappings,
pendingStatus: change.status, pendingStatus: change.status,
}) })
const displayName =
change.status === "pendingRename" && change.sourceServerPath
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
: label
return ( return (
<FileTargetMenu target={target} onAction={onFileAction}> <FileTargetMenu target={target} onAction={onFileAction}>
<div <div
className={cn( className={cn(
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted", "flex h-8 cursor-default items-center gap-1.5 rounded-md pr-2 select-none hover:bg-muted",
!included && "opacity-60", excluded && "opacity-60",
)} )}
style={{ paddingLeft: `${indent + 22}px` }}
title={change.serverPath} title={change.serverPath}
> >
<Checkbox <Checkbox
checked={included} checked={!excluded}
onCheckedChange={() => onToggleExcluded(change.serverPath)} onCheckedChange={() => onSetExcluded([change.serverPath], !excluded)}
aria-label={included ? "移到 Excluded" : "移回 Included"} aria-label={!excluded ? "移到 Excluded" : "移回 Included"}
/> />
{change.folder ? ( <FileIcon name={node.name} folder={change.folder} />
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" /> <span className="min-w-0 flex-1 truncate text-sm">{displayName}</span>
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{change.status === "pendingRename" && change.sourceServerPath
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
: change.name}
</span>
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
{change.serverPath}
</span>
</span>
<Badge <Badge
variant="secondary" variant="secondary"
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))} className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
@@ -257,3 +461,33 @@ function PendingRow({
</FileTargetMenu> </FileTargetMenu>
) )
} }
/**
* 变更目录节点的右键菜单包装:复用统一动作模型,对目录类挂起更改提供动作。
*/
function FolderTargetMenu({
node,
change,
mappings,
onFileAction,
children,
}: {
node: ChangeNode
change: PendingChange
mappings: MappingInfo[]
onFileAction: (target: FileTarget, action: FileActionId) => void
children: React.ReactNode
}) {
const target = makeFileTarget({
source: "pending",
folder: true,
serverPath: node.path,
mappings,
pendingStatus: change.status,
})
return (
<FileTargetMenu target={target} onAction={onFileAction}>
{children}
</FileTargetMenu>
)
}
+118 -11
View File
@@ -1,12 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react" import { Bot, CheckCircle2, Loader2, RefreshCw, User, XCircle } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area" import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api" import { api } from "~/lib/api"
import type { OperationLogEntry } from "~/lib/api" import type { OperationLogEntry, OperationSource } from "~/lib/api"
import { cn, formatDateTime } from "~/lib/utils" import { cn, formatDateTime } from "~/lib/utils"
// 来源过滤选项:全部 / 仅手动 / 仅 AI。
type SourceFilter = "all" | "ui" | "mcp"
/**
* 归一化操作来源:旧记录可能缺省 source,统一按「手动(ui)」处理。
*/
function normalizeSource(entry: OperationLogEntry): OperationSource {
return entry.source === "mcp" ? "mcp" : "ui"
}
/** /**
* 把毫秒耗时格式化为可读文本。 * 把毫秒耗时格式化为可读文本。
*/ */
@@ -33,6 +44,7 @@ export function ConsolePanel({
const [logs, setLogs] = useState<OperationLogEntry[]>([]) const [logs, setLogs] = useState<OperationLogEntry[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sourceFilter, setSourceFilter] = useState<SourceFilter>("all")
/** /**
* 拉取最近操作日志。 * 拉取最近操作日志。
@@ -53,11 +65,25 @@ export function ConsolePanel({
void loadLogs() void loadLogs()
}, [loadLogs, refreshToken]) }, [loadLogs, refreshToken])
// 最新的操作排在最上面。 // 各来源计数,供过滤按钮展示数量。
const ordered = useMemo( const counts = useMemo(() => {
() => [...logs].sort((a, b) => b.startedAt - a.startedAt), let mcp = 0
[logs], for (const entry of logs) {
) if (normalizeSource(entry) === "mcp") {
mcp += 1
}
}
return { all: logs.length, mcp, ui: logs.length - mcp }
}, [logs])
// 按来源过滤后,最新的操作排在最上面。
const ordered = useMemo(() => {
const filtered =
sourceFilter === "all"
? logs
: logs.filter((entry) => normalizeSource(entry) === sourceFilter)
return [...filtered].sort((a, b) => b.startedAt - a.startedAt)
}, [logs, sourceFilter])
return ( return (
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card"> <footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
@@ -71,9 +97,29 @@ export function ConsolePanel({
{busyText ?? "正在执行…"} {busyText ?? "正在执行…"}
</span> </span>
)} )}
<span className="ml-auto text-xs text-muted-foreground">
{ordered.length > 0 && `${ordered.length} 条`} {/* 来源过滤:全部 / 手动 / AI */}
</span> <div className="ml-auto flex items-center gap-0.5 rounded-md border bg-background/60 p-0.5">
<SourceFilterButton
active={sourceFilter === "all"}
label="全部"
count={counts.all}
onClick={() => setSourceFilter("all")}
/>
<SourceFilterButton
active={sourceFilter === "ui"}
label="手动"
count={counts.ui}
onClick={() => setSourceFilter("ui")}
/>
<SourceFilterButton
active={sourceFilter === "mcp"}
label="AI"
count={counts.mcp}
onClick={() => setSourceFilter("mcp")}
/>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
@@ -90,7 +136,13 @@ export function ConsolePanel({
<p className="px-3 py-2 text-xs text-destructive">{error}</p> <p className="px-3 py-2 text-xs text-destructive">{error}</p>
) : ordered.length === 0 ? ( ) : ordered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground"> <p className="px-3 py-2 text-xs text-muted-foreground">
{loading ? "正在加载操作日志…" : "暂无操作日志"} {loading
? "正在加载操作日志…"
: sourceFilter === "mcp"
? "暂无 AI 操作记录"
: sourceFilter === "ui"
? "暂无手动操作记录"
: "暂无操作日志"}
</p> </p>
) : ( ) : (
<ul className="px-2 py-1"> <ul className="px-2 py-1">
@@ -106,6 +158,7 @@ export function ConsolePanel({
) : ( ) : (
<XCircle className="size-3.5 shrink-0 text-destructive" /> <XCircle className="size-3.5 shrink-0 text-destructive" />
)} )}
<SourceBadge source={normalizeSource(entry)} />
<span className="w-36 shrink-0 truncate font-mono text-muted-foreground"> <span className="w-36 shrink-0 truncate font-mono text-muted-foreground">
{entry.operation} {entry.operation}
</span> </span>
@@ -129,3 +182,57 @@ export function ConsolePanel({
</footer> </footer>
) )
} }
/**
* 来源过滤按钮:高亮当前选中项,并显示该来源的记录条数。
*/
function SourceFilterButton({
active,
label,
count,
onClick,
}: {
active: boolean
label: string
count: number
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded px-1.5 py-0.5 text-[11px] leading-none transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{label}
<span className={cn("ml-1 tabular-nums", active ? "opacity-80" : "opacity-60")}>{count}</span>
</button>
)
}
/**
* 来源徽章:AI(MCP 调用)用主色高亮,手动(桌面端)用中性色,便于一眼区分。
*/
function SourceBadge({ source }: { source: OperationSource }) {
if (source === "mcp") {
return (
<Badge
variant="secondary"
className="h-4 shrink-0 gap-0.5 px-1 text-[10px] bg-violet-500/15 text-violet-600 dark:text-violet-300"
>
<Bot className="size-2.5" />
AI
</Badge>
)
}
return (
<Badge variant="outline" className="h-4 shrink-0 gap-0.5 px-1 text-[10px] text-muted-foreground">
<User className="size-2.5" />
手动
</Badge>
)
}
+4 -4
View File
@@ -2,17 +2,18 @@ import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui" import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
import { CheckIcon } from "lucide-react" import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({ function Checkbox({
className, className,
...props ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) { }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const indeterminate = props.checked === "indeterminate"
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
className className
)} )}
{...props} {...props}
@@ -21,8 +22,7 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5" className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
> >
<CheckIcon {indeterminate ? <MinusIcon /> : <CheckIcon />}
/>
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
+14 -7
View File
@@ -40,15 +40,22 @@ export function usePendingChanges() {
}, [refresh]) }, [refresh])
/** /**
* 切换某条挂起更改的 Included / Excluded 归属。 * 批量设置一组挂起更改的 Included / Excluded 归属。
* 单项勾选传单元素数组,目录节点级联传子树全部 serverPath。
* excluded=true 全部排除,false 全部移回 Included。
*/ */
const toggleExcluded = useCallback((serverPath: string) => { const setExcluded = useCallback((serverPaths: string[], excluded: boolean) => {
if (serverPaths.length === 0) {
return
}
setExcludedKeys((prev) => { setExcludedKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(serverPath)) { for (const serverPath of serverPaths) {
next.delete(serverPath) if (excluded) {
} else { next.add(serverPath)
next.add(serverPath) } else {
next.delete(serverPath)
}
} }
return next return next
}) })
@@ -70,6 +77,6 @@ export function usePendingChanges() {
excludedKeys, excludedKeys,
pendingByServerPath, pendingByServerPath,
refresh, refresh,
toggleExcluded, setExcluded,
} }
} }
+2
View File
@@ -45,6 +45,8 @@ async function request<T = Record<string, unknown>>(
const token = await getToken() const token = await getToken()
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json", Accept: "application/json",
// 标记本次请求来自桌面端手动操作,供后端操作日志区分「AI 操作 / 手动操作」。
"X-MacTFS-Source": "ui",
} }
if (token) { if (token) {
headers.Authorization = `Bearer ${token}` headers.Authorization = `Bearer ${token}`
+9
View File
@@ -18,6 +18,7 @@ import type {
OperationLogEntry, OperationLogEntry,
PendingChange, PendingChange,
ServerItem, ServerItem,
TestConnectionResult,
TextDiff, TextDiff,
WorkspaceContext, WorkspaceContext,
WorkspaceInfo, WorkspaceInfo,
@@ -47,6 +48,14 @@ export function connect(config: Partial<AppConfig> = {}) {
return apiClient.post<ConnectResult>("/api/session/connect", { body: config }) return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
} }
/**
* 只读探测当前 TFS 连接是否仍可用(真正回连一次),不改变会话/配置状态。
* 供顶栏「连接测试」手动触发;成功返回 serverUri / collectionCount / 耗时。
*/
export function testTfsConnection() {
return apiClient.post<TestConnectionResult>("/api/session/test")
}
/** /**
* 列出当前账号可见的 Collection。 * 列出当前账号可见的 Collection。
*/ */
+1
View File
@@ -19,6 +19,7 @@ export type {
MappingConfig, MappingConfig,
MappingInfo, MappingInfo,
OperationLogEntry, OperationLogEntry,
OperationSource,
PendingChange, PendingChange,
ServerItem, ServerItem,
TextDiff, TextDiff,
+12
View File
@@ -51,6 +51,13 @@ export interface ConnectResult {
collectionCount?: number collectionCount?: number
} }
// 连接测试结果,对应 /api/session/test(只读探测);coreDurationMs 为本次回连耗时。
export interface TestConnectionResult {
serverUri?: string
collectionCount?: number
coreDurationMs?: number
}
// Collection 信息,对应 core TfsCollectionInfo。 // Collection 信息,对应 core TfsCollectionInfo。
export interface Collection { export interface Collection {
name: string name: string
@@ -215,6 +222,9 @@ export interface ConflictResolutionResult {
remainingConflicts: number remainingConflicts: number
} }
// 操作来源:mcp 为 AI 通过 MCP 调用,ui 为桌面端手动操作。旧记录可能缺省。
export type OperationSource = "mcp" | "ui"
// 操作日志项,对应服务端 OperationLogEntry。 // 操作日志项,对应服务端 OperationLogEntry。
export interface OperationLogEntry { export interface OperationLogEntry {
operation: string operation: string
@@ -224,4 +234,6 @@ export interface OperationLogEntry {
durationMs: number durationMs: number
success: boolean success: boolean
errorMessage: string | null errorMessage: string | null
/** 操作来源:mcp(AI)/ ui(手动);旧记录可能为 undefined,按手动处理。 */
source?: OperationSource
} }
+34 -1
View File
@@ -1,4 +1,4 @@
import type { MactfsBridge, ServiceStatus } from "./types" import type { MactfsBridge, McpLogEntry, McpStatus, ServiceStatus } from "./types"
const DEFAULT_BASE_URL = "http://127.0.0.1:38765" const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
@@ -96,6 +96,39 @@ export async function revealPath(targetPath: string, isFolder: boolean): Promise
return bridge.revealPath(targetPath, isFolder) return bridge.revealPath(targetPath, isFolder)
} }
/**
* 查询 MCP 子进程运行状态,非 Electron 环境返回未运行占位。
*/
export async function getMcpStatus(): Promise<McpStatus> {
const bridge = getBridge()
if (!bridge) {
return {
running: false,
healthy: false,
pid: null,
sseUrl: "http://127.0.0.1:38766/sse",
startedAt: null,
uptimeMs: 0,
restartCount: 0,
lastExitCode: null,
lastError: "当前不在 Electron 桌面环境,无法访问 MCP 服务",
entryResolved: false,
}
}
return bridge.getMcpStatus()
}
/**
* 读取 MCP 子进程运行日志(最近 500 行),非 Electron 环境返回空数组。
*/
export async function getMcpLogs(): Promise<McpLogEntry[]> {
const bridge = getBridge()
if (!bridge) {
return []
}
return bridge.getMcpLogs()
}
/** /**
* 构造非 Electron 环境下的占位服务状态。 * 构造非 Electron 环境下的占位服务状态。
*/ */
+3 -1
View File
@@ -1,7 +1,9 @@
export type { MactfsBridge, ServiceHealth, ServiceStatus } from "./types" export type { MactfsBridge, McpLogEntry, McpStatus, ServiceHealth, ServiceStatus } from "./types"
export { export {
getApiBaseUrl, getApiBaseUrl,
getBridge, getBridge,
getMcpLogs,
getMcpStatus,
getServiceStatus, getServiceStatus,
getToken, getToken,
isElectron, isElectron,
+23
View File
@@ -17,6 +17,27 @@ export interface ServiceStatus {
error: string | null error: string | null
} }
// MCP 子进程运行状态,由 Electron 主进程汇总(含 /healthz 探活)。
export interface McpStatus {
running: boolean
healthy: boolean
pid: number | null
sseUrl: string
startedAt: number | null
uptimeMs: number
restartCount: number
lastExitCode: number | null
lastError: string | null
entryResolved: boolean
}
// MCP 单行运行日志(来自子进程 stdout/stderr)。
export interface McpLogEntry {
ts: number
stream: "stdout" | "stderr"
line: string
}
// preload 暴露到 window.mactfs 的窄接口契约。 // preload 暴露到 window.mactfs 的窄接口契约。
export interface MactfsBridge { export interface MactfsBridge {
isElectron: true isElectron: true
@@ -27,6 +48,8 @@ export interface MactfsBridge {
selectDirectory(): Promise<string | null> selectDirectory(): Promise<string | null>
pathsExist(paths: string[]): Promise<Record<string, boolean>> pathsExist(paths: string[]): Promise<Record<string, boolean>>
revealPath(targetPath: string, isFolder: boolean): Promise<boolean> revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
getMcpStatus(): Promise<McpStatus>
getMcpLogs(): Promise<McpLogEntry[]>
} }
declare global { declare global {
+191 -1
View File
@@ -10,6 +10,10 @@ const API_PORT = 38765
const API_BASE_URL = `http://${API_HOST}:${API_PORT}` const API_BASE_URL = `http://${API_HOST}:${API_PORT}`
const SERVER_MAIN_CLASS = "com.mydev.mactfs.server.MacTfsServer" const SERVER_MAIN_CLASS = "com.mydev.mactfs.server.MacTfsServer"
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token") const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
// MCP SSE 服务地址:随客户端拉起、随客户端退出回收,实现「客户端开着才能用」的绑定。
const MCP_HOST = "127.0.0.1"
const MCP_PORT = 38766
const MCP_RESTART_DELAY_MS = 2000
const HEALTH_TIMEOUT_MS = 2000 const HEALTH_TIMEOUT_MS = 2000
const START_POLL_TIMES = 30 const START_POLL_TIMES = 30
const START_POLL_INTERVAL_MS = 1000 const START_POLL_INTERVAL_MS = 1000
@@ -68,6 +72,109 @@ function registerAppProtocol() {
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。 // 记录由本进程拉起的服务端子进程,便于应用退出时回收。
let serverProcess = null let serverProcess = null
// 记录由本进程拉起的 MCP 子进程;应用退出时一并回收。
let mcpProcess = null
// 标记应用是否正在退出,用于区分「主动退出」与「子进程崩溃需重拉」。
let appQuitting = false
// MCP 运行状态与日志环形缓冲:供「MCP 状态」弹窗展示,最多保留最近 500 行。
const MCP_LOG_MAX_LINES = 500
const MCP_SSE_URL = `http://${MCP_HOST}:${MCP_PORT}/sse`
const MCP_HEALTHZ_URL = `http://${MCP_HOST}:${MCP_PORT}/healthz`
const mcpLogBuffer = []
const mcpLineRemainder = { stdout: "", stderr: "" }
let mcpStartedAt = 0
let mcpRestartCount = 0
let mcpLastExitCode = null
let mcpLastError = null
/**
* 向环形缓冲追加一行 MCP 日志,超出上限时丢弃最旧的行。
*/
function pushMcpLine(stream, line) {
if (typeof line !== "string" || line.length === 0) {
return
}
mcpLogBuffer.push({ ts: Date.now(), stream, line })
if (mcpLogBuffer.length > MCP_LOG_MAX_LINES) {
mcpLogBuffer.splice(0, mcpLogBuffer.length - MCP_LOG_MAX_LINES)
}
}
/**
* 按行切分子进程输出(保留跨 chunk 的半行余量),逐行写入缓冲。
*/
function appendMcpLog(stream, chunk) {
const combined = (mcpLineRemainder[stream] || "") + chunk.toString()
const parts = combined.split(/\r?\n/)
mcpLineRemainder[stream] = parts.pop() || ""
for (const part of parts) {
pushMcpLine(stream, part)
}
}
/**
* 把残留的半行刷入缓冲(用于进程退出时收尾)。
*/
function flushMcpRemainder() {
for (const stream of ["stdout", "stderr"]) {
if (mcpLineRemainder[stream]) {
pushMcpLine(stream, mcpLineRemainder[stream])
mcpLineRemainder[stream] = ""
}
}
}
/**
* 探测 MCP 的 /healthz 是否可达,返回 { ok, body? },不抛异常。
*/
function pingMcpHealthz() {
return new Promise((resolve) => {
const request = http.request(MCP_HEALTHZ_URL, { method: "GET", timeout: 1500 }, (response) => {
let raw = ""
response.on("data", (chunk) => {
raw += chunk
})
response.on("end", () => {
if (response.statusCode !== 200) {
resolve({ ok: false })
return
}
try {
resolve({ ok: true, body: JSON.parse(raw) })
} catch (error) {
resolve({ ok: true })
}
})
})
request.on("timeout", () => {
request.destroy()
resolve({ ok: false })
})
request.on("error", () => resolve({ ok: false }))
request.end()
})
}
/**
* 汇总 MCP 当前状态:进程是否在跑、pid、运行时长、重启次数,并探活 /healthz。
*/
async function getMcpStatus() {
const running = !!(mcpProcess && mcpProcess.exitCode === null)
const healthz = running ? await pingMcpHealthz() : { ok: false }
return {
running,
healthy: healthz.ok,
pid: running ? mcpProcess.pid : null,
sseUrl: MCP_SSE_URL,
startedAt: mcpStartedAt || null,
uptimeMs: running && mcpStartedAt ? Date.now() - mcpStartedAt : 0,
restartCount: mcpRestartCount,
lastExitCode: mcpLastExitCode,
lastError: mcpLastError,
entryResolved: resolveMcpEntry() != null,
}
}
/** /**
* 计算项目根目录,main.cjs 位于 mactfsui/electron 下,上溯两级即为单仓库根目录。 * 计算项目根目录,main.cjs 位于 mactfsui/electron 下,上溯两级即为单仓库根目录。
@@ -270,6 +377,8 @@ async function startService() {
args.push(SERVER_MAIN_CLASS) args.push(SERVER_MAIN_CLASS)
serverProcess = spawn(resolveJavaBin(), args, { serverProcess = spawn(resolveJavaBin(), args, {
cwd: app.isPackaged ? process.resourcesPath : path.join(resolveProjectRoot(), "mactfs"), cwd: app.isPackaged ? process.resourcesPath : path.join(resolveProjectRoot(), "mactfs"),
// 传入父进程 pid,让 Java 后端自我看护:宿主崩溃 / 被强杀时它也会自己退出,避免遗留占用 38765 的孤儿。
env: { ...process.env, MACTFS_PARENT_PID: String(process.pid) },
stdio: "ignore", stdio: "ignore",
}) })
serverProcess.on("exit", () => { serverProcess.on("exit", () => {
@@ -289,6 +398,78 @@ async function startService() {
} }
} }
/**
* 解析 MCP 入口脚本:打包态优先 Resources/mcp,开发态用单仓库内 mactfs-mcp/dist。
*/
function resolveMcpEntry() {
if (app.isPackaged) {
// 打包态:esbuild 单文件包随 extraResources 落在 Resources/mcp/index.cjs。
const packaged = path.join(process.resourcesPath, "mcp", "index.cjs")
if (fs.existsSync(packaged)) {
return packaged
}
}
// 开发态:tsc 产物(ESM + node_modules)。
const dev = path.join(resolveProjectRoot(), "mactfs-mcp", "dist", "index.js")
if (fs.existsSync(dev)) {
return dev
}
return null
}
/**
* 拉起 MCP 子进程(用 Electron 自带 Node 运行,避免依赖系统 node)。
* 通过 MACTFS_PARENT_PID 让 MCP 看护父进程,父死则自杀;崩溃后自动重拉。
*/
function startMcp() {
if (mcpProcess && mcpProcess.exitCode === null) {
return
}
const entry = resolveMcpEntry()
if (!entry) {
mcpLastError = "未找到 MCP 入口(开发态请先在 mactfs-mcp 执行 pnpm build)"
pushMcpLine("stderr", `[mactfs] ${mcpLastError}`)
console.error(`[mactfs] ${mcpLastError}`)
return
}
mcpLastError = null
mcpStartedAt = Date.now()
// stdout/stderr 改为管道捕获,写入环形缓冲供 UI 查看(原来是 ignore,日志被丢弃)。
mcpProcess = spawn(process.execPath, [entry], {
cwd: path.dirname(entry),
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
MACTFS_PARENT_PID: String(process.pid),
MACTFS_API_BASE_URL: API_BASE_URL,
MACTFS_TOKEN_FILE: TOKEN_FILE,
MACTFS_MCP_PORT: String(MCP_PORT),
},
stdio: ["ignore", "pipe", "pipe"],
})
pushMcpLine("stdout", `[mactfs] 已拉起 MCP 子进程 pid=${mcpProcess.pid}`)
if (mcpProcess.stdout) {
mcpProcess.stdout.on("data", (chunk) => appendMcpLog("stdout", chunk))
}
if (mcpProcess.stderr) {
mcpProcess.stderr.on("data", (chunk) => appendMcpLog("stderr", chunk))
}
mcpProcess.on("error", (error) => {
mcpLastError = error.message
pushMcpLine("stderr", `[mactfs] MCP 子进程启动失败:${error.message}`)
})
mcpProcess.on("exit", (code, signal) => {
flushMcpRemainder()
mcpLastExitCode = code
mcpProcess = null
pushMcpLine("stderr", `[mactfs] MCP 子进程退出 code=${code} signal=${signal ?? ""}`)
if (!appQuitting) {
mcpRestartCount += 1
setTimeout(startMcp, MCP_RESTART_DELAY_MS)
}
})
}
/** /**
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。 * 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
*/ */
@@ -313,6 +494,8 @@ function registerIpcHandlers() {
ipcMain.handle("mactfs:get-token", () => readToken()) ipcMain.handle("mactfs:get-token", () => readToken())
ipcMain.handle("mactfs:get-service-status", () => checkHealth()) ipcMain.handle("mactfs:get-service-status", () => checkHealth())
ipcMain.handle("mactfs:start-service", () => startService()) ipcMain.handle("mactfs:start-service", () => startService())
ipcMain.handle("mactfs:get-mcp-status", () => getMcpStatus())
ipcMain.handle("mactfs:get-mcp-logs", () => mcpLogBuffer.slice())
ipcMain.handle("mactfs:select-directory", async () => { ipcMain.handle("mactfs:select-directory", async () => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"], properties: ["openDirectory", "createDirectory"],
@@ -411,6 +594,8 @@ app.whenReady().then(() => {
registerAppProtocol() registerAppProtocol()
registerIpcHandlers() registerIpcHandlers()
createWindow() createWindow()
// 随客户端拉起 MCP;它会自行等待后端就绪,端口随客户端退出而消失。
startMcp()
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
@@ -425,8 +610,13 @@ app.on("window-all-closed", () => {
} }
}) })
// 应用退出时回收由本进程拉起的服务端,避免遗留孤儿进程。 // 应用退出时回收由本进程拉起的服务端与 MCP,避免遗留孤儿进程。
app.on("will-quit", () => { app.on("will-quit", () => {
appQuitting = true
if (mcpProcess && mcpProcess.exitCode === null) {
mcpProcess.kill()
mcpProcess = null
}
if (serverProcess && serverProcess.exitCode === null) { if (serverProcess && serverProcess.exitCode === null) {
serverProcess.kill() serverProcess.kill()
serverProcess = null serverProcess = null
+12
View File
@@ -45,4 +45,16 @@ contextBridge.exposeInMainWorld("mactfs", {
revealPath(targetPath, isFolder) { revealPath(targetPath, isFolder) {
return ipcRenderer.invoke("mactfs:reveal-path", targetPath, isFolder) return ipcRenderer.invoke("mactfs:reveal-path", targetPath, isFolder)
}, },
/**
* 查询 MCP 子进程运行状态(含 /healthz 探活)。
*/
getMcpStatus() {
return ipcRenderer.invoke("mactfs:get-mcp-status")
},
/**
* 读取 MCP 子进程运行日志(最近 500 行)。
*/
getMcpLogs() {
return ipcRenderer.invoke("mactfs:get-mcp-logs")
},
}) })
+6 -1
View File
@@ -13,7 +13,8 @@
"electron:dev": "concurrently -k \"pnpm dev\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"", "electron:dev": "concurrently -k \"pnpm dev\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
"start": "pnpm build && pnpm electron", "start": "pnpm build && pnpm electron",
"prepare:runtime": "node scripts/prepare-runtime.mjs", "prepare:runtime": "node scripts/prepare-runtime.mjs",
"dist": "pnpm build && pnpm prepare:runtime && electron-builder --mac --arm64 --x64", "prepare:mcp": "pnpm --dir ../mactfs-mcp install && pnpm --dir ../mactfs-mcp run build:bundle",
"dist": "pnpm build && pnpm prepare:runtime && pnpm prepare:mcp && electron-builder --mac --arm64 --x64",
"typecheck": "react-router typegen && tsc", "typecheck": "react-router typegen && tsc",
"format": "prettier --write \"**/*.{ts,tsx}\"" "format": "prettier --write \"**/*.{ts,tsx}\""
}, },
@@ -38,6 +39,10 @@
{ {
"from": "runtime/jdk-x64", "from": "runtime/jdk-x64",
"to": "jre-x64" "to": "jre-x64"
},
{
"from": "../mactfs-mcp/dist-bundle",
"to": "mcp"
} }
], ],
"mac": { "mac": {