Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646665f3a3 | ||
|
|
24de23ee61 | ||
|
|
3cf42cdbaf | ||
|
|
b03ca2c4d5 | ||
|
|
03eb61520f | ||
|
|
9018a6c90e | ||
|
|
e9f942e695 | ||
|
|
627adc907e | ||
|
|
b2b2f49db6 | ||
|
|
1dfe081713 | ||
|
|
a107ec1f2c | ||
|
|
4681ff914f | ||
|
|
e1a5793a62 | ||
|
|
fcf7952ee3 | ||
|
|
08bb38b188 | ||
|
|
fe8b23225a | ||
|
|
2768eafd93 | ||
|
|
380280bace | ||
|
|
4703a03f21 | ||
|
|
261778732d | ||
|
|
581cfb559c | ||
|
|
c0f613fcb6 | ||
|
|
eec2cc85a3 | ||
|
|
3d1ffd0e81 | ||
|
|
b19c6895a3 | ||
|
|
fbd16013e6 | ||
|
|
b1a5b2f16b | ||
|
|
bba6e89a16 | ||
|
|
b247a18155 | ||
|
|
b156f505eb | ||
|
|
b3454eb157 | ||
|
|
acb388d22d | ||
|
|
1bc913c54e | ||
|
|
8692edd2dd |
@@ -32,6 +32,7 @@ workspace/
|
|||||||
|
|
||||||
# Local JDK
|
# Local JDK
|
||||||
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
||||||
|
zulu8.94.0.17-ca-jdk8.0.492-macosx_aarch64/
|
||||||
|
|
||||||
# E2E 截图
|
# E2E 截图
|
||||||
**/.e2e-shots/
|
**/.e2e-shots/
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# macTFS
|
||||||
|
|
||||||
|
> 🤖 **本项目由 Claude(Anthropic AI)生成**:从产品需求、前后端代码,到这份 README 和界面截图,均为纯 AI 生成的实验性作品。
|
||||||
|
>
|
||||||
|
> 🤖 _Built entirely by Claude (Anthropic's AI). Requirements, code, this README, and screenshots are all AI-generated._
|
||||||
|
|
||||||
|
> macOS 上的 TFS(Azure DevOps Server)版本控制客户端 —— 脱离 IDE 宿主,独立运行的桌面应用 + 本地 HTTP API。
|
||||||
|
|
||||||
|
macTFS 让你在 **macOS(含 Apple Silicon)** 上直接对 **TFS / TFVC** 仓库做日常版本控制:获取最新、签出、签入、查看历史、目录与文件对比……不再依赖 Windows + Visual Studio,也不用为了一个 TFS 插件背着一整个 IntelliJ。
|
||||||
|
|
||||||
|
它由一个 **Electron 桌面前端** 和一个 **常驻本地 Java 服务** 组成。Java 服务复用微软官方的 TFS Java SDK 与 TFS 通信,并把能力封装成 `127.0.0.1` 上的 REST API —— 所以除了图形界面,你也可以用 `curl` / 脚本 / CI 直接调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
> 以下为演示数据,非真实服务器内容。
|
||||||
|
|
||||||
|
### 连接 TFS
|
||||||
|
|
||||||
|
| 填写连接信息 | 选择 Collection |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
### 工作台
|
||||||
|
|
||||||
|
三栏布局:左侧服务端目录树、中间文件列表、右侧挂起更改与签入、底部操作日志。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 对比与 Diff
|
||||||
|
|
||||||
|
| 目录对比 | 文件 Diff(Monaco) |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载安装
|
||||||
|
|
||||||
|
普通用户无需自行构建,直接到 [GitHub Releases](https://github.com/Farewell0375/MacTFS/releases/latest) 下载对应芯片的安装包:
|
||||||
|
|
||||||
|
| 安装包 | 适用机型 |
|
||||||
|
| --- | --- |
|
||||||
|
| `MacTFS-<版本>-arm64.dmg` | Apple Silicon(M 系列芯片) |
|
||||||
|
| `MacTFS-<版本>-x64.dmg` | Intel 芯片 |
|
||||||
|
|
||||||
|
打开 DMG,把 `MacTFS.app` 拖进「应用程序」即可。
|
||||||
|
|
||||||
|
### 首次打开:应用未签名怎么办
|
||||||
|
|
||||||
|
本应用**未做 Apple 代码签名 / 公证**,首次打开 macOS 的 Gatekeeper 会拦截,提示「无法验证开发者」或「"MacTFS" 已损坏,无法打开」。这是未签名应用的正常现象,二选一解除即可:
|
||||||
|
|
||||||
|
**方法一 · 图形界面**
|
||||||
|
|
||||||
|
1. 双击 `MacTFS.app`,弹窗里先点「完成 / 取消」。
|
||||||
|
2. 打开 **系统设置 → 隐私与安全性**,下拉到「安全性」区域,会看到一条「已阻止使用 MacTFS」的提示,点 **仍要打开**。
|
||||||
|
3. 再次确认,之后即可正常启动。
|
||||||
|
|
||||||
|
**方法二 · 终端去掉隔离属性(推荐,最省事)**
|
||||||
|
|
||||||
|
把应用拖进「应用程序」后,在终端执行下面这条命令,去掉 macOS 给下载文件打的隔离标记(quarantine):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -dr com.apple.quarantine /Applications/MacTFS.app
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后直接双击即可打开。若仍提示「已损坏」,再用这条(会清除全部扩展属性):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo xattr -cr /Applications/MacTFS.app
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这些步骤只是绕过「未签名」拦截,应用本身不会因此被修改。介意签名/公证的话,需要 Apple 开发者账号自行签名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么做它
|
||||||
|
|
||||||
|
- **macOS 上几乎没有顺手的 TFVC 客户端。** 微软官方的 Team Explorer Everywhere 只剩命令行 / 老 Eclipse 插件且基本停更;IntelliJ 的 TFS 插件又必须绑着 IDE。
|
||||||
|
- **想要一个能独立运行、界面现代、对 Apple Silicon 友好的桌面工具。** 这就是 macTFS 的目标——为自己/小团队打造的「天天能用」的工具。
|
||||||
|
- **顺带提供 HTTP API。** 让 TFS 操作能被脚本和 CI 复用,而不只是点界面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **连接与认证**:配置 TFS 服务器地址、NTLM 账号密码,测试连接、保持会话。
|
||||||
|
- **Collection / Workspace / Mapping**:选择项目集合,自动确保默认工作区,集中管理服务端路径 ↔ 本地路径映射。
|
||||||
|
- **服务端目录浏览**:左侧目录树 + 中间文件列表,同步导航、右键菜单统一动作。
|
||||||
|
- **文件同步**:Get Latest、获取指定版本 / Changeset。
|
||||||
|
- **签出签入闭环**:Checkout / Checkin / Add / Delete / Undo,签入附注释。
|
||||||
|
- **本地变更管理**:扫描并展示 Pending Changes,分组签入。
|
||||||
|
- **历史与对比**:文件 / 路径历史、Changeset 详情、本地 vs 云端 Diff、版本间对比、目录对比。
|
||||||
|
- **macOS 原生观感**:仿 Finder 的工作台、Sheet 风格弹窗与轻量动效。
|
||||||
|
- **操作日志**:底部可折叠控制台实时展示操作与错误。
|
||||||
|
|
||||||
|
> 完整的需求与 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 消费端:Electron UI │ curl │ 脚本 │ CI Pipeline │
|
||||||
|
└──────────────────────────┬───────────────────────────────┘
|
||||||
|
│ HTTP REST (localhost:38765)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ mactfs-server(Java 8 + SparkJava) │
|
||||||
|
│ HTTP 路由 · 会话管理 · 配置存储 · TFS 核心服务层 │
|
||||||
|
└──────────────────────────┬───────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Microsoft TFS SDK(tfsIntegration/lib) │
|
||||||
|
│ SOAP 通信 · NTLM 认证 · 版本控制 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前端 | Electron + React 19 + React Router 7 + shadcn/ui + Tailwind CSS 4 + Monaco |
|
||||||
|
| 后端 | Java 8 + SparkJava(内嵌 HTTP 服务) |
|
||||||
|
| TFS 内核 | Microsoft TFS Java SDK(SOAP / NTLM) |
|
||||||
|
| 运行环境 | Zulu JDK 8 **x86_64** + Rosetta 2(见下方说明) |
|
||||||
|
|
||||||
|
> ⚠️ **为什么固定 x64 JDK:** TFS SDK 的 JNI 原生库只含 i386/x86_64,没有 arm64 切片。因此服务端 JVM 固定走 x64,在 Apple Silicon 上经 Rosetta 2 运行;Electron 界面本身仍是原生。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- macOS 12+(Intel 或 Apple Silicon;Apple Silicon 需安装 Rosetta 2)
|
||||||
|
- [pnpm](https://pnpm.io/)(前端包管理)
|
||||||
|
- 首次构建需联网(自动下载 x64 JDK,见下)
|
||||||
|
- 目标 TFS:TFS 2015 及以下(TFVC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <你的仓库地址> mactfs
|
||||||
|
cd mactfs/mactfsui
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 准备 x64 JDK(自动)
|
||||||
|
|
||||||
|
服务端需要一个 x64 的 JDK 8。仓库**不**收录这个 200M+ 的二进制,而是按需从 Azul 官方下载并校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui
|
||||||
|
pnpm prepare:runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会检测本地是否已有 `zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/`,没有就从 Azul 下载 → 校验 sha256 → 解压到仓库根目录。`pnpm dist` 会自动先跑这一步。
|
||||||
|
|
||||||
|
### 3. 构建后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfs
|
||||||
|
../tfsIntegration/gradlew installDist
|
||||||
|
```
|
||||||
|
|
||||||
|
> 后端从 `mactfs/build/install/mactfs/lib/` 加载运行,改了 Java 代码必须重新 `installDist`。
|
||||||
|
|
||||||
|
### 4. 开发模式运行(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui
|
||||||
|
pnpm electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
会同时拉起 vite 开发服务器和 Electron 窗口;Electron 发现后端没运行时会自动拉起它,退出时自动回收。
|
||||||
|
|
||||||
|
### 5. 打包成桌面应用(.app / .dmg)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui
|
||||||
|
pnpm dist
|
||||||
|
```
|
||||||
|
|
||||||
|
产物输出到 `mactfsui/dist-app/`(universal DMG,内置精简后的 x64 JRE 与服务端 jar)。
|
||||||
|
|
||||||
|
> 启动 / 停止 / 重启 / 排错的完整说明见 [`服务启停指南.md`](服务启停指南.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. **连接**:填入 TFS 服务器地址、认证方式(NTLM)、账号 / 域 / 密码,点测试连接。
|
||||||
|
2. **选择 Collection**:登录成功后选择要操作的项目集合,系统自动确保默认 Workspace。
|
||||||
|
3. **建立映射**:在目录树或文件列表上「映射到本地」,把服务端路径绑到本地目录(可选立即 Get Latest)。
|
||||||
|
4. **同步文件**:对映射执行 Get Latest,或获取指定版本。
|
||||||
|
5. **改 → 签出 → 签入**:编辑文件后在右侧 Changes 面板查看 Pending Changes,填注释签入。
|
||||||
|
6. **历史 / 对比**:右键查看历史、Changeset 详情,或做本地 vs 云端、版本间、目录级的 Diff。
|
||||||
|
|
||||||
|
### 也可以直接调 API
|
||||||
|
|
||||||
|
服务监听 `127.0.0.1:38765`,token 存在 `~/.mactfs/server-token`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(cat ~/.mactfs/server-token)
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:38765/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
完整 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md) 第四章。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mydev/
|
||||||
|
├── mactfs/ # Java 后端:CLI + 本地 HTTP API 服务
|
||||||
|
├── mactfsui/ # Electron + React 桌面前端
|
||||||
|
│ ├── app/ # 前端源码(页面、组件、API client)
|
||||||
|
│ ├── electron/ # Electron 主进程 / preload
|
||||||
|
│ └── scripts/ # 打包辅助脚本(prepare-runtime.mjs 等)
|
||||||
|
├── tfsIntegration/ # 微软 TFS Java SDK 及原生库(构建依赖)
|
||||||
|
├── docs/ # PRD、需求、截图等文档
|
||||||
|
├── task/ # 分阶段开发任务单
|
||||||
|
└── testing/ # 端到端 / 接口测试报告
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三方依赖与许可
|
||||||
|
|
||||||
|
- 本项目通过 `tfsIntegration/` 下的 **Microsoft TFS Java SDK** 与 TFS 通信,其分发受微软相应许可约束。
|
||||||
|
- x64 JDK 由 [Azul Zulu](https://www.azul.com/downloads/) 提供,按需下载,不随仓库分发。
|
||||||
|
|
||||||
|
> macTFS 是面向个人 / 小团队的工具型项目,主要解决「在 Mac 上用老 TFS」这一具体痛点。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# macTFS UI 风格改版需求(macOS 现代化 / 精致化 / 动效)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
macTFS 功能层面已完成 FE-001 ~ FE-025,三栏工作台、弹窗体系、挂起更改与签入链路均可用。但当前 UI 是「功能优先」的默认 shadcn 风格:
|
||||||
|
|
||||||
|
- 全局使用 Roboto Slab 衬线字体,与 macOS 原生观感不符
|
||||||
|
- 登录页只有一张简单表单卡片,元素少、缺少品牌感
|
||||||
|
- 窗口使用系统默认标题栏,没有 macOS 原生质感(毛玻璃、隐藏式标题栏)
|
||||||
|
- 几乎没有过渡动效:弹窗、面板折叠、列表加载都是瞬时切换
|
||||||
|
- 明暗主题虽有 token 定义,但没有切换入口、也不跟随系统
|
||||||
|
|
||||||
|
本批需求目标:在**不改任何业务逻辑与接口**的前提下,把整体 UI 升级为「精致、现代、macOS 原生感、富动效」的风格。
|
||||||
|
|
||||||
|
> 说明:本分支(`feature/ui-macos-restyle`)做 macOS 原生风方案;姊妹分支 `feature/ui-modern-restyle` 做 Codex/CC Switch 现代工具风方案,两套并行对比。
|
||||||
|
|
||||||
|
## 二、用户诉求原文与解读
|
||||||
|
|
||||||
|
> 「我希望的 ui 风格是更精致,有 macos 风格,动效更多,登录页元素多点,反正就是希望比较现代一些。」
|
||||||
|
|
||||||
|
解读为四个方向:
|
||||||
|
|
||||||
|
| 诉求 | 落地方向 |
|
||||||
|
|---|---|
|
||||||
|
| 更精致 | 统一设计令牌(颜色/圆角/阴影/间距/图标),细化悬停、选中、禁用等状态 |
|
||||||
|
| macOS 风格 | 系统字体栈、隐藏式标题栏 + 毛玻璃、Finder 式侧栏、Sheet 式弹窗、跟随系统明暗 |
|
||||||
|
| 动效更多 | 建立统一动效规范:弹窗出入场、面板折叠、列表级联、骨架屏、微交互 |
|
||||||
|
| 登录页元素多点 | 品牌区 + 分步表单 + 背景氛围 + 最近连接 + 连接过程动效 |
|
||||||
|
|
||||||
|
## 三、现状盘点
|
||||||
|
|
||||||
|
| 现状 | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主题 token | `mactfsui/app/app.css` | 衬线字体、紫色主色、无 macOS 质感变量,dark 主题无入口 |
|
||||||
|
| Electron 窗口 | `mactfsui/electron/main.cjs` | 默认标题栏,无 vibrancy / hiddenInset |
|
||||||
|
| 登录页 | `app/components/app/connect-view.tsx` | 单卡片表单,无品牌区、无动效、无最近连接 |
|
||||||
|
| 工作台 | `app/components/app/workspace-shell.tsx`、`top-bar.tsx` | 布局可用但视觉平淡,折叠无动画 |
|
||||||
|
| 目录树 / 文件列表 | `app/components/explorer/source-tree-panel.tsx`、`folder-items-panel.tsx` | 行样式简单,加载无骨架屏,空状态简陋 |
|
||||||
|
| 弹窗(13 个) | `app/components/explorer/*-dialog.tsx`、`app/components/app/*` | 风格不一,出入场无动效 |
|
||||||
|
| 反馈 | `changes-panel.tsx`、`console-panel.tsx` | 无 toast,操作反馈靠日志面板 |
|
||||||
|
|
||||||
|
技术栈现状:React 19 + React Router 7 + Tailwind CSS 4 + radix-ui/shadcn + tw-animate-css + lucide-react + Electron 42。动效优先用 CSS / tw-animate-css,复杂编排可引入 `motion`(原 framer-motion)。
|
||||||
|
|
||||||
|
## 四、需求定义
|
||||||
|
|
||||||
|
### U1 macOS 设计基建(FE-026,P0)
|
||||||
|
|
||||||
|
- 字体切换为系统字体栈(`-apple-system, BlinkMacSystemFont, "SF Pro", "PingFang SC"…`),仅代码/Diff 保留等宽字体
|
||||||
|
- 重定义颜色令牌:macOS 蓝为主色(accentColor 风格),中性灰分级、分隔线、半透明材质变量
|
||||||
|
- 统一圆角(macOS 常用 6/10/14px 档位)、阴影(弹窗/浮层分层阴影)、间距档位
|
||||||
|
- 建立动效令牌:统一时长(150/220/320ms)、缓动曲线(ease-out-quart、spring 近似)
|
||||||
|
- 完整 light/dark 两套 token,预留 vibrancy 透明背景变量
|
||||||
|
|
||||||
|
### U2 Electron 窗口 macOS 化(FE-027,P0)
|
||||||
|
|
||||||
|
- `titleBarStyle: "hiddenInset"`,红绿灯融入顶栏,顶栏设置可拖拽区域
|
||||||
|
- 开启窗口 vibrancy(`under-window` 或 `sidebar` 材质),侧栏呈毛玻璃
|
||||||
|
- 窗口最小尺寸、默认尺寸与圆角阴影核对
|
||||||
|
- 兼容退化:非 macOS 或 vibrancy 不可用时回退为纯色背景
|
||||||
|
|
||||||
|
### U3 登录页改版(FE-028,P0)
|
||||||
|
|
||||||
|
- 左右分栏:左侧品牌区(logo、产品名、标语、版本号、氛围背景动效),右侧连接表单
|
||||||
|
- 表单分步过渡动效:连接表单 → Collection 选择,左右滑动 + 淡入淡出
|
||||||
|
- 新增元素:密码可见性切换、记住最近服务器(本地存储多条,可一键填充)、连接状态指示灯、服务健康状态展示
|
||||||
|
- 连接中动效:按钮内 spinner + 步骤文案渐变;失败时表单抖动 + 错误条
|
||||||
|
- Collection 选择改为卡片式列表,悬停浮起、选中高亮动画
|
||||||
|
|
||||||
|
### U4 工作台外壳与顶栏改版(FE-029,P0)
|
||||||
|
|
||||||
|
- 顶栏融合红绿灯区域,按 macOS 工具栏风格重排(上下文信息居左、动作按钮居右、图标化)
|
||||||
|
- 左侧目录树面板改 Finder 式侧栏:毛玻璃背景、分组标题、圆角选中条
|
||||||
|
- 三栏折叠/展开增加宽度过渡动画,底部操作台滑入滑出
|
||||||
|
- 面板间分隔线与留白统一
|
||||||
|
|
||||||
|
### U5 目录树与文件列表精致化(FE-030,P0)
|
||||||
|
|
||||||
|
- 树节点与文件行:统一行高、圆角悬停/选中态、展开箭头旋转动画、子级展开渐入
|
||||||
|
- 文件图标体系:按扩展名区分图标与颜色,文件夹/分支/映射目录差异化
|
||||||
|
- 加载骨架屏替代文字 loading;空状态配插图式占位(图标 + 引导文案)
|
||||||
|
- 状态徽标(pendingEdit/Add/Delete、未下载等)配色与形状统一
|
||||||
|
|
||||||
|
### U6 弹窗体系统一(FE-031,P1)
|
||||||
|
|
||||||
|
- 13 个弹窗统一为 macOS Sheet 风格:顶部标题区、内容区、底部按钮区结构一致
|
||||||
|
- 出入场动效统一:缩放 + 淡入(spring 曲线),遮罩淡入
|
||||||
|
- 弹窗内表格、表单控件按新 token 对齐;大弹窗(History/Compare/Diff)支持更合理的留白与分区
|
||||||
|
|
||||||
|
### U7 操作反馈与微交互(FE-032,P1)
|
||||||
|
|
||||||
|
- 引入轻量 toast(成功/失败/进行中),与日志面板互补
|
||||||
|
- 挂起更改面板、日志面板按新风格改版;新日志条目滑入动画
|
||||||
|
- 按钮、复选框、输入框等控件微交互:按压缩放、聚焦光环、hover 过渡
|
||||||
|
- 长操作(GetLatest、Checkin、目录对比)显示进度反馈动效
|
||||||
|
|
||||||
|
### U8 明暗主题跟随系统(FE-033,P1)
|
||||||
|
|
||||||
|
- 跟随 macOS 系统外观自动切换 light/dark,顶栏提供手动三态开关(跟随/亮/暗)
|
||||||
|
- 切换瞬间全局颜色过渡动画
|
||||||
|
- 校验全部面板与弹窗在 dark 下的对比度与可读性
|
||||||
|
|
||||||
|
### U9 改版回归验收(FE-034,P0)
|
||||||
|
|
||||||
|
- 改版完成后按 `TFS-AI测试流程.md` 大纲做 UI 链路回归:登录、浏览、Mapping、GetLatest、签出签入、历史、Diff、目录对比
|
||||||
|
- 确认零业务逻辑变化:`app/lib` 与接口契约不动
|
||||||
|
- typecheck 通过、关键页面截图存档
|
||||||
|
|
||||||
|
## 五、优先级与排期建议
|
||||||
|
|
||||||
|
| 顺序 | 任务 | 优先级 | 依赖 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | FE-026 设计基建 | P0 | 无 |
|
||||||
|
| 2 | FE-027 窗口 macOS 化 | P0 | FE-026 |
|
||||||
|
| 3 | FE-028 登录页改版 | P0 | FE-026 |
|
||||||
|
| 4 | FE-029 工作台外壳改版 | P0 | FE-026、FE-027 |
|
||||||
|
| 5 | FE-030 目录树与文件列表 | P0 | FE-029 |
|
||||||
|
| 6 | FE-031 弹窗体系统一 | P1 | FE-026 |
|
||||||
|
| 7 | FE-032 反馈与微交互 | P1 | FE-026 |
|
||||||
|
| 8 | FE-033 明暗主题 | P1 | FE-026 ~ FE-032 |
|
||||||
|
| 9 | FE-034 回归验收 | P0 | 全部 |
|
||||||
|
|
||||||
|
## 六、不在本批范围
|
||||||
|
|
||||||
|
- 任何业务逻辑、接口契约、服务端代码改动
|
||||||
|
- 新功能(本批只做视觉与交互层)
|
||||||
|
- Windows / Linux 适配(仅保证不崩溃的回退样式)
|
||||||
|
- Monaco Diff 内部主题深度定制(仅跟随明暗主题切换)
|
||||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 278 KiB |
@@ -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` 的最新值 |
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-bundle/
|
||||||
|
*.log
|
||||||
@@ -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),两条路径互不影响。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -214,7 +214,7 @@ public class MacTfsCli {
|
|||||||
private static String readServerToken() throws Exception {
|
private static String readServerToken() throws Exception {
|
||||||
File tokenFile = serverTokenFile();
|
File tokenFile = serverTokenFile();
|
||||||
if (!tokenFile.exists()) {
|
if (!tokenFile.exists()) {
|
||||||
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start macTFS API server first.");
|
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start MacTFS API server first.");
|
||||||
}
|
}
|
||||||
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
||||||
if (token.length() == 0) {
|
if (token.length() == 0) {
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
|
|||||||
required(merged, "workspace"),
|
required(merged, "workspace"),
|
||||||
required(merged, "server-path"),
|
required(merged, "server-path"),
|
||||||
merged.getProperty("local-path"),
|
merged.getProperty("local-path"),
|
||||||
!"false".equalsIgnoreCase(merged.getProperty("recursive"))
|
!"false".equalsIgnoreCase(merged.getProperty("recursive")),
|
||||||
|
!"false".equalsIgnoreCase(merged.getProperty("include-local-only"))
|
||||||
);
|
);
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", folderDiffsToMaps(result.getData()));
|
data.put("diffs", folderDiffsToMaps(result.getData()));
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
* MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
||||||
*/
|
*/
|
||||||
public class MacTfsCoreService {
|
public class MacTfsCoreService {
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。
|
* 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
|
||||||
*/
|
*/
|
||||||
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
||||||
final String collectionName,
|
final String collectionName,
|
||||||
@@ -579,7 +579,8 @@ public class MacTfsCoreService {
|
|||||||
final String workspaceName,
|
final String workspaceName,
|
||||||
final String serverPath,
|
final String serverPath,
|
||||||
final String localPath,
|
final String localPath,
|
||||||
final boolean recursive) {
|
final boolean recursive,
|
||||||
|
final boolean includeLocalOnly) {
|
||||||
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
||||||
@Override
|
@Override
|
||||||
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
||||||
@@ -593,7 +594,10 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
||||||
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
||||||
Set<String> localFiles = scanLocalFiles(new File(normalizedLocalPath), recursive);
|
// 不统计仅本地存在的项时直接跳过本地全量扫描(如 node_modules 大目录),只对比两端都有的文件。
|
||||||
|
Set<String> localFiles = includeLocalOnly
|
||||||
|
? scanLocalFiles(new File(normalizedLocalPath), recursive)
|
||||||
|
: new LinkedHashSet<String>();
|
||||||
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
||||||
|
|
||||||
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
||||||
@@ -800,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。
|
||||||
*/
|
*/
|
||||||
@@ -1237,8 +1355,12 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
boolean exists = localFile != null && localFile.exists();
|
boolean exists = localFile != null && localFile.exists();
|
||||||
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
||||||
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
// 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
|
||||||
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
// 这样未签出的纯本地修改也能在目录对比中暴露出来。
|
||||||
|
boolean writableFile = exists && !isFolder(item.getItemType()) && localFile.canWrite();
|
||||||
|
// mtime 仅作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
||||||
|
boolean mtimeNewer = exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
||||||
|
boolean localModified = writableFile || (remoteChanged && mtimeNewer);
|
||||||
if (!exists && item.getLocalVersion() > 0) {
|
if (!exists && item.getLocalVersion() > 0) {
|
||||||
return "localDeleted";
|
return "localDeleted";
|
||||||
}
|
}
|
||||||
@@ -2181,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;
|
||||||
@@ -52,7 +53,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
* MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
||||||
*/
|
*/
|
||||||
public class MacTfsServer {
|
public class MacTfsServer {
|
||||||
|
|
||||||
@@ -93,8 +94,66 @@ public class MacTfsServer {
|
|||||||
registerFilters();
|
registerFilters();
|
||||||
registerRoutes();
|
registerRoutes();
|
||||||
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 {
|
||||||
@@ -597,7 +688,7 @@ public class MacTfsServer {
|
|||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
Map<String, Object> body = readBody(request);
|
Map<String, Object> body = readBody(request);
|
||||||
AppConfig config = requestConfig(body);
|
AppConfig config = requestConfig(body);
|
||||||
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true));
|
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true), booleanValue(body, "includeLocalOnly", true));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,8 @@
|
|||||||
# React Router
|
# React Router
|
||||||
/.react-router/
|
/.react-router/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# electron-builder 打包产物与生成的运行时(体积大,不入库)
|
||||||
|
/dist-app/
|
||||||
|
/runtime/
|
||||||
|
/build-resources/icon.iconset/
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
# React Router + shadcn/ui
|
# mactfsui
|
||||||
|
|
||||||
This is a template for a new React Router project with React, TypeScript, and shadcn/ui.
|
macTFS 的 Electron + React 桌面前端。完整的项目介绍、截图、环境要求与使用说明请见仓库根目录的 [README](../README.md)。
|
||||||
|
|
||||||
## Adding components
|
## 常用命令
|
||||||
|
|
||||||
To add components to your app, run the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx shadcn@latest add button
|
pnpm install # 安装依赖
|
||||||
|
pnpm electron:dev # 开发模式(vite + Electron,自动拉起后端)
|
||||||
|
pnpm prepare:runtime # 准备 x64 JDK(本地缺失时自动从 Azul 下载)
|
||||||
|
pnpm dist # 打包 macOS 桌面应用到 dist-app/
|
||||||
```
|
```
|
||||||
|
|
||||||
This will place the ui components in the `components` directory.
|
启动 / 停止 / 重启 / 排错见根目录的 [`服务启停指南.md`](../服务启停指南.md)。
|
||||||
|
|
||||||
## Using components
|
|
||||||
|
|
||||||
To use the components in your app, import them as follows:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/roboto-slab";
|
|
||||||
|
|
||||||
|
/* 应用只有亮色主题:保留 class 方案的 dark 变体定义,使组件库模板中残留的
|
||||||
|
dark: 工具类永远不会命中(根节点不再挂 .dark),避免回退到系统媒体查询。 */
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* macOS 原生风设计基建(FE-026)
|
||||||
|
* 基调:系统字体 + macOS 蓝主色 + 中性灰分级 + 半透明材质(vibrancy)变量。
|
||||||
|
* 动效约定:快 150ms / 标准 220ms / 慢 320ms,缓动 ease-out-quart / spring 近似。
|
||||||
|
*/
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-serif);
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
--font-serif: 'Roboto Slab Variable', serif;
|
--font-heading: var(--font-sans);
|
||||||
|
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
|
/* 圆角档位:macOS 常用 6/8/10/14px(--radius = 10px) */
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -46,75 +54,63 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗(Sheet)明显 */
|
||||||
|
--shadow-card: 0 0 0 0.5px rgb(0 0 0 / 0.03), 0 1px 1px rgb(0 0 0 / 0.04), 0 4px 14px rgb(0 0 0 / 0.06);
|
||||||
|
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06);
|
||||||
|
--shadow-dialog: 0 18px 50px rgb(0 0 0 / 0.22), 0 4px 12px rgb(0 0 0 / 0.1);
|
||||||
|
/* 缓动曲线 */
|
||||||
|
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||||
|
/* 反馈动效:表单错误抖动 */
|
||||||
|
--animate-shake: shake 0.4s var(--ease-out-quart);
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% { transform: translateX(-1px); }
|
||||||
|
20%, 80% { transform: translateX(2px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-3px); }
|
||||||
|
40%, 60% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.98 0.002 286);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.22 0.006 286);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.22 0.006 286);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.22 0.006 286);
|
||||||
--primary: oklch(0.488 0.243 264.376);
|
/* macOS 系统蓝(≈ #007AFF),仅用于主按钮、选中态、状态点 */
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary: oklch(0.585 0.205 256.5);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--primary-foreground: oklch(0.99 0.002 286);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary: oklch(0.957 0.004 286);
|
||||||
--muted: oklch(0.97 0 0);
|
--secondary-foreground: oklch(0.22 0.006 286);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted: oklch(0.957 0.004 286);
|
||||||
--accent: oklch(0.488 0.243 264.376);
|
--muted-foreground: oklch(0.55 0.012 285);
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
/* accent 为中性悬停底色 */
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--accent: oklch(0.94 0.004 286);
|
||||||
--border: oklch(0.922 0 0);
|
--accent-foreground: oklch(0.22 0.006 286);
|
||||||
--input: oklch(0.922 0 0);
|
--destructive: oklch(0.59 0.24 27.5);
|
||||||
--ring: oklch(0.708 0 0);
|
--border: oklch(0.905 0.004 286);
|
||||||
|
--input: oklch(0.905 0.004 286);
|
||||||
|
--ring: oklch(0.62 0.19 256.5);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.7 0.165 254);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.585 0.205 256.5);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.5 0.21 259);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.43 0.19 262);
|
||||||
--radius: 0.875rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
/* 侧栏半透明材质:vibrancy 开启时透出窗口毛玻璃,关闭时回退近实色 */
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar: oklch(0.962 0.003 286 / 72%);
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
--sidebar-foreground: oklch(0.22 0.006 286);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary: oklch(0.585 0.205 256.5);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-primary-foreground: oklch(0.99 0.002 286);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent: oklch(0.88 0.005 286 / 80%);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.89 0.004 286);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.62 0.19 256.5);
|
||||||
}
|
/* 动效时长:快 / 标准 / 慢 */
|
||||||
|
--motion-fast: 150ms;
|
||||||
.dark {
|
--motion-base: 220ms;
|
||||||
--background: oklch(0.145 0 0);
|
--motion-slow: 320ms;
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.424 0.199 265.638);
|
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.424 0.199 265.638);
|
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -125,6 +121,26 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-serif;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
|
||||||
|
html.vibrancy,
|
||||||
|
html.vibrancy body {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工作台画布底色:比卡片深一档的中性灰,让白色卡片浮起;vibrancy 下半透明透出窗口毛玻璃。 */
|
||||||
|
@utility canvas-surface {
|
||||||
|
background-color: oklch(0.934 0.004 286 / 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
|
||||||
|
@utility app-drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility app-no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
||||||
|
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { Input } from "~/components/ui/input"
|
import { Input } from "~/components/ui/input"
|
||||||
import { Label } from "~/components/ui/label"
|
import { Label } from "~/components/ui/label"
|
||||||
import { api } from "~/lib/api"
|
import { api } from "~/lib/api"
|
||||||
import type { AppConfig, Collection } from "~/lib/api"
|
import type { AppConfig, Collection } from "~/lib/api"
|
||||||
|
import { getServiceStatus } from "~/lib/electron"
|
||||||
|
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
|
||||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ const EMPTY_FORM: ConnectFormState = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
||||||
|
* macOS 风:左侧毛玻璃品牌区(大 logo + 一句话),右侧表单,带分阶段过渡与最近连接。
|
||||||
*/
|
*/
|
||||||
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
||||||
const [phase, setPhase] = useState<ConnectPhase>("form")
|
const [phase, setPhase] = useState<ConnectPhase>("form")
|
||||||
@@ -37,9 +41,15 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [busyText, setBusyText] = useState("")
|
const [busyText, setBusyText] = useState("")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [recent, setRecent] = useState<RecentServer[]>([])
|
||||||
|
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
|
||||||
|
// 每次失败自增,作为 key 触发表单抖动动画重放。
|
||||||
|
const [shakeCount, setShakeCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
setRecent(loadRecentServers())
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await api.getConfig()
|
const result = await api.getConfig()
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -58,6 +68,12 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
void (async () => {
|
||||||
|
const status = await getServiceStatus()
|
||||||
|
if (active) {
|
||||||
|
setServiceOk(status.running)
|
||||||
|
}
|
||||||
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
@@ -71,6 +87,27 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
setForm((prev) => ({ ...prev, [key]: value }))
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败统一处理:展示错误条并触发表单抖动。
|
||||||
|
*/
|
||||||
|
const failWith = useCallback((message: string) => {
|
||||||
|
setError(message)
|
||||||
|
setShakeCount((count) => count + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用一条最近连接记录填充表单(不含密码)。
|
||||||
|
*/
|
||||||
|
const applyRecent = useCallback((item: RecentServer) => {
|
||||||
|
setError(null)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverUri: item.serverUri,
|
||||||
|
domain: item.domain,
|
||||||
|
username: item.username,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
||||||
*/
|
*/
|
||||||
@@ -88,23 +125,30 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
const connectResult = await api.connect(payload)
|
const connectResult = await api.connect(payload)
|
||||||
if (!connectResult.ok) {
|
if (!connectResult.ok) {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setError(connectResult.errorMessage ?? "连接失败")
|
failWith(connectResult.errorMessage ?? "连接失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBusyText("正在加载 Collection…")
|
setBusyText("正在加载 Collection…")
|
||||||
const collectionsResult = await api.listCollections()
|
const collectionsResult = await api.listCollections()
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (!collectionsResult.ok) {
|
if (!collectionsResult.ok) {
|
||||||
setError(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setRecent(
|
||||||
|
saveRecentServer({
|
||||||
|
serverUri: form.serverUri.trim(),
|
||||||
|
domain: form.domain.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const list = collectionsResult.data?.collections ?? []
|
const list = collectionsResult.data?.collections ?? []
|
||||||
setCollections(list)
|
setCollections(list)
|
||||||
setSelectedCollection((prev) =>
|
setSelectedCollection((prev) =>
|
||||||
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
||||||
)
|
)
|
||||||
setPhase("collection")
|
setPhase("collection")
|
||||||
}, [form])
|
}, [form, failWith])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
||||||
@@ -120,7 +164,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
const workspace = ensureResult.data?.workspace
|
const workspace = ensureResult.data?.workspace
|
||||||
if (!ensureResult.ok || !workspace) {
|
if (!ensureResult.ok || !workspace) {
|
||||||
setError(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onReady({
|
onReady({
|
||||||
@@ -129,7 +173,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
workspace: workspace.name,
|
workspace: workspace.name,
|
||||||
mappings: workspace.mappings ?? [],
|
mappings: workspace.mappings ?? [],
|
||||||
})
|
})
|
||||||
}, [selectedCollection, form.serverUri, onReady])
|
}, [selectedCollection, form.serverUri, onReady, failWith])
|
||||||
|
|
||||||
const canConnect =
|
const canConnect =
|
||||||
form.serverUri.trim().length > 0 &&
|
form.serverUri.trim().length > 0 &&
|
||||||
@@ -137,131 +181,247 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
form.password.length > 0
|
form.password.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
<div className="relative flex min-h-svh">
|
||||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
|
||||||
<div className="space-y-1">
|
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
|
||||||
<h1 className="text-base font-semibold">macTFS</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
{/* 左侧品牌区:半透明材质(vibrancy 下呈毛玻璃),大 logo + 一句话 */}
|
||||||
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"}
|
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col border-r bg-sidebar md:flex">
|
||||||
</p>
|
<div className="flex flex-1 flex-col items-center justify-center gap-5 p-10">
|
||||||
|
<img src="/logo.png" alt="MacTFS" className="size-24 drop-shadow-2xl" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">MacTFS</p>
|
||||||
|
<p className="mt-1.5 text-sm text-muted-foreground">macOS 上的 TFS 源码管理工作台</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase === "form" && (
|
<div className="flex items-center gap-2 p-6 text-xs text-muted-foreground">
|
||||||
<form
|
<span
|
||||||
className="mt-5 space-y-3"
|
className={cn(
|
||||||
onSubmit={(event) => {
|
"size-1.5 rounded-full",
|
||||||
event.preventDefault()
|
serviceOk == null ? "bg-muted-foreground/50" : serviceOk ? "bg-emerald-500" : "bg-red-500",
|
||||||
if (canConnect && !busy) {
|
|
||||||
void handleConnect()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="serverUri">服务器地址</Label>
|
|
||||||
<Input
|
|
||||||
id="serverUri"
|
|
||||||
placeholder="http://tfs.example.com:8080/tfs"
|
|
||||||
value={form.serverUri}
|
|
||||||
onChange={updateField("serverUri")}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="domain">域(可选)</Label>
|
|
||||||
<Input
|
|
||||||
id="domain"
|
|
||||||
placeholder="DOMAIN"
|
|
||||||
value={form.domain}
|
|
||||||
onChange={updateField("domain")}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="username">用户名</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
autoComplete="username"
|
|
||||||
value={form.username}
|
|
||||||
onChange={updateField("username")}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="password">密码</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={updateField("password")}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
|
|
||||||
{busy ? busyText || "处理中…" : "连接 TFS"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === "collection" && (
|
|
||||||
<div className="mt-5 space-y-4">
|
|
||||||
{collections.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">当前账号没有可见的 Collection。</p>
|
|
||||||
) : (
|
|
||||||
<ul className="max-h-64 space-y-1 overflow-y-auto">
|
|
||||||
{collections.map((collection) => {
|
|
||||||
const active = collection.name === selectedCollection
|
|
||||||
return (
|
|
||||||
<li key={collection.id || collection.name}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedCollection(collection.name)}
|
|
||||||
disabled={busy}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-background hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{collection.name}</span>
|
|
||||||
{active && <span className="text-xs text-primary">已选择</span>}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
{serviceOk == null
|
||||||
|
? "正在检测本地服务…"
|
||||||
|
: serviceOk
|
||||||
|
? "本地服务运行中 · 127.0.0.1:38765"
|
||||||
|
: "本地服务未就绪"}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{/* 右侧表单区 */}
|
||||||
|
<main className="flex flex-1 items-center justify-center bg-background p-6">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{phase === "form" && (
|
||||||
|
<div
|
||||||
|
key={`form-${shakeCount}`}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-left-2 duration-200",
|
||||||
|
error != null && "animate-shake",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">连接 TFS</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
使用域账号连接 Team Foundation Server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<form
|
||||||
<Button
|
className="mt-6 space-y-4"
|
||||||
type="button"
|
onSubmit={(event) => {
|
||||||
variant="ghost"
|
event.preventDefault()
|
||||||
size="sm"
|
if (canConnect && !busy) {
|
||||||
onClick={() => {
|
void handleConnect()
|
||||||
setError(null)
|
}
|
||||||
setPhase("form")
|
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
|
||||||
>
|
>
|
||||||
返回
|
<div className="space-y-1.5">
|
||||||
</Button>
|
<Label htmlFor="serverUri">服务器地址</Label>
|
||||||
<Button
|
<Input
|
||||||
type="button"
|
id="serverUri"
|
||||||
onClick={() => void handleEnter()}
|
placeholder="http://tfs.example.com:8080/tfs"
|
||||||
disabled={busy || !selectedCollection}
|
value={form.serverUri}
|
||||||
>
|
onChange={updateField("serverUri")}
|
||||||
{busy ? busyText || "处理中…" : "进入工作台"}
|
disabled={busy}
|
||||||
</Button>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="domain">域(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
placeholder="DOMAIN"
|
||||||
|
value={form.domain}
|
||||||
|
onChange={updateField("domain")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={updateField("username")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="pr-9"
|
||||||
|
value={form.password}
|
||||||
|
onChange={updateField("password")}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||||
|
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||||
|
>
|
||||||
|
{showPassword ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{busy ? busyText || "处理中…" : "连接 TFS"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Clock3 className="size-3.5" />
|
||||||
|
最近连接
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{recent.map((item) => (
|
||||||
|
<li key={`${item.serverUri}-${item.username}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyRecent(item)}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-md border bg-card px-3 py-2 text-left shadow-card transition-colors duration-150 hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{item.serverUri}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{item.domain ? `${item.domain} \\ ` : ""}
|
||||||
|
{item.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-primary">填充</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{phase === "collection" && (
|
||||||
|
<div
|
||||||
|
key={`collection-${shakeCount}`}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-right-4 duration-200",
|
||||||
|
error != null && "animate-shake",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">选择 Collection</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
已连接 <span className="font-mono text-xs">{form.serverUri.trim()}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{collections.length === 0 ? (
|
||||||
|
<p className="rounded-md border bg-card px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
当前账号没有可见的 Collection。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
||||||
|
{collections.map((collection) => {
|
||||||
|
const active = collection.name === selectedCollection
|
||||||
|
return (
|
||||||
|
<li key={collection.id || collection.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCollection(collection.name)}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg border px-3.5 py-3 text-left text-sm transition-colors duration-150",
|
||||||
|
active
|
||||||
|
? "border-primary/60 bg-primary/5"
|
||||||
|
: "bg-card shadow-card hover:bg-muted/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(active && "font-medium")}>{collection.name}</span>
|
||||||
|
{active && (
|
||||||
|
<span className="animate-in zoom-in-75 fade-in flex size-4.5 items-center justify-center rounded-full bg-primary text-primary-foreground duration-150">
|
||||||
|
<Check className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
setPhase("form")
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleEnter()}
|
||||||
|
disabled={busy || !selectedCollection}
|
||||||
|
>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{busy ? busyText || "处理中…" : "进入工作台"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { PanelBottom, PanelLeft, PanelRight } 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 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
|
||||||
@@ -14,8 +21,8 @@ export interface PanelVisibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
|
* 顶部上下文栏:仿 Finder 的居中上下文胶囊展示 Collection / Workspace
|
||||||
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
|
* (悬停查看服务器地址),右侧为面板折叠开关与重新连接入口,不承载对象级操作。
|
||||||
*/
|
*/
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
session,
|
session,
|
||||||
@@ -23,33 +30,77 @@ 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()
|
||||||
|
|
||||||
|
// 连接测试状态:手动触发,真正回连一次 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 className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3">
|
<header
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
className={cn(
|
||||||
<span className="shrink-0 font-semibold">macTFS</span>
|
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
|
||||||
<Separator orientation="vertical" className="h-4!" />
|
macInset && "pl-20",
|
||||||
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
|
)}
|
||||||
服务器
|
>
|
||||||
</span>
|
{/* 左侧:程序名 + Collection 胶囊;服务器地址与工作区收进悬停提示 */}
|
||||||
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
{session.serverUri}
|
<span className="shrink-0 text-sm font-medium">MacTFS</span>
|
||||||
</span>
|
<Tooltip>
|
||||||
<Separator orientation="vertical" className="hidden h-4! lg:block" />
|
<TooltipTrigger asChild>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
|
<div className="app-no-drag flex h-7 min-w-0 items-center rounded-lg bg-foreground/5 px-3 text-xs text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground">
|
||||||
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
|
<span className="truncate">{session.collection}</span>
|
||||||
<Separator orientation="vertical" className="h-4!" />
|
</div>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
|
</TooltipTrigger>
|
||||||
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p>
|
||||||
|
服务器 <span className="font-mono">{session.serverUri}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
工作区 <span className="font-mono">{session.workspace}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="app-no-drag flex shrink-0 items-center gap-1">
|
||||||
<PanelToggle
|
<PanelToggle
|
||||||
label="源码目录面板"
|
label="源码目录面板"
|
||||||
active={panels.tree}
|
active={panels.tree}
|
||||||
@@ -72,12 +123,54 @@ export function TopBar({
|
|||||||
<PanelBottom />
|
<PanelBottom />
|
||||||
</PanelToggle>
|
</PanelToggle>
|
||||||
<Separator orientation="vertical" className="mx-1 h-4!" />
|
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||||
<Button size="sm" variant="outline" onClick={onManageWorkspace}>
|
<Tooltip>
|
||||||
工作区
|
<TooltipTrigger asChild>
|
||||||
</Button>
|
<Button
|
||||||
<Button size="sm" variant="outline" onClick={onReconnect}>
|
size="icon-sm"
|
||||||
重新连接
|
variant="ghost"
|
||||||
</Button>
|
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>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
|
||||||
|
<FolderCog />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">工作区与映射管理</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onReconnect} aria-label="重新连接">
|
||||||
|
<Unplug />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">重新连接</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, 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"
|
||||||
@@ -7,6 +8,7 @@ import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
|
|||||||
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
|
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
|
||||||
import { ChangesPanel } from "~/components/inspector/changes-panel"
|
import { ChangesPanel } from "~/components/inspector/changes-panel"
|
||||||
import { ConsolePanel } from "~/components/logs/console-panel"
|
import { ConsolePanel } from "~/components/logs/console-panel"
|
||||||
|
import { Toaster, showToast } from "~/components/ui/toaster"
|
||||||
import { TooltipProvider } from "~/components/ui/tooltip"
|
import { TooltipProvider } from "~/components/ui/tooltip"
|
||||||
import { useFileActions } from "~/hooks/use-file-actions"
|
import { useFileActions } from "~/hooks/use-file-actions"
|
||||||
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
||||||
@@ -41,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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换指定面板的展开 / 收起状态。
|
* 切换指定面板的展开 / 收起状态。
|
||||||
@@ -71,35 +75,40 @@ export function WorkspaceShell({
|
|||||||
refreshLogs,
|
refreshLogs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 把动作编排的 notice 转成右上角 toast:固定 id 让「正在…」与结果复用同一条目。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actions.notice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { kind, text } = actions.notice
|
||||||
|
showToast({
|
||||||
|
id: "action",
|
||||||
|
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}, [actions.notice])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex h-svh flex-col overflow-hidden bg-background">
|
{/* 毛玻璃画布 + 卡片化面板:半透明画布透出 vibrancy,四个面板为圆角卡片留缝隙 */}
|
||||||
|
<div className="canvas-surface flex h-svh flex-col overflow-hidden">
|
||||||
<TopBar
|
<TopBar
|
||||||
session={session}
|
session={session}
|
||||||
panels={panels}
|
panels={panels}
|
||||||
onTogglePanel={togglePanel}
|
onTogglePanel={togglePanel}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
onManageWorkspace={() => setManageOpen(true)}
|
onManageWorkspace={() => setManageOpen(true)}
|
||||||
|
onOpenMcpStatus={() => setMcpOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{actions.notice && (
|
<div className="flex min-h-0 flex-1 px-2 pb-2">
|
||||||
|
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-2 border-b px-3 py-1 text-xs",
|
"flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
actions.notice.kind === "error"
|
panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
|
||||||
? "bg-destructive/10 text-destructive"
|
|
||||||
: "bg-primary/5 text-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{actions.actionBusy && (
|
|
||||||
<span className="size-2 animate-pulse rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
{actions.notice.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
|
||||||
{panels.tree && (
|
|
||||||
<SourceTreePanel
|
<SourceTreePanel
|
||||||
collection={session.collection}
|
collection={session.collection}
|
||||||
mappings={session.mappings}
|
mappings={session.mappings}
|
||||||
@@ -107,7 +116,7 @@ export function WorkspaceShell({
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onFileAction={actions.handleFileAction}
|
onFileAction={actions.handleFileAction}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<FolderItemsPanel
|
<FolderItemsPanel
|
||||||
session={session}
|
session={session}
|
||||||
selectedServerPath={selectedServerPath}
|
selectedServerPath={selectedServerPath}
|
||||||
@@ -117,7 +126,12 @@ export function WorkspaceShell({
|
|||||||
onFileAction={actions.handleFileAction}
|
onFileAction={actions.handleFileAction}
|
||||||
onRefresh={refreshItems}
|
onRefresh={refreshItems}
|
||||||
/>
|
/>
|
||||||
{panels.changes && (
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 shrink-0 justify-end overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
|
panels.changes ? "ml-2 w-[340px]" : "ml-0 w-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ChangesPanel
|
<ChangesPanel
|
||||||
mappings={session.mappings}
|
mappings={session.mappings}
|
||||||
pendingChanges={pending.pendingChanges}
|
pendingChanges={pending.pendingChanges}
|
||||||
@@ -125,21 +139,27 @@ 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()}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{panels.console && (
|
{/* 底部操作台通过高度过渡实现滑入滑出 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border shrink-0 overflow-hidden px-2 transition-[height] duration-250 ease-out-quart",
|
||||||
|
panels.console ? "h-[188px] pb-2" : "h-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ConsolePanel
|
<ConsolePanel
|
||||||
refreshToken={logsRefreshToken}
|
refreshToken={logsRefreshToken}
|
||||||
busy={actions.actionBusy || actions.checkinBusy}
|
busy={actions.actionBusy || actions.checkinBusy}
|
||||||
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<WorkspaceDialogs
|
<WorkspaceDialogs
|
||||||
dialog={actions.dialog}
|
dialog={actions.dialog}
|
||||||
@@ -169,6 +189,10 @@ export function WorkspaceShell({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mcpOpen && <McpStatusDialog onClose={() => setMcpOpen(false)} />}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState } from "react"
|
||||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog"
|
} from "~/components/ui/dialog"
|
||||||
@@ -30,6 +31,9 @@ import { cn } from "~/lib/utils"
|
|||||||
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
||||||
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
||||||
*/
|
*/
|
||||||
|
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
|
||||||
|
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
|
||||||
|
|
||||||
export function CompareDialog({
|
export function CompareDialog({
|
||||||
serverPath,
|
serverPath,
|
||||||
mappings,
|
mappings,
|
||||||
@@ -39,33 +43,51 @@ export function CompareDialog({
|
|||||||
serverPath: string
|
serverPath: string
|
||||||
mappings: MappingInfo[]
|
mappings: MappingInfo[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
|
||||||
|
const [phase, setPhase] = useState<"setup" | "result">("setup")
|
||||||
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showUpToDate, setShowUpToDate] = useState(false)
|
const [showUpToDate, setShowUpToDate] = useState(false)
|
||||||
|
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
|
||||||
|
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
|
||||||
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
||||||
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行(或重新执行)目录对比。
|
* 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
|
||||||
*/
|
*/
|
||||||
const runCompare = useCallback(async () => {
|
const runCompare = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const result = await api.compareFolder({ serverPath, recursive: true })
|
const result = await api.compareFolder({
|
||||||
|
serverPath,
|
||||||
|
recursive: true,
|
||||||
|
includeLocalOnly: !ignoreLocalOnly,
|
||||||
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.errorMessage ?? "目录对比失败")
|
setError(result.errorMessage ?? "目录对比失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDiffs(result.data?.diffs ?? [])
|
setDiffs(result.data?.diffs ?? [])
|
||||||
}, [serverPath])
|
}, [serverPath, ignoreLocalOnly])
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
void runCompare()
|
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
|
||||||
}, [runCompare])
|
* 让结果状态(如 签出编辑)立即反映最新事实。
|
||||||
|
*/
|
||||||
|
const handleAction = useCallback(
|
||||||
|
async (target: FileTarget, action: FileActionId) => {
|
||||||
|
await onFileAction(target, action)
|
||||||
|
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
|
||||||
|
await runCompare()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFileAction, runCompare],
|
||||||
|
)
|
||||||
|
|
||||||
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
||||||
const presentStatuses = useMemo(() => {
|
const presentStatuses = useMemo(() => {
|
||||||
@@ -104,11 +126,56 @@ export function CompareDialog({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
|
||||||
|
if (phase === "setup") {
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>目录对比</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={ignoreLocalOnly}
|
||||||
|
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">忽略仅本地存在的项</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
|
||||||
|
并跳过本地全量扫描,大目录速度更快。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setPhase("result")
|
||||||
|
void runCompare()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始对比
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>目录对比</DialogTitle>
|
<DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
|
||||||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -134,6 +201,14 @@ export function CompareDialog({
|
|||||||
/>
|
/>
|
||||||
显示最新项
|
显示最新项
|
||||||
</label>
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPhase("setup")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
调整选项
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -181,7 +256,11 @@ export function CompareDialog({
|
|||||||
compareStatus: diff.status,
|
compareStatus: diff.status,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<FileTargetMenu key={diff.serverPath} target={target} onAction={onFileAction}>
|
<FileTargetMenu
|
||||||
|
key={diff.serverPath}
|
||||||
|
target={target}
|
||||||
|
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
|
||||||
|
>
|
||||||
<TableRow className="cursor-default select-none">
|
<TableRow className="cursor-default select-none">
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function ConflictDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
|
||||||
{conflicts.length} 个冲突
|
{conflicts.length} 个冲突
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ function splitDiffSides(raw: string[]): { original: string; modified: string } {
|
|||||||
return { original: original.join("\n"), modified: modified.join("\n") }
|
return { original: original.join("\n"), modified: modified.join("\n") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
|
|
||||||
*/
|
|
||||||
function isDarkTheme(): boolean {
|
|
||||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
||||||
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
||||||
@@ -143,7 +136,7 @@ export function DiffDialog({
|
|||||||
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
|
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
|
||||||
compactMode: viewMode === "unified",
|
compactMode: viewMode === "unified",
|
||||||
hideUnchangedRegions: { enabled: onlyChanges },
|
hideUnchangedRegions: { enabled: onlyChanges },
|
||||||
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
theme: "mactfs-light",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
|
||||||
@@ -250,10 +243,10 @@ export function DiffDialog({
|
|||||||
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
|
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
|
||||||
{diff && (
|
{diff && (
|
||||||
<>
|
<>
|
||||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
|
||||||
− {diff.sourceLabel}
|
− {diff.sourceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
|
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700">
|
||||||
+ {diff.targetLabel}
|
+ {diff.targetLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Database,
|
||||||
|
File,
|
||||||
|
FileArchive,
|
||||||
|
FileCode,
|
||||||
|
FileImage,
|
||||||
|
FileJson,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileTerminal,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
|
FolderSymlink,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
|
||||||
|
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
|
||||||
|
{
|
||||||
|
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
|
||||||
|
icon: FileCode,
|
||||||
|
className: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
|
||||||
|
icon: FileJson,
|
||||||
|
className: "text-amber-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
|
||||||
|
icon: FileText,
|
||||||
|
className: "text-zinc-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sql", "db"],
|
||||||
|
icon: Database,
|
||||||
|
className: "text-violet-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
|
||||||
|
icon: FileImage,
|
||||||
|
className: "text-emerald-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
|
||||||
|
icon: FileArchive,
|
||||||
|
className: "text-orange-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["xls", "xlsx", "csv"],
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
className: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
|
||||||
|
icon: FileTerminal,
|
||||||
|
className: "text-zinc-600",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取文件名扩展名(小写,无点);无扩展名返回空串。
|
||||||
|
*/
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const index = name.lastIndexOf(".")
|
||||||
|
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
|
||||||
|
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
|
||||||
|
*/
|
||||||
|
export function FileIcon({
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
mapped = false,
|
||||||
|
expanded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
folder: boolean
|
||||||
|
mapped?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (folder) {
|
||||||
|
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
mapped ? "text-primary" : "text-sky-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
|
||||||
|
const Icon = rule?.icon ?? File
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
rule?.className ?? "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -127,8 +128,8 @@ export function FolderItemsPanel({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
|
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
||||||
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
||||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||||
@@ -152,23 +153,26 @@ export function FolderItemsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
|
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
{/* 由 table-container 自身承担纵横滚动:表头 sticky 不随内容滚走,横向滚动条始终贴可视区底部 */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden [&_[data-slot=table-container]]:h-full [&_[data-slot=table-container]]:overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
<ItemsSkeleton />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
正在加载目录…
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
|
||||||
{error}
|
<AlertCircle className="size-8 text-destructive/60" />
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||||
当前目录为空
|
<FolderOpen className="size-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">当前目录为空</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
右键空白处可对当前目录执行获取、映射或添加本地文件
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-[oklch(0.972_0.003_286)]">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
||||||
@@ -182,7 +186,7 @@ export function FolderItemsPanel({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => {
|
{rows.map((row, index) => {
|
||||||
const selected = row.item.serverPath === selectedItemPath
|
const selected = row.item.serverPath === selectedItemPath
|
||||||
const target = makeFileTarget({
|
const target = makeFileTarget({
|
||||||
source: "list",
|
source: "list",
|
||||||
@@ -199,7 +203,8 @@ export function FolderItemsPanel({
|
|||||||
>
|
>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={selected ? "selected" : undefined}
|
data-state={selected ? "selected" : undefined}
|
||||||
className="cursor-default select-none"
|
className="animate-in fade-in cursor-default select-none fill-mode-backwards duration-200"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 12) * 18}ms` }}
|
||||||
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
|
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
|
||||||
@@ -214,19 +219,20 @@ export function FolderItemsPanel({
|
|||||||
>
|
>
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
||||||
{row.item.folder ? (
|
<FileIcon
|
||||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
name={row.item.name}
|
||||||
) : (
|
folder={row.item.folder}
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
mapped={row.item.folder && row.localPath != null}
|
||||||
)}
|
/>
|
||||||
<span className="truncate text-sm">{row.item.name}</span>
|
<span className="truncate text-sm">{row.item.name}</span>
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn("rounded-md", statusBadgeClass(row.localState))}
|
className={cn("gap-1.5 rounded-md", statusBadgeClass(row.localState))}
|
||||||
>
|
>
|
||||||
|
<span className="size-1.5 rounded-full bg-current opacity-60" />
|
||||||
{statusLabel(row.localState)}
|
{statusLabel(row.localState)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -269,3 +275,25 @@ export function FolderItemsPanel({
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 loading。
|
||||||
|
*/
|
||||||
|
function ItemsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 9 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex animate-pulse items-center gap-3"
|
||||||
|
style={{ animationDelay: `${index * 80}ms` }}
|
||||||
|
>
|
||||||
|
<span className="size-4 rounded bg-muted" />
|
||||||
|
<span className="h-3.5 rounded bg-muted" style={{ width: `${160 + ((index * 53) % 120)}px` }} />
|
||||||
|
<span className="h-3.5 w-14 rounded-full bg-muted" />
|
||||||
|
<span className="ml-auto h-3 w-28 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { ChevronRight, FolderClosed, FolderOpen, Loader2, RefreshCw } from "lucide-react"
|
import { ChevronRight, 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 { 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"
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
getAncestorPaths,
|
getAncestorPaths,
|
||||||
makeFileTarget,
|
makeFileTarget,
|
||||||
normalizeServerPath,
|
normalizeServerPath,
|
||||||
|
resolveLocalPath,
|
||||||
} from "~/lib/tfs"
|
} from "~/lib/tfs"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
@@ -127,9 +129,11 @@ export function SourceTreePanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-[280px] shrink-0 flex-col border-r bg-sidebar">
|
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
源码目录
|
||||||
|
</span>
|
||||||
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -200,6 +204,7 @@ function TreeNode({
|
|||||||
const isSelected = selectedServerPath === path
|
const isSelected = selectedServerPath === path
|
||||||
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
||||||
const isLeaf = children != null && children.length === 0
|
const isLeaf = children != null && children.length === 0
|
||||||
|
const isMapped = resolveLocalPath(mappings, path) != null
|
||||||
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -207,10 +212,11 @@ function TreeNode({
|
|||||||
<FileTargetMenu target={target} onAction={onFileAction}>
|
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex h-7 items-center rounded-md pr-1",
|
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
|
||||||
isSelected ? "bg-primary/10" : "hover:bg-muted",
|
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||||
|
title={path}
|
||||||
>
|
>
|
||||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -220,10 +226,13 @@ function TreeNode({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
||||||
onClick={() => onToggle(path)}
|
onClick={() => onToggle(path)}
|
||||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")}
|
className={cn(
|
||||||
|
"size-3.5 transition-transform duration-200 ease-out-quart",
|
||||||
|
isExpanded && "rotate-90",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -233,11 +242,7 @@ function TreeNode({
|
|||||||
onClick={() => onSelect(path)}
|
onClick={() => onSelect(path)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||||
>
|
>
|
||||||
{isExpanded && !isLeaf ? (
|
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
|
||||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
@@ -249,23 +254,26 @@ function TreeNode({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FileTargetMenu>
|
</FileTargetMenu>
|
||||||
{isExpanded &&
|
{isExpanded && children != null && children.length > 0 && (
|
||||||
children?.map((child) => (
|
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
<TreeNode
|
{children.map((child) => (
|
||||||
key={child.serverPath}
|
<TreeNode
|
||||||
path={child.serverPath}
|
key={child.serverPath}
|
||||||
name={child.name}
|
path={child.serverPath}
|
||||||
depth={depth + 1}
|
name={child.name}
|
||||||
mappings={mappings}
|
depth={depth + 1}
|
||||||
childrenByPath={childrenByPath}
|
mappings={mappings}
|
||||||
expanded={expanded}
|
childrenByPath={childrenByPath}
|
||||||
loadingPaths={loadingPaths}
|
expanded={expanded}
|
||||||
selectedServerPath={selectedServerPath}
|
loadingPaths={loadingPaths}
|
||||||
onToggle={onToggle}
|
selectedServerPath={selectedServerPath}
|
||||||
onSelect={onSelect}
|
onToggle={onToggle}
|
||||||
onFileAction={onFileAction}
|
onSelect={onSelect}
|
||||||
/>
|
onFileAction={onFileAction}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function WorkspaceManageDialog({
|
|||||||
) : (
|
) : (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-red-500/10 text-red-700 dark:text-red-400"
|
className="bg-red-500/10 text-red-700"
|
||||||
>
|
>
|
||||||
不存在
|
不存在
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -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 项,成功后清空注释。
|
||||||
*/
|
*/
|
||||||
@@ -66,9 +183,11 @@ export function ChangesPanel({
|
|||||||
}, [onCheckin, included, comment])
|
}, [onCheckin, included, comment])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-[340px] shrink-0 flex-col border-l bg-sidebar">
|
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">挂起更改</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
挂起更改
|
||||||
|
</span>
|
||||||
{pendingChanges.length > 0 && (
|
{pendingChanges.length > 0 && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
||||||
)}
|
)}
|
||||||
@@ -104,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>
|
||||||
)}
|
)}
|
||||||
@@ -150,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 ? (
|
||||||
<>
|
<>
|
||||||
@@ -173,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({
|
||||||
@@ -215,42 +429,65 @@ 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 rounded-md", statusBadgeClass(change.status))}
|
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)}
|
{statusLabel(change.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,25 +65,61 @@ 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-[180px] shrink-0 flex-col border-t bg-background">
|
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">操作日志</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
操作日志
|
||||||
|
</span>
|
||||||
{busy && (
|
{busy && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-primary">
|
<span className="flex items-center gap-1.5 text-xs text-primary">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
{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"
|
||||||
@@ -88,14 +136,21 @@ 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">
|
||||||
{ordered.map((entry, index) => (
|
{ordered.map((entry, index) => (
|
||||||
<li
|
<li
|
||||||
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
||||||
className="flex h-7 items-center gap-2 rounded px-1.5 text-xs hover:bg-muted"
|
className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
|
||||||
title={entry.errorMessage ?? entry.summary}
|
title={entry.errorMessage ?? entry.summary}
|
||||||
>
|
>
|
||||||
{entry.success ? (
|
{entry.success ? (
|
||||||
@@ -103,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>
|
||||||
@@ -126,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ 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}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="grid place-content-center text-current transition-none [&>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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 isolate z-50 bg-black/20 duration-200 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-150",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -59,7 +59,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"ease-spring fixed top-12 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 gap-4 rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-dialog duration-200 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-top-6 data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-top-4 data-closed:duration-150 data-closed:ease-out",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -128,7 +128,7 @@ function DialogTitle({
|
|||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-none font-medium",
|
"font-heading text-base leading-none font-semibold tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
// Radix 默认给内容包一层 display:table + min-width:100% 的 div,会让内容按
|
||||||
|
// 自身宽度撑开导致 truncate 失效、文字被面板边缘硬切;改为 block 占满视口宽度。
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:block! [&>div]:w-full [&>div]:min-w-0!"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
@@ -44,7 +46,7 @@ function ScrollBar({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
data-slot="scroll-area-thumb"
|
data-slot="scroll-area-thumb"
|
||||||
className="relative flex-1 rounded-full bg-border"
|
className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
|
||||||
/>
|
/>
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-primary/8 data-[state=selected]:hover:bg-primary/10",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Check, Loader2, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
|
||||||
|
export type ToastKind = "success" | "error" | "loading"
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string
|
||||||
|
kind: ToastKind
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (toasts: ToastItem[]) => void
|
||||||
|
|
||||||
|
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
|
||||||
|
let currentToasts: ToastItem[] = []
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 3200
|
||||||
|
const MAX_VISIBLE = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有挂载中的 Toaster 重新渲染。
|
||||||
|
*/
|
||||||
|
function emit() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(currentToasts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭指定 toast。
|
||||||
|
*/
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
currentToasts = currentToasts.filter((item) => item.id !== id)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
|
||||||
|
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
|
||||||
|
*/
|
||||||
|
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
|
||||||
|
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
const existing = currentToasts.find((item) => item.id === id)
|
||||||
|
const next: ToastItem = { id, kind: input.kind, text: input.text }
|
||||||
|
currentToasts = existing
|
||||||
|
? currentToasts.map((item) => (item.id === id ? next : item))
|
||||||
|
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
if (input.kind !== "loading") {
|
||||||
|
dismissTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// toast 类型对应的状态徽标:macOS 通知风的彩色小圆徽(成功绿 / 失败红),loading 用旋转图标。
|
||||||
|
const KIND_BADGES: Record<"success" | "error", { icon: typeof Check; chipClass: string }> = {
|
||||||
|
success: { icon: Check, chipClass: "bg-emerald-500" },
|
||||||
|
error: { icon: X, chipClass: "bg-destructive" },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
|
||||||
|
* 视觉对齐 macOS 系统通知:白色毛玻璃圆角卡片 + 彩色状态圆徽 + 柔和投影,无彩色边框。
|
||||||
|
*/
|
||||||
|
export function Toaster() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener: Listener = (toasts) => setItems([...toasts])
|
||||||
|
listeners.add(listener)
|
||||||
|
setItems([...currentToasts])
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-14 right-4 z-[60] flex flex-col items-end gap-2.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(item.id)}
|
||||||
|
className="animate-in fade-in slide-in-from-top-2 ease-out-quart pointer-events-auto flex w-fit max-w-[360px] min-w-[200px] items-center gap-2.5 rounded-xl border border-black/8 bg-popover/90 py-2.5 pr-4 pl-3 text-left shadow-[0_8px_24px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.06)] backdrop-blur-xl duration-300"
|
||||||
|
>
|
||||||
|
{item.kind === "loading" ? (
|
||||||
|
<Loader2 className="size-4.5 shrink-0 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
<ToastBadge kind={item.kind} />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 text-[13px] leading-snug font-medium break-words text-foreground">
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功 / 失败的彩色圆形徽标:实色圆底 + 白色粗描边图标。
|
||||||
|
*/
|
||||||
|
function ToastBadge({ kind }: { kind: "success" | "error" }) {
|
||||||
|
const badge = KIND_BADGES[kind]
|
||||||
|
const Icon = badge.icon
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||||
|
badge.chipClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3 text-white" strokeWidth={3.2} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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。
|
||||||
*/
|
*/
|
||||||
@@ -225,8 +234,14 @@ export function getFileContent(params: { serverPath?: string; localPath?: string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已映射目录执行目录对比。
|
* 对已映射目录执行目录对比。
|
||||||
|
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
|
||||||
*/
|
*/
|
||||||
export function compareFolder(body: { serverPath: string; localPath?: string; recursive?: boolean }) {
|
export function compareFolder(body: {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
recursive?: boolean
|
||||||
|
includeLocalOnly?: boolean
|
||||||
|
}) {
|
||||||
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type {
|
|||||||
MappingConfig,
|
MappingConfig,
|
||||||
MappingInfo,
|
MappingInfo,
|
||||||
OperationLogEntry,
|
OperationLogEntry,
|
||||||
|
OperationSource,
|
||||||
PendingChange,
|
PendingChange,
|
||||||
ServerItem,
|
ServerItem,
|
||||||
TextDiff,
|
TextDiff,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 环境下的占位服务状态。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -54,34 +54,6 @@ monaco.editor.defineTheme("mactfs-light", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
monaco.editor.defineTheme("mactfs-dark", {
|
|
||||||
base: "vs-dark",
|
|
||||||
inherit: true,
|
|
||||||
rules: [],
|
|
||||||
colors: {
|
|
||||||
"editor.background": "#1b1b1b",
|
|
||||||
"editor.foreground": "#fafafa",
|
|
||||||
"editor.lineHighlightBackground": "#ffffff08",
|
|
||||||
"editorLineNumber.foreground": "#6b6b6b",
|
|
||||||
"editorLineNumber.activeForeground": "#a3a3a3",
|
|
||||||
"editorCursor.foreground": "#818cf8",
|
|
||||||
"focusBorder": "#00000000",
|
|
||||||
"diffEditor.insertedTextBackground": "#22c55e30",
|
|
||||||
"diffEditor.insertedLineBackground": "#22c55e17",
|
|
||||||
"diffEditor.removedTextBackground": "#ef444430",
|
|
||||||
"diffEditor.removedLineBackground": "#ef444417",
|
|
||||||
"diffEditorGutter.insertedLineBackground": "#22c55e24",
|
|
||||||
"diffEditorGutter.removedLineBackground": "#ef444424",
|
|
||||||
"diffEditorOverview.insertedForeground": "#22c55e99",
|
|
||||||
"diffEditorOverview.removedForeground": "#ef444499",
|
|
||||||
"diffEditor.unchangedRegionBackground": "#222222",
|
|
||||||
"diffEditor.unchangedRegionForeground": "#9c9c9c",
|
|
||||||
"scrollbarSlider.background": "#ffffff1f",
|
|
||||||
"scrollbarSlider.hoverBackground": "#ffffff33",
|
|
||||||
"scrollbarSlider.activeBackground": "#ffffff40",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
|
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 是否运行在 macOS 的 Electron 窗口内。
|
||||||
|
* 用于判断隐藏式标题栏与 vibrancy 场景:需要为红绿灯预留空间并启用顶栏拖拽。
|
||||||
|
*/
|
||||||
|
export function isMacElectron(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
return ua.includes("Electron") && ua.includes("Macintosh")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 登录页「最近连接」本地记录,仅存非敏感字段(不存密码)。
|
||||||
|
export interface RecentServer {
|
||||||
|
serverUri: string
|
||||||
|
domain: string
|
||||||
|
username: string
|
||||||
|
lastUsedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mactfs.recent-servers"
|
||||||
|
const MAX_ENTRIES = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取最近连接记录,本地存储不可用或数据损坏时返回空列表。
|
||||||
|
*/
|
||||||
|
export function loadRecentServers(): RecentServer[] {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as RecentServer[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入一条最近连接记录:按 serverUri + username 去重置顶,最多保留 5 条。
|
||||||
|
*/
|
||||||
|
export function saveRecentServer(entry: Omit<RecentServer, "lastUsedAt">): RecentServer[] {
|
||||||
|
const next: RecentServer[] = [
|
||||||
|
{ ...entry, lastUsedAt: Date.now() },
|
||||||
|
...loadRecentServers().filter(
|
||||||
|
(item) => item.serverUri !== entry.serverUri || item.username !== entry.username,
|
||||||
|
),
|
||||||
|
].slice(0, MAX_ENTRIES)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// 本地存储写入失败不影响连接流程
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -21,18 +21,18 @@ export const STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
||||||
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
localModified: "bg-blue-500/10 text-blue-700",
|
||||||
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
remoteChanged: "bg-violet-500/10 text-violet-700",
|
||||||
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
|
bothChanged: "bg-red-500/15 text-red-700",
|
||||||
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
|
localOnly: "bg-green-500/10 text-green-700",
|
||||||
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
remoteOnly: "bg-violet-500/10 text-violet-700",
|
||||||
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
notDownloaded: "bg-amber-500/10 text-amber-700",
|
||||||
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
|
localDeleted: "bg-red-500/10 text-red-700",
|
||||||
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
pendingEdit: "bg-blue-500/10 text-blue-700",
|
||||||
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
pendingAdd: "bg-green-500/10 text-green-700",
|
||||||
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
|
pendingDelete: "bg-red-500/10 text-red-700",
|
||||||
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
pendingRename: "bg-blue-500/10 text-blue-700",
|
||||||
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
pending: "bg-blue-500/10 text-blue-700",
|
||||||
upToDate: "bg-muted text-muted-foreground",
|
upToDate: "bg-muted text-muted-foreground",
|
||||||
notMapped: "bg-muted text-muted-foreground",
|
notMapped: "bg-muted text-muted-foreground",
|
||||||
mapped: "bg-muted text-foreground/80",
|
mapped: "bg-muted text-foreground/80",
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
{/* 首屏前标记 vibrancy 环境(macOS Electron 下透出窗口毛玻璃) */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy")}catch(e){}})()`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import type { WorkspaceSession } from "~/lib/tfs/session"
|
|||||||
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
||||||
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面标题(同时作为 Electron 窗口标题)。
|
||||||
|
*/
|
||||||
|
export function meta() {
|
||||||
|
return [{ title: "MacTFS" }]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
||||||
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
||||||
@@ -88,7 +95,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
||||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
||||||
<h1 className="text-base font-semibold">macTFS</h1>
|
<h1 className="text-base font-semibold">MacTFS</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
||||||
|
|
||||||
{phase === "checking" && (
|
{phase === "checking" && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron")
|
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, protocol } = require("electron")
|
||||||
const path = require("node:path")
|
const path = require("node:path")
|
||||||
const fs = require("node:fs")
|
const fs = require("node:fs")
|
||||||
const os = require("node:os")
|
const os = require("node:os")
|
||||||
@@ -10,12 +10,171 @@ 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
|
||||||
|
|
||||||
|
// 自定义协议:以 app://bundle/ 提供前端静态资源。
|
||||||
|
// 打包后构建产物使用绝对路径(/assets/...),用 file:// 会指向磁盘根目录导致白屏,故走自定义协议从 build/client 映射。
|
||||||
|
const APP_SCHEME = "app"
|
||||||
|
const APP_ORIGIN = `${APP_SCHEME}://bundle/`
|
||||||
|
const MIME_BY_EXT = {
|
||||||
|
".html": "text/html",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".mjs": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".map": "application/json",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须在 app ready 之前注册自定义协议的特权(标准协议 + 安全上下文)。
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: APP_SCHEME,
|
||||||
|
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 app:// 协议处理器:把请求路径映射到 build/client 下的静态文件,找不到时回退到 index.html(适配前端路由)。
|
||||||
|
*/
|
||||||
|
function registerAppProtocol() {
|
||||||
|
const clientRoot = path.join(__dirname, "..", "build", "client")
|
||||||
|
protocol.handle(APP_SCHEME, async (request) => {
|
||||||
|
let relativePath = decodeURIComponent(new URL(request.url).pathname)
|
||||||
|
if (!relativePath || relativePath === "/") {
|
||||||
|
relativePath = "/index.html"
|
||||||
|
}
|
||||||
|
let filePath = path.normalize(path.join(clientRoot, relativePath))
|
||||||
|
if (!filePath.startsWith(clientRoot) || !fs.existsSync(filePath)) {
|
||||||
|
filePath = path.join(clientRoot, "index.html")
|
||||||
|
}
|
||||||
|
const data = await fs.promises.readFile(filePath)
|
||||||
|
const contentType = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "application/octet-stream"
|
||||||
|
return new Response(data, { headers: { "content-type": contentType } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
|
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
|
||||||
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 下,上溯两级即为单仓库根目录。
|
||||||
@@ -24,19 +183,30 @@ function resolveProjectRoot() {
|
|||||||
return path.resolve(__dirname, "..", "..")
|
return path.resolve(__dirname, "..", "..")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务端 JVM 固定使用 x64:TFS 的 JNI 原生库(libnative_*.jnilib)只含 i386/x86_64,无 arm64 切片,
|
||||||
|
// 用 arm64 JVM 加载会 UnsatisfiedLinkError。Apple Silicon 上 x64 JVM 经 Rosetta 2 运行;
|
||||||
|
// 服务端是 IO 密集型,Rosetta 开销可接受。UI(Electron)走 universal 原生,已解决界面卡顿。
|
||||||
|
const PACKAGED_JRE_DIR = "jre-x64"
|
||||||
|
const DEV_JDK_DIR = "zulu8.94.0.17-ca-jdk8.0.492-macosx_x64"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析运行服务端使用的 Java 可执行文件,优先项目内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
* 解析运行服务端使用的 Java 可执行文件:固定 x64 内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
||||||
*/
|
*/
|
||||||
function resolveJavaBin() {
|
function resolveJavaBin() {
|
||||||
|
// 打包态:x64 JRE 随 extraResources 落在 Resources/jre-x64 下。
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packaged = path.join(process.resourcesPath, PACKAGED_JRE_DIR, "bin", "java")
|
||||||
|
if (fs.existsSync(packaged)) {
|
||||||
|
return packaged
|
||||||
|
}
|
||||||
|
// 兼容旧单架构包结构(Resources/jre)。
|
||||||
|
const legacy = path.join(process.resourcesPath, "jre", "bin", "java")
|
||||||
|
if (fs.existsSync(legacy)) {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
}
|
||||||
const projectRoot = resolveProjectRoot()
|
const projectRoot = resolveProjectRoot()
|
||||||
const bundled = path.join(
|
const bundled = path.join(projectRoot, DEV_JDK_DIR, "Contents", "Home", "bin", "java")
|
||||||
projectRoot,
|
|
||||||
"zulu8.94.0.17-ca-jdk8.0.492-macosx_x64",
|
|
||||||
"Contents",
|
|
||||||
"Home",
|
|
||||||
"bin",
|
|
||||||
"java",
|
|
||||||
)
|
|
||||||
if (fs.existsSync(bundled)) {
|
if (fs.existsSync(bundled)) {
|
||||||
return bundled
|
return bundled
|
||||||
}
|
}
|
||||||
@@ -53,6 +223,13 @@ function resolveJavaBin() {
|
|||||||
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
|
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
|
||||||
*/
|
*/
|
||||||
function resolveServerClasspath() {
|
function resolveServerClasspath() {
|
||||||
|
// 打包态:服务端 jar 随 extraResources 落在 Resources/server/lib 下。
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packagedLib = path.join(process.resourcesPath, "server", "lib")
|
||||||
|
if (fs.existsSync(packagedLib)) {
|
||||||
|
return path.join(packagedLib, "*")
|
||||||
|
}
|
||||||
|
}
|
||||||
const installLib = path.join(
|
const installLib = path.join(
|
||||||
resolveProjectRoot(),
|
resolveProjectRoot(),
|
||||||
"mactfs",
|
"mactfs",
|
||||||
@@ -71,6 +248,13 @@ function resolveServerClasspath() {
|
|||||||
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
|
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
|
||||||
*/
|
*/
|
||||||
function resolveNativeDirectory() {
|
function resolveNativeDirectory() {
|
||||||
|
// 打包态:JNI native 库随服务端 lib 一起落在 Resources/server/lib/native 下。
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packagedNative = path.join(process.resourcesPath, "server", "lib", "native")
|
||||||
|
if (fs.existsSync(packagedNative)) {
|
||||||
|
return packagedNative
|
||||||
|
}
|
||||||
|
}
|
||||||
const projectRoot = resolveProjectRoot()
|
const projectRoot = resolveProjectRoot()
|
||||||
const installNative = path.join(
|
const installNative = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
@@ -192,7 +376,9 @@ async function startService() {
|
|||||||
}
|
}
|
||||||
args.push(SERVER_MAIN_CLASS)
|
args.push(SERVER_MAIN_CLASS)
|
||||||
serverProcess = spawn(resolveJavaBin(), args, {
|
serverProcess = spawn(resolveJavaBin(), args, {
|
||||||
cwd: 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", () => {
|
||||||
@@ -212,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
|
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
|
||||||
*/
|
*/
|
||||||
@@ -236,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"],
|
||||||
@@ -260,13 +520,47 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析应用图标路径:开发态用 public/logo.png,打包态用构建产物中的 logo.png。
|
||||||
|
*/
|
||||||
|
function resolveAppIcon() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, "..", "public", "logo.png"),
|
||||||
|
path.join(__dirname, "..", "build", "client", "logo.png"),
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
|
const isMac = process.platform === "darwin"
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 840,
|
height: 840,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 640,
|
||||||
|
title: "MacTFS",
|
||||||
|
// 预设背景并延迟显示,避免启动白闪;macOS 下背景透明以透出 vibrancy 毛玻璃。
|
||||||
|
show: false,
|
||||||
|
backgroundColor: isMac ? "#00000000" : "#f9f9fa",
|
||||||
|
// macOS 隐藏系统标题栏 + 窗口级毛玻璃材质;其它平台回退系统标题栏与实色背景。
|
||||||
|
...(isMac
|
||||||
|
? {
|
||||||
|
titleBarStyle: "hiddenInset",
|
||||||
|
trafficLightPosition: { x: 16, y: 16 },
|
||||||
|
vibrancy: "sidebar",
|
||||||
|
visualEffectState: "followWindow",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.cjs"),
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -274,17 +568,34 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
// 启动即铺满当前屏幕可视区域(保留程序坞与菜单栏,非独立全屏 Space)。
|
||||||
|
mainWindow.maximize()
|
||||||
|
mainWindow.show()
|
||||||
|
})
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.loadURL("http://localhost:5173")
|
mainWindow.loadURL("http://localhost:5173")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
// 生产/打包态走自定义协议,保证前端的绝对资源路径(/assets/...)可被正确解析。
|
||||||
|
mainWindow.loadURL(APP_ORIGIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.setName("MacTFS")
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
|
if (process.platform === "darwin" && iconPath && app.dock) {
|
||||||
|
app.dock.setIcon(nativeImage.createFromPath(iconPath))
|
||||||
|
}
|
||||||
|
registerAppProtocol()
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
// 随客户端拉起 MCP;它会自行等待后端就绪,端口随客户端退出而消失。
|
||||||
|
startMcp()
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
@@ -299,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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,66 @@
|
|||||||
{
|
{
|
||||||
"name": "mactfsui",
|
"name": "mactfsui",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MacTFS - 面向 macOS 的 TFS 客户端(前端 + 内置 Java 服务)",
|
||||||
|
"author": "mydev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "react-router build",
|
"build": "react-router build",
|
||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"electron": "electron electron/main.cjs",
|
"electron": "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\"",
|
"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: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}\""
|
||||||
},
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.mydev.mactfs",
|
||||||
|
"productName": "MacTFS",
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "build-resources",
|
||||||
|
"output": "dist-app"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"electron/**/*",
|
||||||
|
"build/client/**/*",
|
||||||
|
"package.json",
|
||||||
|
"!node_modules/**/*"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "../mactfs/build/install/mactfs/lib",
|
||||||
|
"to": "server/lib"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "runtime/jdk-x64",
|
||||||
|
"to": "jre-x64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../mactfs-mcp/dist-bundle",
|
||||||
|
"to": "mcp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dmg",
|
||||||
|
"arch": [
|
||||||
|
"arm64",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "public.app-category.developer-tools",
|
||||||
|
"icon": "build-resources/icon.icns",
|
||||||
|
"identity": null
|
||||||
|
}
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/roboto-slab": "^5.2.8",
|
|
||||||
"@react-router/node": "7.13.1",
|
"@react-router/node": "7.13.1",
|
||||||
"@react-router/serve": "7.13.1",
|
"@react-router/serve": "7.13.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -36,6 +84,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"electron": "^42.2.0",
|
"electron": "^42.2.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"playwright-core": "^1.60.0",
|
"playwright-core": "^1.60.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,127 @@
|
|||||||
|
// FE-034 补充回归:目录已被既有 Workspace 映射(含用户真实挂起更改),
|
||||||
|
// 因此跳过创建/取消映射,只验证 目录对比、签出→Diff→撤销(仅 ai-smoke 文件)、操作日志。
|
||||||
|
import { _electron as electron } from "playwright-core"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
|
||||||
|
const SMOKE_FILE = "ai-smoke-20260606051038.txt"
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
/** 记录一步结果并打印。 */
|
||||||
|
function record(step, ok, detail = "") {
|
||||||
|
results.push({ step, ok, detail })
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截图保存。 */
|
||||||
|
async function shot(win, name) {
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: ["electron/main.cjs"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, NODE_ENV: "development" },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
win.setDefaultTimeout(90000)
|
||||||
|
|
||||||
|
// 进入工作台并导航到测试目录
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||||
|
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||||
|
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||||
|
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||||
|
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||||
|
const tree = win.locator("aside").first()
|
||||||
|
for (const name of TEST_TREE_CHAIN) {
|
||||||
|
const node = tree.getByText(name, { exact: true }).first()
|
||||||
|
await node.waitFor({ timeout: 120000 })
|
||||||
|
await node.click()
|
||||||
|
await win.waitForTimeout(2500)
|
||||||
|
}
|
||||||
|
record("0 进入工作台并导航到测试目录", true)
|
||||||
|
|
||||||
|
// A. 目录对比(目录已映射 → 菜单应可用;忽略仅本地/仅服务器项保持默认)
|
||||||
|
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||||
|
const compareItem = win.getByRole("menuitem", { name: "目录对比", exact: true })
|
||||||
|
await compareItem.waitFor({ timeout: 10000 })
|
||||||
|
await compareItem.click()
|
||||||
|
await win.locator("[data-slot=dialog-title]", { hasText: "目录对比" }).waitFor()
|
||||||
|
await shot(win, "12-compare-options")
|
||||||
|
// 新交互:先确认选项再执行
|
||||||
|
const startButton = win.getByRole("button", { name: /开始对比|执行对比|对比/ }).first()
|
||||||
|
if ((await startButton.count()) > 0) {
|
||||||
|
await startButton.click()
|
||||||
|
}
|
||||||
|
await win.waitForTimeout(10000)
|
||||||
|
const compareText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||||
|
record("A 目录对比弹窗", !compareText.includes("目录对比失败"), compareText.slice(0, 100).replace(/\n/g, " "))
|
||||||
|
await shot(win, "12-compare")
|
||||||
|
await win.keyboard.press("Escape")
|
||||||
|
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
|
||||||
|
// B. 签出 ai-smoke 文件 → 挂起更改出现 → 与最新版本比较 → 撤销(只动这一个文件)
|
||||||
|
const fileRow = win.locator("section table tbody tr").filter({ hasText: SMOKE_FILE }).first()
|
||||||
|
await fileRow.click({ button: "right" })
|
||||||
|
const checkoutItem = win.getByRole("menuitem", { name: "签出编辑", exact: true })
|
||||||
|
await checkoutItem.waitFor({ timeout: 10000 })
|
||||||
|
await checkoutItem.click()
|
||||||
|
await win.waitForTimeout(15000)
|
||||||
|
const pendingPanel = await win.locator("aside").nth(1).innerText()
|
||||||
|
const hasPending = pendingPanel.includes(SMOKE_FILE)
|
||||||
|
record("B1 签出文件产生挂起更改", hasPending, "")
|
||||||
|
await shot(win, "13-pending")
|
||||||
|
|
||||||
|
if (hasPending) {
|
||||||
|
const row = win.locator("aside").nth(1).getByText(SMOKE_FILE).first()
|
||||||
|
await row.click({ button: "right" })
|
||||||
|
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
|
||||||
|
if ((await diffItem.count()) > 0) {
|
||||||
|
await diffItem.click()
|
||||||
|
await win.waitForTimeout(8000)
|
||||||
|
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||||
|
record("B2 本地 vs latest Diff(Monaco)", diffText.includes("差异") || diffText.includes("一致"), diffText.slice(0, 80).replace(/\n/g, " "))
|
||||||
|
await shot(win, "14-diff")
|
||||||
|
await win.keyboard.press("Escape")
|
||||||
|
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
// 仅撤销 ai-smoke 这一个文件,避免影响用户已有挂起更改
|
||||||
|
await row.click({ button: "right" })
|
||||||
|
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
|
||||||
|
await win.waitForTimeout(12000)
|
||||||
|
const afterUndo = await win.locator("aside").nth(1).innerText()
|
||||||
|
record("B3 撤销该文件挂起更改", !afterUndo.includes(SMOKE_FILE), "")
|
||||||
|
await shot(win, "15-undone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 操作日志面板有真实记录
|
||||||
|
const consoleText = await win.locator("footer").innerText()
|
||||||
|
record("C 操作日志记录", /checkout|undo|connect|serverTree|folderDiff|compare/i.test(consoleText), consoleText.split("\n").slice(0, 5).join(" | "))
|
||||||
|
await shot(win, "16-console")
|
||||||
|
} catch (error) {
|
||||||
|
record("EXCEPTION", false, String(error).slice(0, 300))
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
await shot(win, "98-exception")
|
||||||
|
} catch {}
|
||||||
|
} finally {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n==== RESULT SUMMARY ====")
|
||||||
|
for (const item of results) {
|
||||||
|
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
|
||||||
|
}
|
||||||
|
const failed = results.filter((item) => !item.ok).length
|
||||||
|
console.log(`\n${results.length - failed}/${results.length} passed`)
|
||||||
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
@@ -57,6 +57,18 @@ try {
|
|||||||
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
|
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
|
||||||
await shot(win, "03-workspace")
|
await shot(win, "03-workspace")
|
||||||
|
|
||||||
|
// 3.5 明暗主题三态切换(FE-033 回归):跟随系统 → 亮 → 暗 → 跟随系统
|
||||||
|
const themeButton = win.getByRole("button", { name: /主题:/ })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
const darkApplied = await win.evaluate(() => document.documentElement.classList.contains("dark"))
|
||||||
|
record("3.5 主题切换到暗色", darkApplied, "")
|
||||||
|
await shot(win, "03b-dark-workspace")
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
|
||||||
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
|
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
|
||||||
const tree = win.locator("aside").first()
|
const tree = win.locator("aside").first()
|
||||||
for (const name of TEST_TREE_CHAIN) {
|
for (const name of TEST_TREE_CHAIN) {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { existsSync, rmSync, cpSync, mkdtempSync } from "node:fs"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join, resolve, dirname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const projectRoot = resolve(here, "..", "..")
|
||||||
|
|
||||||
|
// 服务端 JVM 固定 x64:TFS 的 JNI 原生库只含 i386/x86_64(无 arm64),arm64 JVM 无法加载。
|
||||||
|
// 因此 universal 包内只随附 x64 JRE,在 Apple Silicon 上经 Rosetta 2 运行服务端。
|
||||||
|
//
|
||||||
|
// JDK 体积大且是第三方二进制,不入库(见 .gitignore)。本地缺失时从 Azul 官方按需下载,
|
||||||
|
// 校验 sha256 后解压到仓库根目录,再继续复制流程。
|
||||||
|
const JDK_DIR_NAME = "zulu8.94.0.17-ca-jdk8.0.492-macosx_x64"
|
||||||
|
const JDK_URL = `https://cdn.azul.com/zulu/bin/${JDK_DIR_NAME}.tar.gz`
|
||||||
|
const JDK_SHA256 =
|
||||||
|
"5114b269b88e3d89b0d6b2c28af0c96b5489f340fbaded8fa17613b2adca180c"
|
||||||
|
|
||||||
|
const source = join(projectRoot, JDK_DIR_NAME, "Contents", "Home")
|
||||||
|
const dest = resolve(here, "..", "runtime", "jdk-x64")
|
||||||
|
|
||||||
|
function ensureJdkPresent() {
|
||||||
|
if (existsSync(join(source, "bin", "java"))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prepare-runtime] 未找到本地 x64 JDK,开始从 Azul 下载…")
|
||||||
|
console.log("[prepare-runtime] URL:", JDK_URL)
|
||||||
|
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "mactfs-jdk-"))
|
||||||
|
const archive = join(workDir, "zulu.tar.gz")
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"curl",
|
||||||
|
["-fsSL", "--retry", "3", "--max-time", "600", "-o", archive, JDK_URL],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const actualSha = execFileSync("shasum", ["-a", "256", archive])
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0]
|
||||||
|
if (actualSha !== JDK_SHA256) {
|
||||||
|
throw new Error(
|
||||||
|
`JDK sha256 校验失败:期望 ${JDK_SHA256},实际 ${actualSha}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log("[prepare-runtime] sha256 校验通过,解压到", projectRoot)
|
||||||
|
|
||||||
|
execFileSync("tar", ["xzf", archive, "-C", projectRoot], {
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
rmSync(workDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(join(source, "bin", "java"))) {
|
||||||
|
console.error("[prepare-runtime] 下载解压后仍未找到 x64 JDK:", source)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureJdkPresent()
|
||||||
|
|
||||||
|
console.log("[prepare-runtime] 复制 x64 JDK ->", dest)
|
||||||
|
rmSync(dest, { recursive: true, force: true })
|
||||||
|
cpSync(source, dest, { recursive: true })
|
||||||
|
|
||||||
|
// 运行服务用不到的部分,复制后删除以缩小打包体积(仍保留 bin/java + jre + lib)。
|
||||||
|
const drop = ["src.zip", "demo", "sample", "man", "include"]
|
||||||
|
for (const name of drop) {
|
||||||
|
rmSync(join(dest, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(join(dest, "bin", "java"))) {
|
||||||
|
console.error("[prepare-runtime] 精简后 bin/java 缺失,终止。")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prepare-runtime] x64 JRE 准备完成:", dest)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 快速视觉确认:进入工作台后截图亮色 / 暗色画布卡片布局,不做任何写操作。
|
||||||
|
import { _electron as electron } from "playwright-core"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: ["electron/main.cjs"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, NODE_ENV: "development" },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
win.setDefaultTimeout(90000)
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "20-connect-v2.png") })
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||||
|
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "21-collections-v2.png") })
|
||||||
|
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||||
|
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||||
|
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||||
|
await win.waitForTimeout(6000)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "22-workspace-cards.png") })
|
||||||
|
// 验证表头 sticky:滚动文件列表后表头应仍可见
|
||||||
|
await win.evaluate(() => {
|
||||||
|
const container = document.querySelector('[data-slot="table-container"]')
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "24-sticky-header.png") })
|
||||||
|
const themeButton = win.getByRole("button", { name: /主题:/ })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "23-workspace-cards-dark.png") })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
console.log("screenshots done")
|
||||||
|
} finally {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
}
|
||||||
@@ -31,14 +31,14 @@ blocked
|
|||||||
| Core | 12 | 12 | 100% |
|
| Core | 12 | 12 | 100% |
|
||||||
| Server API | 10 | 10 | 100% |
|
| Server API | 10 | 10 | 100% |
|
||||||
| CLI | 2 | 2 | 100% |
|
| CLI | 2 | 2 | 100% |
|
||||||
| Frontend | 25 | 25 | 100% |
|
| Frontend | 34 | 34 | 100% |
|
||||||
| Feature E2E | 0 | 5 | 0% |
|
| Feature E2E | 0 | 5 | 0% |
|
||||||
| Release | 0 | 4 | 0% |
|
| Release | 0 | 4 | 0% |
|
||||||
| Overall | 49 | 58 | 84% |
|
| Overall | 58 | 67 | 87% |
|
||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真实 TFS 环境)
|
UI 风格改版·macOS 原生风方案完成(分支 `feature/ui-macos-restyle`,FE-026 ~ FE-034 全部 done;姊妹分支 `feature/ui-modern-restyle` 的现代工具风方案也已完成,两套可并行对比体验,截图见 [docs/ui-restyle-screenshots](/Users/fenghp/Desktop/DEV/project/mydev/docs/ui-restyle-screenshots))
|
||||||
|
|
||||||
## 前端规范
|
## 前端规范
|
||||||
|
|
||||||
@@ -46,7 +46,9 @@ Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真
|
|||||||
|
|
||||||
## 下一步任务
|
## 下一步任务
|
||||||
|
|
||||||
|
- 两个 UI 分支对比体验后选定方案
|
||||||
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
||||||
|
- UI 改版完成后再回到 [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -116,6 +118,15 @@ Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真
|
|||||||
| [FE-023-实现分支.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-023-实现分支.md) | done | P1 |
|
| [FE-023-实现分支.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-023-实现分支.md) | done | P1 |
|
||||||
| [FE-024-实现合并.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-024-实现合并.md) | done | P1 |
|
| [FE-024-实现合并.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-024-实现合并.md) | done | P1 |
|
||||||
| [FE-025-实现访达打开与添加本地文件.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-025-实现访达打开与添加本地文件.md) | done | P0 |
|
| [FE-025-实现访达打开与添加本地文件.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-025-实现访达打开与添加本地文件.md) | done | P0 |
|
||||||
|
| [FE-026-建立macOS设计基建.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-026-建立macOS设计基建.md) | done | P0 |
|
||||||
|
| [FE-027-Electron窗口macOS化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-027-Electron窗口macOS化.md) | done | P0 |
|
||||||
|
| [FE-028-重设计登录页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-028-重设计登录页.md) | done | P0 |
|
||||||
|
| [FE-029-工作台外壳与顶栏改版.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-029-工作台外壳与顶栏改版.md) | done | P0 |
|
||||||
|
| [FE-030-目录树与文件列表精致化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-030-目录树与文件列表精致化.md) | done | P0 |
|
||||||
|
| [FE-031-弹窗体系统一macOS风格.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-031-弹窗体系统一macOS风格.md) | done | P1 |
|
||||||
|
| [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) | done | P1 |
|
||||||
|
| [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md) | done | P1 |
|
||||||
|
| [FE-034-UI改版回归验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-034-UI改版回归验收.md) | done | P0 |
|
||||||
|
|
||||||
## Feature E2E
|
## Feature E2E
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# FE-026 建立macOS设计基建
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U1
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把全局设计令牌升级为 macOS 现代风格,作为后续所有 UI 改版任务的统一基础。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 字体切换为系统字体栈(`-apple-system, BlinkMacSystemFont, "PingFang SC"` 等),移除 Roboto Slab 作为全局字体;代码/Diff 场景保留等宽字体
|
||||||
|
- 重定义颜色令牌:macOS 蓝主色、中性灰分级、分隔线、半透明材质变量,light/dark 两套完整定义
|
||||||
|
- 统一圆角档位(约 6/10/14px)、分层阴影(卡片/浮层/弹窗)、间距档位
|
||||||
|
- 建立动效令牌:统一时长(150/220/320ms)与缓动曲线变量
|
||||||
|
- 预留 vibrancy 透明背景变量(供 FE-027 使用)
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改任何组件结构与业务逻辑
|
||||||
|
- 不实现主题切换入口(FE-033 做)
|
||||||
|
- 不改 Electron 窗口配置(FE-027 做)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/package.json`(如需移除字体依赖或引入 motion)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 全局字体为系统字体栈,界面文字观感与 macOS 原生应用一致
|
||||||
|
- light/dark 两套 token 完整、可用
|
||||||
|
- 圆角、阴影、动效时长全部来自统一变量,无散落魔法值
|
||||||
|
- `pnpm typecheck` 通过,现有页面无功能回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/package.json`、`mactfsui/pnpm-lock.yaml`(移除 `@fontsource-variable/roboto-slab`)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 全局字体切换为系统字体栈(SF Pro / PingFang SC 优先),`html` 改 `font-sans`;新增 `--font-mono` 等宽令牌
|
||||||
|
- 主色改为 macOS 系统蓝(light ≈ #007AFF、dark ≈ #0A84FF),`--accent` 与主色解耦改中性悬停色
|
||||||
|
- 中性灰分级 light/dark 两套完整定义(dark 深灰非纯黑)
|
||||||
|
- 半透明材质变量:`--sidebar` / `--sidebar-accent` 带透明度,配套 `html.vibrancy` 时页面底透明(供 FE-027 毛玻璃透出),vibrancy 未开启时半透明叠在实色上近似实色、可安全回退
|
||||||
|
- 圆角基准 0.875rem → 0.625rem(6/8/10/14px 档位);阴影分层 card/overlay/dialog;动效令牌 150/220/320ms + ease-out-quart / spring
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 视觉走查随 FE-027 ~ FE-034 进行
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# FE-027 Electron窗口macOS化
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U2
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让应用窗口具备 macOS 原生质感:隐藏式标题栏、红绿灯融入顶栏、毛玻璃材质。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- BrowserWindow 配置 `titleBarStyle: "hiddenInset"`,校准 trafficLightPosition
|
||||||
|
- 顶栏对应区域设置 `-webkit-app-region: drag`,交互控件设置 no-drag
|
||||||
|
- 开启窗口 vibrancy(`sidebar` 或 `under-window` 材质),配合 FE-026 的透明背景变量让侧栏呈毛玻璃
|
||||||
|
- 核对窗口默认尺寸与最小尺寸
|
||||||
|
- 非 macOS 或 vibrancy 不可用时回退纯色背景,不影响功能
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 顶栏内容与布局重排(FE-029 做)
|
||||||
|
- 打包签名相关配置(RELEASE 阶段做)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/electron/main.cjs`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`(仅拖拽区域标记)
|
||||||
|
- `mactfsui/app/app.css`(透明背景接线)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 窗口无系统标题栏,红绿灯悬浮于顶栏内且不遮挡控件
|
||||||
|
- 顶栏空白区可拖动窗口,按钮等控件可正常点击
|
||||||
|
- 侧栏可见毛玻璃效果;关闭 vibrancy 时回退正常
|
||||||
|
- 全部既有功能无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/electron/main.cjs`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`(拖拽区域 + 红绿灯留白)
|
||||||
|
- `mactfsui/app/app.css`(app-drag / app-no-drag 工具类)
|
||||||
|
- `mactfsui/app/root.tsx`(首屏前挂载 html.vibrancy 标记)
|
||||||
|
- 新增 `mactfsui/app/lib/platform.ts`
|
||||||
|
- 实际实现内容:
|
||||||
|
- macOS 下 `titleBarStyle: "hiddenInset"` + `trafficLightPosition {16,16}`,非 macOS 回退系统标题栏
|
||||||
|
- 开启窗口 vibrancy(`sidebar` 材质,followWindow);macOS 背景设全透明配合 `html.vibrancy body { background: transparent }` 让毛玻璃可透出;非 macOS 用实色背景
|
||||||
|
- `show:false + ready-to-show` 显示并默认最大化铺满屏幕(带上现代风分支已确认的体验决策)
|
||||||
|
- `minWidth 980 / minHeight 640`
|
||||||
|
- 顶栏 `app-drag` 拖拽 + 控件 `app-no-drag`,macOS Electron 下 `pl-20` 为红绿灯留白
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过,产物确认 app-drag / vibrancy 已编译
|
||||||
|
- 毛玻璃透出效果在 FE-029 侧栏完成后整体截图验证
|
||||||
|
- 遗留问题:
|
||||||
|
- 登录页(无顶栏)拖拽区在 FE-028 补充
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# FE-028 重设计登录页
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U3
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把登录页从单张表单卡片升级为有品牌感、有动效、元素丰富的现代登录页。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 左右分栏布局:左侧品牌区(logo、产品名、标语、版本号、氛围背景动效),右侧连接表单卡片
|
||||||
|
- 表单 → Collection 选择两阶段切换增加滑动 + 淡入淡出过渡
|
||||||
|
- 新增:密码可见性切换按钮、最近连接记录(localStorage 多条、一键填充)、本地服务健康状态指示灯
|
||||||
|
- 连接中按钮内 spinner、阶段文案过渡;连接失败表单抖动 + 错误提示条
|
||||||
|
- Collection 列表改卡片式:悬停浮起、选中高亮过渡
|
||||||
|
- 保持现有 `api.connect / listCollections / ensureWorkspace` 调用链路不变
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改连接协议与认证方式
|
||||||
|
- 不改 `app/lib/api` 接口契约(最近连接仅存前端本地)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/app/connect-view.tsx`
|
||||||
|
- 新增 `mactfsui/app/lib/recent-servers.ts`(或等价的本地存储工具)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 登录页具备品牌区与氛围背景,观感现代精致
|
||||||
|
- 两阶段切换有流畅过渡动效
|
||||||
|
- 最近连接可保存、可一键填充;密码可见性切换可用
|
||||||
|
- 连接成功进入工作台的链路与改版前完全一致
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:连接真实 TFS → 选择 Collection → 进入工作台
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/connect-view.tsx`(整体重写)
|
||||||
|
- 新增 `mactfsui/app/lib/recent-servers.ts`
|
||||||
|
- `mactfsui/app/app.css`(shake 抖动动画令牌)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 左右分栏:左侧 `bg-sidebar` 半透明品牌区(vibrancy 下呈毛玻璃),居中大 logo(drop-shadow 跟随透明轮廓)+「MacTFS」+ 一句话描述 + 底部服务健康指示灯(吸收现代风分支用户反馈:品牌区只放大 logo + 一句话)
|
||||||
|
- 表单 → Collection 两阶段滑动淡入过渡;密码可见性切换;最近连接(localStorage 5 条去重置顶、一键填充、不存密码)
|
||||||
|
- 连接中按钮 spinner + 阶段文案;失败错误条 + 表单抖动
|
||||||
|
- Collection 卡片式选择:选中 = 主色细边框 + 浅主色底 + 蓝色对勾(zoom 入场),未选中 hover 仅底色微变(直接采用对比分支已确认的选中样式)
|
||||||
|
- 登录页顶部 `app-drag` 拖拽条(补 FE-027 遗留)
|
||||||
|
- `api.connect / listCollections / ensureWorkspace` 链路不变
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过,shake 动画已编译
|
||||||
|
- 真实连接流程随 FE-034 回归验证
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# FE-029 工作台外壳与顶栏改版
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
- FE-027
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U4
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把三栏工作台外壳与顶栏升级为 macOS 工具栏 / Finder 侧栏风格,并补齐布局动效。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 顶栏按 macOS 工具栏风格重排:为红绿灯预留左侧空间,上下文信息居左、动作按钮图标化居右
|
||||||
|
- 左侧目录树面板改 Finder 式侧栏:毛玻璃背景(接 FE-027 vibrancy)、圆角选中条、分组标题样式
|
||||||
|
- 左右面板折叠/展开增加宽度过渡动画,底部操作台增加滑入滑出动画
|
||||||
|
- 统一三栏分隔线、内边距与留白
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 目录树节点与文件列表行内样式(FE-030 做)
|
||||||
|
- 弹窗样式(FE-031 做)
|
||||||
|
- 不改动作编排与状态模型
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 顶栏与红绿灯融合自然,动作按钮可用且有 tooltip
|
||||||
|
- 侧栏呈毛玻璃 Finder 风格
|
||||||
|
- 折叠/展开、底部操作台均有流畅动画且不卡顿
|
||||||
|
- 同步导航、刷新等既有行为无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:折叠/展开左右面板与底部操作台,检查动画与功能
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`(面板标题样式)
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`(改为不透明底 + 标题样式)
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`(高度交由外层控制 + 标题样式)
|
||||||
|
- 实际实现内容:
|
||||||
|
- Finder 式布局:根容器不铺底色,vibrancy 下顶栏整条与左侧目录树呈窗口级毛玻璃;中间列表 / 右侧挂起 / 底部日志保持不透明内容底
|
||||||
|
- 顶栏透明融入材质,红绿灯左侧留白,动作按钮图标化(工作区→FolderCog、重新连接→Unplug)配 tooltip
|
||||||
|
- 左右面板折叠改保持挂载 + 宽度过渡(250ms ease-out-quart),底部操作台高度过渡滑入滑出
|
||||||
|
- 三个面板标题统一小字号大写风格
|
||||||
|
- 非 macOS / 非 Electron 环境 body 实色回退,布局不受影响
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 毛玻璃整体效果在 FE-034 回归截图确认
|
||||||
|
- 遗留问题:
|
||||||
|
- 顶部通知条仍为旧样式,FE-032 用 toast 替换
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# FE-030 目录树与文件列表精致化
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-029
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U5
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把左侧目录树与中间文件列表的行样式、图标、加载与空状态升级到精致水平。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 树节点与文件行统一行高、圆角悬停/选中态;展开箭头旋转动画、子级展开渐入
|
||||||
|
- 按扩展名区分文件图标与颜色,文件夹/分支/已映射目录差异化图标
|
||||||
|
- 列表加载改骨架屏;空目录、未连接等空状态配图标 + 引导文案
|
||||||
|
- pendingEdit / pendingAdd / pendingDelete / 未下载等状态徽标统一配色与形状
|
||||||
|
- 文件列表行级联渐入动效(首次加载时)
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改目录加载、右键菜单与动作编排逻辑
|
||||||
|
- 不改服务端接口与数据结构
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 树与列表行有清晰的悬停/选中反馈,展开收起有动画
|
||||||
|
- 不同类型文件图标可区分,状态徽标风格统一
|
||||||
|
- 加载呈骨架屏,空状态有引导
|
||||||
|
- 导航、右键菜单、双击打开等既有行为无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:浏览多层目录、含挂起更改的目录,检查图标与徽标
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/components/explorer/file-icon.tsx`(扩展名 → 图标/配色映射)
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- `mactfsui/app/components/ui/scroll-area.tsx`(滚动条加深,与选中行区分)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 文件图标体系:代码/配置/文档/SQL/图片/压缩包/表格/脚本 8 类图标与配色;文件夹普通蓝色、已映射 FolderSymlink 主色、展开态 FolderOpen
|
||||||
|
- 树节点:选中态 `bg-sidebar-accent` 半透明圆角条(毛玻璃上呈 Finder 选中观感)、悬停过渡、展开箭头旋转、子级渐入
|
||||||
|
- 文件列表:行级联渐入;状态徽标统一「小圆点 + 浅底色」;9 行骨架屏;空目录/错误空状态
|
||||||
|
- 修正滚动体验(吸收对比分支用户反馈):表头 sticky 改由表格容器承担纵横滚动,横向滚动条贴可视区底部;滚动条滑块加深
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 真实目录浏览随 FE-034 回归
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# FE-031 弹窗体系统一macOS风格
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U6
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把全部弹窗统一为 macOS Sheet 风格,结构一致、动效一致。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 统一 Dialog 基础组件:顶部标题区、内容区、底部按钮区结构与间距规范
|
||||||
|
- 出入场动效统一为缩放 + 淡入(spring 近似曲线),遮罩淡入淡出
|
||||||
|
- 覆盖弹窗:mapping / history / compare / diff / file-view / conflict / properties / rename / get-version / branch / merge / workspace-manage / add-files / confirm / workspace-dialogs
|
||||||
|
- 大弹窗(History、Compare、Diff)优化分区留白与表格样式
|
||||||
|
- 弹窗内表单控件按 FE-026 token 对齐
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改各弹窗的业务流程与接口调用
|
||||||
|
- Monaco Diff 编辑器内部主题深度定制
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/ui/dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/*-dialog.tsx`(13 个)
|
||||||
|
- `mactfsui/app/components/app/confirm-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 所有弹窗结构、间距、按钮排布一致,出入场动效统一流畅
|
||||||
|
- 大弹窗内表格与分区样式精致清晰
|
||||||
|
- 各弹窗功能(确认、提交、二次弹窗)无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:逐一打开全部弹窗检查样式与动效
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/ui/dialog.tsx`
|
||||||
|
- 实际实现内容:
|
||||||
|
- 全部 15 个弹窗共用同一 Dialog 基础组件,在基础组件统一升级为 macOS Sheet 风格,等效全量统一:
|
||||||
|
- 定位改为顶部锚定(top-12 水平居中),打开时自顶部下滑 + 淡入(spring 近似曲线 200ms),关闭时上滑淡出 150ms——贴近 macOS sheet 从标题栏垂下的动效
|
||||||
|
- 容器 `border + shadow-dialog` 取代原 ring,rounded-xl(14px)
|
||||||
|
- 标题统一 `font-semibold tracking-tight`
|
||||||
|
- 遮罩 `bg-black/20 + backdrop-blur` 淡入淡出
|
||||||
|
- 底部按钮区沿用 DialogFooter 的 `bg-muted/50 + border-t` 分区带;查看器形态弹窗(History/Diff/FileView/Properties)无底栏,结构保持
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过,产物确认 slide-in-from-top-6 / ease-spring 已编译
|
||||||
|
- 顶部锚定与最小窗口(980×640)下 92svh 高弹窗的边界在 FE-034 回归确认
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# FE-032 操作反馈与微交互
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U7
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
补齐全局操作反馈(toast、进度)与控件微交互,让操作结果即时可感知。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 引入轻量 toast 通知(成功/失败/进行中),接入既有动作编排的结果回调,与日志面板互补
|
||||||
|
- 挂起更改面板、日志面板按新 token 改版;新日志条目滑入动画
|
||||||
|
- 控件微交互:按钮按压缩放、输入框聚焦光环、复选框勾选动画、hover 过渡
|
||||||
|
- 长操作(GetLatest、Checkin、目录对比)期间展示进度反馈动效
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改动作编排逻辑与日志数据结构
|
||||||
|
- 不做系统级通知(macOS 通知中心)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`
|
||||||
|
- `mactfsui/app/components/ui/button.tsx`、`input.tsx`、`checkbox.tsx`
|
||||||
|
- 新增 toast 组件与挂载点(`app/root.tsx` 或 workspace-shell)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 关键操作(签出、签入、获取、撤销等)成功/失败均有 toast 反馈
|
||||||
|
- 面板样式与全局风格统一,日志新条目有动画
|
||||||
|
- 控件具备统一微交互,无性能问题
|
||||||
|
- 既有操作链路无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:执行签出/撤销/获取最新,观察 toast 与面板反馈
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/components/ui/toaster.tsx`(无依赖轻量 toast)
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`(notice → toast 接线,移除顶部通知条,挂载 Toaster)
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`(日志条目滑入动画)
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`(状态徽标加圆点)
|
||||||
|
- `mactfsui/app/components/ui/button.tsx`(按压缩放 + 150ms 过渡)
|
||||||
|
- `mactfsui/app/components/ui/checkbox.tsx`(勾选 zoom+fade 动画)
|
||||||
|
- 实际实现内容:
|
||||||
|
- toast 体系:success / error 3.2 秒自动消失,loading(「正在…」)常驻被同 id 结果替换;右下角滑入、点击关闭、同屏最多 4 条
|
||||||
|
- 动作编排零改动:监听 `actions.notice` 映射 toast,长操作(GetLatest、Checkin、目录对比)天然获得进行中→结果反馈
|
||||||
|
- 日志面板新条目 fade+slide 滑入;控件微交互(按钮按压、复选框勾选、输入框聚焦光环沿用既有 ring)
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- toast 实际触发随 FE-034 回归验证
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# FE-033 明暗主题跟随系统
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
- FE-027
|
||||||
|
- FE-028
|
||||||
|
- FE-029
|
||||||
|
- FE-030
|
||||||
|
- FE-031
|
||||||
|
- FE-032
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U8
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
实现明暗主题自动跟随 macOS 系统外观,并提供手动切换入口。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 监听系统外观(`prefers-color-scheme` / Electron nativeTheme),自动切换 `.dark`
|
||||||
|
- 顶栏增加三态切换:跟随系统 / 亮色 / 暗色,选择持久化到 localStorage
|
||||||
|
- 主题切换时全局颜色过渡动画
|
||||||
|
- 校验全部页面、面板、弹窗、Monaco Diff 在 dark 下的对比度与可读性,修正不达标 token
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 自定义主题色 / 多主题方案
|
||||||
|
- Monaco 编辑器自定义配色(仅在明暗两套内置主题间切换)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
- 新增 `mactfsui/app/hooks/use-theme.ts`(或等价实现)
|
||||||
|
- `mactfsui/app/components/explorer/diff-dialog.tsx`、`file-view-dialog.tsx`(Monaco 主题接线)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 系统切换外观时应用自动跟随;手动选择优先于系统并可持久化
|
||||||
|
- 切换有平滑过渡,无闪烁
|
||||||
|
- dark 模式下所有界面可读性达标,无「白块」遗漏
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:切换 macOS 系统外观 + 顶栏手动三态切换,全页面走查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/hooks/use-theme.ts`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`(三态主题切换按钮)
|
||||||
|
- `mactfsui/app/root.tsx`(首屏前同步主题,防暗色闪白)
|
||||||
|
- `mactfsui/app/app.css`(theme-transition 全局颜色过渡)
|
||||||
|
- `mactfsui/app/components/explorer/diff-dialog.tsx`(MutationObserver 同步 Monaco 主题)
|
||||||
|
- 实际实现内容:
|
||||||
|
- `useTheme`:「跟随系统 / 亮 / 暗」三态持久化;跟随系统时监听 `prefers-color-scheme`
|
||||||
|
- 顶栏循环切换按钮(Monitor/Sun/Moon + tooltip)
|
||||||
|
- 切换瞬间 250ms 全局颜色过渡;root 内联脚本首屏前置 dark class
|
||||||
|
- Diff 弹窗打开期间切主题即时跟随(mactfs-light / mactfs-dark)
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- dark + vibrancy 组合观感随 FE-034 截图确认
|
||||||
|
- 遗留问题:
|
||||||
|
- 手动强制亮/暗与系统外观不一致时,窗口 vibrancy 毛玻璃底色仍跟随系统(Electron nativeTheme 未随手动开关切换),如需完全一致可后续通过 preload 增加 themeSource 同步
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# FE-034 UI改版回归验收
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026 ~ FE-033 全部完成
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U9
|
||||||
|
- [TFS-AI测试流程.md](/Users/fenghp/Desktop/DEV/project/mydev/TFS-AI测试流程.md)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
确认 UI 改版后所有业务链路零回归,作为本批改版的收口验收。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 按测试大纲走查 UI 链路:登录连接、Collection 选择、目录浏览、Mapping、GetLatest、签出/新增/删除/撤销、签入、历史、Diff、目录对比、分支合并、工作区管理
|
||||||
|
- 明暗两种主题下分别走查关键页面
|
||||||
|
- 确认 `app/lib` 与接口契约零改动(diff 检查)
|
||||||
|
- 关键页面截图存档到 `docs/` 或任务完成记录
|
||||||
|
- `pnpm typecheck` 通过
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 真实 TFS 写操作验收(属 FEATURE-001 ~ 005,按测试大纲限定目录执行)
|
||||||
|
- 性能压测
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- 无新增改动(验收任务,发现问题回各任务修复)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 全部业务链路在新 UI 下可用,无功能回归
|
||||||
|
- 明暗主题下均无样式破损
|
||||||
|
- typecheck 通过
|
||||||
|
- 验收记录与截图归档
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 按 TFS-AI测试流程.md 第 6 节分组走查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 移植 `mactfsui/scripts/e2e-electron.mjs`、`e2e-electron-part2.mjs`、`shot-workspace.mjs`(与现代风分支共用同一套回归脚本)
|
||||||
|
- 新增 `docs/ui-restyle-screenshots/`(macOS 风 6 张关键页面截图)
|
||||||
|
- 实际实现内容(验收执行):
|
||||||
|
- playwright-core 驱动真实 Electron 应用连真实 TFS,两轮共 13 项检查通过:
|
||||||
|
1. 本地服务自动拉起 → macOS 风登录页(毛玻璃品牌区/最近连接/服务指示灯)
|
||||||
|
2. 连接真实 TFS → Collection 卡片选择 → 进入工作台(上下文正确)
|
||||||
|
3. 主题切换 跟随系统→亮→暗 生效(暗色工作台截图存档)
|
||||||
|
4. 目录树懒加载导航至指定测试目录
|
||||||
|
5. 目录历史弹窗(Sheet 式,真实 Changeset 678739)
|
||||||
|
6. 文件查看弹窗(服务器 latest)
|
||||||
|
7. 目录对比(先确认选项再执行流程正常)
|
||||||
|
8. 签出文件 → 挂起更改出现 → 本地 vs latest Monaco Diff → 撤销恢复现场(仅动 ai-smoke 测试文件)
|
||||||
|
9. 操作日志面板记录真实 API 调用(undo /api/files/undo 等)
|
||||||
|
- 接口契约零改动:`app/lib/api` 未触碰(新增 platform.ts / recent-servers.ts 为纯前端工具)
|
||||||
|
- `pnpm typecheck` / `pnpm build` 每任务提交前均通过
|
||||||
|
- 测试结果:
|
||||||
|
- 13/14 PASS;1 项 FAIL 为环境因素:「创建/取消映射」跳过(测试目录已被既有 Workspace 映射且含用户真实挂起更改,避免破坏现场)
|
||||||
|
- 遗留问题:
|
||||||
|
- 「映射到本地 / 取消映射」建议在干净环境人工补验一次
|
||||||
|
- vibrancy 毛玻璃实际透出效果(截图合成不含窗后模糊)建议真机肉眼确认一次
|
||||||