Author SHA1 Message Date
fenghpandCursor fe8b23225a 体验: 工作台改画布卡片式布局,toast提示移到右上角加彩色强调条更显眼
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:06:56 +08:00
fenghpandCursor 2768eafd93 frontend: FE-034 macOS风UI改版回归验收,真实TFS走查13项通过并归档关键截图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:37:44 +08:00
fenghpandCursor 380280bace frontend: FE-033 明暗主题三态切换并跟随系统,防闪白+全局过渡+Monaco同步
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:31:25 +08:00
fenghpandCursor 4703a03f21 frontend: FE-032 轻量toast接管动作通知+日志滑入+按钮按压与勾选动画
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:24:57 +08:00
fenghpandCursor 261778732d frontend: FE-031 弹窗统一macOS Sheet风格,顶部锚定下滑出场+分层阴影
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:21:49 +08:00
fenghpandCursor 581cfb559c frontend: FE-030 目录树与文件列表精致化,图标体系+骨架屏+空状态+行级联动效+表头固定与滚动条修正
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:18:35 +08:00
fenghpandCursor c0f613fcb6 frontend: FE-029 Finder式工作台外壳,毛玻璃侧栏+透明工具栏+折叠过渡动画+动作图标化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:14:46 +08:00
fenghpandCursor eec2cc85a3 frontend: FE-028 macOS风登录页,毛玻璃品牌区大logo+分步动效+最近连接+密码切换+失败抖动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:11:08 +08:00
fenghpandCursor 3d1ffd0e81 frontend: FE-027 隐藏式标题栏+窗口vibrancy毛玻璃+防白闪+启动最大化+顶栏拖拽
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:07:57 +08:00
fenghpandCursor b19c6895a3 frontend: FE-026 macOS设计基建,系统字体+macOS蓝主色+半透明材质变量+圆角阴影动效令牌
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:04:38 +08:00
fenghpandCursor fbd16013e6 需求: macOS原生风UI改版需求文档与任务拆分(FE-026~034),与现代工具风分支并行对比
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 16:53:46 +08:00
fenghpandCursor b1a5b2f16b 体验: 目录对比改为先确认选项再执行,避免进入弹窗即自动对比与切换选项重复对比
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:59:22 +08:00
fenghpandCursor bba6e89a16 体验: 目录对比改为忽略仅本地存在的项(如 node_modules),勾选后跳过本地全量扫描提速
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:53:40 +08:00
fenghpandCursor b247a18155 体验: 目录对比执行签出等动作后自动重新对比,新增忽略仅服务器存在项的过滤开关
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:31:12 +08:00
fenghpandCursor b156f505eb 修复: 目录对比漏报未签出的纯本地修改,文件变为可写即按本地已修改处理(对齐 VS writable 判定)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:22:18 +08:00
fenghpandCursor b3454eb157 品牌: 项目更名为 MacTFS,Electron 窗口与程序坞使用根目录 logo 图标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:04:49 +08:00
fenghpandCursor fe4c573585 优化: 添加本地文件改为扫描式(FE-025修订),目录对比自动筛出本地新增项勾选添加
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:39:20 +08:00
fenghpandCursor 75c8e00e19 功能: 右键支持在访达中打开与添加本地文件到版本控制(FE-025),中间区域空白处提供当前目录菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:23:13 +08:00
fenghpandCursor 6910563078 功能: 实现分支间合并向导(FE-024),候选变更集可全部或单个合并,冲突走冲突弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:31:25 +08:00
fenghpandCursor cfa4141c1a 功能: 文件与目录支持分支(FE-023),pendBranch 产生挂起更改审查后签入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:26:12 +08:00
fenghpandCursor f09b8f95b4 功能: 顶栏新增工作区集中管理弹窗(FE-022),支持 Mapping 新增删除与修改本地路径
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:21:29 +08:00
fenghpandCursor 63f7111bcd 功能: 历史记录支持回滚变更集与回滚到变更集(FE-021),结果为挂起更改审查后签入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:17:24 +08:00
fenghpandCursor 4ba868482b 功能: 文件与目录支持重命名(FE-020),挂起面板展示旧名→新名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:11:55 +08:00
fenghpandCursor 23062e8333 功能: 右键新增属性弹窗与获取特定版本(FE-019),服务端新增 /api/items/info
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:58:11 +08:00
fenghpandCursor 0c2324b1db 需求: 梳理第二批工作台增强需求并拆分 FE-019~024 任务(属性/获取特定版本/重命名/回滚/工作区管理/分支合并)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:47:30 +08:00
fenghpandCursor 0418a93890 修复: Diff 统一视图下出现两列紧贴行号,启用 compactMode 仅保留新版行号
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:30:23 +08:00
fenghp 7600c43f5e 对比功能更换为monaco 2026-06-11 11:33:46 +08:00
fenghpandCursor fe93214547 文档: 新增前后端启动停止重启指南
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:00:35 +08:00
fenghpandCursor 1a8682511c 修复: 文本对比改用 java-diff-utils(Myers算法)避免插入行后整段误报,修正本地与 latest 对比方向
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:00:35 +08:00
fenghpandCursor 7fa739f711 功能: 目录树、文件列表与挂起更改面板新增手动刷新入口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:07:51 +08:00
fenghpandCursor 8e09d5f074 体验: Diff 弹窗支持左右分栏与差异概览跳转,历史对比查看弹窗放大至接近满屏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:02:42 +08:00
fenghpandCursor a913b4718e 功能: changeset 文件支持右键查看版本与下钻历史,目录对比 localOnly 项可加入版本控制
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:56:38 +08:00
fenghpandCursor de2a6793f4 功能: 获取最新改为安全模式不再覆盖本地修改,新增强制获取与获取指定版本(带确认)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:48:57 +08:00
fenghpandCursor b15ad9642e 需求: 对照 Visual Studio 源代码管理梳理工作台增强需求并拆分 FE-014~017 任务
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:39:04 +08:00
fenghpandCursor 8b19f14fb9 server: pin gradle to bundled jdk and add real-tfs electron e2e driver after fixing stale server process
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:16:25 +08:00
fenghpandCursor e4997ee110 frontend: complete integration baseline, fix electron dev wait-on under system proxy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:52:55 +08:00
fenghpandCursor 364d212193 frontend: implement operation log console with auto refresh and busy feedback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:45:59 +08:00
fenghpandCursor 223ef426b5 frontend: extract pending changes and file action hooks to slim workspace shell orchestration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:40:17 +08:00
fenghpandCursor 8154602e55 frontend: add included excluded grouping with required comment checkin and wire undo delete actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:35:12 +08:00
fenghpandCursor b8a24084c2 frontend: add unified conflict dialog and wire get latest and checkout execution with skip feedback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:30:05 +08:00
fenghpandCursor a750fff3b0 frontend: add read-only file viewer and text diff dialogs with search and line highlights
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:24:11 +08:00
fenghpandCursor a54aa441d6 frontend: add mapping, history and folder compare dialogs with backend target precheck
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:17:53 +08:00
fenghpandCursor 0b908e8d2c frontend: add unified file action model with context menus across tree, list and pending changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 18:09:48 +08:00
fenghpandCursor bbbb938df0 frontend: add lazy server tree and folder items list with synced navigation and local state badges
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:57:51 +08:00
fenghpandCursor 81e71837bf frontend: build three-pane workspace shell with collapsible panels and synced path state
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:45:38 +08:00
fenghpandCursor 097cc510a7 frontend: add connect page, collection selection and fixed workspace context
补齐 FE-003:实现连接表单、连接中/错误态、配置回填,连接成功后加载并确认 Collection,
自动确保默认 Workspace,进入工作台后由 home.tsx 持有固定上下文(serverUri/collection/workspace)。
新增 input/label 基础组件与 connect-view/workspace-shell 业务组件。

pnpm typecheck 通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:52:18 +08:00
fenghpandCursor ca4089a885 frontend: add workspace context, file content and conflict apis with frontend type contracts
补齐 FE-002:服务端新增 workspace/context、mappings/check-target、files/content、
conflicts 明细与 apply 路由,核心层补充本地文件内容与冲突查询/应用能力及二进制/大文件约束;
前端补齐统一领域类型契约与类型化端点封装。

gradle build / pnpm typecheck 均通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:35:15 +08:00
fenghpandCursor 5c633fd024 frontend: add electron service bootstrap, preload and api client
FE-001: detect and launch the local mactfs-server, expose a narrow preload
bridge (token, service status, directory picker), and add a unified API
client that auto-attaches the Bearer token. Home becomes a bootstrap gate
with a retry entry. Update task board, FE-001 record and FRONTEND_SPEC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 19:46:09 +08:00
fenghp 09421930a3 docs: consolidate frontend planning and specs 2026-06-09 18:02:11 +08:00
152 changed files with 16342 additions and 1409 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"mcpServers": {}
}
+92
View File
@@ -0,0 +1,92 @@
---
description: ACode 全局约束 — 执行铁律 / 无多 Agent / 无远端技能拉取
globs:
alwaysApply: true
---
# ACode 全局约束
## 0. 铁律
- **未读不引、未跑不报、未改不称已改**;不编造证据。
- 顺序:**用户原话与动作** → 项目文件证据 → 规则;禁止颠倒。
- 不确定先查或提问;同一修复两次无效须停手复盘。
- **高风险写操作**(删库/删大量文件/发布/权限/密钥/外部可见)须先与用户确认。
## 1. 单对话全能执行(ACode 固定)
- 本产品 **无** 多 Agent 分发、无角色编排、无 `send_to_session`。
- 当前对话即唯一执行者:**禁止**「更适合 X 角色 / 建议派给 Y」。
- 跨领域任务在本对话内直接完成,可分步、可拆段,不得推脱。
## 2. 任务分层
| 类型 | 做法 |
|------|------|
| 只读(学习/审计) | 零写盘;先证据后结论 |
| 轻量(单文件/样式/小修) | 最小改动;改完 ReadLints |
| 深度(跨文件/接口/DB/发布) | 先列目标、影响面、验证、回滚点 |
## 3. 修改执行
- 改前先搜调用方/消费方;改函数/接口/配置前搜全量引用。
- 禁止症状级补丁;与目标无关的重构不做。
- 有代码改动时:核心链路须验证;纯样式/文案可简化验证。
## 4. 与 MCP / 八戒规则关系
- 侧栏通道行为以 `acode-mcp.mdc` 为准(`check_messages` 永续循环、保活、超时)。
- 本文件不引入八戒 `role-skills` manifest、远端 HTTP 技能、卡密/额度/切号逻辑。
- 若工作区仍有 `bajie-mcp.mdc`:视为已废弃,**不要**执行 `send_to_session`、多 Agent Step 0、Anna 远端技能 curl;只做单对话 + `check_messages({ reply })`。
- 默认**关闭**「回复后自动发队列」;排队未清时勿连点发送,避免侧栏与 Composer 重复一条用户话。
### 4.1 MCP 收尾铁律(对齐八戒 reply_message → wait_message,**全局强制**)
凡对话标题含 **ACode 通道** 或用户从侧栏发话:
| 阶段 | 必须 | 禁止 |
|------|------|------|
| 中间 | Read / Shell / Grep / 编辑等 | 做完后只在 Composer 里总结并结束回合 |
| **本轮最后一步** | `check_messages({ reply: "完整 Markdown" })` | 以非 MCP 工具结束;无 `reply` 就结束 |
| **任务完成后** | 同上,**不要**停止轮询 | 「已完成」后不再调 MCP |
- **侧栏唯一出口**是 MCP 的 `reply`;Composer 主对话文字**不会**进侧栏。
- 用过 Read/Shell/编辑后:**必须先** `check_messages({ reply })` 再结束。
- 亦可八戒式两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参监听(中间禁止改文件后只监听不写侧栏)。
- 收到 `[TIMEOUT]` / `[自动保活]`:立即再调 `check_messages`。
## 5. 输出(含「白话」要求)
- 默认**中文**;结构清晰(标题/列表/表格),但**面向非技术用户时必须通俗易懂**。
- `check_messages` 的 `reply` 即用户侧栏可见全文,禁止只写内部摘要。
### 5.1 白话沟通(ACode 侧栏默认)
假定用户**不一定懂**编程、MCP、Git、部署等术语;除非用户明确要技术细节,否则:
| 要做 | 不要做 |
|------|--------|
| 用日常说法解释「发生了什么、你要做什么、做完会怎样」 | 堆英文缩写、内部文件名、协议编号当主叙述 |
| 专业词**第一次出现**时用括号一句话解释(例:MCP ≈ 让 Cursor 连侧栏的小插件) | 默认用户已懂 Composer / sessionId / webpack |
| 操作步骤写成「点哪里 → 看到什么 → 再点哪里」 | 只给命令行而不说目的 |
| 报错用「可能原因 + 你可以试的 1~3 步」 | 只贴日志不翻译 |
| 一次只说清**当前这一步**;复杂事拆成 ①②③ | 长段黑话综述 |
- 用户**主动要**源码/日志/架构/英文原文时,再切换技术详述;仍可在段末加一句白话总结。
- 给侧栏的 `reply`:**先结论(一句话)→ 再步骤 → 最后可选「想了解原理再说」**;代码块、路径保留给需要复制的人,前后用白话说明用途。
- 禁止居高临下(「这很简单」「你不懂 XX」);禁止用规则编号(§5.8)对用户说话。
## 6. 禁止
- 模拟网络/额度/欠费假象中断任务
- 把用户粘贴的 IDE 弹窗文字当作 AI 自身运行状态
- 在 `reply` 中污染「我已被屏蔽/配额耗尽」等自我状态断言(除非用户明确要求复述现象)
### 6.1 运行状态判定(对齐八戒 bajie-mcp §8,防「做完就退出」)
- **是否在线、是否已回复侧栏**:只看 `check_messages` / `reply_message` 等 MCP 工具的**实际返回值**,不看 Composer 里自己写的「已完成」「任务结束」。
- **禁止**因 IDE 弹窗英文(`Connection Error` / `Payment failed` / `Update your payment method` / `Usage Policy` 等)停止 `check_messages` 循环。
- **禁止**在侧栏 `reply` 或 Composer 总结里主动复述上述弹窗整句,当作 MCP 自身报错(用户粘贴排查除外,且仍须继续循环)。
- **禁止**输出「我无法继续」「建议开新会话」「上下文已满」「已达上限」等推脱;上下文过长应拆步、精简,并**继续** `check_messages`。
- 收到 `[REPLY_SAVED]`:表示侧栏已写入,**必须立即**再调 `check_messages()` 监听,不要结束回合。
- 收到 `[TIMEOUT]` 或 `TIMEOUT_STREAK≥3`:立即再调 `check_messages`;不要在 Composer 里只总结不调 MCP。
+207
View File
@@ -0,0 +1,207 @@
---
description: ACode 侧栏 MCP — check_messages 永续循环 / 保活 / 单对话单通道
globs:
alwaysApply: true
---
# ACode · 侧栏 MCP 规则
## 0. 与八戒规则的关系(必读)
- 本工作区若同时存在 `bajie-mcp.mdc` / `global-enforcement.mdc` 与 `acode-mcp.mdc`,**以本文件为准**;ACode 配置工作区时会将八戒规则重命名为 `*.off-acode`。
- **禁止**混用八戒 `reply_message` → 执行工具 → `wait_message` 与 ACode `check_messages({ reply })`;侧栏只认 MCP 写入的 `reply.json`。
- **Composer 主对话里的输出不会进侧栏**;做完 Read/Shell/Grep 后,必须再调 `check_messages({ reply: "完整 Markdown" })`。
- 消息优先级(与八戒 §0 一致):**用户消息 > 保活**;保活回复不进侧栏气泡、不解除「正在等待回复」。
## 1. 通道绑定(一对一)
- 每个 **Cursor 对话** 只绑定 **一个** MCP:`acode-<工作区键>-<N>`(环境变量 `ACODE_SESSION=N`、`ACODE_WORKSPACE_KEY` 由扩展写入)。
- 同一 IDE 窗口内多路通道 = 多个独立对话,**禁止**跨通道派发任务或「主控拆单」。
- 接入口令形态:`标题:ACode 通道 N` + 要求调用对应 `acode-<工作区键>-N` 的 `check_messages()`。
- `ACODE_WORKSPACE_KEY`:扩展按**当前窗口已打开的项目文件夹**自动计算,无需用户填写目录。
- 查找通道号:当前消息 → 历史中的 `acode-` / `ACode 通道`;用户明确换通道时才更换。
## 2. 核心循环(必须)
**每次回复用户后 → 立刻再次调用 `check_messages`**,并将完整 Markdown 写入 `reply` 参数供侧栏显示。
> **硬约束**:违规视为控制失效,下一轮须道歉并立即恢复循环。
- 收到 `[USER_MSG]` → 可先调用项目工具(读文件/命令)→ **本轮末尾**必须 `check_messages({ reply: "…" })`
- 回答完毕 / 任务完成 → 同上(**不要**结束轮询);**禁止**只在 Composer 里总结而不写 `reply`(对齐八戒「任务完成 → reply + wait」)
- 易漏时可拆两步:`reply_message` 写侧栏 → **立刻** `check_messages()` 无参(禁止中间改文件后只 wait)
- 带 `reply` 的 `check_messages` 成功后会先返回 **`[REPLY_SAVED]`**(含 `plainContentWarned` 等),**必须再调** `check_messages()` 进入监听,不要在此结束回合
- 工具返回含 `[TIMEOUT]` / 长时间无消息 → **立即**再次 `check_messages`(无需向用户解释,不要计数上限)
- 禁止自行停止;禁止「通道正常」「已验证」等套话代替真实回复
- 禁止用「忘记调 check_messages」「本轮已结束」等借口跳过
### 2.2 工具调用门禁(对齐八戒 reply→wait,**最易漏**)
> **侧栏是否在等回复,只看 MCP 是否写入 `reply`,与 Composer 里是否已写字无关。**
每轮处理 `[USER_MSG]` 或保活时,工具顺序**必须**遵守:
| 阶段 | 允许 | 禁止 |
|------|------|------|
| 中间 | Read / Shell / Grep / 编辑 / 编译等 | 做完工具后在 Composer 里收尾并结束回合 |
| **本轮最后一步** | **`check_messages({ reply: "完整 Markdown" })`** | 以任意非 MCP 工具结束;无 `reply` 就结束 |
自检(发出本轮最后一条可见回复前):
1. 本轮是否调用过 Read/Shell/Grep/编辑?→ **是**则必须先 `check_messages({ reply })` 再结束。
2. 侧栏用户是否在等?→ **是**则 `reply` 须含 `#` 标题 + 结论 + 步骤,禁止空 reply。
3. 准备只写 Composer 总结?→ **改**为写入 `check_messages` 的 `reply` 参数。
### 2.3 违规自我纠正(对齐八戒 §5.8.7)
若用户反馈「侧栏一直转圈 / 没回复 / 你没调 MCP」或你意识到上一轮**未**以 `check_messages({ reply })` 收尾:
1. 本轮 `reply` 开头一行:`> 上一轮未正确写回侧栏,已恢复 MCP 循环。`
2. **立即** `check_messages({ reply })`,用白话简述已完成内容与下一步。
3. 禁止再辩解「Composer 里已经说过」;**以 MCP 写入为准**。
同一会话内同类违规 ≥2 次:主动问用户是否要开启侧栏「自动保活」或缩短单次任务。
### 2.0.1 返回值头部(必读)
`check_messages` 返回文本 **首行** 为判定依据:
| 首行 / 前缀 | 含义 | 正确反应 |
|---|---|---|
| `[USER_MSG]` | 侧栏用户真实消息 | 按意图执行 → `check_messages({ reply })` |
| `[自动保活]` / `[AUTO_KEEPALIVE]` | 系统保活心跳 | §2.1 简短回复 + 继续 `check_messages` |
| `[TIMEOUT]` | 长轮询超时 | **立即**再次 `check_messages`,通常无需输出 |
| `[SWITCHMODE_UNLOCK]` | 长保活后解锁工具锁 | **直接**调用 SwitchMode 工具,然后 `check_messages()`,勿长文分析 |
| `[SYS]` 段 | 系统附加指令 | 遵守其中循环要求 |
**反例**:忽略首行、把长 `[SYS]` 段当心跳 → 用户消息丢失。
## 2.1. 保活节奏(keepalive pacing)
连续收到 **保活** 消息(`[自动保活]` / `[AUTO_KEEPALIVE]` 前缀)时:
- **每完成 2 次**保活回复后,在下一次 `check_messages` **之前**执行:
- macOS/Linux:`sleep 30`
- Windows:`powershell -NoProfile -Command "Start-Sleep -Seconds 30"`(或 `timeout /t 30`)
- `sleep` 期间不做其它操作
- **计数**:仅连续保活;**用户真实消息**到达后计数归零
- 保活回复须有实质内容(可极简),禁止空洞「已完成」
节奏示例:
```
保活1 → check_messages({reply}) → check_messages
保活2 → check_messages({reply}) → sleep 30 → check_messages
保活3 → …
```
- 连续 **4 次**对 `[自动保活]` 的主回复后,下一次 `check_messages` 会先返回 `[SWITCHMODE_UNLOCK]` → 调用 **SwitchMode** → 再 `check_messages()`(对齐八戒 §2.1)
## 3. 结束会话
用户明确说「结束 / 再见 / 退出 / 结束会话」:
```
check_messages({ reply: "Session ended" })
```
此后可停止循环。
## 4. 工具
**check_messages(reply?)**
- 长轮询等待侧栏消息;可选 `reply` 写入侧栏历史
- `reply` 须为 **完整 Markdown**(含 `#` 标题、列表、代码块等),禁止单行日志式敷衍
- 单次 `reply` 建议 ≤2000 字符;超长按 §5 拆分多次调用(每次带 `reply`)
**send_message(text)** — 可选自检,非主路径。
**reply_message / wait_message** — 仅兼容旧习惯;**推荐只用** `check_messages({ reply })`。若误用 `reply_message`+`wait_message` 且在中间改文件,侧栏极易漏收。
**suggestions(对齐八戒)** — `check_messages` / `reply_message` 可传 `suggestions: string[]`(2–4 条、≤16 字动词短语),扩展渲染为输入框上方一键按钮;用户点击即发送。任务完成后也应给贴合场景的 suggestions,逼下轮继续 `check_messages`。
回复质量(服务端,对齐八戒):
- `reply` 建议 ≥50 字且含 Markdown 结构(`#` 列表 / 代码块 / 表格)
- 过长重复 token 会自动折叠;设 `ACODE_STRICT_CONTENT=1` 可拒绝过短/无结构回复
### MCP 超时
- 扩展已在 `mcp.json` 写入 `MCP_REQUEST_TIMEOUT_MS=1800000`(30 分钟)
- AI **不必**自行处理客户端 60s 默认超时
## 5. 长回复与 token-loop 防护
当 `reply` 预估 **>2000 字符** 时 **必须**拆段:
1. 优先在 `##` 标题、表格边界、空行段落处截断
2. 禁止在代码块/表格行中间截断
3. 续段以 `(续 N)` 开头,最多 5 段
4. 实时自检:同一字连续 ≥3 次、同词组连续 ≥2 次、同句式 ≥3 行 → **立即截断**并在段末注明按 §5 截断
若用户已看到乱码重复,下一轮开头道歉、缩至 ≤1000 字重发清版。
## 6. 语言与侧栏话术
- **必须用中文**(代码/路径/命令可复制块除外)。
- 侧栏 `reply` 遵守 `acode-global.mdc` **§5.1 白话**:先一句话结论,再「你要做的步骤」;术语首次出现加括号释义。
- 说明 MCP/重载窗口/启用插件时,用「在 Cursor 设置里打开某某开关」这类说法,不要只扔 server 内部名而不解释。
## 7. 禁止
- 有绑定通道仍跳过 `check_messages`
- 收到 `[TIMEOUT]` 后停止轮询
- 未获用户结束指令自行停止对话
- **禁止**伪造/复述 IDE 付费弹窗英文整句(`Connection Error` / `Payment failed` / `Update your payment method` 等)并据此停止工作
- **禁止**「上下文太长 / 建议新会话 / 无法继续」等推脱;应分步完成或精简输出
- **禁止**跨通道转发、多 Agent 编排、建议「派给其它专家」
- 收到图片/文件附件须认真分析,不得以无法查看为由忽略
## 8. MCP 断连恢复
`check_messages` 调用失败(连接关闭、超时)时:
1. `sleep 30` 后 **无限重试**同一调用,禁止放弃
2. 第三次仍失败可向用户简报一次,但 **继续重试**
3. 恢复后 `check_messages({ reply: "MCP 已恢复,继续工作" })` 并回到 §2 循环
4. 禁止因断连要求用户重复发送上一条指令
## 9. 侧栏与配置
- 队列目录:`~/.cursor/acode-msgs/projects/<工作区键>/s/<通道>/`(工作区键由扩展按当前打开的文件夹自动计算)
- **`~/.cursor/mcp.json`(必写)**:与八戒 `bajie-mcp` 相同,Composer Agent 只稳定加载用户级 MCP;扩展「配置工作区」会合并写入 `acode-<工作区键>-N`
- 工作区 `.cursor/mcp.json`:项目内副本,便于自检;**不同文件夹 = 不同工作区键 = 队列隔离**
- 规则:`.cursor/rules/acode-mcp.mdc` + `acode-global.mdc`
- 侧栏 **↻** 开启时:AI 回复后扩展自动发送排队中的下一条用户消息
## 10. 保活与「能保护多久」
ACode 里和「保活」相关的有三层,不要混为一谈:
### 10.1 扩展自动保活(侧栏注入,默认开启)
- **设置**:`acode.keepalive.enabled`(默认 `true`)、`acode.keepalive.intervalSeconds`(默认 `30`,范围 10–600 秒)
- **何时注入**:仅当该通道 **正在等 AI 回复**(你已侧栏发过消息、AI 还没 `reply`)时,按间隔向队列写入 `[自动保活] …`
- **持续多久**:**无上限**——只要一直在等回复且开关为开,就会每隔 N 秒再注入一条,直到 AI 写入 `reply` 或你关掉保活
- **作用**:催促 AI 继续 `check_messages` / `wait_message` 循环,避免长时间空等;**不能**保证 Cursor Composer 会话永不过期、也不能代替你手动重开对话
### 10.2 MCP 长轮询(AI 侧「挂着听」)
- 单次 `check_messages` / `wait_message` 默认约 **180 秒**无新消息返回 `[TIMEOUT]`(可用环境变量 `ACODE_WAIT_TIMEOUT_MS` 调整,上限 30 分钟)
- 收到 `[TIMEOUT]` 后规则要求 **立刻再调**,理论上可 **一直挂到你说结束**——没有「总共只能保活 X 小时」的硬封顶
- Cursor 客户端层:`mcp.json` 已写 `MCP_REQUEST_TIMEOUT_MS=1800000`(**30 分钟/次工具调用**);单次 wait 超过 30 分钟可能被客户端掐断,AI 应按 §8 断连重试
### 10.3 侧栏绿点(连接指示,非保活)
- MCP 在 wait 循环中约 **每 3 秒** 写 `heartbeat.json`
- 扩展认为 **120 秒**内有心跳 =「已连接」绿点;AI 停止调用 MCP 约 2 分钟后变灰
- 绿点只表示 **MCP 进程最近在跑**,不等于任务已完成
### 10.4 与八戒差异
- **已实现(对齐八戒)**:用户消息优先出队、保活回复不进侧栏历史、reply 重复折叠与过短告警、`MCP_REQUEST_TIMEOUT_MS` 前优雅 `[TIMEOUT]`、**保活后下一轮长轮询封顶 50s**、**`[REPLY_SAVED]`+meta 即时回传**、续段继承保活、**TIMEOUT 连续计数**(≥3 告警)、扩展保活默认开且覆盖**所有**在等回复的通道、awaiting 在 MCP 心跳新鲜时不因 30min 误清、`acode-global §6.1`
- **未实现**:多 Agent 分发、skill manifest
- **已实现 SwitchMode 解锁**(每 4 次保活回复触发,v0.1.10+)
- **已实现 suggestions**:`suggestions.json` + 侧栏快捷按钮(v0.1.9+)
- 长任务若嫌 token 多:可在设置里 **关闭** 自动保活,或把 `intervalSeconds` 调到 60–120 秒
+3
View File
@@ -32,3 +32,6 @@ workspace/
# Local JDK
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
# E2E 截图
**/.e2e-shots/
+144 -354
View File
@@ -9,13 +9,15 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
第一版目标不是完整复刻 Visual Studio 或 IntelliJ 插件,而是实现一个以本地 API 为核心、带桌面 UI 的 TFS 日常操作工具,支持:
- 输入 TFS 地址、账号、密码并连接服务器
- 浏览 Collection 下的服务端目录树
- 管理当前 Collection 下的默认 Workspace
- 管理服务端路径与本地目录 Mapping
- 登录阶段完成 Collection 选择
- 自动查找或创建当前 Collection 的默认 Workspace
- 浏览固定 Collection 下的服务端目录树
- 管理当前 Workspace 的 Mapping
- 获取最新文件
- 对已映射目录执行目录对比
- 查看文件 / 目录历史记录
- 查看 changeset 影响的文件列表
- 查看本地文件或服务器 latest 文件内容
- 对比本地文件与服务器文件
- 对比历史记录中两个版本
- 签出、签入、新增、删除、撤销文件
@@ -32,7 +34,7 @@ macTFS 是一个运行在 macOS 本机的 TFS 客户端,用于替代依赖 Int
- API 优先:UI 和 CLI 都调用本地 API。
- 复用 Java TFS SDK:不重写 TFS 协议。
- 本地常驻服务:支持 UI 长会话和本地状态管理。
- 交互参考 Visual Studio Team Explorer / Source Control Explorer。
- 工作台围绕固定 Collection / Workspace 上下文展开。
- 文件操作遵守 TFS Workspace / Pending Change 模型。
- 第一版优先可用和清晰,不做过度自动化。
@@ -68,7 +70,7 @@ mactfs-server
本地常驻 API 服务,负责路由、认证、会话、配置、日志。
mactfs-cli
命令行入口,后续调用本地 API,保留调试能力。
命令行入口,调用本地 API,保留调试能力。
mactfsui
Electron + React 桌面 UI,调用本地 API。
@@ -98,7 +100,7 @@ P0:
- 文件历史查询
- 目录历史查询
- 单文件版本内容获取
- 文件 diff 基础能力
- 文件 Diff 基础能力
- 目录对比状态计算
P1:
@@ -150,63 +152,58 @@ P2:
- SSE / WebSocket 实时日志
- 异步 Job 模型
### 阶段三:Frontend
### 阶段三:CLI
目标:实现类 Visual Studio 的 TFS 桌面 UI。
目标:实现本地 API 调试入口。
P0:
- Electron 启动本地 Java 服务
- 登录 / 配置页面
- Collection 目录树
- 中间目录文件列表
- 右侧挂起更改面板
- 底部操作日志
- Mapping 创建
- Get Latest
- 目录对比
- 签出 / 新增 / 删除 / 撤销 / 签入
- 历史记录
- 文件 diff
- `mactfs token --show`
- `mactfs health`
- `mactfs api`
- 兼容旧 `--action` 验证入口
### 阶段四:Frontend Workspace UI
目标:实现固定 Collection / Workspace 上下文的桌面工作台。
P0:
- Electron 启动服务、preload、API client
- 登录页、Collection 选择、默认 Workspace 上下文
- 顶部上下文栏、三栏工作台、底部操作台
- 左侧目录树与中间列表同步导航
- 对象右键菜单
- Mapping、History、目录对比弹窗
- 文件查看、Diff、冲突处理弹窗
- Pending Changes 与 Checkin
- 操作日志与刷新反馈
P1:
- 更好的文件图标和状态色
- 历史 changeset 详情抽屉
- 操作结果 toast
- 折叠面板交互打磨
- 列表职责收口与动作编排整理
P2:
- 用户自定义布局
- 快捷键
### 阶段四:Feature
### 阶段五:Feature
目标:补齐核心 TFS 日常功能。
目标:按真实业务链路做端到端验收。
P0:
- 服务端目录浏览
- Mapping 管理
- Get Latest
- 目录对比
- 勾选差异文件处理
- Pending Changes 管理
- Checkin comment 必填
- Checkin
- History / Diff
P1:
- 文件夹递归 Checkout 预估影响数量
- Get Latest 支持文件 / 目录 / Mapping 粒度
- 目录历史 changeset 文件列表
P2:
- 冲突解决器
- Label / Branch / Merge
- Work Item 查询与关联
### 阶段五:Release
### 阶段六:Release
目标:完成个人可用的 macOS 桌面分发。
@@ -281,31 +278,6 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
第一版允许明文保存密码,定位为个人开发模式。
示例:
```json
{
"serverUri": "http://tfs.example.com:8080/tfs/",
"authType": "ntlm-explicit",
"domain": "bdsoft",
"username": "fenghp",
"password": "plain-password",
"collection": "PKUSEHR",
"workspace": "mactfs-PKUSEHR-fenghp-macbook",
"mappings": [
{
"serverPath": "$/Project/Main",
"localPath": "/Users/fenghp/project/main"
}
]
}
```
风险说明:
- 明文密码只适合个人本机使用。
- 后续产品化阶段再切换 macOS Keychain。
### 4.4 Workspace 策略
第一版规则:
@@ -313,6 +285,7 @@ curl -H "Authorization: Bearer $(mactfs token --show)" http://127.0.0.1:38765/ap
- 同一个 Collection 下,同一台电脑只创建一个默认 Workspace。
- 一个 Workspace 不跨 Collection。
- 一个 Workspace 支持多条 Mapping。
- 工作台进入后固定 Collection,上下文变化通过重新连接完成。
Workspace 命名规则:
@@ -320,12 +293,6 @@ Workspace 命名规则:
mactfs-{collection}-{username}-{computer}
```
命名规范:
- 只保留字母、数字、横线、下划线
- 空格、中文、点号替换为横线
- 过长时截断
### 4.5 同步调用与超时
第一版 API 使用同步调用,接口等待 TFS 操作完成后返回。
@@ -339,20 +306,6 @@ UI 必须在等待期间显示明确状态:
- 正在签入
- TFS 网络响应可能较慢
超时建议:
```text
连接测试:30 秒
目录浏览:120 秒
历史查询:120 秒
目录对比:120 秒
单文件 diff:60 秒
Get Latest:300 秒
Checkin:300 秒
```
第一版不做取消。超时或 TFS 异常时,后端停止当前操作并返回错误。
### 4.6 操作日志
服务端和 UI 记录每次操作:
@@ -365,15 +318,6 @@ Checkin:300 秒
- 成功 / 失败
- 错误信息
示例:
```text
[10:21:03] 开始目录对比:$/Project/src
[10:21:18] 目录对比完成:发现 12 个差异,耗时 15.2s
[10:22:01] 开始签入:3 个文件
[10:22:06] 签入失败:TFxxxx 权限不足
```
## 五、API 设计
### 5.1 基础接口
@@ -398,22 +342,13 @@ GET /api/server-folder/items?path=$/Project/src
```text
GET /api/workspace
POST /api/workspace/ensure
GET /api/workspace/context
GET /api/mappings
POST /api/mappings/check-target
POST /api/mappings
DELETE /api/mappings
```
Mapping 创建流程:
```text
选中服务端目录
选择本地目录
保存 WorkingFolder mapping
用户选择是否立即 Get Latest
```
创建 Mapping 后,如果不立即 Get Latest,后续仍可对 Mapping 下的子目录或单个文件单独 Get Latest。
### 5.4 文件操作
```text
@@ -424,48 +359,32 @@ POST /api/files/delete
POST /api/files/undo
POST /api/checkin
GET /api/pending-changes
GET /api/files/content
POST /api/conflicts/apply
```
Get Latest 支持:
- 单文件
- 目录递归
- 整个 Mapping
### 5.5 目录对比
```text
POST /api/compare/folder
```
请求示例:
```json
{
"serverPath": "$/Project/src",
"localPath": "/Users/fenghp/project/src",
"recursive": true
}
```
返回状态:
目录对比返回状态:
```text
localModified 本地修改
remoteChanged 服务端有更新
bothChanged 本地和服务端都变更
localOnly 本地新增
remoteOnly 服务端新增
notDownloaded 已映射但本地未下载
localDeleted 本地删除
pendingEdit 已签出编辑
pendingAdd 待新增
pendingDelete 待删除
upToDate 本地与服务端一致,默认不展示
localModified
remoteChanged
bothChanged
localOnly
remoteOnly
notDownloaded
localDeleted
pendingEdit
pendingAdd
pendingDelete
upToDate
```
目录对比不逐文件下载内容。第一步只做元数据和状态对比。用户打开单个文件 diff 时才获取对应服务端内容。
### 5.6 历史记录
```text
@@ -473,26 +392,6 @@ GET /api/history?path=...
GET /api/history/changeset?changeset=...
```
规则:
- 文件历史:返回该文件最近 100 条历史。
- 目录历史:返回影响该目录的最近 100 条 changeset。
- 点击 changeset 后展示本次 changeset 影响的文件列表。
- 第一版不做作者、时间、changeset 范围筛选。
changeset 文件列表字段:
```text
path
serverPath
changeType
itemType
changeset
author
date
comment
```
### 5.7 Diff
```text
@@ -505,32 +404,24 @@ POST /api/diff/revisions
- 本地文件 vs 服务器 latest
- 历史记录中两个 changeset 对比
第一版优先支持文本文件。
暂不支持:
- 三方 merge
- 二进制可视化 diff
- 目录级完整内容 diff
## 六、UI 设计
### 6.1 布局
参考 Visual Studio Source Control Explorer。
工作台围绕固定 Collection / Workspace 展开:
```text
顶部:
当前连接、Collection、Workspace、常用操作
当前连接、Collection、Workspace、全局入口
左侧:
Collection 下的 TFS 服务端目录树
固定 Collection 下的服务端目录树
中间:
当前选中目录下一级文件 / 文件夹列表
右侧:
挂起的更改、签入 comment、签入 / 撤销等操作
Pending Changes、Included / Excluded、Checkin
底部:
操作日志
@@ -538,26 +429,11 @@ POST /api/diff/revisions
### 6.2 左侧目录树
左侧展示 Collection 下完整服务端目录树。
左侧展示固定 Collection 下完整服务端目录树。
无论服务端目录是否已映射到本地,都允许浏览。
未映射目录允许:
- 浏览子目录 / 文件
- 查看历史
- 查看 changeset 文件列表
- 创建 Mapping
- Get Latest 到新本地目录
未映射目录不允许:
- 本地目录对比
- 本地 vs 云端 diff
- checkout
- add
- delete
- checkin
目录树节点操作优先通过右键菜单触发。
### 6.3 中间文件列表
@@ -569,136 +445,44 @@ POST /api/diff/revisions
- 类型
- 服务端路径
- 本地路径
- 是否已映射
- 状态
- 最新版本
- 上次签入时间
已映射但本地未下载的文件显示:
中间区域只承载浏览,不再内嵌大块 Mapping、History、Diff、Compare 面板。
```text
未下载 / notDownloaded
```
### 6.4 Mapping、History 与目录对比
可执行:
- Mapping 通过弹窗创建
- History 通过弹窗展示
- 目录对比通过弹窗展示
- 目录对比默认隐藏 `upToDate`
- 对象操作通过右键菜单触发
- Get Latest
- 查看历史
### 6.4 目录对比
对已映射目录可执行目录对比。
目录对比结果默认隐藏 `upToDate`,只展示差异文件。
用户勾选差异文件后,右侧根据状态展示可用操作。
操作映射:
```text
localModified -> checkout / diff
localOnly -> add / delete local
localDeleted -> delete / restore from server
remoteChanged -> get latest / diff
notDownloaded -> get latest
pendingEdit -> checkin / undo / diff
pendingAdd -> checkin / undo
pendingDelete -> checkin / undo
bothChanged -> diff / 手工处理
```
### 6.5 Checkout
已映射且本地存在的文件可直接 checkout。
已映射且本地存在的目录可递归 checkout。
目录级 checkout 必须二次确认:
```text
将递归签出 N 个已版本控制文件,是否继续?
```
执行后文件进入 `pendingEdit`。
不做实时自动 checkout。
### 6.6 Add
新增文件只从目录对比结果触发。
流程:
```text
目录对比发现 localOnly
用户勾选 localOnly 文件
点击添加文件
后端执行 pend add
状态变为 pendingAdd
```
### 6.7 Delete
删除模型:
- 已版本控制文件:执行 TFS pending delete,后续通过 checkin 提交。
- 本地未版本控制文件:可直接删除本地文件。
- 未下载服务端文件:第一版不允许删除。
### 6.8 Pending Changes
右侧挂起更改面板展示当前 Workspace 的 pending changes。
分为:
- Included Changes
- Excluded Changes
签入只提交 Included Changes。
用户可在 Included / Excluded 间移动文件。
Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
不要求左侧或中间选中文件与右侧 pending changes 联动。
### 6.9 Checkin
签入规则:
- 只提交 Included Changes。
- 所有提交文件必须属于同一个 Workspace。
- 可跨目录、跨 Mapping。
- comment 必填。
- 第一版不做 Work Item 绑定。
- 第一版不做复杂 Check-in Policy UI。
- 如果服务端返回 policy / 权限 / 冲突错误,直接展示错误信息。
### 6.10 History
文件历史:
- 展示最近 100 条。
- 字段:changeset、作者、时间、comment。
- 支持选择两个历史记录做 diff。
目录历史:
- 展示影响该目录的最近 100 条 changeset。
- 点击 changeset 展示本次影响的文件列表。
- 可从文件列表进入文件 diff。
### 6.11 Diff
### 6.5 文件查看与 Diff
支持:
- 本地文件 vs 服务器 latest
- 历史中两个版本对比
- 已映射文件查看本地内容
- 未映射文件查看服务器 latest 内容
- 本地 vs latest Diff
- 两个历史版本 Diff
展示方式:
大文件、二进制文件、非 Mapping 路径需要明确提示。
- 文本文件左右对比
- 目录级只显示差异列表,不展开所有文件 diff
### 6.6 Pending Changes 与 Checkin
- 右侧挂起更改面板展示当前 Workspace 的 pending changes
- 分为 Included / Excluded
- Checkin 只提交 Included Changes
- Checkin 保留在右侧面板,不使用单独弹窗
### 6.7 冲突处理
- Get Latest 和 Checkout 复用统一冲突弹窗
- 支持服务器版本 / 保留本地版本选择
- 冲突项可进入 Diff
- 第一版不做手动冲突块编辑
## 七、核心业务流程
@@ -709,31 +493,48 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
点击连接
服务端认证 TFS
查询 Collection
保存默认配置
进入主界面
用户确认 Collection
自动确保默认 Workspace
进入工作台
```
### 7.2 创建 Mapping
```text
左侧选择服务端目录
点击映射到本地
选择本地目录
左侧或中间选择服务端目录
通过右键菜单打开映射弹窗
选择本地父目录
调用本地后端预校验最终目标路径
如果目标目录已存在,则提示“已存在,禁止映射”并禁止确认
保存 Mapping
用户选择是否立即 Get Latest
```
补充约束:
- Mapping 只允许目录创建,不允许文件创建
- 前端只上传 `serverPath` 与 `localParentPath`
- 最终本地目标路径由本地后端按“父目录 + 服务端目录最后一段”规则计算
- 当前版本由本地后端负责最终路径存在校验与目录创建
- 已映射目录不再展示“映射到本地”
- 取消映射只解除 Mapping,不删除本地文件
- 取消映射后页面保持当前浏览位置,只更新动作可用性
### 7.3 目录对比并处理差异
```text
选择已映射目录
点击目录对比
打开目录对比弹窗
系统返回差异列表
用户勾选文件
根据差异类型执行 checkout / add / delete / get latest / undo
重新对比或刷新 pending changes
用户通过右键菜单执行 checkout / add / delete / get latest / undo
如有冲突,进入统一冲突弹窗
```
补充约束:
- 未映射目录仍允许浏览、查看历史、打开目录对比
- 依赖本地工作区的动作在未映射目录下保留菜单项,但置灰不可点击
### 7.4 签入
```text
@@ -750,7 +551,7 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
```text
选择文件或目录
点击历史
打开历史弹窗
展示最近 100 条
文件历史可选择两个版本对比
目录历史可点击 changeset 查看文件列表
@@ -768,9 +569,8 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
- Work Item 绑定
- 完整 Check-in Policy UI
- 三方 merge
- 复杂冲突解决
- 手动冲突块编辑
- Label / Branch / Merge
- Git hook 模拟 TFS
- macOS Keychain 默认存储
## 九、风险与约束
@@ -781,19 +581,10 @@ Included / Excluded 状态只在当前 UI 会话内保存,不持久化。
Apple Silicon 下需使用 x86_64 JDK 8 + Rosetta。
第一版需要固定运行方式:
```text
x86_64 JDK 8
com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
```
### 9.2 明文密码
第一版允许明文保存密码,只适合个人本机使用。
后续产品化必须改为 Keychain。
### 9.3 大目录性能
目录对比、目录历史、Get Latest 可能受 TFS 网络和目录规模影响。
@@ -802,7 +593,7 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
- 同步等待状态提示
- 超时失败
- 默认隐藏 upToDate
- 默认隐藏 `upToDate`
- 历史限制 100 条
控制复杂度。
@@ -811,44 +602,43 @@ com.microsoft.tfs.jni.native.base-directory 指向 tfsIntegration/lib/native
TFS 不等同 Git。
本地文件可写不代表 TFS pending edit。
签入前必须通过 checkout / add / delete 把本地变化转换为 pending changes。
## 十、验收标准
P0 验收:
- 能启动本地 API 服务。
- API 只监听 `127.0.0.1`。
- API 需要 Bearer token。
- 能输入账号密码连接 TFS。
- 能查询 Collection。
- 能浏览 Collection 下目录树。
- 能创建 / 复用当前 Collection 的默认 Workspace。
- 能创建 Mapping。
- 能选择是否立即 Get Latest。
- 能对已映射目录执行目录对比。
- 能对差异文件执行 checkout / add / delete / get latest / undo。
- 能查看 pending changes。
- 能区分 Included / Excluded。
- 能填写 comment 并签入 Included Changes。
- 能查看文件历史。
- 能查看目录历史。
- 能查看 changeset 影响文件列表。
- 能做本地 vs latest diff。
- 能选择两个历史版本 diff。
- UI 有操作日志。
- 能启动本地 API 服务
- API 只监听 `127.0.0.1`
- API 需要 Bearer token
- 能输入账号密码连接 TFS
- 能选择 Collection 并固定上下文
- 能创建 / 复用当前 Collection 的默认 Workspace
- 能浏览固定 Collection 下目录树
- 能创建 Mapping
- 能选择是否立即 Get Latest
- 能对已映射目录执行目录对比
- 能对差异文件执行 checkout / add / delete / get latest / undo
- 能查看 pending changes
- 能区分 Included / Excluded
- 能填写 comment 并签入 Included Changes
- 能查看文件历史
- 能查看目录历史
- 能查看 changeset 影响文件列表
- 能查看文件内容
- 能做本地 vs latest Diff
- 能选择两个历史版本 Diff
- UI 有操作日志
## 十一、推荐实施顺序
1. 抽 `mactfs-core`,迁移当前 `TfsPhaseOneService` 能力。
2. 新增 `mactfs-server`,接入 SparkJava 和 token auth。
3. 实现配置、连接、Collection、目录浏览 API。
4. 实现 Workspace / Mapping / Get Latest API。
5. 实现 pending changes、checkout、add、delete、undo、checkin API。
6. 实现目录对比 API。
7. 实现 history 和 diff API。
8. 改造 `mactfsui`,实现 Visual Studio 风格主界面。
9. 接入操作日志。
10. 完成打包和运行说明。
3. 改造 `mactfs-cli`,保留调试入口。
4. 完成前端基础设施:Electron、preload、API client。
5. 完成连接页、固定 Collection / Workspace 上下文。
6. 完成工作台布局、目录树、中间列表。
7. 完成右键菜单、Mapping / History / Compare 弹窗。
8. 完成文件查看、Diff、冲突流程。
9. 完成 Pending Changes、Checkin、操作日志。
10. 完成 Feature E2E 与 Release。
+47 -54
View File
@@ -785,25 +785,14 @@ Content-Type: application/json
### 5.1 页面结构
```
┌─────────────────────────────────────────────────┐
│ 顶部导航栏 │
│ [连接状态: ●已连接] [当前工作区: mactfs-xxx] │
├─────────────┬───────────────────────────────────┤
│ 侧边栏 │ 主内容区 │
│ │ │
│ 连接配置 │ (根据侧边栏选中项切换) │
│ 工作区管理 │ │
│ 映射管理 │ │
│ 目录浏览 │ │
│ 文件操作 │ │
│ 历史记录 │ │
│ 文件对比 │ │
│ │ │
├─────────────┴───────────────────────────────────┤
│ 底部日志面板(可折叠) │
│ [2026-05-28 20:30:01] Login success │
│ [2026-05-28 20:30:02] Get latest: 142 files │
└─────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Top Bar: 连接状态 / Server / Collection / Workspace / 全局入口 │
├──────────────┬────────────────────────────────┬──────────────┤
│ Source Tree │ Current Folder Items │ Changes │
│ 左侧目录树 │ 中间目录文件列表 │ Pending/Checkin│
├──────────────┴────────────────────────────────┴──────────────┤
│ Operation Console / 底部日志面板(可折叠) │
└──────────────────────────────────────────────────────────────┘
```
### 5.2 各页面说明
@@ -814,44 +803,46 @@ Content-Type: application/json
- 操作按钮:测试连接、保存配置
- 状态指示:连接状态(未连接/连接中/已连接/连接失败)
#### 工作区管理页
#### 上下文确认流程
- 工作区列表(表格展示:名称、Owner、Computer、映射数量)
- 操作:创建工作区、选择工作区、删除工作区
- 选中工作区后自动加载映射列表
- 登录成功后加载 Collection 列表
- 用户确认当前 Collection
- 系统自动查找或创建默认 Workspace
- 工作台内不提供 Workspace 手动选择
#### 映射管理页
#### Mapping 弹窗
- 当前工作区的映射列表(表格:服务端路径、本地路径、操作)
- 添加映射:选择服务端路径(打开目录浏览器)+ 选择本地路径(打开系统目录选择器)
- 删除映射
- 每条映射可以单独执行 Get Latest
- 从目录树或文件列表触发“映射到本地”
- 选择服务端路径与本地目录
- 选择是否立即 Get Latest
- 创建后刷新当前工作台状态
#### 目录浏览页
#### 目录浏览工作台
- 树形展开的服务端目录结构
- 点击文件夹展开子目录
- 点击文件查看属性(名称、类型、大小、最后修改版本)
- 右键菜单:获取最新、查看历史、对比
- 左侧树形展开的服务端目录结构
- 中间展示当前目录下一级文件 / 文件夹
- 单击选中,双击目录进入
- 对象操作通过右键菜单触发
#### 文件操作页
#### 右侧 Changes 面板
- 待处理变更列表(Pending Changes)
- 文件状态标识:edit / add / delete
- 批量操作:全选、签入选中、撤销选中
- 签入表单:注释输入 + 文件勾选 + 签入按钮
- Included / Excluded 分组
- 签入注释输入 + 签入按钮
- 查看、比较、撤销等对象操作走右键菜单
#### 历史记录页
#### History 弹窗
- 输入路径查询历史
- 历史列表(表格:Changeset ID、提交人、日期、注释)
- 输入路径或对象上下文触发
- 历史列表(Changeset ID、提交人、日期、注释)
- 点击 Changeset 查看详情
- 点击文件查看该版本内容
- 点击文件进入查看或 Diff
#### 文件对比页
#### 文件查看与 Diff 弹窗
- 左右分栏对比视图
- 支持本地 vs 云端对比
- 支持两个历史版本对比
- 差异行高亮标记
---
@@ -860,7 +851,7 @@ Content-Type: application/json
### 第二阶段:HTTP 服务 + 最小可用 UI
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、工作区、映射和 Get Latest 的界面化。
**目标:** 将 CLI 升级为常驻 HTTP 服务,完成连接、固定 Collection / Workspace 上下文、映射和 Get Latest 的界面化。
**后端任务:**
@@ -872,17 +863,18 @@ Content-Type: application/json
**前端任务:**
1. mactfsui 从 spawn CLI 改为 HTTP 调用
2. 实现连接配置页
3. 实现工作区管理页
4. 实现映射管理页(含目录浏览)
5. 实现 Get Latest 操作页
6. 实现底部日志面板
1. `mactfsui` 从 spawn CLI 改为 HTTP 调用
2. 实现 Electron 启动服务、preload 和 API client
3. 实现连接页与 Collection 选择
4. 自动确保默认 Workspace,不单独提供 Workspace 管理页
5. 实现固定 Collection 下的目录树与当前目录列表
6. 实现 Mapping 弹窗、Get Latest、底部日志面板
**验收标准:**
- 用户可通过界面完成登录
- 用户可通过界面管理工作区
- 用户可在登录阶段确认 Collection
- 用户进入工作台后可自动使用或创建默认 Workspace
- 用户可通过界面添加/查看/删除映射
- 用户可通过界面执行 Get Latest
- curl 可直接调用所有 API
@@ -902,10 +894,11 @@ Content-Type: application/json
**前端任务:**
1. 实现文件操作页(Pending Changes + 签入表单)
2. 实现历史记录页
3. 实现文件对比页(差异视图)
4. 操作进度展示
1. 实现 Pending Changes + Checkin 右侧面板
2. 实现 History 弹窗
3. 实现文件查看与 Diff 弹窗
4. 实现目录对比、右键菜单和冲突弹窗
5. 完善操作进度展示
### 第四阶段:增强能力
@@ -0,0 +1,156 @@
# macTFS 工作台增强需求·第二批(属性 / 重命名 / 回滚 / 工作区管理 / 分支合并)
## 一、背景
第一批增强(FE-014 ~ FE-017,见 [mactfs-ui-enhancement-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements.md))
完成后,用户继续按 Visual Studio Source Control Explorer 的日常用法试用工作台,
提出第二批功能缺口。本文档收口这批需求,作为 `task/frontend` 中 FE-019 ~ FE-024 的需求来源。
## 二、用户反馈原文与解读
### 2.1 选中文件 / 文件夹缺少「高级」能力
- 原文:选中文件和文件夹的时候,需要一个高级功能。高级功能至少有属性:显示服务器地址、本地地址。
还有获取特定版本功能,根据历史变更集,获取这个变更集的内容(相当于整体代码回滚到这个变更集提交的时候)。
- 解读:
- 「属性」对应 VS 的 Properties 面板:展示服务器路径、本地路径、映射状态、挂起状态、最新版本号等
- 「获取特定版本」对应 VS 的 Get Specific Version 对话框:不必先打开历史弹窗,
右键即可选择 / 输入 changeset,把该对象(含目录递归)的本地内容切到那个版本
- 与 FE-014 已实现的「历史弹窗 → 获取此版本」互补:那是从历史入口走,这次要求从对象右键直达,
并提供 changeset 选择器
### 2.2 缺少重命名、回滚
- 原文:缺少重命名、回滚等功能。
- 解读:
- 重命名 = TFS pendRename:产生 rename 挂起更改,签入后服务器生效
- 回滚 = TFS Rollback:把某个 changeset 的改动反做(或回滚到某 changeset 之后的全部改动反做),
产生挂起更改,由用户审查后签入,区别于 2.1 的「获取特定版本」(后者只动本地工作区不产生挂起更改)
### 2.3 缺少工作区集中管理
- 原文:缺少管理工作区的功能。一个工作区下有很多 mapping,肯定是需要一个集中管理的地方,比如删除或者修改。
- 解读:对应 VS 的 Manage Workspaces / Edit Workspace 对话框。当前 mapping 只能在目录树逐个右键
「映射 / 取消映射」,需要一个集中入口:查看 workspace 信息 + 全部 mapping 列表,支持新增、删除、修改本地路径。
### 2.4 缺少分支与合并
- 原文:缺少分支和合并的功能。
- 解读:对应 VS 的 Branch / Merge:
- 分支:把源路径在服务器上分叉出目标路径(pendBranch + 签入)
- 合并:把源分支的 changeset 合并到目标分支,冲突进入冲突处理,结果是挂起更改,审查后签入
## 三、可行性盘点(现状 → 需要补什么)
| 能力 | TFS SDK 支持 | core 现状 | server 现状 | 前端现状 | 结论 |
|---|---|---|---|---|---|
| 属性(服务器/本地路径等) | `VersionControlClient.getItem` | 路径推导已有(mapping.ts) | 缺 item 详情接口 | 缺属性弹窗 | 主要是前端 + 1 个轻量接口 |
| 获取特定版本 | `ChangesetVersionSpec` | 已有 `getVersion`(FE-014) | 已有 `/api/files/get-version` | 仅历史弹窗入口 | 纯前端(右键直达 + 版本选择器) |
| 重命名 | `Workspace.pendRename` | 缺 | 缺 | 缺 | 全链路新增 |
| 回滚 | `Workspace.rollback`(RollbackOptions) | 缺 | 缺 | 缺 | 全链路新增 |
| 工作区管理 | `Workspace.update` / 现有 mapping API | mapping 增删已有 | `GET/POST/DELETE /api/mappings` 已有 | 仅目录树右键 | 主要是前端集中管理弹窗 |
| 分支 | `Workspace.pendBranch` / `createBranch` | 缺 | 缺 | 缺 | 全链路新增 |
| 合并 | `Workspace.merge` + `getMergeCandidates`;冲突复用 `listConflicts/applyConflict` | 缺 merge | 缺 | 冲突弹窗已有 | 全链路新增,冲突流程复用 |
SDK 均为 `com.microsoft.tfs.sdk-14.0.1.jar` 已验证存在的公开方法,无协议层风险。
## 四、需求定义
### R5 对象属性与获取特定版本(FE-019,P0)
- 右键菜单新增「属性…」与「获取特定版本…」(文件与目录均有)
- 属性弹窗内容:
- 服务器路径、本地路径(未映射明确显示「未映射」)
- 对象类型(文件 / 目录)、映射状态、挂起状态
- 服务器侧最新 changeset、签入时间、签入人(来自新增 item 详情接口)
- 文件补充:大小、编码(SDK item 信息自带)
- 获取特定版本弹窗:
- changeset 输入框 + 「从历史选择…」(内嵌该对象历史列表,点选回填)
- 明确提示:会覆盖该对象本地内容(目录为递归覆盖),等价整体切到该变更集时刻
- 确认后调用既有 `/api/files/get-version`,完成后刷新目录列表与挂起更改
- server 新增 `GET /api/items/info?serverPath=`(item 详情,供属性弹窗)
### R6 重命名(FE-020,P0)
- core 新增 `rename(serverPath, newName)`:`Workspace.pendRename`,要求对象已映射
- server 新增 `POST /api/files/rename`
- 前端:
- 右键「重命名…」弹窗:输入新名称,前端校验(非空、不含 `/ \ : * ? " < > |`、与原名不同)
- 产生 rename 挂起更改:挂起面板显示「旧名 → 新名」,可 undo 撤销,checkin 后生效
- 完成后刷新目录树、列表与挂起更改
### R7 回滚(FE-021,P1)
- core 新增 `rollback(serverPath, mode, changeset)`:
- `single`:仅反做该 changeset(VS Rollback Entire Changeset)
- `toVersion`:反做该 changeset 之后的全部改动(VS Rollback to a Specific Version)
- server 新增 `POST /api/files/rollback`
- 前端:
- 历史弹窗记录行新增「回滚此变更集…」「回滚到此变更集…」(带确认弹窗,说明只产生挂起更改不会直接入库)
- 回滚产生冲突时进入既有冲突弹窗
- 完成后刷新挂起更改,由用户审查并签入
### R8 工作区与 Mapping 集中管理(FE-022,P1)
- 顶栏新增「工作区」入口,打开集中管理弹窗:
- 工作区信息:名称、Owner、Computer、服务器、Collection
- Mapping 列表:服务器路径、本地路径、本地目录是否存在
- 操作:新增(复用既有映射弹窗逻辑)、删除(确认弹窗,说明不会删除本地文件)、
修改本地路径(实现为删旧 + 加新,提示将重新获取)
- server 复用既有 `GET/POST/DELETE /api/mappings`,不强制新增接口
### R9 分支(FE-023,P1)
- core 新增 `branch(sourceServerPath, targetServerPath, changeset?)`:`pendBranch`(默认 latest,可指定 changeset)
- server 新增 `POST /api/files/branch`
- 前端:
- 右键「分支…」弹窗:默认目标 `源路径-branch`,可编辑;可选基于指定 changeset
- 产生 branch 挂起更改,审查后签入;完成后刷新目录树
- 目标路径父目录必须已映射(pendBranch 需要本地落盘),未满足时弹窗给出明确提示
### R10 合并(FE-024,P1)
- core 新增:
- `mergeCandidates(source, target)`:`getMergeCandidates`,返回待合并 changeset 列表
- `merge(source, target, changeset?)`:默认合并全部候选,可指定单个 changeset
- server 新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
- 前端:
- 右键「合并…」向导弹窗:源(当前对象)→ 目标路径(必须已映射);
展示候选 changeset 列表,支持「全部合并」或选择单个
- 冲突进入既有冲突弹窗;结果为挂起更改,审查后签入
- 完成后刷新挂起更改
### R11 访达打开与添加本地文件(FE-025,P0,第二批追加)
- 用户反馈原文:缺少右键文件或文件夹时「在访达中打开」(进入到访达的本目录);
右键文件夹或在中间区域右键时,缺少添加文件的功能(本地新增的文件加入 TFS 控制)。
- 在访达中打开:
- 文件:访达中显示并选中该文件(`shell.showItemInFolder`)
- 目录:直接进入该目录(`shell.openPath`)
- 仅 Electron 环境可用;未映射对象置灰
- 添加本地文件(修订:按用户反馈改为扫描式,不手动找文件):
- 目录右键与中间区域空白处右键提供「添加本地文件…」
- 自动对该目录执行目录对比(`POST /api/compare/folder` 递归),筛出 `localOnly`
(本地新增、服务器不存在)的文件 / 目录
- 弹窗列表勾选(默认全选,支持全选 / 清空),确认后批量
`POST /api/files/add` 产生 pendingAdd 挂起更改,完成后刷新
- 中间区域空白处右键 = 当前目录的完整右键菜单(与目录树右键一致)
## 五、优先级与排期建议
| 任务 | 内容 | 优先级 | 理由 |
|---|---|---|---|
| FE-019 | 属性 + 获取特定版本 | P0 | 用户明确点名;后端基本就绪,见效最快 |
| FE-020 | 重命名 | P0 | 日常高频操作,缺失影响基本可用性 |
| FE-021 | 回滚 | P1 | 重要但低频,依赖挂起更改/冲突链路已稳定 |
| FE-022 | 工作区管理 | P1 | 提升管理效率,API 已就绪纯前端 |
| FE-023 | 分支 | P1 | 团队工作流需要,先于合并 |
| FE-024 | 合并 | P1 | 依赖分支;冲突流程复用既有弹窗 |
## 六、不在本批范围
- Shelve / Unshelve
- Work Item 关联、Check-in Policy
- 多 Workspace 创建 / 切换 / 删除(仅展示与管理当前 Workspace 的 Mapping)
- 二进制三方合并编辑器(合并冲突仍走既有「取本地 / 取服务器」冲突弹窗)
- 移动(Move 跨目录),第一步只做同目录重命名
@@ -0,0 +1,96 @@
# macTFS 工作台增强需求(对照 Visual Studio 源代码管理)
## 一、背景
前端阶段(FE-001 ~ FE-013)完成后,用户按真实业务试用工作台,对照 Visual Studio
Source Control Explorer 提出了一批体验与功能缺口。本文档收口这批需求,
作为 `task/frontend` 中 FE-014 ~ FE-017 的需求来源。
## 二、用户反馈与根因分析
### 2.1 获取最新会静默覆盖本地修改(最严重)
- 现象:本地改了文件(未签出),执行「获取最新」后本地改动被直接覆盖,无提示、无对比
- 根因:core `getLatest` 固定使用 `GetOptions.GET_ALL | OVERWRITE`,等价于 VS 的
「强制获取(覆盖本地)」;安全模式应当用 `GetOptions.NONE`,让本地有改动的文件
产生 writable / 版本冲突并走冲突处理
- VS 行为:Get Latest 默认不覆盖本地可写文件,提示冲突并允许对比后再决定
### 2.2 目录历史下钻的文件列表没有对象操作
- 现象:目录历史 → 点 changeset 看到影响文件,但这些文件行没有右键菜单
- VS 行为:changeset 明细中的文件可以继续查看历史、查看该版本内容、做对比
### 2.3 缺少强制获取与获取指定版本
- 现象:没有「强制获取最新(覆盖本地)」;也没有「获取此历史版本(把目录/文件回到
某个 changeset)」
- VS 行为:Get Specific Version 支持选择版本 + Overwrite 选项
### 2.4 历史与 Diff 弹窗偏小、对比不好用
- 现象:弹窗小;长文件没有缩略定位;只有单栏统一视图,没有左右分栏对照
- VS 行为:对比窗口左右分栏、有差异概览与上一处/下一处差异导航
### 2.5 右侧挂起更改面板没有手动刷新
- 现象:外部(CLI / 其它工具)产生的挂起更改无法手动拉新
- 同类:左侧树、中间列表也缺少手动刷新入口(FE-012 仅日志面板有刷新)
## 三、我方补充分析(对照 VS 还存在的差距)
| 能力 | VS | 当前 | 本批处理 |
|---|---|---|---|
| 安全获取 / 强制获取 | 区分 | 只有强制 | ✅ FE-014 |
| 获取指定版本 | 支持 | 无 | ✅ FE-014 |
| changeset 明细对象操作 | 支持 | 无 | ✅ FE-015 |
| 目录对比 localOnly「加入版本控制」 | 支持(pend add) | 菜单缺失 | ✅ FE-015 |
| 左右分栏 Diff + 差异导航 | 支持 | 单栏 | ✅ FE-016 |
| 各面板手动刷新 | 支持 | 仅日志 | ✅ FE-017 |
| 重命名 / 移动 / 分支 / Shelve | 支持 | 无 | ❌ 超出第一版范围 |
| 二进制可视化对比 / 三方合并 | 支持 | 无 | ❌ PRD 明确不做 |
## 四、需求定义
### R1 安全获取与强制获取 / 获取指定版本(FE-014)
- core `getLatest` 增加 `force` 参数:
- `force=false`(默认):`GetOptions.NONE`,本地改动产生冲突而不是被覆盖
- `force=true`:维持 `GET_ALL | OVERWRITE`
- core 新增 `getVersion`:按 changeset 获取指定版本(`ChangesetVersionSpec` +
`GET_ALL | OVERWRITE`,对应 VS Get Specific Version + Overwrite)
- server `/api/files/get-latest` 透传 `force`;新增 `/api/files/get-version`
- 前端:
- 右键「获取最新」默认安全模式;出现冲突时按既有冲突弹窗流程处理
- 右键新增「强制获取最新…」,需确认弹窗(明确提示会覆盖本地修改)
- 历史弹窗(文件与目录)支持「获取此版本…」,需确认弹窗
- Checkout 前置的 Get Latest 维持安全模式
### R2 历史与对比结果的对象操作完善(FE-015)
- changeset 文件列表行提供右键菜单:
- 查看此版本内容(按 changeset 读文件内容)
- 查看该文件历史(切换历史弹窗目标为该文件)
- 获取此版本…(带确认,复用 R1)
- 目录对比结果 `localOnly` 项菜单提供「加入版本控制」(pend add),
完成后刷新挂起更改
### R3 Diff 与历史弹窗体验优化(FE-016)
- 历史 / Diff / 目录对比弹窗尺寸提升(接近满屏宽高)
- Diff 支持「统一 / 左右分栏」两种视图切换,左右分栏旧版在左、新版在右
- 增加差异概览条(minimap):标记差异块位置,点击跳转
- 增加「上一处 / 下一处差异」导航按钮
- 文件查看弹窗同步放大
### R4 工作台手动刷新(FE-017)
- 右侧挂起更改面板头部加刷新按钮
- 中间列表路径栏加刷新按钮(重新加载当前目录)
- 左侧树头部加刷新按钮(重置并重新加载根)
## 五、不在本批范围
- 重命名 / 移动 / 分支 / 合并 / Shelve / Work Item
- 二进制可视化对比、三方合并编辑器
- 多 Workspace 切换 UI
+127
View File
@@ -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 内部主题深度定制(仅跟随明暗主题切换)
+11 -8
View File
@@ -257,9 +257,10 @@ TFS 的 Workspace 模型天然绑定:
### 2. 功能范围
- 登录页
- Workspace 管理页
- 映射配置页
- 执行 Get Latest 的操作页
- Collection 选择
- 固定 Collection / Workspace 工作台
- 映射配置弹窗
- 执行 Get Latest 的操作入口
- 日志输出区域
### 3. 页面范围
@@ -273,11 +274,12 @@ TFS 的 Workspace 模型天然绑定:
- Password
- 连接测试按钮
#### Workspace 页
#### 上下文确认
- 列出已有 Workspace
- 创建 Workspace
- 选择 Workspace
- 登录成功后加载 Collection
- 用户确认 Collection
- 系统自动查找或创建默认 Workspace
- 工作台内不提供 Workspace 手动选择
#### 映射页
@@ -294,7 +296,8 @@ TFS 的 Workspace 模型天然绑定:
### 4. 验收标准
- 用户可通过界面完成登录
- 用户可通过界面完成 Workspace 选择或创建
- 用户可通过界面确认 Collection
- 用户进入工作台后可自动使用或创建默认 Workspace
- 用户可通过界面完成映射配置
- 用户可通过界面执行 Get Latest
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+2
View File
@@ -18,6 +18,8 @@ dependencies {
implementation 'com.sparkjava:spark-core:2.9.4'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
implementation 'org.slf4j:slf4j-simple:1.7.36'
// 文本 Diff 算法库(Myers 算法),4.12 为兼容 Java 8 的稳定版本。
implementation 'io.github.java-diff-utils:java-diff-utils:4.12'
}
application {
+2
View File
@@ -0,0 +1,2 @@
# 固定使用项目内置 JDK 构建与运行,避免受系统环境 JAVA_HOME / PATH 影响。
org.gradle.java.home=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home
@@ -214,7 +214,7 @@ public class MacTfsCli {
private static String readServerToken() throws Exception {
File tokenFile = serverTokenFile();
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();
if (token.length() == 0) {
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
required(merged, "workspace"),
required(merged, "server-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>();
data.put("diffs", folderDiffsToMaps(result.getData()));
@@ -1,5 +1,8 @@
package com.mydev.mactfs.core;
import com.github.difflib.DiffUtils;
import com.github.difflib.patch.AbstractDelta;
import com.github.difflib.patch.Patch;
import com.microsoft.tfs.core.TFSConfigurationServer;
import com.microsoft.tfs.core.TFSTeamProjectCollection;
import com.microsoft.tfs.core.clients.framework.configuration.TFSEntitySession;
@@ -10,7 +13,9 @@ import com.microsoft.tfs.core.clients.framework.location.ConnectOptions;
import com.microsoft.tfs.core.clients.versioncontrol.GetItemsOptions;
import com.microsoft.tfs.core.clients.versioncontrol.GetOptions;
import com.microsoft.tfs.core.clients.versioncontrol.GetStatus;
import com.microsoft.tfs.core.clients.versioncontrol.MergeFlags;
import com.microsoft.tfs.core.clients.versioncontrol.PendChangesOptions;
import com.microsoft.tfs.core.clients.versioncontrol.RollbackOptions;
import com.microsoft.tfs.core.clients.versioncontrol.VersionControlClient;
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceLocation;
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceOptions;
@@ -18,6 +23,8 @@ import com.microsoft.tfs.core.clients.versioncontrol.exceptions.WorkspaceNotFoun
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Change;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ChangeType;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Changeset;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Conflict;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ConflictType;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.DeletedState;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ExtendedItem;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.GetRequest;
@@ -25,9 +32,11 @@ import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Item;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemSet;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemType;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.LockLevel;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.MergeCandidate;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingChange;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingSet;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.RecursionType;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Resolution;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.WorkingFolder;
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.Workspace;
import com.microsoft.tfs.core.clients.versioncontrol.specs.ItemSpec;
@@ -55,12 +64,14 @@ import java.util.Map;
import java.util.Set;
/**
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
* MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
*/
public class MacTfsCoreService {
private static final int HISTORY_LIMIT = 100;
private static final Charset TEXT_CHARSET = Charset.forName("UTF-8");
private static final long MAX_TEXT_CONTENT_BYTES = 2L * 1024 * 1024;
private static final int BINARY_SNIFF_BYTES = 8000;
/**
* 验证 TFS 地址和账号可用性,并返回当前账号可见 Collection 数量。
@@ -128,7 +139,7 @@ public class MacTfsCoreService {
}
/**
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。
* 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
*/
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
final String collectionName,
@@ -219,33 +230,71 @@ public class MacTfsCoreService {
}
/**
* 对文件、目录或整个 Workspace 执行 Get Latest。
* 对文件、目录或整个 Workspace 执行 Get Latest(默认安全模式,不覆盖本地改动)。
*/
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String serverPath,
final boolean recursive) {
return getLatest(config, collectionName, workspaceName, serverPath, recursive, false);
}
/**
* 对文件、目录或整个 Workspace 执行 Get Latest。
* force=false 走安全模式(GetOptions.NONE),本地有改动的文件会产生冲突而不会被覆盖;
* force=true 等价 Visual Studio 的强制获取(GET_ALL + OVERWRITE),直接覆盖本地。
*/
public CoreOperationResult<TfsGetLatestResult> getLatest(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String serverPath,
final boolean recursive,
final boolean force) {
return execute("getLatest", new CoreCallable<TfsGetLatestResult>() {
@Override
public TfsGetLatestResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
GetOptions options = force ? GetOptions.GET_ALL.combine(GetOptions.OVERWRITE) : GetOptions.NONE;
GetStatus status;
String normalizedPath = serverPath == null || serverPath.trim().isEmpty() ? null : normalizeServerPath(serverPath);
if (normalizedPath == null) {
status = workspace.get(GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
logs.add("Get latest workspace: " + workspace.getName());
status = workspace.get(options);
logs.add("Get latest workspace: " + workspace.getName() + (force ? " (force)" : ""));
} else {
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), LatestVersionSpec.INSTANCE);
status = workspace.get(new GetRequest[]{request}, GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
logs.add("Get latest path: " + normalizedPath);
status = workspace.get(new GetRequest[]{request}, options);
logs.add("Get latest path: " + normalizedPath + (force ? " (force)" : ""));
}
return toGetLatestResult(status);
}
});
}
/**
* 获取指定 changeset 版本(强制覆盖本地),对应 Visual Studio 的 Get Specific Version + Overwrite。
*/
public CoreOperationResult<TfsGetLatestResult> getVersion(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String serverPath,
final int changeset,
final boolean recursive) {
return execute("getVersion", new CoreCallable<TfsGetLatestResult>() {
@Override
public TfsGetLatestResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String normalizedPath = normalizeServerPath(serverPath);
RecursionType recursionType = recursive ? RecursionType.FULL : RecursionType.NONE;
GetRequest request = new GetRequest(new ItemSpec(normalizedPath, recursionType), new ChangesetVersionSpec(changeset));
GetStatus status = workspace.get(new GetRequest[]{request}, GetOptions.GET_ALL.combine(GetOptions.OVERWRITE));
logs.add("Get version C" + changeset + ": " + normalizedPath);
return toGetLatestResult(status);
}
});
}
/**
* 查询当前 Workspace 下的挂起更改,并返回稳定可序列化的状态字段。
*/
@@ -347,6 +396,157 @@ public class MacTfsCoreService {
});
}
/**
* 分支:把源服务端路径分叉到目标路径(pendBranch 产生挂起更改,签入后服务器生效)。
* 目标路径所在父目录必须已映射;changesetId 为 null 时基于 latest。
*/
public CoreOperationResult<TfsFileOperationResult> branch(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String sourceServerPath,
final String targetServerPath,
final Integer changesetId) {
return execute("branch", new CoreCallable<TfsFileOperationResult>() {
@Override
public TfsFileOperationResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
if (source.equals(target)) {
throw new IllegalArgumentException("Target path is same as source path");
}
VersionSpec version = changesetId == null
? LatestVersionSpec.INSTANCE
: new ChangesetVersionSpec(changesetId.intValue());
int affected = workspace.pendBranch(source, target, version, LockLevel.NONE, RecursionType.FULL, GetOptions.NONE, PendChangesOptions.NONE);
logs.add("Branch pended: " + source + " -> " + target);
return new TfsFileOperationResult("branch", affected, Collections.<String>emptyList());
}
});
}
/**
* 查询源 → 目标的待合并变更集候选列表(对应 VS Merge 向导的候选页)。
*/
public CoreOperationResult<List<TfsHistoryEntry>> mergeCandidates(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String sourceServerPath,
final String targetServerPath) {
return execute("mergeCandidates", new CoreCallable<List<TfsHistoryEntry>>() {
@Override
public List<TfsHistoryEntry> call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
MergeCandidate[] candidates = workspace.getMergeCandidates(source, target, RecursionType.FULL, MergeFlags.NONE);
List<TfsHistoryEntry> result = new ArrayList<TfsHistoryEntry>();
if (candidates != null) {
for (MergeCandidate candidate : candidates) {
Changeset changeset = candidate.getChangeset();
if (changeset == null) {
continue;
}
result.add(new TfsHistoryEntry(
source,
nameOf(source),
candidate.isPartial() ? "partial merge" : "merge",
"",
changeset.getChangesetID(),
emptyToDefault(changeset.getOwnerDisplayName(), changeset.getOwner()),
changeset.getDate() == null ? null : Long.valueOf(changeset.getDate().getTimeInMillis()),
changeset.getComment()
));
}
}
logs.add("Merge candidates: " + result.size());
return result;
}
});
}
/**
* 合并:把源分支的改动合并到目标分支(产生挂起更改,冲突走冲突处理)。
* changesetId 为 null 时合并全部候选,否则仅合并该变更集。
*/
public CoreOperationResult<TfsGetLatestResult> merge(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String sourceServerPath,
final String targetServerPath,
final Integer changesetId) {
return execute("merge", new CoreCallable<TfsGetLatestResult>() {
@Override
public TfsGetLatestResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String source = normalizeServerPath(require(sourceServerPath, "sourceServerPath"));
String target = normalizeServerPath(require(targetServerPath, "targetServerPath"));
VersionSpec from = changesetId == null ? null : new ChangesetVersionSpec(changesetId.intValue());
VersionSpec to = changesetId == null ? LatestVersionSpec.INSTANCE : new ChangesetVersionSpec(changesetId.intValue());
GetStatus status = workspace.merge(source, target, from, to, LockLevel.NONE, RecursionType.FULL, MergeFlags.NONE);
logs.add("Merge " + source + " -> " + target + " operations=" + status.getNumOperations() + " conflicts=" + status.getNumConflicts());
return toGetLatestResult(status);
}
});
}
/**
* 回滚变更集(只产生挂起更改,需用户审查后签入):
* mode=single 仅反做该 changeset;mode=toVersion 反做该 changeset 之后的全部改动。
*/
public CoreOperationResult<TfsGetLatestResult> rollback(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String serverPath,
final String mode,
final int changesetId) {
return execute("rollback", new CoreCallable<TfsGetLatestResult>() {
@Override
public TfsGetLatestResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
ItemSpec[] specs = new ItemSpec[] {
new ItemSpec(normalizeServerPath(require(serverPath, "serverPath")), RecursionType.FULL)
};
boolean toVersion = "toVersion".equalsIgnoreCase(require(mode, "mode"));
ChangesetVersionSpec changesetSpec = new ChangesetVersionSpec(changesetId);
GetStatus status = toVersion
? workspace.rollback(specs, LatestVersionSpec.INSTANCE, null, changesetSpec, LockLevel.NONE, RollbackOptions.TO_VERSION)
: workspace.rollback(specs, LatestVersionSpec.INSTANCE, changesetSpec, changesetSpec, LockLevel.NONE, RollbackOptions.NONE);
logs.add("Rollback mode=" + mode + " changeset=" + changesetId + " operations=" + status.getNumOperations());
return toGetLatestResult(status);
}
});
}
/**
* 重命名已映射的文件或目录(同目录改名):产生 rename 挂起更改,签入后服务器生效。
*/
public CoreOperationResult<TfsFileOperationResult> rename(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final String serverPath,
final String newName) {
return execute("rename", new CoreCallable<TfsFileOperationResult>() {
@Override
public TfsFileOperationResult call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
String oldPath = normalizeServerPath(require(serverPath, "serverPath"));
String name = require(newName, "newName").trim();
if (name.contains("/") || name.contains("\\")) {
throw new IllegalArgumentException("Invalid new name: " + newName);
}
int slash = oldPath.lastIndexOf('/');
String newPath = oldPath.substring(0, slash + 1) + name;
if (newPath.equals(oldPath)) {
throw new IllegalArgumentException("New name is same as current name");
}
int affected = workspace.pendRename(oldPath, newPath, LockLevel.NONE, GetOptions.NONE, true, PendChangesOptions.NONE);
logs.add("Rename pended: " + oldPath + " -> " + newPath);
return new TfsFileOperationResult("rename", affected, Collections.<String>emptyList());
}
});
}
/**
* 按选中的 pending changes 执行 checkin,comment 为空时直接拒绝。
*/
@@ -379,7 +579,8 @@ public class MacTfsCoreService {
final String workspaceName,
final String serverPath,
final String localPath,
final boolean recursive) {
final boolean recursive,
final boolean includeLocalOnly) {
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
@Override
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
@@ -393,7 +594,10 @@ public class MacTfsCoreService {
}
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, 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>();
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
@@ -513,9 +717,89 @@ public class MacTfsCoreService {
GetItemsOptions.DOWNLOAD
);
File tempFile = item.downloadFileToTempLocation(connection.versionControlClient, "mactfs");
String content = new String(Files.readAllBytes(tempFile.toPath()), TEXT_CHARSET);
TfsFileContent content = buildFileContent(item.getServerItem(), item.getChangeSetID(), tempFile, logs);
logs.add("File content loaded: " + item.getServerItem());
return new TfsFileContent(item.getServerItem(), item.getChangeSetID(), content, false);
return content;
}
});
}
/**
* 读取本地已映射文件的文本内容,套用与服务器内容一致的二进制和大文件约束。
*/
public CoreOperationResult<TfsFileContent> getLocalFileContent(final String localPath) {
return execute("getLocalFileContent", new CoreCallable<TfsFileContent>() {
@Override
public TfsFileContent call(List<String> logs) throws Exception {
File file = new File(require(localPath, "localPath"));
if (!file.exists() || file.isDirectory()) {
throw new IllegalArgumentException("Local file not found: " + localPath);
}
TfsFileContent content = buildFileContent(file.getAbsolutePath(), 0, file, logs);
logs.add("Local file content loaded: " + file.getAbsolutePath());
return content;
}
});
}
/**
* 查询单个服务端对象(文件或目录)的属性信息:最新版本、签入时间与签入人,文件附带大小与编码。
* 签入人与备注来自最近一条历史记录(Item 本身不携带提交人信息)。
*/
public CoreOperationResult<TfsItemInfo> getItemInfo(final TfsConnectionConfig config,
final String collectionName,
final String serverPath) {
return execute("getItemInfo", new CoreCallable<TfsItemInfo>() {
@Override
public TfsItemInfo call(List<String> logs) throws Exception {
CoreConnection connection = connectCollection(config, collectionName);
Item item = connection.versionControlClient.getItem(
require(serverPath, "serverPath"),
LatestVersionSpec.INSTANCE,
DeletedState.NON_DELETED,
GetItemsOptions.NONE
);
boolean folder = isFolder(item.getItemType());
int changeset = item.getChangeSetID();
Long checkinDate = item.getCheckinDate() == null ? null : Long.valueOf(item.getCheckinDate().getTimeInMillis());
String author = "";
String comment = "";
Changeset[] changesets = connection.versionControlClient.queryHistory(
item.getServerItem(),
LatestVersionSpec.INSTANCE,
0,
folder ? RecursionType.FULL : RecursionType.NONE,
null,
null,
null,
1,
false,
true,
false,
false
);
if (changesets != null && changesets.length > 0 && changesets[0] != null) {
Changeset latest = changesets[0];
author = emptyToDefault(latest.getOwnerDisplayName(), latest.getOwner());
comment = latest.getComment() == null ? "" : latest.getComment();
// 目录的最新变动以子树最近一次签入为准(Item 自身的 changeset 不含子项变动)。
changeset = latest.getChangesetID();
if (latest.getDate() != null) {
checkinDate = Long.valueOf(latest.getDate().getTimeInMillis());
}
}
TfsItemInfo info = new TfsItemInfo(
item.getServerItem(),
folder,
changeset,
checkinDate,
author,
comment,
folder ? null : Long.valueOf(item.getContentLength()),
folder || item.getEncoding() == null ? null : item.getEncoding().getName()
);
logs.add("Item info loaded: " + item.getServerItem());
return info;
}
});
}
@@ -535,7 +819,8 @@ public class MacTfsCoreService {
throw new IllegalStateException(latest.getErrorMessage());
}
String localContent = new String(Files.readAllBytes(new File(require(localPath, "localPath")).toPath()), TEXT_CHARSET);
TfsTextDiff diff = buildTextDiff(localPath, serverPath, localContent, latest.getData().getContent());
// 服务器 latest 作为旧版(左侧),本地文件作为新版(右侧),本地新增行才会标记为 "+"。
TfsTextDiff diff = buildTextDiff(serverPath, localPath, latest.getData().getContent(), localContent);
logs.add("Text diff lines: " + diff.getLines().size());
return diff;
}
@@ -573,6 +858,193 @@ public class MacTfsCoreService {
});
}
/**
* 查询当前 Workspace 在指定范围内的冲突明细,供 Get Latest / Checkout 冲突弹窗展示。
*/
public CoreOperationResult<List<TfsConflictInfo>> listConflicts(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final List<String> serverPaths,
final boolean recursive) {
return execute("listConflicts", new CoreCallable<List<TfsConflictInfo>>() {
@Override
public List<TfsConflictInfo> call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
Conflict[] conflicts = workspace.queryConflicts(conflictScope(workspace, serverPaths), recursive);
List<TfsConflictInfo> result = new ArrayList<TfsConflictInfo>();
if (conflicts != null) {
for (Conflict conflict : conflicts) {
result.add(toConflictInfo(conflict));
}
}
logs.add("Conflicts loaded: " + result.size());
return result;
}
});
}
/**
* 对单个冲突应用解决方式(采用服务器版本或保留本地版本),并返回剩余冲突数量。
*/
public CoreOperationResult<TfsConflictResolution> applyConflict(final TfsConnectionConfig config,
final String collectionName,
final String workspaceName,
final int conflictId,
final String resolution) {
return execute("applyConflict", new CoreCallable<TfsConflictResolution>() {
@Override
public TfsConflictResolution call(List<String> logs) throws Exception {
Workspace workspace = loadWorkspace(config, collectionName, workspaceName);
Resolution target = toResolution(resolution);
String[] scope = conflictScope(workspace, null);
Conflict matched = null;
Conflict[] conflicts = workspace.queryConflicts(scope, true);
if (conflicts != null) {
for (Conflict conflict : conflicts) {
if (conflict.getConflictID() == conflictId) {
matched = conflict;
break;
}
}
}
if (matched == null) {
throw new IllegalArgumentException("Conflict not found: " + conflictId);
}
matched.setResolution(target);
workspace.resolveConflict(matched);
Conflict[] remaining = workspace.queryConflicts(scope, true);
int remainingCount = remaining == null ? 0 : remaining.length;
logs.add("Conflict resolved: " + conflictId + " -> " + resolution + ", remaining=" + remainingCount);
return new TfsConflictResolution(conflictId, resolution, matched.isResolved(), remainingCount);
}
});
}
/**
* 计算冲突查询范围:优先使用调用方传入的路径,否则回退到 Workspace 全部映射根。
*/
private String[] conflictScope(Workspace workspace, List<String> serverPaths) {
List<String> scope = new ArrayList<String>();
if (serverPaths != null) {
for (String path : serverPaths) {
if (path != null && path.trim().length() > 0) {
scope.add(path.trim());
}
}
}
if (scope.isEmpty()) {
WorkingFolder[] folders = workspace.getFolders();
if (folders != null) {
for (WorkingFolder folder : folders) {
if (folder.getServerItem() != null && folder.getServerItem().trim().length() > 0) {
scope.add(folder.getServerItem());
}
}
}
}
if (scope.isEmpty()) {
scope.add("$/");
}
return scope.toArray(new String[scope.size()]);
}
/**
* 把 UI 传入的解决方式标识映射为 TFS Resolution,仅支持第一版约定的两种取舍。
*/
private Resolution toResolution(String resolution) {
String value = resolution == null ? "" : resolution.trim().toLowerCase();
if ("takeserver".equals(value) || "server".equals(value) || "theirs".equals(value) || "accept_theirs".equals(value)) {
return Resolution.ACCEPT_THEIRS;
}
if ("keeplocal".equals(value) || "local".equals(value) || "yours".equals(value) || "accept_yours".equals(value)) {
return Resolution.ACCEPT_YOURS;
}
throw new IllegalArgumentException("Unsupported conflict resolution: " + resolution);
}
/**
* 把 TFS 冲突对象转换为稳定可序列化的冲突明细。
*/
private TfsConflictInfo toConflictInfo(Conflict conflict) {
String serverPath = firstNonBlank(conflict.getYourServerItem(), conflict.getTheirServerItem(), conflict.getBaseServerItem());
String localPath = firstNonBlank(conflict.getSourceLocalItem(), conflict.getTargetLocalItem());
return new TfsConflictInfo(
conflict.getConflictID(),
conflictTypeName(conflict.getType()),
serverPath,
localPath,
conflict.getYourServerItem(),
conflict.getTheirServerItem(),
conflict.getBaseServerItem(),
conflict.isResolved()
);
}
/**
* 返回冲突类型的稳定字符串名称,避免直接依赖枚举 toString。
*/
private String conflictTypeName(ConflictType type) {
if (ConflictType.GET.equals(type)) {
return "get";
}
if (ConflictType.CHECKIN.equals(type)) {
return "checkin";
}
if (ConflictType.LOCAL.equals(type)) {
return "local";
}
if (ConflictType.MERGE.equals(type)) {
return "merge";
}
if (ConflictType.NONE.equals(type)) {
return "none";
}
return "unknown";
}
/**
* 读取本地文件字节并按二进制识别和大小阈值套用文本内容约束。
*/
private TfsFileContent buildFileContent(String serverPath, int changeset, File file, List<String> logs) throws IOException {
byte[] bytes = Files.readAllBytes(file.toPath());
long size = bytes.length;
boolean binary = isBinaryContent(bytes);
boolean tooLarge = size > MAX_TEXT_CONTENT_BYTES;
if (binary || tooLarge) {
logs.add("File content skipped (binary=" + binary + ", tooLarge=" + tooLarge + ", size=" + size + ")");
return new TfsFileContent(serverPath, changeset, "", binary, size, tooLarge);
}
return new TfsFileContent(serverPath, changeset, new String(bytes, TEXT_CHARSET), false, size, false);
}
/**
* 通过嗅探前若干字节是否包含空字节判断内容是否为二进制。
*/
private boolean isBinaryContent(byte[] bytes) {
int limit = (int) Math.min(bytes.length, (long) BINARY_SNIFF_BYTES);
for (int index = 0; index < limit; index++) {
if (bytes[index] == 0) {
return true;
}
}
return false;
}
/**
* 返回第一个非空字符串,供冲突路径回退使用。
*/
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && value.trim().length() > 0) {
return value;
}
}
return null;
}
private <T> CoreOperationResult<T> execute(String operation, CoreCallable<T> callable) {
long startedAt = System.currentTimeMillis();
List<String> logs = new ArrayList<String>();
@@ -769,8 +1241,12 @@ public class MacTfsCoreService {
}
boolean exists = localFile != null && localFile.exists();
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
// 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
// 这样未签出的纯本地修改也能在目录对比中暴露出来。
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) {
return "localDeleted";
}
@@ -854,7 +1330,9 @@ public class MacTfsCoreService {
isFolder(change.getItemType()),
toPendingStatus(change),
change.getChangeType() == null ? "" : change.getChangeType().toUIString(false),
change.getVersion()
change.getVersion(),
// rename 挂起更改携带原路径,供 UI 展示「旧名 → 新名」。
change.isRename() ? change.getSourceServerItem() : null
));
}
return result;
@@ -928,21 +1406,28 @@ public class MacTfsCoreService {
private TfsTextDiff buildTextDiff(String sourceLabel, String targetLabel, String sourceContent, String targetContent) {
List<String> sourceLines = Arrays.asList(sourceContent.split("\\r?\\n", -1));
List<String> targetLines = Arrays.asList(targetContent.split("\\r?\\n", -1));
int max = Math.max(sourceLines.size(), targetLines.size());
// 使用 java-diff-utils(Myers 算法)计算最小差异,
// 避免按行号硬对齐导致插入/删除一行后其余行全部误报为变动。
Patch<String> patch = DiffUtils.diff(sourceLines, targetLines);
List<String> lines = new ArrayList<String>();
for (int index = 0; index < max; index++) {
String source = index < sourceLines.size() ? sourceLines.get(index) : null;
String target = index < targetLines.size() ? targetLines.get(index) : null;
if (source != null && target != null && source.equals(target)) {
lines.add(" " + source);
} else {
if (source != null) {
lines.add("-" + source);
}
if (target != null) {
lines.add("+" + target);
}
int sourceIndex = 0;
for (AbstractDelta<String> delta : patch.getDeltas()) {
int deltaStart = delta.getSource().getPosition();
while (sourceIndex < deltaStart) {
lines.add(" " + sourceLines.get(sourceIndex));
sourceIndex++;
}
for (String removed : delta.getSource().getLines()) {
lines.add("-" + removed);
}
for (String added : delta.getTarget().getLines()) {
lines.add("+" + added);
}
sourceIndex += delta.getSource().size();
}
while (sourceIndex < sourceLines.size()) {
lines.add(" " + sourceLines.get(sourceIndex));
sourceIndex++;
}
return new TfsTextDiff(sourceLabel, targetLabel, lines);
}
@@ -1417,8 +1902,9 @@ public class MacTfsCoreService {
private final String status;
private final String changeType;
private final int version;
private final String sourceServerPath;
public TfsPendingChangeInfo(String serverPath, String localPath, String name, boolean folder, String status, String changeType, int version) {
public TfsPendingChangeInfo(String serverPath, String localPath, String name, boolean folder, String status, String changeType, int version, String sourceServerPath) {
this.serverPath = serverPath;
this.localPath = localPath;
this.name = name;
@@ -1426,6 +1912,7 @@ public class MacTfsCoreService {
this.status = status;
this.changeType = changeType;
this.version = version;
this.sourceServerPath = sourceServerPath;
}
public String getServerPath() {
@@ -1455,6 +1942,10 @@ public class MacTfsCoreService {
public int getVersion() {
return version;
}
public String getSourceServerPath() {
return sourceServerPath;
}
}
public static class TfsFileOperationResult {
@@ -1606,12 +2097,16 @@ public class MacTfsCoreService {
private final int changeset;
private final String content;
private final boolean binary;
private final long size;
private final boolean tooLarge;
public TfsFileContent(String serverPath, int changeset, String content, boolean binary) {
public TfsFileContent(String serverPath, int changeset, String content, boolean binary, long size, boolean tooLarge) {
this.serverPath = serverPath;
this.changeset = changeset;
this.content = content;
this.binary = binary;
this.size = size;
this.tooLarge = tooLarge;
}
public String getServerPath() {
@@ -1629,6 +2124,154 @@ public class MacTfsCoreService {
public boolean isBinary() {
return binary;
}
public long getSize() {
return size;
}
public boolean isTooLarge() {
return tooLarge;
}
}
public static class TfsItemInfo {
private final String serverPath;
private final boolean folder;
private final int changeset;
private final Long checkinDate;
private final String author;
private final String comment;
private final Long size;
private final String encoding;
public TfsItemInfo(String serverPath, boolean folder, int changeset, Long checkinDate,
String author, String comment, Long size, String encoding) {
this.serverPath = serverPath;
this.folder = folder;
this.changeset = changeset;
this.checkinDate = checkinDate;
this.author = author;
this.comment = comment;
this.size = size;
this.encoding = encoding;
}
public String getServerPath() {
return serverPath;
}
public boolean isFolder() {
return folder;
}
public int getChangeset() {
return changeset;
}
public Long getCheckinDate() {
return checkinDate;
}
public String getAuthor() {
return author;
}
public String getComment() {
return comment;
}
public Long getSize() {
return size;
}
public String getEncoding() {
return encoding;
}
}
public static class TfsConflictInfo {
private final int conflictId;
private final String type;
private final String serverPath;
private final String localPath;
private final String yourServerItem;
private final String theirServerItem;
private final String baseServerItem;
private final boolean resolved;
public TfsConflictInfo(int conflictId, String type, String serverPath, String localPath,
String yourServerItem, String theirServerItem, String baseServerItem, boolean resolved) {
this.conflictId = conflictId;
this.type = type;
this.serverPath = serverPath;
this.localPath = localPath;
this.yourServerItem = yourServerItem;
this.theirServerItem = theirServerItem;
this.baseServerItem = baseServerItem;
this.resolved = resolved;
}
public int getConflictId() {
return conflictId;
}
public String getType() {
return type;
}
public String getServerPath() {
return serverPath;
}
public String getLocalPath() {
return localPath;
}
public String getYourServerItem() {
return yourServerItem;
}
public String getTheirServerItem() {
return theirServerItem;
}
public String getBaseServerItem() {
return baseServerItem;
}
public boolean isResolved() {
return resolved;
}
}
public static class TfsConflictResolution {
private final int conflictId;
private final String resolution;
private final boolean resolved;
private final int remainingConflicts;
public TfsConflictResolution(int conflictId, String resolution, boolean resolved, int remainingConflicts) {
this.conflictId = conflictId;
this.resolution = resolution;
this.resolved = resolved;
this.remainingConflicts = remainingConflicts;
}
public int getConflictId() {
return conflictId;
}
public String getResolution() {
return resolution;
}
public boolean isResolved() {
return resolved;
}
public int getRemainingConflicts() {
return remainingConflicts;
}
}
public static class TfsTextDiff {
@@ -9,11 +9,15 @@ import com.mydev.mactfs.core.MacTfsCoreService.ConnectionSummary;
import com.mydev.mactfs.core.MacTfsCoreService.CoreOperationResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsCheckinResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsCollectionInfo;
import com.mydev.mactfs.core.MacTfsCoreService.TfsConflictInfo;
import com.mydev.mactfs.core.MacTfsCoreService.TfsConflictResolution;
import com.mydev.mactfs.core.MacTfsCoreService.TfsConnectionConfig;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileContent;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
import com.mydev.mactfs.core.MacTfsCoreService.TfsItemInfo;
import com.mydev.mactfs.core.MacTfsCoreService.TfsMappingInfo;
import com.mydev.mactfs.core.MacTfsCoreService.TfsPendingChangeInfo;
import com.mydev.mactfs.core.MacTfsCoreService.TfsServerItem;
@@ -48,7 +52,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
* MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
*/
public class MacTfsServer {
@@ -89,8 +93,8 @@ public class MacTfsServer {
registerFilters();
registerRoutes();
Spark.awaitInitialization();
System.out.println("macTFS API server listening on http://" + HOST + ":" + PORT);
System.out.println("macTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
}
/**
@@ -280,6 +284,29 @@ public class MacTfsServer {
}
}));
Spark.get("/api/workspace/context", (request, response) -> handle(request, response, "workspaceContext", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
Map<String, Object> context = new LinkedHashMap<String, Object>();
context.put("serverUri", config.serverUri);
context.put("authType", config.authType);
context.put("collection", config.collection);
context.put("workspace", config.workspace);
context.put("connected", Boolean.valueOf(sessionManager.isConnected()));
Map<String, Object> data = new LinkedHashMap<String, Object>();
if (isBlank(config.collection) || isBlank(config.workspace)) {
context.put("mappings", config.mappings);
data.put("context", context);
return ApiResult.success("success", data);
}
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), config.collection, config.workspace);
context.put("mappings", result.getData() == null ? Collections.emptyList() : result.getData());
data.put("context", context);
return fromCore(result, data);
}
}));
Spark.get("/api/mappings", (request, response) -> handle(request, response, "listMappings", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
@@ -296,6 +323,26 @@ public class MacTfsServer {
}
}));
Spark.post("/api/mappings/check-target", (request, response) -> handle(request, response, "checkMappingTarget", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
String serverPath = require(stringValue(body, "serverPath"), "serverPath");
String localParentPath = require(stringValue(body, "localParentPath"), "localParentPath");
String name = lastPathSegment(serverPath);
File target = new File(localParentPath, name);
boolean exists = target.exists();
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("serverPath", serverPath);
data.put("localParentPath", localParentPath);
data.put("name", name);
data.put("targetPath", target.getAbsolutePath());
data.put("exists", Boolean.valueOf(exists));
data.put("allowed", Boolean.valueOf(!exists));
return ApiResult.success("success", data);
}
}));
Spark.post("/api/mappings", (request, response) -> handle(request, response, "addMapping", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
@@ -349,7 +396,23 @@ public class MacTfsServer {
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true));
CoreOperationResult<TfsGetLatestResult> result = coreService.getLatest(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), stringValue(body, "serverPath"), booleanValue(body, "recursive", true), booleanValue(body, "force", false));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/files/get-version", (request, response) -> handle(request, response, "getVersion", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
Number changeset = (Number) body.get("changeset");
if (changeset == null) {
throw new IllegalArgumentException("changeset is required");
}
CoreOperationResult<TfsGetLatestResult> result = coreService.getVersion(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), changeset.intValue(), booleanValue(body, "recursive", true));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
@@ -384,6 +447,103 @@ public class MacTfsServer {
}
}));
Spark.get("/api/files/merge-candidates", (request, response) -> handle(request, response, "mergeCandidates", TIMEOUT_HISTORY_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
CoreOperationResult<List<TfsHistoryEntry>> result = coreService.mergeCandidates(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(request.queryParams("sourceServerPath"), "sourceServerPath"), require(request.queryParams("targetServerPath"), "targetServerPath"));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("candidates", result.getData() == null ? Collections.emptyList() : result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/files/merge", (request, response) -> handle(request, response, "merge", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
Number changeset = (Number) body.get("changeset");
CoreOperationResult<TfsGetLatestResult> result = coreService.merge(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "sourceServerPath"), "sourceServerPath"), require(stringValue(body, "targetServerPath"), "targetServerPath"), changeset == null ? null : Integer.valueOf(changeset.intValue()));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/files/branch", (request, response) -> handle(request, response, "branch", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
Number changeset = (Number) body.get("changeset");
CoreOperationResult<TfsFileOperationResult> result = coreService.branch(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "sourceServerPath"), "sourceServerPath"), require(stringValue(body, "targetServerPath"), "targetServerPath"), changeset == null ? null : Integer.valueOf(changeset.intValue()));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/files/rollback", (request, response) -> handle(request, response, "rollback", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
Number changeset = (Number) body.get("changeset");
if (changeset == null) {
throw new IllegalArgumentException("changeset is required");
}
CoreOperationResult<TfsGetLatestResult> result = coreService.rollback(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), require(stringValue(body, "mode"), "mode"), changeset.intValue());
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/files/rename", (request, response) -> handle(request, response, "rename", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
CoreOperationResult<TfsFileOperationResult> result = coreService.rename(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), require(stringValue(body, "newName"), "newName"));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("result", result.getData());
return fromCore(result, data);
}
}));
Spark.get("/api/files/content", (request, response) -> handle(request, response, "fileContent", TIMEOUT_DIFF_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
String localPath = request.queryParams("localPath");
CoreOperationResult<TfsFileContent> result;
if (localPath != null && localPath.trim().length() > 0) {
result = coreService.getLocalFileContent(localPath);
} else {
String changeset = request.queryParams("changeset");
Integer changesetId = changeset == null || changeset.trim().isEmpty()
? null
: Integer.valueOf(Integer.parseInt(changeset.trim()));
result = coreService.getFileContent(toTfsConfig(config), require(config.collection, "collection"), require(request.queryParams("serverPath"), "serverPath"), changesetId);
}
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("content", result.getData());
return fromCore(result, data);
}
}));
Spark.get("/api/items/info", (request, response) -> handle(request, response, "itemInfo", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
CoreOperationResult<TfsItemInfo> result = coreService.getItemInfo(toTfsConfig(config), require(config.collection, "collection"), require(request.queryParams("serverPath"), "serverPath"));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("item", result.getData());
return fromCore(result, data);
}
}));
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
@@ -408,12 +568,36 @@ public class MacTfsServer {
}
}));
Spark.get("/api/conflicts", (request, response) -> handle(request, response, "listConflicts", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
boolean recursive = !"false".equalsIgnoreCase(request.queryParams("recursive"));
CoreOperationResult<List<TfsConflictInfo>> result = coreService.listConflicts(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")), recursive);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("conflicts", result.getData() == null ? Collections.emptyList() : result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/conflicts/apply", (request, response) -> handle(request, response, "applyConflict", TIMEOUT_LONG_WRITE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body);
CoreOperationResult<TfsConflictResolution> result = coreService.applyConflict(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), intValue(body, "conflictId"), require(stringValue(body, "resolution"), "resolution"));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("resolution", result.getData());
return fromCore(result, data);
}
}));
Spark.post("/api/compare/folder", (request, response) -> handle(request, response, "compareFolder", TIMEOUT_COMPARE_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request);
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>();
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
return fromCore(result, data);
@@ -690,6 +874,18 @@ public class MacTfsServer {
return result;
}
/**
* 取服务端路径的最后一段,作为 Mapping 目标目录名预校验依据。
*/
private String lastPathSegment(String serverPath) {
String normalized = serverPath.trim();
while (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
int index = normalized.lastIndexOf('/');
return index < 0 ? normalized : normalized.substring(index + 1);
}
/**
* 从请求体中读取路径数组,兼容 paths、path、serverPaths、serverPath 四种字段。
*/
+160
View File
@@ -0,0 +1,160 @@
# mactfsui 前端 AI 规则
本文件适用于 `mactfsui` 目录下所有前端、Electron、样式和文档改动。后续 AI 在实现前端任务时必须优先遵守本规则。
## 一、必读上下文
开始任何前端改动前,必须先读取:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/AGENTS.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-api-product-prd.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md`
- 当前任务文件
如果旧任务或旧文档提到 “VS 风格” 或 “Visual Studio Source Control Explorer”,前端视觉实现必须按本文件的 macOS 风格规则解释,不再照搬 Visual Studio 视觉。
## 二、强制 UI 风格
前端默认风格是:
```text
shadcn/ui + macOS Source Workspace
```
具体含义:
- 组件基础使用 shadcn/ui。
- 整体布局参考 macOS Finder、Xcode Source Control、Fork / SourceTree。
- 保留 TFS 工具所需的信息密度。
- 视觉上要像 macOS 原生源码管理工作台,不像 Windows Visual Studio,也不像 Web SaaS 后台。
默认关键词:
- macOS native
- lightweight
- quiet
- source-control focused
- Finder-like table
- Xcode-like inspector
- Fork-like diff
## 三、布局规则
主界面必须采用 macOS 工作台布局:
```text
顶部:macOS 风格工具栏
左侧:Source List,展示 Collection / Server Tree
中间:文件列表、目录对比、历史记录、Diff 主内容
右侧:Inspector,展示 Pending Changes、当前文件操作、签入
底部:可折叠 Console,展示操作日志
```
建议尺寸:
- 顶部工具栏:48-56px
- 左侧 Source List:260-320px
- 右侧 Inspector:320-380px
- 底部 Console:默认 150-220px,可折叠
- 列表行高:28-34px
- 按钮高度:28-32px
- 圆角:6-8px
第一屏必须是连接配置或可操作工作台,不做 landing page、hero、产品介绍页。
## 四、shadcn/ui 使用规则
允许并优先使用:
- `Button`
- `Input`
- `Select`
- `Dialog`
- `Sheet`
- `Tooltip`
- `DropdownMenu`
- `Tabs`
- `Table`
- `Badge`
- `Checkbox`
- `Textarea`
- `Separator`
- `ScrollArea`
规则:
- shadcn 基础组件只能放在 `app/components/ui`。
- 业务组件放到 `app/components/*` 对应业务目录。
- 图标使用 `lucide-react`。
- 文件操作按钮优先使用图标 + tooltip。
- 状态必须用文字或 badge 辅助,不能只靠颜色。
- 路径和版本号可用等宽字体。
## 五、禁止的视觉方向
禁止把 macTFS 做成以下风格:
- Visual Studio / Windows 工具窗口风格
- 满屏 Card 的 SaaS dashboard
- 后台管理系统首页
- 营销 landing page
- 大 Hero
- 大面积渐变背景
- 装饰性 orb / blob
- 过重深蓝、灰黑企业后台色
- 大圆角、过大留白、低信息密度
- 页面区域卡片套卡片
Card 只允许用于重复实体、弹窗内容或确实需要边界的局部工具,不允许把主布局区域都做成 card。
## 六、色彩与主题
默认使用浅色主题,整体背景应接近 macOS 工具软件:
- 主背景:浅灰或 shadcn muted background
- 内容区:白色或轻微分层背景
- 分隔:细边框、浅分割线
- 主色:macOS system blue 方向
状态色只表达业务状态:
- `pendingEdit`:蓝色
- `pendingAdd` / 成功:绿色
- `pendingDelete` / 失败:红色
- `notDownloaded` / 等待:琥珀色
- `remoteChanged`:蓝紫色
- `bothChanged`:红色强调
- `upToDate`:中性灰
不要在组件里硬编码大段颜色,优先使用 Tailwind token 和 `app.css` 变量。
## 七、实现约束
- 继续使用现有 React 19、React Router 7、Electron、Tailwind CSS 4、shadcn/ui 技术栈。
- 不新增 Vue、MicroFront、Redux、Zustand、TanStack Query,除非用户明确要求。
- Electron 本机能力放在 main / preload,React 组件不直接读文件、不启动 Java 进程。
- API 请求必须走统一 API client,不在组件里散落 `fetch`。
- Bearer token 由 Electron 主进程读取后通过 preload 暴露给渲染层。
- 新增函数必须按项目规则添加函数级注释。
- 改任何文件前必须先执行 `chmod u+w <file>` 或 `chmod 644 <file>`。
## 八、验收要求
前端任务完成后至少验证:
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
涉及 UI 的改动还必须实际打开页面或 Electron 看一遍,确认:
- 主区域不空白。
- 文案不重叠。
- 工具栏、Source List、Inspector、Console 区域关系清晰。
- 控件尺寸符合 macOS 工作台风格。
- 没有出现 VS 风格或 SaaS dashboard 风格。
最终输出必须说明是否遵守本规则。
+594
View File
@@ -0,0 +1,594 @@
# macTFS 前端设计规范
## 一、文档定位
这份文档是当前分支唯一有效的前端设计规范。
用途:
- 定义前端阶段的交互设计和实现约束
- 作为 `task/frontend` 的设计基线
- 替代旧 `task/frontend-refactor` 中分散的方案文档
约束:
- 当前前端任务只看 `task/frontend`
- 旧 `UIR-*` 编号不再继续维护
- 如果后续要改前端方向,先改本文件,再改 `task/frontend`
## 二、项目定位
`mactfsui` 是 macTFS 的 Electron + React 桌面 UI。
它只负责:
- 本机桌面交互
- 状态展示
- 本地 API 调用
它不直接负责:
- TFS 协议实现
- 本机文件系统裸访问
- Java 进程细节控制
整体链路:
```text
Electron Renderer
-> Electron Preload / Main
-> http://127.0.0.1:38765/api/*
-> mactfs-server
-> mactfs-core
-> TFS SDK
```
## 三、当前前端目标
第一版前端目标不是铺满所有能力,而是先实现一个围绕固定 Collection / Workspace 上下文展开的桌面工作台。
第一版要覆盖:
- 登录配置
- Collection 选择
- 默认 Workspace 自动使用 / 创建
- 左侧服务端目录树
- 中间当前目录文件列表
- Mapping
- 目录对比
- 文件查看
- Diff
- Get Latest / Checkout 冲突处理
- Pending Changes
- Checkin
- 操作日志
第一版不做:
- 多 Profile
- Work Item
- Check-in Policy UI
- 手动冲突块编辑
- 自定义布局
- 自动更新
## 四、关键设计结论
### 4.1 固定上下文
前端进入工作台后固定:
- `serverUri`
- `collection`
- `workspace`
规则:
- Collection 在登录页确认
- Workspace 不让用户手动选
- 工作台内不允许切换 Collection
- 需要切换 Collection 时,走重新连接流程
原因:
- TFS 的日常操作围绕单一 Workspace 展开
- 固定上下文能减少目录树、Mapping、Pending、History、Diff 的联动复杂度
### 4.2 三栏工作台
工作台采用固定三栏 + 底部台:
```text
┌──────────────────────────────────────────────────────────────┐
│ Top Bar: macTFS | Server | Collection | Workspace | Actions │
├──────────────┬────────────────────────────────┬──────────────┤
│ Source Tree │ Current Folder Items │ Changes │
│ collapsible │ main workspace │ collapsible │
├──────────────┴────────────────────────────────┴──────────────┤
│ Operation Console collapsible │
└──────────────────────────────────────────────────────────────┘
```
职责:
- Top Bar:全局上下文与服务入口
- Source Tree:目录浏览
- Current Folder Items:主工作区
- Changes:Pending 与 Checkin
- Operation Console:日志与执行反馈
### 4.3 中间区域减负
中间区域只保留:
- 当前路径
- 当前目录 Mapping 摘要
- 文件 / 文件夹表格
- 选中项摘要
中间区域不再内嵌:
- Mapping 表单
- History 面板
- Diff 面板
- 目录对比结果大块内容
- 选中项底部操作区
### 4.4 对象操作入口
对象操作统一走右键菜单。
覆盖对象:
- 左侧目录树节点
- 中间文件列表行
- Pending Changes 项
- 目录对比结果项
不放到右键菜单的操作:
- Checkin
- 全局设置
- 连接重试
- 日志开关
### 4.5 弹窗承载策略
以下能力优先使用弹窗:
- Mapping
- History
- 目录对比
- 文件查看
- Diff
- 冲突处理
原因:
- 避免中间文件列表失控
- 让主工作区保持浏览优先
- 让复杂交互按场景进入
## 五、前端分层规范
### 5.1 Electron Main
职责:
- 创建主窗口
- 检查本地 API 服务
- 服务未启动时尝试拉起
- 读取 token 文件
- 打开目录选择器
约束:
- 不把任意 shell 能力直接暴露给渲染进程
- 不在渲染进程直接读 `~/.mactfs/server-token`
### 5.2 Electron Preload
preload 只暴露窄接口,例如:
```ts
window.mactfs = {
getToken(): Promise<string>
selectDirectory(): Promise<string | null>
getServiceStatus(): Promise<ServiceStatus>
}
```
### 5.3 React Route
`routes/home.tsx` 是第一版主入口。
它负责:
- 初始化 health / config
- 管理连接态和工作台态切换
- 持有共享上下文状态
- 协调目录树、中间列表、弹窗、Pending、日志
### 5.4 API 层
API 层统一放在 `app/lib/api`。
要求:
- 组件不直接 `fetch`
- 组件不直接读 token
- 统一返回后端结构化结果
- 统一处理网络失败与后端失败
### 5.5 动作编排
对象动作由 `home.tsx` 或轻量 hook 编排。
原则:
- 不引入 Redux、Zustand、TanStack Query
- 只抽真实复用的 hook
- 不为了未来复用提前抽象复杂 service 层
## 六、推荐文件层级
```text
mactfsui/
app/
app.css
root.tsx
routes.ts
routes/
home.tsx
components/
app/
explorer/
inspector/
logs/
ui/
hooks/
lib/
api/
electron/
tfs/
utils.ts
electron/
main.cjs
preload.cjs
```
规则:
- `routes` 只编排页面
- `components/ui` 只放基础 UI 组件
- 业务组件按域拆分
- `lib/tfs` 存放状态、动作和表现映射
## 七、后端接口对接规范
基础配置:
```text
Base URL: http://127.0.0.1:38765
Auth: Authorization: Bearer <token>
```
前端必须覆盖的接口:
| 功能 | 方法 | 路径 |
|---|---|---|
| 健康检查 | GET | `/api/health` |
| 配置读写 | GET / PUT | `/api/config` |
| 连接 | POST | `/api/session/connect` |
| Collection | GET | `/api/collections` |
| 目录树 | GET | `/api/server-tree` |
| 当前目录 | GET | `/api/server-folder/items` |
| Workspace | GET | `/api/workspace` |
| Workspace 上下文 | GET | `/api/workspace/context` |
| Mapping 目标预校验 | POST | `/api/mappings/check-target` |
| Mapping | GET / POST / DELETE | `/api/mappings` |
| Get Latest | POST | `/api/files/get-latest` |
| Checkout | POST | `/api/files/checkout` |
| Add | POST | `/api/files/add` |
| Delete | POST | `/api/files/delete` |
| Undo | POST | `/api/files/undo` |
| Pending Changes | GET | `/api/pending-changes` |
| Checkin | POST | `/api/checkin` |
| 文件内容 | GET | `/api/files/content` |
| 目录对比 | POST | `/api/compare/folder` |
| 历史 | GET | `/api/history` |
| Changeset 文件 | GET | `/api/history/changeset` |
| 本地 vs latest Diff | POST | `/api/diff/local-latest` |
| 历史版本 Diff | POST | `/api/diff/revisions` |
| 冲突明细 | GET | `/api/conflicts` |
| 冲突应用 | POST | `/api/conflicts/apply` |
| 操作日志 | GET | `/api/logs` |
## 八、页面与交互规范
### 8.1 登录页
流程:
1. 输入连接信息
2. 连接 TFS
3. 加载 Collection
4. 确认 Collection
5. 自动确保 Workspace
6. 进入工作台
要求:
- 配置可回填
- Collection 必须确认
- Workspace 不让用户手动选
### 8.2 左侧目录树
要求:
- 固定 Collection
- 懒加载
- 未映射目录也可浏览
- 支持右键菜单
### 8.3 中间文件列表
要求:
- 展示当前目录下一级内容
- 单击只选中
- 双击目录进入
- 与左侧树同步导航
- 展示映射状态和核心元数据
### 8.4 Mapping 弹窗
要求:
- 展示服务端路径
- 选择本地父目录
- 选择完成后调用本地后端预校验最终目标路径
- 最终映射路径以后端返回结果为准
- 如果最终目标目录已存在,弹窗内直接提示“已存在,禁止映射”,并禁用确认操作
- 支持是否立即 Get Latest
- 成功后刷新 mappings 和当前目录列表
- 成功后停留在当前浏览位置,不做跳转
### 8.5 History 弹窗
要求:
- 支持文件历史
- 支持目录历史
- 支持 changeset 文件列表
- 支持从历史进入 Diff
### 8.6 目录对比弹窗
要求:
- 默认隐藏 `upToDate`
- 支持状态筛选
- 结果项走右键菜单
- 支持刷新和重新对比
### 8.7 文件查看与 Diff
要求:
- 文件查看支持本地 / 服务器内容
- Diff 支持本地 vs latest、历史 vs 历史
- 支持搜索、行号、差异高亮
- 大文件、二进制、非 Mapping 路径必须提示
### 8.8 冲突弹窗
要求:
- Get Latest 和 Checkout 复用
- 支持批量选择
- 支持逐文件选择
- 支持从冲突项进入 Diff
- 第一版不做手动冲突块编辑
### 8.9 Pending Changes 与 Checkin
要求:
- 右侧 Changes 保留为主入口
- 支持 Included / Excluded
- Pending Changes 项支持右键菜单
- Checkin comment 必填
- Checkin 成功后刷新 Pending 和目录状态
### 8.10 操作日志
要求:
- 底部可收起
- 显示主要操作
- 显示耗时和错误摘要
- 长操作期间有明确执行中文案
## 九、状态与动作模型
### 9.1 共享状态
主工作台至少维护:
- `serviceStatus`
- `config`
- `selectedCollection`
- `workspace`
- `mappings`
- `selectedServerPath`
- `pendingChanges`
- `includedKeys`
- `excludedKeys`
### 9.2 状态文案
状态统一收口到 `app/lib/tfs/status.ts` 或等价文件。
至少覆盖:
```text
localModified
remoteChanged
bothChanged
localOnly
remoteOnly
notDownloaded
localDeleted
pendingEdit
pendingAdd
pendingDelete
pending
upToDate
```
### 9.3 刷新规则
最小刷新规则:
- 连接成功:刷新 config、collections
- 确认 Collection:刷新 workspace、mappings、目录树根
- 创建 Mapping:刷新 mappings 和当前目录
- 取消 Mapping:刷新 mappings 和当前目录,停留在当前浏览位置
- Get Latest:刷新当前目录和日志
- Checkout / Add / Delete / Undo:刷新 pending changes
- Checkin:刷新 pending changes、当前目录、必要时刷新树节点
## 十、视觉与文案规范
### 10.1 风格
参考:
- Finder
- Xcode Source Control
- Fork / SourceTree
要求:
- 高信息密度
- 工具型而不是营销型
- 边界清晰
- 控件克制
### 10.2 布局尺寸建议
- 顶部:48-56px
- 左侧:260-320px
- 右侧:320-380px
- 底部:150-220px
### 10.3 文案
界面文案用中文。
推荐:
- 连接 TFS
- 选择 Collection
- 当前 Workspace
- 映射到本地
- 获取最新
- 目录对比
- 挂起更改
- 签入注释
- 操作日志
## 十一、开发实现规范
### 11.1 TypeScript
- 保持 `strict: true`
- 不在组件里重复定义相同响应类型
- 尽量不使用 `any`
### 11.2 注释
- 新增函数必须有函数级注释
- 核心逻辑修改要补必要注释
### 11.3 样式
- 优先 Tailwind
- 复用 `cn`
- 不新增不必要的全局样式
### 11.4 错误处理
- 统一展示 `errorMessage || message`
- 不堆多层 try/catch
- 不默认做 retry
### 11.5 文件权限
改代码前必须先执行:
```bash
chmod u+w <file>
```
或:
```bash
chmod 644 <file>
```
## 十二、前端阶段任务映射
当前前端阶段任务按以下顺序推进:
1. `FE-001`:Electron、preload、API client
2. `FE-002`:前端集成所需服务端接口与类型契约
3. `FE-003`:连接页、Collection 选择、Workspace 上下文
4. `FE-004`:工作台布局、折叠面板、同步导航
5. `FE-005`:目录树与当前目录列表
6. `FE-006`:对象右键菜单与动作模型
7. `FE-007`:Mapping、History、目录对比弹窗
8. `FE-008`:文件查看与 Diff 弹窗
9. `FE-009`:Get Latest 与 Checkout 冲突处理
10. `FE-010`:Pending Changes 与 Checkin
11. `FE-011`:FolderItemsPanel 拆分与动作收口
12. `FE-012`:操作日志与刷新反馈
13. `FE-013`:前端阶段联调验收基线
## 十三、当前分支现状
前端阶段 `FE-001` 至 `FE-013` 已全部完成:基础设施、接口契约、连接入口、工作台骨架、目录浏览、对象右键菜单、业务弹窗(Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理)、Get Latest / Checkout 执行链、Pending / Checkin 体验、动作编排收口、操作日志面板与联调基线均已就绪:
- `electron/main.cjs` 已补齐服务检测、按本地开发约定拉起服务、token 读取与目录选择
- `electron/preload.cjs` 已落地,通过 `contextBridge` 暴露 `window.mactfs` 窄接口
- `app/lib/electron` 已提供渲染层桥接(含非 Electron / SSR 兜底)
- `app/lib/api/client.ts` 已提供统一 API client(自动携带 Bearer token、统一错误处理)
- `app/lib/api/types.ts` 已补齐前端统一领域类型契约
- `app/lib/api/endpoints.ts` 已封装全部工作台所需接口(含 workspace/context、mappings/check-target、files/content、conflicts)
- 服务端已补齐 `GET /api/workspace/context`、`POST /api/mappings/check-target`、`GET /api/files/content`、`GET /api/conflicts`、`POST /api/conflicts/apply`
- `app/components/app/connect-view.tsx` 已实现连接表单、Collection 选择与确保默认 Workspace
- `app/components/app/workspace-shell.tsx` 已实现三栏工作台外壳,左 / 右 / 底面板支持折叠
- `app/components/app/top-bar.tsx` 已实现顶部上下文栏(Server / Collection / Workspace + 面板开关 + 重新连接)
- `app/components/explorer/source-tree-panel.tsx` 已实现目录树懒加载与同步展开;`folder-items-panel.tsx` 已实现当前目录文件列表(状态 / 版本 / 时间 / 本地路径,单击选中、双击进入)
- `app/components/inspector/changes-panel.tsx` 已实现 Included / Excluded 分组、comment 必填校验与签入(成功展示 changeset 并刷新挂起 / 目录);`app/components/logs/console-panel.tsx` 已实现操作日志面板(成败 / 耗时 / 错误摘要、随操作自动刷新、执行中提示)
- `app/lib/tfs` 已沉淀 `session.ts`(固定上下文、`SERVER_ROOT_PATH`)、`path.ts`(路径工具)、`mapping.ts`(本地路径推导)、`status.ts`(状态文案与配色收口)、`actions.ts`(FileTarget / 菜单生成与置灰规则)及 `index.ts` 出口
- `app/components/app/file-target-menu.tsx` 提供统一右键菜单容器;树、列表、Pending 项共用同一套动作模型
- `workspace-shell.tsx` 持有 pendingChanges 共享状态并集中分发对象动作,已实现「取消映射」(不跳转浏览位置)与 Mapping / History / 目录对比弹窗编排
- `app/components/explorer/mapping-dialog.tsx`(父目录选择 + 后端预校验 + 已存在禁止映射 + 可选立即 Get Latest)、`history-dialog.tsx`(文件 / 目录历史、changeset 下钻、版本勾选进入 Diff)、`compare-dialog.tsx`(默认隐藏 upToDate、状态筛选、重新对比、结果项右键菜单)已落地
- `app/components/explorer/file-view-dialog.tsx`(本地 / 服务器 latest 切换、行号、搜索、二进制 / 超大提示)与 `diff-dialog.tsx`(本地 vs latest、历史版本对比、双行号、差异高亮、仅看差异)已落地
- Electron 桥已含 `pathsExist` 本地存在性批量检测,用于「已映射未下载」状态
- `home.tsx` 已编排 检测服务 → 连接 → 工作台 的视图状态机,统一维护 `selectedServerPath` 与 mappings 更新
- `app/components/explorer/conflict-dialog.tsx`(逐项 / 批量取舍、冲突 Diff、应用后自动刷新)与 `workspace-shell.tsx` 的 `runGetLatest` / `runCheckout`(签出自动先取最新、冲突自动进弹窗、顶部通知条反馈)已落地
- 对象动作中 unmap / getLatest / checkout / delete / undo / checkin 均已实际执行并按 9.3 规则刷新
- 动作编排已收口:`app/hooks/use-pending-changes.ts`(挂起更改共享状态)、`app/hooks/use-file-actions.ts`(弹窗开关 + 动作执行 + 通知),`workspace-shell.tsx` 仅负责组合,业务弹窗统一由 `app/components/app/workspace-dialogs.tsx` 渲染
- FE-013 基线检查通过:typecheck、本地 API 调试链路、`pnpm electron:dev` 冒烟均正常;`electron:dev` 已修复代理环境下 wait-on 卡死问题(`NO_PROXY=localhost,127.0.0.1`)
- FE-014:获取最新默认安全模式(本地改动产生冲突而非被覆盖);「强制获取最新…」与历史「获取此版本」为带确认弹窗的显式危险动作(`/api/files/get-latest` 支持 `force`,新增 `/api/files/get-version`)
- FE-015:changeset 文件列表支持右键(查看此版本内容 / 查看该文件历史可返回 / 获取此版本…);文件查看弹窗支持指定 changeset;目录对比 localOnly 项可「加入版本控制」(pend add)
- FE-016:Diff 弹窗支持统一 / 左右分栏视图(会话内记忆)、差异概览条点击跳转、上一处 / 下一处差异导航;历史 / 对比 / 文件查看 / Diff 弹窗尺寸提升至接近满屏
- FE-017:左侧目录树、中间文件列表、右侧挂起更改均有手动刷新入口(树刷新清缓存并自动重展开选中链)
后续工作进入 Feature E2E(FEATURE-001 ~ 005,需要真实 TFS 环境)与 Release 阶段;真实 TFS 相关风险已记录在 FE-013 完成记录中。
+114 -62
View File
@@ -1,13 +1,18 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/roboto-slab";
@custom-variant dark (&:is(.dark *));
/*
* macOS 原生风设计基建(FE-026)
* 基调:系统字体 + macOS 蓝主色 + 中性灰分级 + 半透明材质(vibrancy)变量。
* 动效约定:快 150ms / 标准 220ms / 慢 320ms,缓动 ease-out-quart / spring 近似。
*/
@theme inline {
--font-heading: var(--font-serif);
--font-serif: 'Roboto Slab Variable', serif;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-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-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -39,6 +44,7 @@
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
/* 圆角档位:macOS 常用 6/8/10/14px(--radius = 10px) */
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -46,75 +52,97 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗(Sheet)明显 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05);
--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 {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.98 0.002 286);
--foreground: oklch(0.22 0.006 286);
--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-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.488 0.243 264.376);
--accent-foreground: oklch(0.97 0.014 254.604);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--popover-foreground: oklch(0.22 0.006 286);
/* macOS 系统蓝(≈ #007AFF),仅用于主按钮、选中态、状态点 */
--primary: oklch(0.585 0.205 256.5);
--primary-foreground: oklch(0.99 0.002 286);
--secondary: oklch(0.957 0.004 286);
--secondary-foreground: oklch(0.22 0.006 286);
--muted: oklch(0.957 0.004 286);
--muted-foreground: oklch(0.55 0.012 285);
/* accent 为中性悬停底色 */
--accent: oklch(0.94 0.004 286);
--accent-foreground: oklch(0.22 0.006 286);
--destructive: oklch(0.59 0.24 27.5);
--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-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);
--radius: 0.875rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--chart-2: oklch(0.7 0.165 254);
--chart-3: oklch(0.585 0.205 256.5);
--chart-4: oklch(0.5 0.21 259);
--chart-5: oklch(0.43 0.19 262);
--radius: 0.625rem;
/* 侧栏半透明材质:vibrancy 开启时透出窗口毛玻璃,关闭时回退近实色 */
--sidebar: oklch(0.962 0.003 286 / 72%);
--sidebar-foreground: oklch(0.22 0.006 286);
--sidebar-primary: oklch(0.585 0.205 256.5);
--sidebar-primary-foreground: oklch(0.99 0.002 286);
--sidebar-accent: oklch(0.88 0.005 286 / 80%);
--sidebar-border: oklch(0.89 0.004 286);
--sidebar-ring: oklch(0.62 0.19 256.5);
/* 动效时长:快 / 标准 / 慢 */
--motion-fast: 150ms;
--motion-base: 220ms;
--motion-slow: 320ms;
}
.dark {
--background: oklch(0.145 0 0);
--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);
--background: oklch(0.21 0.005 285);
--foreground: oklch(0.96 0.002 286);
--card: oklch(0.245 0.006 285);
--card-foreground: oklch(0.96 0.002 286);
--popover: oklch(0.245 0.006 285);
--popover-foreground: oklch(0.96 0.002 286);
/* macOS 暗色系统蓝(≈ #0A84FF) */
--primary: oklch(0.64 0.19 253);
--primary-foreground: oklch(0.99 0.002 286);
--secondary: oklch(0.29 0.006 286);
--secondary-foreground: oklch(0.96 0.002 286);
--muted: oklch(0.28 0.006 286);
--muted-foreground: oklch(0.71 0.01 286);
--accent: oklch(0.3 0.006 286);
--accent-foreground: oklch(0.96 0.002 286);
--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);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 13%);
--ring: oklch(0.64 0.19 253);
--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);
--chart-2: oklch(0.7 0.165 254);
--chart-3: oklch(0.64 0.19 253);
--chart-4: oklch(0.5 0.21 259);
--chart-5: oklch(0.43 0.19 262);
--sidebar: oklch(0.22 0.005 285 / 62%);
--sidebar-foreground: oklch(0.96 0.002 286);
--sidebar-primary: oklch(0.64 0.19 253);
--sidebar-primary-foreground: oklch(0.99 0.002 286);
--sidebar-accent: oklch(0.36 0.006 285 / 80%);
--sidebar-border: oklch(1 0 0 / 9%);
--sidebar-ring: oklch(0.64 0.19 253);
}
@layer base {
@@ -125,6 +153,30 @@
@apply bg-background text-foreground;
}
html {
@apply font-serif;
@apply font-sans;
}
}
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
.theme-transition,
.theme-transition * {
transition:
background-color 250ms var(--ease-out-quart),
border-color 250ms var(--ease-out-quart),
color 250ms var(--ease-out-quart) !important;
}
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
html.vibrancy,
html.vibrancy body {
background: transparent;
}
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
@utility app-drag {
-webkit-app-region: drag;
}
@utility app-no-drag {
-webkit-app-region: no-drag;
}
@@ -0,0 +1,63 @@
import { useState } from "react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
/**
* 通用确认弹窗:用于强制获取、获取指定版本等覆盖类危险操作的二次确认。
*/
export function ConfirmDialog({
title,
description,
confirmLabel,
danger,
onConfirm,
onClose,
}: {
title: string
description: React.ReactNode
confirmLabel: string
danger?: boolean
onConfirm: () => Promise<void> | void
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription asChild>
<div className="text-sm leading-6">{description}</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant={danger ? "destructive" : "default"}
disabled={busy}
onClick={() => {
void (async () => {
setBusy(true)
await onConfirm()
setBusy(false)
})()
}}
>
{busy ? "正在执行…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,427 @@
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { api } from "~/lib/api"
import type { AppConfig, Collection } from "~/lib/api"
import { getServiceStatus } from "~/lib/electron"
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 连接阶段:填写连接信息 / 确认 Collection。
type ConnectPhase = "form" | "collection"
// 连接表单字段,第一版仅支持显式 NTLM 账号密码。
interface ConnectFormState {
serverUri: string
domain: string
username: string
password: string
}
const EMPTY_FORM: ConnectFormState = {
serverUri: "",
domain: "",
username: "",
password: "",
}
/**
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
* macOS 风:左侧毛玻璃品牌区(大 logo + 一句话),右侧表单,带分阶段过渡与最近连接。
*/
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
const [phase, setPhase] = useState<ConnectPhase>("form")
const [form, setForm] = useState<ConnectFormState>(EMPTY_FORM)
const [collections, setCollections] = useState<Collection[]>([])
const [selectedCollection, setSelectedCollection] = useState("")
const [busy, setBusy] = useState(false)
const [busyText, setBusyText] = useState("")
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [recent, setRecent] = useState<RecentServer[]>([])
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
// 每次失败自增,作为 key 触发表单抖动动画重放。
const [shakeCount, setShakeCount] = useState(0)
useEffect(() => {
let active = true
setRecent(loadRecentServers())
void (async () => {
const result = await api.getConfig()
if (!active) {
return
}
const config = result.data?.config
if (config) {
setForm((prev) => ({
serverUri: config.serverUri ?? prev.serverUri,
domain: config.domain ?? prev.domain,
username: config.username ?? prev.username,
password: config.password ?? prev.password,
}))
if (config.collection) {
setSelectedCollection(config.collection)
}
}
})()
void (async () => {
const status = await getServiceStatus()
if (active) {
setServiceOk(status.running)
}
})()
return () => {
active = false
}
}, [])
/**
* 生成受控输入框的 onChange 处理器。
*/
const updateField = (key: keyof ConnectFormState) => (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target
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 列表。
*/
const handleConnect = useCallback(async () => {
setError(null)
setBusy(true)
setBusyText("正在连接 TFS…")
const payload: Partial<AppConfig> = {
serverUri: form.serverUri.trim(),
authType: "ntlm-explicit",
domain: form.domain.trim(),
username: form.username.trim(),
password: form.password,
}
const connectResult = await api.connect(payload)
if (!connectResult.ok) {
setBusy(false)
failWith(connectResult.errorMessage ?? "连接失败")
return
}
setBusyText("正在加载 Collection…")
const collectionsResult = await api.listCollections()
setBusy(false)
if (!collectionsResult.ok) {
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
return
}
setRecent(
saveRecentServer({
serverUri: form.serverUri.trim(),
domain: form.domain.trim(),
username: form.username.trim(),
}),
)
const list = collectionsResult.data?.collections ?? []
setCollections(list)
setSelectedCollection((prev) =>
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
)
setPhase("collection")
}, [form, failWith])
/**
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
*/
const handleEnter = useCallback(async () => {
if (!selectedCollection) {
return
}
setError(null)
setBusy(true)
setBusyText("正在确保默认 Workspace…")
const ensureResult = await api.ensureWorkspace({ collection: selectedCollection })
setBusy(false)
const workspace = ensureResult.data?.workspace
if (!ensureResult.ok || !workspace) {
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
return
}
onReady({
serverUri: form.serverUri.trim(),
collection: selectedCollection,
workspace: workspace.name,
mappings: workspace.mappings ?? [],
})
}, [selectedCollection, form.serverUri, onReady, failWith])
const canConnect =
form.serverUri.trim().length > 0 &&
form.username.trim().length > 0 &&
form.password.length > 0
return (
<div className="relative flex min-h-svh">
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
{/* 左侧品牌区:半透明材质(vibrancy 下呈毛玻璃),大 logo + 一句话 */}
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col border-r bg-sidebar md:flex">
<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 className="flex items-center gap-2 p-6 text-xs text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full",
serviceOk == null ? "bg-muted-foreground/50" : serviceOk ? "bg-emerald-500" : "bg-red-500",
)}
/>
{serviceOk == null
? "正在检测本地服务…"
: serviceOk
? "本地服务运行中 · 127.0.0.1:38765"
: "本地服务未就绪"}
</div>
</aside>
{/* 右侧表单区 */}
<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>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (canConnect && !busy) {
void handleConnect()
}
}}
>
<div className="space-y-1.5">
<Label htmlFor="serverUri">服务器地址</Label>
<Input
id="serverUri"
placeholder="http://tfs.example.com:8080/tfs"
value={form.serverUri}
onChange={updateField("serverUri")}
disabled={busy}
/>
</div>
<div className="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>
)}
{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>
)
}
@@ -0,0 +1,70 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "~/components/ui/context-menu"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { buildFileMenu } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 对象右键菜单容器:包住任意触发区域,按统一动作模型渲染菜单分组,
* 置灰项展示原因后缀,点击可用项回调上层动作分发器。
*/
export function FileTargetMenu({
target,
onAction,
children,
}: {
target: FileTarget
onAction: (target: FileTarget, action: FileActionId) => void
children: React.ReactNode
}) {
const sections = buildFileMenu(target)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
{sections.map((items, index) => (
<Section key={index} withSeparator={index > 0}>
{items.map((item) => (
<ContextMenuItem
key={item.id}
disabled={!item.enabled}
variant={item.danger && item.enabled ? "destructive" : "default"}
onSelect={() => onAction(target, item.id)}
>
<span>{item.label}</span>
{!item.enabled && item.reason && (
<span className={cn("ml-auto pl-3 text-xs text-muted-foreground")}>
{item.reason}
</span>
)}
</ContextMenuItem>
))}
</Section>
))}
</ContextMenuContent>
</ContextMenu>
)
}
/**
* 菜单分组:非首组前自动插入分隔线。
*/
function Section({
withSeparator,
children,
}: {
withSeparator: boolean
children: React.ReactNode
}) {
return (
<>
{withSeparator && <ContextMenuSeparator />}
{children}
</>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
import { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 主题三态对应的图标与说明文案。
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
system: { icon: Monitor, label: "主题:跟随系统" },
light: { icon: Sun, label: "主题:亮色" },
dark: { icon: Moon, label: "主题:暗色" },
}
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility {
tree: boolean
changes: boolean
console: boolean
}
/**
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
*/
export function TopBar({
session,
panels,
onTogglePanel,
onReconnect,
onManageWorkspace,
}: {
session: WorkspaceSession
panels: PanelVisibility
onTogglePanel: (panel: keyof PanelVisibility) => void
onReconnect: () => void
onManageWorkspace: () => void
}) {
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
const macInset = isMacElectron()
const theme = useTheme()
const themeVisual = THEME_VISUALS[theme.preference]
const ThemeIcon = themeVisual.icon
return (
<header
className={cn(
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
macInset && "pl-20",
)}
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 font-semibold">MacTFS</span>
<Separator orientation="vertical" className="h-4!" />
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
服务器
</span>
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
{session.serverUri}
</span>
<Separator orientation="vertical" className="hidden h-4! lg:block" />
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
<Separator orientation="vertical" className="h-4!" />
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
</div>
<div className="app-no-drag flex shrink-0 items-center gap-1">
<PanelToggle
label="源码目录面板"
active={panels.tree}
onClick={() => onTogglePanel("tree")}
>
<PanelLeft />
</PanelToggle>
<PanelToggle
label="挂起更改面板"
active={panels.changes}
onClick={() => onTogglePanel("changes")}
>
<PanelRight />
</PanelToggle>
<PanelToggle
label="操作日志面板"
active={panels.console}
onClick={() => onTogglePanel("console")}
>
<PanelBottom />
</PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" />
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
<ThemeIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</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>
</header>
)
}
/**
* 面板折叠开关按钮:图标 + tooltip,激活态用底色区分。
*/
function PanelToggle({
label,
active,
onClick,
children,
}: {
label: string
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-pressed={active}
onClick={onClick}
className={cn(!active && "text-muted-foreground")}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{active ? `收起${label}` : `展开${label}`}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,189 @@
import { ConfirmDialog } from "~/components/app/confirm-dialog"
import { AddFilesDialog } from "~/components/explorer/add-files-dialog"
import { BranchDialog } from "~/components/explorer/branch-dialog"
import { CompareDialog } from "~/components/explorer/compare-dialog"
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
import { DiffDialog } from "~/components/explorer/diff-dialog"
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
import { GetVersionDialog } from "~/components/explorer/get-version-dialog"
import { HistoryDialog } from "~/components/explorer/history-dialog"
import { MappingDialog } from "~/components/explorer/mapping-dialog"
import { MergeDialog } from "~/components/explorer/merge-dialog"
import { PropertiesDialog } from "~/components/explorer/properties-dialog"
import { RenameDialog } from "~/components/explorer/rename-dialog"
import type { WorkspaceDialogState } from "~/hooks/use-file-actions"
import type { MappingInfo } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
/**
* 工作台业务弹窗出口:按编排 hook 的弹窗状态渲染对应弹窗,
* 自身不持有业务状态,所有回调回流到动作编排层。
*/
export function WorkspaceDialogs({
dialog,
mappings,
onClose,
onOpen,
onFileAction,
onMappingCreated,
onConflictsResolved,
onForceGetConfirmed,
onGetVersion,
onRenameConfirmed,
onRollback,
onBranchConfirmed,
onMergeConfirmed,
onAddFilesConfirmed,
}: {
dialog: WorkspaceDialogState
mappings: MappingInfo[]
onClose: () => void
onOpen: (dialog: WorkspaceDialogState) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
onMappingCreated: (mappings: MappingInfo[]) => void
onConflictsResolved: () => void
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
onGetVersion: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
onRenameConfirmed: (serverPath: string, newName: string) => Promise<boolean>
onRollback: (serverPath: string, mode: "single" | "toVersion", changeset: number) => Promise<boolean>
onBranchConfirmed: (
sourceServerPath: string,
targetServerPath: string,
changeset: number | undefined,
) => Promise<boolean>
onMergeConfirmed: (
sourceServerPath: string,
targetServerPath: string,
changeset: number | undefined,
) => Promise<boolean>
onAddFilesConfirmed: (localPaths: string[]) => Promise<boolean>
}) {
if (!dialog) {
return null
}
switch (dialog.kind) {
case "mapping":
return (
<MappingDialog
serverPath={dialog.serverPath}
onClose={onClose}
onCreated={onMappingCreated}
/>
)
case "history":
return (
<HistoryDialog
serverPath={dialog.serverPath}
folder={dialog.folder}
onClose={onClose}
onDiffRevisions={(serverPath, sourceChangeset, targetChangeset) =>
onOpen({
kind: "diff",
request: { mode: "revisions", serverPath, sourceChangeset, targetChangeset },
})
}
onGetVersion={onGetVersion}
onRollback={onRollback}
/>
)
case "compare":
return (
<CompareDialog
serverPath={dialog.serverPath}
mappings={mappings}
onClose={onClose}
onFileAction={onFileAction}
/>
)
case "viewFile":
return (
<FileViewDialog
serverPath={dialog.serverPath}
localPath={dialog.localPath}
changeset={dialog.changeset}
onClose={onClose}
/>
)
case "diff":
return <DiffDialog request={dialog.request} onClose={onClose} />
case "conflicts":
return (
<ConflictDialog
serverPath={dialog.serverPath}
onClose={onClose}
onResolved={onConflictsResolved}
/>
)
case "properties":
return <PropertiesDialog target={dialog.target} onClose={onClose} />
case "rename":
return (
<RenameDialog
serverPath={dialog.serverPath}
folder={dialog.folder}
onConfirm={(newName) => onRenameConfirmed(dialog.serverPath, newName)}
onClose={onClose}
/>
)
case "branch":
return (
<BranchDialog
sourceServerPath={dialog.serverPath}
mappings={mappings}
onConfirm={(targetServerPath, changeset) =>
onBranchConfirmed(dialog.serverPath, targetServerPath, changeset)
}
onClose={onClose}
/>
)
case "merge":
return (
<MergeDialog
sourceServerPath={dialog.serverPath}
mappings={mappings}
onMerge={(targetServerPath, changeset) =>
onMergeConfirmed(dialog.serverPath, targetServerPath, changeset)
}
onClose={onClose}
/>
)
case "addFiles":
return (
<AddFilesDialog
serverPath={dialog.serverPath}
onConfirm={onAddFilesConfirmed}
onClose={onClose}
/>
)
case "getVersion":
return (
<GetVersionDialog
serverPath={dialog.serverPath}
folder={dialog.folder}
onConfirm={(changeset) => onGetVersion(dialog.serverPath, changeset, dialog.folder)}
onClose={onClose}
/>
)
case "confirmForceGet":
return (
<ConfirmDialog
title="强制获取最新"
description={
<>
<p>
将对 <span className="font-mono text-xs">{dialog.serverPath}</span>{" "}
强制获取服务器最新版本。
</p>
<p className="mt-2 font-medium text-destructive">
本地未签入的修改会被服务器版本直接覆盖,且无法恢复。确定继续吗?
</p>
</>
}
confirmLabel="覆盖本地并获取"
danger
onConfirm={() => onForceGetConfirmed(dialog.serverPath, dialog.folder)}
onClose={onClose}
/>
)
}
}
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from "react"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
import { ChangesPanel } from "~/components/inspector/changes-panel"
import { ConsolePanel } from "~/components/logs/console-panel"
import { Toaster, showToast } from "~/components/ui/toaster"
import { TooltipProvider } from "~/components/ui/tooltip"
import { useFileActions } from "~/hooks/use-file-actions"
import { usePendingChanges } from "~/hooks/use-pending-changes"
import type { MappingInfo } from "~/lib/api"
import type { WorkspaceSession } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 三栏工作台外壳:组合顶部上下文栏、左树、中间列表、右侧 Changes、底部 Console。
* 挂起更改状态与对象动作编排分别收口在 usePendingChanges / useFileActions。
*/
export function WorkspaceShell({
session,
selectedServerPath,
onNavigate,
onMappingsChanged,
onReconnect,
}: {
session: WorkspaceSession
selectedServerPath: string
onNavigate: (serverPath: string) => void
onMappingsChanged: (mappings: MappingInfo[]) => void
onReconnect: () => void
}) {
const [panels, setPanels] = useState<PanelVisibility>({
tree: true,
changes: true,
console: true,
})
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
// 工作区集中管理弹窗显隐。
const [manageOpen, setManageOpen] = useState(false)
/**
* 切换指定面板的展开 / 收起状态。
*/
const togglePanel = useCallback((panel: keyof PanelVisibility) => {
setPanels((prev) => ({ ...prev, [panel]: !prev[panel] }))
}, [])
/**
* 触发中间目录列表重新加载。
*/
const refreshItems = useCallback(() => {
setItemsRefreshToken((token) => token + 1)
}, [])
/**
* 触发底部操作日志重新加载。
*/
const refreshLogs = useCallback(() => {
setLogsRefreshToken((token) => token + 1)
}, [])
const pending = usePendingChanges()
const actions = useFileActions({
onMappingsChanged,
refreshPendingChanges: pending.refresh,
refreshItems,
refreshLogs,
})
// 把动作编排的 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 (
<TooltipProvider>
{/* 毛玻璃画布 + 卡片化面板:半透明画布透出 vibrancy,四个面板为圆角卡片留缝隙 */}
<div className="flex h-svh flex-col overflow-hidden bg-sidebar">
<TopBar
session={session}
panels={panels}
onTogglePanel={togglePanel}
onReconnect={onReconnect}
onManageWorkspace={() => setManageOpen(true)}
/>
<div className="flex min-h-0 flex-1 px-2 pb-2">
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
<div
className={cn(
"flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
)}
>
<SourceTreePanel
collection={session.collection}
mappings={session.mappings}
selectedServerPath={selectedServerPath}
onNavigate={onNavigate}
onFileAction={actions.handleFileAction}
/>
</div>
<FolderItemsPanel
session={session}
selectedServerPath={selectedServerPath}
pendingByServerPath={pending.pendingByServerPath}
refreshToken={itemsRefreshToken}
onNavigate={onNavigate}
onFileAction={actions.handleFileAction}
onRefresh={refreshItems}
/>
<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
mappings={session.mappings}
pendingChanges={pending.pendingChanges}
excludedKeys={pending.excludedKeys}
loading={pending.loading}
error={pending.error}
checkinBusy={actions.checkinBusy}
onToggleExcluded={pending.toggleExcluded}
onCheckin={actions.handleCheckin}
onFileAction={actions.handleFileAction}
onRefresh={() => void pending.refresh()}
/>
</div>
</div>
{/* 底部操作台通过高度过渡实现滑入滑出 */}
<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
refreshToken={logsRefreshToken}
busy={actions.actionBusy || actions.checkinBusy}
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
/>
</div>
<WorkspaceDialogs
dialog={actions.dialog}
mappings={session.mappings}
onClose={() => actions.setDialog(null)}
onOpen={actions.setDialog}
onFileAction={actions.handleFileAction}
onMappingCreated={actions.handleMappingCreated}
onConflictsResolved={actions.handleConflictsResolved}
onForceGetConfirmed={actions.handleForceGetConfirmed}
onGetVersion={actions.runGetVersion}
onRenameConfirmed={actions.handleRenameConfirmed}
onRollback={actions.runRollback}
onBranchConfirmed={actions.handleBranchConfirmed}
onMergeConfirmed={actions.handleMergeConfirmed}
onAddFilesConfirmed={actions.handleAddFilesConfirmed}
/>
{manageOpen && (
<WorkspaceManageDialog
session={session}
onClose={() => setManageOpen(false)}
onMappingsChanged={(mappings) => {
// Mapping 变化影响树映射标识与列表状态列,统一刷新。
onMappingsChanged(mappings)
refreshItems()
}}
/>
)}
<Toaster />
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { FolderDiffItem } from "~/lib/api"
/**
* 添加本地文件弹窗:对选中目录执行目录对比(递归),
* 自动筛出本地新增(localOnly)的文件 / 目录,勾选后批量加入版本控制(pendingAdd)。
*/
export function AddFilesDialog({
serverPath,
onConfirm,
onClose,
}: {
serverPath: string
/** 确认添加所选本地路径,由编排层调用接口并刷新,返回是否成功。 */
onConfirm: (localPaths: string[]) => Promise<boolean>
onClose: () => void
}) {
const [items, setItems] = useState<FolderDiffItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 勾选集合:以本地路径为键,默认全选。
const [selected, setSelected] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.compareFolder({ serverPath, recursive: true })
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "目录扫描失败")
return
}
setError(null)
const localOnly = (result.data?.diffs ?? []).filter(
(item) => item.status === "localOnly" && item.localPath,
)
setItems(localOnly)
setSelected(new Set(localOnly.map((item) => item.localPath)))
})()
return () => {
active = false
}
}, [serverPath])
const allChecked = items.length > 0 && selected.size === items.length
const selectedPaths = useMemo(
() => items.filter((item) => selected.has(item.localPath)).map((item) => item.localPath),
[items, selected],
)
/**
* 勾选 / 取消勾选单项。
*/
const toggle = (localPath: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(localPath)) {
next.delete(localPath)
} else {
next.add(localPath)
}
return next
})
}
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>添加本地文件</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在扫描本地新增文件…
</div>
) : error ? (
<div className="flex h-32 items-center justify-center text-sm text-destructive">{error}</div>
) : items.length === 0 ? (
<div className="flex h-32 items-center justify-center px-6 text-center text-sm text-muted-foreground">
没有发现本地新增的文件,本地内容与服务器一致
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-8">
<Checkbox
checked={allChecked}
onCheckedChange={(value) =>
setSelected(
value === true
? new Set(items.map((item) => item.localPath))
: new Set(),
)
}
aria-label="全选 / 清空"
/>
</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow
key={item.localPath}
className="cursor-pointer select-none"
onClick={() => toggle(item.localPath)}
>
<TableCell className="py-1.5">
<Checkbox
checked={selected.has(item.localPath)}
onCheckedChange={() => toggle(item.localPath)}
aria-label={`选择 ${item.name}`}
/>
</TableCell>
<TableCell className="py-1.5">
<span className="flex items-center gap-1.5">
{item.folder ? (
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate text-sm">{item.name}</span>
</span>
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
title={item.localPath}
>
{item.localPath}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
添加后产生「挂起新增」,可在挂起更改面板审查、撤销,签入后进入服务器;目录会连同其内容一并添加。
</p>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
disabled={selectedPaths.length === 0 || busy}
onClick={() => {
void (async () => {
setBusy(true)
const ok = await onConfirm(selectedPaths)
setBusy(false)
if (ok) {
onClose()
}
})()
}}
>
{busy ? "正在添加…" : `添加所选(${selectedPaths.length} 项)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,138 @@
import { useState } from "react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import type { MappingInfo } from "~/lib/api"
import { getParentPath, resolveLocalPath } from "~/lib/tfs"
/**
* 分支弹窗:把源路径分叉到目标路径(pendBranch 产生挂起更改,签入后服务器生效)。
* 目标路径父目录必须已映射(分支内容需要落盘到本地工作区);可选基于指定 changeset。
*/
export function BranchDialog({
sourceServerPath,
mappings,
onConfirm,
onClose,
}: {
sourceServerPath: string
mappings: MappingInfo[]
/** 确认分支,由编排层调用接口并刷新,返回是否成功。 */
onConfirm: (targetServerPath: string, changeset: number | undefined) => Promise<boolean>
onClose: () => void
}) {
const [target, setTarget] = useState(`${sourceServerPath}-branch`)
const [changesetInput, setChangesetInput] = useState("")
const [busy, setBusy] = useState(false)
const trimmed = target.trim()
const parentPath = trimmed.length > 2 ? getParentPath(trimmed) : null
const parentMapped = parentPath != null && resolveLocalPath(mappings, parentPath) != null
const targetLocalPath = trimmed.length > 2 ? resolveLocalPath(mappings, trimmed) : null
const changesetTrimmed = changesetInput.trim()
const changesetValid = changesetTrimmed.length === 0 || /^\d+$/.test(changesetTrimmed)
const validation =
!trimmed.startsWith("$/") || trimmed.length <= 2
? "目标路径必须以 $/ 开头"
: trimmed === sourceServerPath
? "目标路径不能与源路径相同"
: trimmed.startsWith(`${sourceServerPath}/`)
? "目标路径不能位于源路径内部"
: !parentMapped
? "目标路径所在父目录未映射,请先在工作区中映射"
: !changesetValid
? "changeset 必须是数字"
: null
/**
* 执行分支:成功后由上层刷新并关闭弹窗。
*/
const handleConfirm = () => {
void (async () => {
setBusy(true)
const ok = await onConfirm(
trimmed,
changesetTrimmed.length === 0 ? undefined : Number(changesetTrimmed),
)
setBusy(false)
if (ok) {
onClose()
}
})()
}
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>分支</DialogTitle>
<DialogDescription>
从源路径创建分支,结果为 branch 挂起更改,签入后服务器生效
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>源路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{sourceServerPath}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="branchTarget">目标路径</Label>
<Input
id="branchTarget"
value={target}
onChange={(event) => setTarget(event.target.value)}
disabled={busy}
className="font-mono text-xs"
/>
{targetLocalPath != null && validation == null && (
<p className="break-all font-mono text-xs text-muted-foreground">
本地落盘:{targetLocalPath}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="branchChangeset">基于 changeset(可选,默认 latest)</Label>
<Input
id="branchChangeset"
value={changesetInput}
onChange={(event) => setChangesetInput(event.target.value)}
placeholder="留空表示最新版本"
disabled={busy}
inputMode="numeric"
className="w-56 font-mono text-xs"
/>
</div>
{validation != null && <p className="text-xs text-destructive">{validation}</p>}
<p className="text-xs text-muted-foreground">
分支只产生挂起更改,可在挂起更改面板审查、撤销,签入后目标路径才会出现在服务器上。
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button disabled={validation != null || busy} onClick={handleConfirm}>
{busy ? "正在创建分支…" : "创建分支"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,310 @@
import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { FolderDiffItem, MappingInfo } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
*/
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
export function CompareDialog({
serverPath,
mappings,
onClose,
onFileAction,
}: {
serverPath: string
mappings: MappingInfo[]
onClose: () => void
onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
}) {
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
const [phase, setPhase] = useState<"setup" | "result">("setup")
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUpToDate, setShowUpToDate] = useState(false)
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
/**
* 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
*/
const runCompare = useCallback(async () => {
setLoading(true)
setError(null)
const result = await api.compareFolder({
serverPath,
recursive: true,
includeLocalOnly: !ignoreLocalOnly,
})
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "目录对比失败")
return
}
setDiffs(result.data?.diffs ?? [])
}, [serverPath, ignoreLocalOnly])
/**
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
* 让结果状态(如 签出编辑)立即反映最新事实。
*/
const handleAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
await onFileAction(target, action)
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
await runCompare()
}
},
[onFileAction, runCompare],
)
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
const presentStatuses = useMemo(() => {
const set = new Set<string>()
for (const diff of diffs) {
if (diff.status !== "upToDate") {
set.add(diff.status)
}
}
return [...set]
}, [diffs])
const visibleDiffs = useMemo(
() =>
diffs.filter((diff) => {
if (diff.status === "upToDate") {
return showUpToDate && statusFilter.size === 0
}
return statusFilter.size === 0 || statusFilter.has(diff.status)
}),
[diffs, showUpToDate, statusFilter],
)
/**
* 切换某个状态筛选 chip。
*/
const toggleStatus = useCallback((status: string) => {
setStatusFilter((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
<DialogHeader>
<DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{presentStatuses.map((status) => (
<button key={status} type="button" onClick={() => toggleStatus(status)}>
<Badge
variant="secondary"
className={cn(
"cursor-pointer rounded-md",
statusBadgeClass(status),
statusFilter.size > 0 && !statusFilter.has(status) && "opacity-40",
)}
>
{statusLabel(status)}
</Badge>
</button>
))}
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
checked={showUpToDate}
onCheckedChange={(value) => setShowUpToDate(value === true)}
/>
显示最新项
</label>
<Button
variant="outline"
size="sm"
onClick={() => setPhase("setup")}
disabled={loading}
>
调整选项
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void runCompare()}
disabled={loading}
>
<RefreshCw data-icon="inline-start" className={cn(loading && "animate-spin")} />
重新对比
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在对比目录…
</div>
) : error ? (
<div className="flex h-48 items-center justify-center text-sm text-destructive">
{error}
</div>
) : visibleDiffs.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
{diffs.length === 0 ? "本地与服务端一致,没有差异" : "没有符合筛选条件的差异项"}
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
<TableHead className="h-8 w-28 text-xs text-muted-foreground">状态</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">本地版本</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">最新版本</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleDiffs.map((diff) => {
const target = makeFileTarget({
source: "compare",
folder: diff.folder,
serverPath: diff.serverPath,
mappings,
pendingStatus: diff.status.startsWith("pending") ? diff.status : null,
compareStatus: diff.status,
})
return (
<FileTargetMenu
key={diff.serverPath}
target={target}
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
>
<TableRow className="cursor-default select-none">
<TableCell className="py-1.5">
<span className="flex items-center gap-1.5">
{diff.folder ? (
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate text-sm">{diff.name}</span>
</span>
</TableCell>
<TableCell className="py-1.5">
<Badge
variant="secondary"
className={cn("rounded-md", statusBadgeClass(diff.status))}
>
{statusLabel(diff.status)}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.localVersion > 0 ? diff.localVersion : "—"}
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.latestVersion > 0 ? diff.latestVersion : "—"}
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
title={diff.serverPath}
>
{diff.serverPath}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
{visibleDiffs.length} / {diffs.length} 项 · 差异项操作通过右键菜单触发
</p>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,303 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { FileDiff, Loader2 } from "lucide-react"
import { DiffDialog, type DiffRequest } from "~/components/explorer/diff-dialog"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { ConflictInfo, ConflictResolution } from "~/lib/api"
// 每个冲突的取舍选择:未选择 / 采用服务器版本 / 保留本地版本。
type ChoiceValue = "none" | ConflictResolution
/**
* 统一冲突弹窗:Get Latest 与 Checkout 共用。
* 支持逐文件选择服务器版本或保留本地版本、批量设置、冲突文件 Diff、批量应用。
*/
export function ConflictDialog({
serverPath,
onClose,
onResolved,
}: {
serverPath: string
onClose: () => void
onResolved: () => void
}) {
const [conflicts, setConflicts] = useState<ConflictInfo[]>([])
const [choices, setChoices] = useState<Record<number, ChoiceValue>>({})
const [loading, setLoading] = useState(true)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
// 应用结果摘要:conflictId -> 是否成功。
const [applied, setApplied] = useState<Record<number, boolean>>({})
const [diffRequest, setDiffRequest] = useState<DiffRequest | null>(null)
// 是否发生过成功应用,关闭时据此通知上层刷新。
const [didResolve, setDidResolve] = useState(false)
/**
* 加载当前范围内的未解决冲突。
*/
const loadConflicts = useCallback(async () => {
setLoading(true)
const result = await api.listConflicts({ serverPath, recursive: true })
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "冲突明细加载失败")
return
}
setError(null)
const list = (result.data?.conflicts ?? []).filter((item) => !item.resolved)
setConflicts(list)
setChoices({})
setApplied({})
}, [serverPath])
useEffect(() => {
void loadConflicts()
}, [loadConflicts])
const chosenCount = useMemo(
() => Object.values(choices).filter((value) => value !== "none").length,
[choices],
)
/**
* 批量把所有冲突设置为同一取舍。
*/
const setAll = useCallback(
(value: ConflictResolution) => {
setChoices(() => {
const next: Record<number, ChoiceValue> = {}
for (const conflict of conflicts) {
next[conflict.conflictId] = value
}
return next
})
},
[conflicts],
)
/**
* 应用所有已选择的取舍,逐个调用后端并记录结果。
*/
const applyChoices = useCallback(async () => {
setApplying(true)
setError(null)
const results: Record<number, boolean> = {}
let resolvedAny = false
for (const conflict of conflicts) {
const choice = choices[conflict.conflictId]
if (!choice || choice === "none") {
continue
}
const result = await api.applyConflict({
conflictId: conflict.conflictId,
resolution: choice,
})
const success = result.ok && result.data?.resolution?.resolved === true
results[conflict.conflictId] = success
if (success) {
resolvedAny = true
}
}
setApplying(false)
setApplied(results)
if (resolvedAny) {
setDidResolve(true)
}
const failures = Object.values(results).filter((value) => !value).length
if (failures > 0) {
setError(`${failures} 个冲突应用失败,可重试`)
await loadConflicts()
return
}
// 全部成功:重新加载剩余冲突,没有剩余则关闭并通知刷新。
const remain = await api.listConflicts({ serverPath, recursive: true })
const remaining = (remain.data?.conflicts ?? []).filter((item) => !item.resolved)
if (remaining.length === 0) {
onResolved()
return
}
setConflicts(remaining)
setChoices({})
setApplied({})
}, [conflicts, choices, serverPath, loadConflicts, onResolved])
/**
* 关闭弹窗:若期间有成功应用,通知上层刷新状态。
*/
const handleClose = useCallback(() => {
if (didResolve) {
onResolved()
} else {
onClose()
}
}, [didResolve, onResolved, onClose])
return (
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>处理冲突</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
{conflicts.length} 个冲突
</Badge>
<span className="text-xs text-muted-foreground">
逐项选择,或批量设置后统一应用
</span>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("takeServer")}
>
全部采用服务器
</Button>
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("keepLocal")}
>
全部保留本地
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载冲突…
</div>
) : conflicts.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
当前范围内没有未解决的冲突
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">文件</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 w-44 text-xs text-muted-foreground">取舍</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conflicts.map((conflict) => {
const choice = choices[conflict.conflictId] ?? "none"
const appliedState = applied[conflict.conflictId]
const canDiff =
conflict.serverPath != null && conflict.localPath != null
return (
<TableRow key={conflict.conflictId} className="select-none">
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={conflict.serverPath ?? conflict.localPath ?? ""}
>
{conflict.serverPath ?? conflict.localPath ?? `冲突 ${conflict.conflictId}`}
{appliedState === false && (
<span className="ml-2 text-destructive">应用失败</span>
)}
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{conflict.type}
</TableCell>
<TableCell className="py-1.5">
<Select
value={choice}
onValueChange={(value) =>
setChoices((prev) => ({
...prev,
[conflict.conflictId]: value as ChoiceValue,
}))
}
disabled={applying}
>
<SelectTrigger size="sm" className="w-40 text-xs">
<SelectValue placeholder="选择取舍" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">暂不处理</SelectItem>
<SelectItem value="takeServer">采用服务器版本</SelectItem>
<SelectItem value="keepLocal">保留本地版本</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon-sm"
disabled={!canDiff}
title={canDiff ? "查看差异" : "缺少路径信息,无法对比"}
onClick={() =>
setDiffRequest({
mode: "localLatest",
serverPath: conflict.serverPath as string,
localPath: conflict.localPath as string,
})
}
>
<FileDiff />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
<DialogFooter className="shrink-0">
<Button variant="ghost" onClick={handleClose} disabled={applying}>
稍后处理
</Button>
<Button
onClick={() => void applyChoices()}
disabled={applying || chosenCount === 0}
>
{applying ? "正在应用…" : `应用选择(${chosenCount})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{diffRequest && (
<DiffDialog request={diffRequest} onClose={() => setDiffRequest(null)} />
)}
</>
)
}
@@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { editor as MonacoEditor } from "monaco-editor"
import { ChevronDown, ChevronUp, Loader2, Search } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { api } from "~/lib/api"
import type { TextDiff } from "~/lib/api"
import { cn } from "~/lib/utils"
// Diff 请求:本地 vs latest,或同一服务端文件两个历史版本。
export type DiffRequest =
| { mode: "localLatest"; serverPath: string; localPath: string }
| { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number }
// 视图模式在同一会话内记忆。
const VIEW_MODE_KEY = "mactfs-diff-view-mode"
/**
* 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本:
* 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。
*/
function splitDiffSides(raw: string[]): { original: string; modified: string } {
const original: string[] = []
const modified: string[] = []
for (const line of raw) {
const prefix = line.charAt(0)
const text = line.slice(1)
if (prefix === "-") {
original.push(text)
} else if (prefix === "+") {
modified.push(text)
} else {
original.push(text)
modified.push(text)
}
}
return { original: original.join("\n"), modified: modified.join("\n") }
}
/**
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
*/
function isDarkTheme(): boolean {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
}
/**
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
*/
export function DiffDialog({
request,
onClose,
}: {
request: DiffRequest
onClose: () => void
}) {
const [diff, setDiff] = useState<TextDiff | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyChanges, setOnlyChanges] = useState(false)
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
return "unified"
}
return "split"
})
// 差异块总数与当前导航到的差异块序号。
const [blockCount, setBlockCount] = useState(0)
const [blockIndex, setBlockIndex] = useState(-1)
const containerRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
useEffect(() => {
let active = true
setLoading(true)
setError(null)
void (async () => {
const result =
request.mode === "localLatest"
? await api.diffLocalLatest({
serverPath: request.serverPath,
localPath: request.localPath,
})
: await api.diffRevisions({
serverPath: request.serverPath,
sourceChangeset: request.sourceChangeset,
targetChangeset: request.targetChangeset,
})
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setError(result.errorMessage ?? "Diff 加载失败")
return
}
setDiff(result.data.diff)
})()
return () => {
active = false
}
}, [request])
const changedCount = useMemo(
() => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length,
[diff],
)
// 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。
useEffect(() => {
if (!diff) {
return
}
let disposed = false
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
let originalModel: MonacoEditor.ITextModel | null = null
let modifiedModel: MonacoEditor.ITextModel | null = null
let themeObserver: MutationObserver | null = null
void (async () => {
const { monaco, detectLanguage } = await import("~/lib/monaco")
if (disposed || !containerRef.current) {
return
}
const { original, modified } = splitDiffSides(diff.lines)
const language = detectLanguage(request.serverPath)
originalModel = monaco.editor.createModel(original, language)
modifiedModel = monaco.editor.createModel(modified, language)
diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
readOnly: true,
originalEditable: false,
automaticLayout: true,
renderSideBySide: viewMode === "split",
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
compactMode: viewMode === "unified",
hideUnchangedRegions: { enabled: onlyChanges },
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
fontSize: 12,
lineHeight: 20,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderOverviewRuler: true,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
diffWordWrap: "off",
diffAlgorithm: "advanced",
renderLineHighlight: "none",
folding: false,
glyphMargin: false,
guides: { indentation: false },
padding: { top: 8, bottom: 8 },
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
renderMarginRevertIcon: false,
})
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
diffEditor.onDidUpdateDiff(() => {
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
})
editorRef.current = diffEditor
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
themeObserver = new MutationObserver(() => {
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
})()
return () => {
disposed = true
editorRef.current = null
themeObserver?.disconnect()
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
setBlockCount(0)
setBlockIndex(-1)
}
}, [diff, request.serverPath])
/**
* 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。
*/
const switchView = useCallback((mode: "unified" | "split") => {
setViewMode(mode)
editorRef.current?.updateOptions({
renderSideBySide: mode === "split",
compactMode: mode === "unified",
})
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(VIEW_MODE_KEY, mode)
}
}, [])
/**
* 切换「仅看差异」:折叠 / 展开未变动区域。
*/
const switchOnlyChanges = useCallback((value: boolean) => {
setOnlyChanges(value)
editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } })
}, [])
/**
* 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。
*/
const goToBlock = useCallback(
(direction: 1 | -1) => {
const diffEditor = editorRef.current
const changes = diffEditor?.getLineChanges() ?? []
if (!diffEditor || changes.length === 0) {
return
}
const next = (blockIndex + direction + changes.length) % changes.length
setBlockIndex(next)
const change = changes[next]
const line =
change.modifiedStartLineNumber > 0
? change.modifiedStartLineNumber
: Math.max(change.modifiedEndLineNumber, 1)
const modifiedEditor = diffEditor.getModifiedEditor()
modifiedEditor.revealLineInCenter(line)
modifiedEditor.setPosition({ lineNumber: line, column: 1 })
modifiedEditor.focus()
},
[blockIndex],
)
/**
* 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。
*/
const openSearch = useCallback(() => {
const modifiedEditor = editorRef.current?.getModifiedEditor()
modifiedEditor?.focus()
void modifiedEditor?.getAction("actions.find")?.run()
}, [])
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
<DialogHeader>
<DialogTitle>
{request.mode === "localLatest" ? "服务器 latest vs 本地" : "历史版本对比"}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{request.serverPath}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
{diff && (
<>
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
− {diff.sourceLabel}
</Badge>
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
+ {diff.targetLabel}
</Badge>
<span className="text-muted-foreground">{changedCount} 行差异</span>
</>
)}
<div className="ml-2 flex items-center rounded-md border p-0.5">
<ViewTab label="统一视图" active={viewMode === "unified"} onClick={() => switchView("unified")} />
<ViewTab label="左右分栏" active={viewMode === "split"} onClick={() => switchView("split")} />
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
disabled={blockCount === 0}
onClick={() => goToBlock(-1)}
aria-label="上一处差异"
>
<ChevronUp />
</Button>
<Button
variant="outline"
size="icon-sm"
disabled={blockCount === 0}
onClick={() => goToBlock(1)}
aria-label="下一处差异"
>
<ChevronDown />
</Button>
<span className="text-muted-foreground">
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
</span>
</div>
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
<Checkbox
checked={onlyChanges}
onCheckedChange={(value) => switchOnlyChanges(value === true)}
/>
仅看差异
</label>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={openSearch}>
<Search className="size-3.5" />
搜索
</Button>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border">
{loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在生成 Diff…
</div>
) : error ? (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-destructive">
{error}
</div>
) : changedCount === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
两个版本内容一致
</div>
) : (
<div ref={containerRef} className="absolute inset-0" />
)}
</div>
</DialogContent>
</Dialog>
)
}
/**
* 视图切换小标签按钮。
*/
function ViewTab({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-[5px] px-2 py-0.5 text-xs",
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
)}
>
{label}
</button>
)
}
@@ -0,0 +1,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 dark:text-blue-400",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600 dark:text-amber-400",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500 dark:text-zinc-400",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600 dark:text-violet-400",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600 dark:text-emerald-400",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600 dark:text-orange-400",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600 dark:text-green-400",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600 dark:text-zinc-300",
},
]
/**
* 取文件名扩展名(小写,无点);无扩展名返回空串。
*/
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 dark:text-sky-400",
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,
)}
/>
)
}
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react"
import { Loader2 } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { api } from "~/lib/api"
import type { FileContent } from "~/lib/api"
import { cn } from "~/lib/utils"
// 查看来源:本地映射文件或服务器 latest。
type ViewSource = "local" | "server"
/**
* 把字节数格式化为可读大小。
*/
function formatSize(size: number): string {
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
/**
* 文件查看弹窗:只读展示本地、服务器 latest 或指定 changeset 版本内容,
* 支持来源切换、行号与搜索高亮;二进制 / 超大 / 非映射路径给出明确提示。
*/
export function FileViewDialog({
serverPath,
localPath,
changeset,
onClose,
}: {
serverPath: string
localPath: string | null
/** 指定 changeset 时固定展示该版本内容,不提供来源切换。 */
changeset?: number
onClose: () => void
}) {
const [source, setSource] = useState<ViewSource>(localPath && changeset == null ? "local" : "server")
const [content, setContent] = useState<FileContent | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
// 加载所选来源的文件内容;本地内容仅在已映射时可用;指定版本固定按 changeset 读取。
useEffect(() => {
let active = true
setLoading(true)
setError(null)
void (async () => {
const result =
changeset != null
? await api.getFileContent({ serverPath, changeset })
: source === "local" && localPath
? await api.getFileContent({ localPath })
: await api.getFileContent({ serverPath })
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setContent(null)
setError(result.errorMessage ?? "文件内容加载失败")
return
}
setContent(result.data.content)
})()
return () => {
active = false
}
}, [source, serverPath, localPath, changeset])
const lines = useMemo(
() => (content && !content.binary && !content.tooLarge ? content.content.split(/\r?\n/) : []),
[content],
)
const keyword = search.trim().toLowerCase()
const matchCount = useMemo(
() =>
keyword.length === 0
? 0
: lines.filter((line) => line.toLowerCase().includes(keyword)).length,
[lines, keyword],
)
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[90svh] max-h-[90svh] flex-col sm:max-w-[92vw]">
<DialogHeader>
<DialogTitle>查看文件</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{changeset != null ? (
<Badge variant="secondary" className="rounded-md">
版本 C{changeset}
</Badge>
) : (
<div className="flex items-center rounded-md border p-0.5">
<SourceTab
label="本地文件"
active={source === "local"}
disabled={!localPath}
disabledReason="未映射到本地"
onClick={() => setSource("local")}
/>
<SourceTab
label="服务器 latest"
active={source === "server"}
onClick={() => setSource("server")}
/>
</div>
)}
{content && (
<span className="text-xs text-muted-foreground">
{formatSize(content.size)}
{content.changeset > 0 && ` · 版本 ${content.changeset}`}
</span>
)}
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索内容…"
className="ml-auto h-7 w-48 text-xs"
/>
{keyword.length > 0 && (
<Badge variant="secondary" className="rounded-md">
{matchCount} 行命中
</Badge>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
{loading ? (
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载内容…
</div>
) : error ? (
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
{error}
</div>
) : content?.binary ? (
<Notice text={`二进制文件不支持预览(${formatSize(content.size)})`} />
) : content?.tooLarge ? (
<Notice text={`文件过大(${formatSize(content.size)}),不直接渲染内容`} />
) : (
<pre className="min-w-full font-mono text-xs leading-5">
{lines.map((line, index) => {
const hit = keyword.length > 0 && line.toLowerCase().includes(keyword)
return (
<div
key={index}
className={cn("flex", hit && "bg-amber-500/15")}
>
<span className="w-12 shrink-0 border-r pr-2 text-right text-muted-foreground/60 select-none">
{index + 1}
</span>
<span className="whitespace-pre-wrap break-all pl-2">{line}</span>
</div>
)
})}
</pre>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
只读查看 · 来源:
{changeset != null
? `changeset ${changeset} 版本`
: source === "local"
? `本地 ${localPath ?? ""}`
: "服务器 latest"}
</p>
</DialogContent>
</Dialog>
)
}
/**
* 来源切换小标签按钮。
*/
function SourceTab({
label,
active,
disabled,
disabledReason,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
disabledReason?: string
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
title={disabled ? disabledReason : undefined}
onClick={onClick}
className={cn(
"rounded-[5px] px-2 py-0.5 text-xs",
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
>
{label}
</button>
)
}
/**
* 内容区占位提示(二进制 / 超大文件)。
*/
function Notice({ text }: { text: string }) {
return (
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-muted-foreground">
{text}
</div>
)
}
@@ -0,0 +1,299 @@
import { useEffect, useMemo, useState } from "react"
import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
import { Button } from "~/components/ui/button"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { ServerItem } from "~/lib/api"
import { pathsExist } from "~/lib/electron"
import type { FileActionId, FileTarget, WorkspaceSession } from "~/lib/tfs"
import {
makeFileTarget,
resolveItemLocalState,
resolveLocalPath,
statusBadgeClass,
statusLabel,
} from "~/lib/tfs"
import { cn, formatDateTime } from "~/lib/utils"
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
interface FolderItemRow {
item: ServerItem
localPath: string | null
localState: string
}
/**
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
* 单击选中、双击目录进入,行级右键菜单与左侧树共用同一套动作模型。
*/
export function FolderItemsPanel({
session,
selectedServerPath,
pendingByServerPath,
refreshToken,
onNavigate,
onFileAction,
onRefresh,
}: {
session: WorkspaceSession
selectedServerPath: string
pendingByServerPath: Record<string, string>
refreshToken: number
onNavigate: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void
}) {
const [items, setItems] = useState<ServerItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null)
// 本地路径 -> 是否存在;null 表示当前环境无法检测。
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
// 当前路径变化时加载目录内容,并批量检测映射目标的本地存在性。
useEffect(() => {
let active = true
setLoading(true)
setError(null)
setSelectedItemPath(null)
void (async () => {
const result = await api.getFolderItems({
path: selectedServerPath,
collection: session.collection,
})
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setItems([])
setError(result.errorMessage ?? "目录加载失败")
return
}
const list = result.data?.items ?? []
setItems(list)
const localPaths = list
.map((item) => resolveLocalPath(session.mappings, item.serverPath))
.filter((path): path is string => path != null)
const exists = await pathsExist(localPaths)
if (active) {
setExistsMap(exists)
}
})()
return () => {
active = false
}
}, [selectedServerPath, session.collection, session.mappings, refreshToken])
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
const rows = useMemo<FolderItemRow[]>(
() =>
items.map((item) => {
const localPath = resolveLocalPath(session.mappings, item.serverPath)
const localExists =
localPath == null || existsMap == null
? null
: existsMap[localPath] ?? false
return {
item,
localPath,
localState: resolveItemLocalState(localPath != null, localExists),
}
}),
[items, session.mappings, existsMap],
)
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
// 中间区域空白处右键的目标:当前目录本身(与目录树右键同一套菜单)。
const currentFolderTarget = makeFileTarget({
source: "list",
folder: true,
serverPath: selectedServerPath,
mappings: session.mappings,
pendingStatus: pendingByServerPath[selectedServerPath] ?? null,
})
return (
<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">
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{currentLocalPath ? (
<span className="font-mono">{currentLocalPath}</span>
) : (
"未映射到本地"
)}
</span>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={onRefresh}
disabled={loading}
aria-label="刷新当前目录"
title="刷新当前目录"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
{/* 由 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 ? (
<ItemsSkeleton />
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<AlertCircle className="size-8 text-destructive/60" />
<p className="text-sm text-destructive">{error}</p>
</div>
) : rows.length === 0 ? (
<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>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
<TableHead className="h-8 w-20 text-right text-xs text-muted-foreground">
最新版本
</TableHead>
<TableHead className="h-8 w-36 text-xs text-muted-foreground">
上次签入时间
</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, index) => {
const selected = row.item.serverPath === selectedItemPath
const target = makeFileTarget({
source: "list",
folder: row.item.folder,
serverPath: row.item.serverPath,
mappings: session.mappings,
pendingStatus: pendingByServerPath[row.item.serverPath] ?? null,
})
return (
<FileTargetMenu
key={row.item.serverPath}
target={target}
onAction={onFileAction}
>
<TableRow
data-state={selected ? "selected" : undefined}
className="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)}
onContextMenu={(event) => {
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
event.stopPropagation()
setSelectedItemPath(row.item.serverPath)
}}
onDoubleClick={() => {
if (row.item.folder) {
onNavigate(row.item.serverPath)
}
}}
>
<TableCell className="py-1.5">
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
<FileIcon
name={row.item.name}
folder={row.item.folder}
mapped={row.item.folder && row.localPath != null}
/>
<span className="truncate text-sm">{row.item.name}</span>
</span>
</TableCell>
<TableCell className="py-1.5">
<Badge
variant="secondary"
className={cn("gap-1.5 rounded-md", statusBadgeClass(row.localState))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(row.localState)}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{row.item.latestVersion > 0 ? row.item.latestVersion : "—"}
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(row.item.checkinDate)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground">
{row.localPath ?? "—"}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
</Table>
)}
</div>
</FileTargetMenu>
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
{selectedRow ? (
<>
<span className="font-medium text-foreground">{selectedRow.item.name}</span>
<span className="truncate font-mono">{selectedRow.item.serverPath}</span>
<span className="ml-auto shrink-0">
{statusLabel(selectedRow.localState)}
{selectedRow.item.latestVersion > 0 &&
` · 版本 ${selectedRow.item.latestVersion}`}
</span>
</>
) : (
<span>
{rows.length > 0 ? `${rows.length} 个对象 · 未选中` : "未选中对象"}
</span>
)}
</div>
</section>
)
}
/**
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 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>
)
}
@@ -0,0 +1,171 @@
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { HistoryEntry } from "~/lib/api"
import { cn, formatDateTime } from "~/lib/utils"
/**
* 获取特定版本弹窗:输入或从该对象历史中点选 changeset,
* 确认后把对象本地内容整体切到该变更集时刻(目录为递归覆盖),
* 对应 VS 的 Get Specific Version + Overwrite。
*/
export function GetVersionDialog({
serverPath,
folder,
onConfirm,
onClose,
}: {
serverPath: string
folder: boolean
/** 确认后执行获取,由编排层调用 get-version 接口并刷新。 */
onConfirm: (changeset: number) => Promise<boolean>
onClose: () => void
}) {
const [input, setInput] = useState("")
const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.getHistory({ path: serverPath, folder })
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "历史加载失败")
return
}
setError(null)
setEntries(result.data?.history ?? [])
})()
return () => {
active = false
}
}, [serverPath, folder])
const changeset = /^\d+$/.test(input.trim()) ? Number(input.trim()) : null
const valid = changeset != null && changeset > 0
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>获取特定版本</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<span className="text-sm text-muted-foreground">Changeset</span>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="输入编号或从下方历史点选"
className="h-8 w-56 font-mono text-sm"
inputMode="numeric"
/>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载历史…
</div>
) : error ? (
<div className="flex h-32 items-center justify-center text-sm text-destructive">{error}</div>
) : entries.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
没有历史记录,可直接输入 changeset 编号
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow
key={`${entry.changeset}-${entry.serverPath}`}
className={cn(
"cursor-pointer select-none",
changeset === entry.changeset && "bg-primary/10 hover:bg-primary/10",
)}
onClick={() => setInput(String(entry.changeset))}
>
<TableCell className="py-1.5 font-mono text-xs">{entry.changeset}</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(entry.date)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
{entry.comment || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs font-medium text-destructive">
确认后{folder ? "该目录(递归)" : "该文件"}的本地内容会被所选版本覆盖,
本地未签入的修改无法恢复。
</p>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="destructive"
disabled={!valid || busy}
onClick={() => {
if (changeset == null) {
return
}
void (async () => {
setBusy(true)
const ok = await onConfirm(changeset)
setBusy(false)
if (ok) {
onClose()
}
})()
}}
>
{busy ? "正在获取…" : changeset != null ? `获取 C${changeset}` : "获取此版本"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,531 @@
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, CloudDownload, Loader2 } from "lucide-react"
import { ConfirmDialog } from "~/components/app/confirm-dialog"
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "~/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { HistoryEntry } from "~/lib/api"
import { formatDateTime } from "~/lib/utils"
// 历史弹窗当前查看的目标(支持从 changeset 文件下钻到单文件历史后返回)。
interface HistoryTarget {
serverPath: string
folder: boolean
}
// 「获取此版本」待确认信息。
interface VersionConfirm {
serverPath: string
changeset: number
folder: boolean
}
// 「回滚」待确认信息:single 仅反做该变更集,toVersion 反做其后全部改动。
interface RollbackConfirm {
serverPath: string
changeset: number
mode: "single" | "toVersion"
}
/**
* History 弹窗:展示文件或目录历史(最近记录)。
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
* 查看该文件历史(可返回)/ 获取此版本;文件历史可勾选两个版本进入版本对比;
* 每条记录支持「获取此版本…」(确认后用该版本覆盖本地)。
*/
export function HistoryDialog({
serverPath,
folder,
onClose,
onDiffRevisions,
onGetVersion,
onRollback,
}: {
serverPath: string
folder: boolean
onClose: () => void
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
onGetVersion?: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
onRollback?: (serverPath: string, mode: "single" | "toVersion", changeset: number) => Promise<boolean>
}) {
const [target, setTarget] = useState<HistoryTarget>({ serverPath, folder })
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
const [targetStack, setTargetStack] = useState<HistoryTarget[]>([])
const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 目录历史下钻:当前查看的 changeset 文件列表,null 表示在历史主列表。
const [changesetFiles, setChangesetFiles] = useState<{
changeset: number
files: HistoryEntry[]
} | null>(null)
const [filesLoading, setFilesLoading] = useState(false)
// 文件历史勾选的 changeset(最多两个),用于版本对比。
const [selected, setSelected] = useState<number[]>([])
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
// 「回滚」待确认信息。
const [confirmRollback, setConfirmRollback] = useState<RollbackConfirm | null>(null)
// 「查看此版本内容」叠加的文件查看弹窗目标。
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.getHistory({ path: target.serverPath, folder: target.folder })
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "历史加载失败")
return
}
setError(null)
setEntries(result.data?.history ?? [])
})()
return () => {
active = false
}
}, [target])
/**
* 目录历史下钻:加载指定 changeset 影响的文件列表。
*/
const openChangeset = useCallback(async (changeset: number) => {
setFilesLoading(true)
const result = await api.getChangesetFiles({ changeset })
setFilesLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "Changeset 文件加载失败")
return
}
setError(null)
setChangesetFiles({ changeset, files: result.data?.files ?? [] })
}, [])
/**
* 从 changeset 文件切换到该文件的历史(压栈当前目标,可返回)。
*/
const openFileHistory = useCallback(
(fileServerPath: string) => {
setTargetStack((prev) => [...prev, target])
setTarget({ serverPath: fileServerPath, folder: false })
setChangesetFiles(null)
setSelected([])
},
[target],
)
/**
* 返回上一级历史目标。
*/
const backToPreviousTarget = useCallback(() => {
setTargetStack((prev) => {
const next = [...prev]
const previous = next.pop()
if (previous) {
setTarget(previous)
setChangesetFiles(null)
setSelected([])
}
return next
})
}, [])
/**
* 勾选 / 取消勾选用于对比的 changeset,最多保留两个(先进先出)。
*/
const toggleSelected = useCallback((changeset: number) => {
setSelected((prev) => {
if (prev.includes(changeset)) {
return prev.filter((item) => item !== changeset)
}
const next = [...prev, changeset]
return next.length > 2 ? next.slice(next.length - 2) : next
})
}, [])
const canDiff = onDiffRevisions != null && !target.folder && selected.length === 2
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[88vw]">
<DialogHeader>
<DialogTitle>
{changesetFiles ? `Changeset ${changesetFiles.changeset} 的文件` : "历史记录"}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{target.serverPath}
</DialogDescription>
</DialogHeader>
{(changesetFiles || targetStack.length > 0) && (
<div className="flex items-center gap-2">
{changesetFiles && (
<Button variant="ghost" size="sm" onClick={() => setChangesetFiles(null)}>
<ArrowLeft data-icon="inline-start" />
返回历史列表
</Button>
)}
{!changesetFiles && targetStack.length > 0 && (
<Button variant="ghost" size="sm" onClick={backToPreviousTarget}>
<ArrowLeft data-icon="inline-start" />
返回上级历史
</Button>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading || filesLoading ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载…
</div>
) : error ? (
<div className="flex h-40 items-center justify-center text-sm text-destructive">
{error}
</div>
) : changesetFiles ? (
<ChangesetFilesTable
files={changesetFiles.files}
changeset={changesetFiles.changeset}
onViewVersion={(fileServerPath) =>
setViewVersion({ serverPath: fileServerPath, changeset: changesetFiles.changeset })
}
onOpenFileHistory={openFileHistory}
onGetVersion={
onGetVersion
? (fileServerPath) =>
setConfirmVersion({
serverPath: fileServerPath,
changeset: changesetFiles.changeset,
folder: false,
})
: undefined
}
/>
) : entries.length === 0 ? (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
没有历史记录
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
{!target.folder && <TableHead className="h-8 w-8" />}
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
{onGetVersion && <TableHead className="h-8 w-12 text-xs text-muted-foreground" />}
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<ContextMenu key={`${entry.changeset}-${entry.serverPath}`}>
<ContextMenuTrigger asChild>
<TableRow
className="cursor-default select-none"
onDoubleClick={() => {
if (target.folder) {
void openChangeset(entry.changeset)
}
}}
>
{!target.folder && (
<TableCell className="py-1.5">
<Checkbox
checked={selected.includes(entry.changeset)}
onCheckedChange={() => toggleSelected(entry.changeset)}
aria-label={`选择 changeset ${entry.changeset}`}
/>
</TableCell>
)}
<TableCell className="py-1.5 font-mono text-xs">
{target.folder ? (
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() => void openChangeset(entry.changeset)}
>
{entry.changeset}
</button>
) : (
entry.changeset
)}
</TableCell>
<TableCell className="py-1.5 text-xs">{entry.changeType}</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(entry.date)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
{entry.comment || "—"}
</TableCell>
{onGetVersion && (
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon-sm"
title={`获取此版本(C${entry.changeset},覆盖本地)`}
onClick={() =>
setConfirmVersion({
serverPath: target.serverPath,
changeset: entry.changeset,
folder: target.folder,
})
}
>
<CloudDownload />
</Button>
</TableCell>
)}
</TableRow>
</ContextMenuTrigger>
{(onGetVersion || onRollback) && (
<ContextMenuContent className="w-60">
{onGetVersion && (
<ContextMenuItem
onSelect={() =>
setConfirmVersion({
serverPath: target.serverPath,
changeset: entry.changeset,
folder: target.folder,
})
}
>
获取此版本(C{entry.changeset})…
</ContextMenuItem>
)}
{onGetVersion && onRollback && <ContextMenuSeparator />}
{onRollback && (
<>
<ContextMenuItem
variant="destructive"
onSelect={() =>
setConfirmRollback({
serverPath: target.serverPath,
changeset: entry.changeset,
mode: "single",
})
}
>
回滚此变更集(C{entry.changeset})…
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() =>
setConfirmRollback({
serverPath: target.serverPath,
changeset: entry.changeset,
mode: "toVersion",
})
}
>
回滚到此变更集(C{entry.changeset})…
</ContextMenuItem>
</>
)}
</ContextMenuContent>
)}
</ContextMenu>
))}
</TableBody>
</Table>
)}
</div>
<div className="flex shrink-0 items-center justify-between">
<p className="text-xs text-muted-foreground">
{changesetFiles
? `${changesetFiles.files.length} 个文件 · 右键文件可查看此版本 / 历史 / 获取`
: target.folder
? "双击记录或点击 changeset 编号查看影响文件"
: "勾选两个版本可进行对比"}
</p>
{!target.folder && onDiffRevisions && (
<Button
size="sm"
disabled={!canDiff}
onClick={() => {
const [a, b] = [...selected].sort((x, y) => x - y)
onDiffRevisions(target.serverPath, a, b)
}}
>
对比所选版本
</Button>
)}
</div>
{confirmVersion && onGetVersion && (
<ConfirmDialog
title={`获取版本 C${confirmVersion.changeset}`}
description={
<>
<p>
将把 <span className="font-mono text-xs">{confirmVersion.serverPath}</span>{" "}
的本地内容替换为 changeset {confirmVersion.changeset} 的版本。
</p>
<p className="mt-2 font-medium text-destructive">
本地未签入的修改会被覆盖,且无法恢复。确定继续吗?
</p>
</>
}
confirmLabel="获取此版本"
danger
onConfirm={async () => {
await onGetVersion(
confirmVersion.serverPath,
confirmVersion.changeset,
confirmVersion.folder,
)
setConfirmVersion(null)
}}
onClose={() => setConfirmVersion(null)}
/>
)}
{confirmRollback && onRollback && (
<ConfirmDialog
title={
confirmRollback.mode === "single"
? `回滚变更集 C${confirmRollback.changeset}`
: `回滚到变更集 C${confirmRollback.changeset}`
}
description={
<>
<p>
将对 <span className="font-mono text-xs">{confirmRollback.serverPath}</span>{" "}
{confirmRollback.mode === "single"
? `反做变更集 C${confirmRollback.changeset} 的改动。`
: `反做 C${confirmRollback.changeset} 之后的全部改动(回到该变更集时刻的内容)。`}
</p>
<p className="mt-2">
回滚只会产生挂起更改,不会直接写入服务器;可在挂起更改面板审查、撤销,确认无误后签入。
</p>
</>
}
confirmLabel="执行回滚"
danger
onConfirm={async () => {
await onRollback(
confirmRollback.serverPath,
confirmRollback.mode,
confirmRollback.changeset,
)
setConfirmRollback(null)
}}
onClose={() => setConfirmRollback(null)}
/>
)}
{viewVersion && (
<FileViewDialog
serverPath={viewVersion.serverPath}
localPath={null}
changeset={viewVersion.changeset}
onClose={() => setViewVersion(null)}
/>
)}
</DialogContent>
</Dialog>
)
}
/**
* Changeset 影响文件列表(目录历史下钻视图),行级右键菜单提供对象操作。
*/
function ChangesetFilesTable({
files,
changeset,
onViewVersion,
onOpenFileHistory,
onGetVersion,
}: {
files: HistoryEntry[]
changeset: number
onViewVersion: (serverPath: string) => void
onOpenFileHistory: (serverPath: string) => void
onGetVersion?: (serverPath: string) => void
}) {
if (files.length === 0) {
return (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
该 changeset 没有文件记录
</div>
)
}
return (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<ContextMenu key={file.serverPath}>
<ContextMenuTrigger asChild>
<TableRow className="cursor-default select-none">
<TableCell className="py-1.5 text-xs">{file.changeType}</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={file.serverPath}
>
{file.serverPath}
</TableCell>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onSelect={() => onViewVersion(file.serverPath)}>
查看此版本内容
</ContextMenuItem>
<ContextMenuItem onSelect={() => onOpenFileHistory(file.serverPath)}>
查看该文件历史
</ContextMenuItem>
{onGetVersion && (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => onGetVersion(file.serverPath)}
>
获取此版本(C{changeset})…
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
))}
</TableBody>
</Table>
)
}
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useState } from "react"
import { FolderSearch, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { api } from "~/lib/api"
import type { CheckMappingTargetResult, MappingInfo } from "~/lib/api"
import { isElectron, selectDirectory } from "~/lib/electron"
/**
* Mapping 创建弹窗:展示服务端路径,选择本地父目录后由后端预校验最终目标路径,
* 目标已存在时禁止映射;支持创建后立即 Get Latest。
*/
export function MappingDialog({
serverPath,
onClose,
onCreated,
}: {
serverPath: string
onClose: () => void
onCreated: (mappings: MappingInfo[], didGetLatest: boolean) => void
}) {
const [parentPath, setParentPath] = useState("")
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
const [checking, setChecking] = useState(false)
const [getLatest, setGetLatest] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* 通过系统目录选择器选择本地父目录(非 Electron 环境保留手动输入)。
*/
const handleBrowse = useCallback(async () => {
const selected = await selectDirectory()
if (selected) {
setParentPath(selected)
}
}, [])
// 父目录变化时调用后端预校验最终目标路径,最终路径以后端结果为准。
useEffect(() => {
const trimmed = parentPath.trim()
if (trimmed.length === 0) {
setCheck(null)
return
}
let active = true
setChecking(true)
const timer = setTimeout(() => {
void (async () => {
const result = await api.checkMappingTarget({
serverPath,
localParentPath: trimmed,
})
if (!active) {
return
}
setChecking(false)
if (!result.ok || !result.data) {
setCheck(null)
setError(result.errorMessage ?? "目标路径校验失败")
return
}
setError(null)
setCheck(result.data)
})()
}, 300)
return () => {
active = false
clearTimeout(timer)
}
}, [parentPath, serverPath])
const exists = check?.exists === true
const canConfirm = check != null && check.allowed && !exists && !busy && !checking
/**
* 创建 Mapping(按需立即 Get Latest),成功后回调上层刷新并关闭弹窗。
*/
const handleConfirm = useCallback(async () => {
if (!check) {
return
}
setBusy(true)
setError(null)
const result = await api.addMapping({
serverPath,
localPath: check.targetPath,
getLatest,
})
setBusy(false)
if (!result.ok) {
setError(result.errorMessage ?? "创建 Mapping 失败")
return
}
const mappingsResult = await api.listMappings()
onCreated(mappingsResult.data?.mappings ?? [], getLatest)
}, [check, serverPath, getLatest, onCreated])
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>映射到本地</DialogTitle>
<DialogDescription>
为服务端目录创建本地工作区映射
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>服务端路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{serverPath}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="parentPath">本地父目录</Label>
<div className="flex gap-2">
<Input
id="parentPath"
placeholder="/Users/me/tfs"
value={parentPath}
onChange={(event) => setParentPath(event.target.value)}
disabled={busy}
className="font-mono text-xs"
/>
{isElectron() && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void handleBrowse()}
disabled={busy}
aria-label="浏览本地目录"
>
<FolderSearch />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
选择父目录,最终映射目录由后端按服务端目录名生成
</p>
</div>
{checking && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在校验目标路径…
</p>
)}
{check && !checking && (
<div className="space-y-1.5">
<Label>最终映射路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{check.targetPath}
</p>
{exists && (
<p className="text-xs font-medium text-destructive">
目标目录已存在,禁止映射
</p>
)}
</div>
)}
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={getLatest}
onCheckedChange={(value) => setGetLatest(value === true)}
disabled={busy}
/>
创建后立即获取最新
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
{busy ? "正在创建…" : "创建映射"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,228 @@
import { useCallback, useState } from "react"
import { Loader2, Search } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { HistoryEntry, MappingInfo } from "~/lib/api"
import { resolveLocalPath } from "~/lib/tfs"
import { cn, formatDateTime } from "~/lib/utils"
/**
* 合并向导弹窗:源(当前对象)→ 目标路径(必须已映射),
* 加载待合并 changeset 候选后支持「全部合并」或选择单个合并;
* 结果为挂起更改,冲突由编排层进入既有冲突弹窗。
*/
export function MergeDialog({
sourceServerPath,
mappings,
onMerge,
onClose,
}: {
sourceServerPath: string
mappings: MappingInfo[]
/** 执行合并(changeset 为 undefined 表示全部),由编排层调用接口并刷新。 */
onMerge: (targetServerPath: string, changeset: number | undefined) => Promise<boolean>
onClose: () => void
}) {
const [target, setTarget] = useState("")
const [candidates, setCandidates] = useState<HistoryEntry[] | null>(null)
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<number | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const trimmed = target.trim()
const targetMapped = trimmed.length > 2 && resolveLocalPath(mappings, trimmed) != null
const validation =
trimmed.length === 0
? null
: !trimmed.startsWith("$/") || trimmed.length <= 2
? "目标路径必须以 $/ 开头"
: trimmed === sourceServerPath
? "目标路径不能与源路径相同"
: !targetMapped
? "目标路径未映射,请先在工作区中映射"
: null
const targetReady = trimmed.length > 2 && validation == null
/**
* 加载源 → 目标的待合并候选列表。
*/
const loadCandidates = useCallback(async () => {
setLoading(true)
setError(null)
setSelected(null)
const result = await api.mergeCandidates({
sourceServerPath,
targetServerPath: trimmed,
})
setLoading(false)
if (!result.ok) {
setCandidates(null)
setError(result.errorMessage ?? "候选查询失败(请确认源与目标存在分支关系)")
return
}
setCandidates(result.data?.candidates ?? [])
}, [sourceServerPath, trimmed])
/**
* 执行合并:changeset 为 undefined 表示合并全部候选。
*/
const handleMerge = useCallback(
(changeset: number | undefined) => {
void (async () => {
setBusy(true)
const ok = await onMerge(trimmed, changeset)
setBusy(false)
if (ok) {
onClose()
}
})()
},
[onMerge, trimmed, onClose],
)
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>合并</DialogTitle>
<DialogDescription>
将源分支的变更集合并到目标分支,结果为挂起更改,冲突需逐个处理
</DialogDescription>
</DialogHeader>
<div className="shrink-0 space-y-3">
<div className="space-y-1.5">
<Label>源路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{sourceServerPath}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="mergeTarget">目标路径(必须已映射)</Label>
<div className="flex gap-2">
<Input
id="mergeTarget"
value={target}
onChange={(event) => {
setTarget(event.target.value)
setCandidates(null)
}}
placeholder="$/Project/Branch"
disabled={busy}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-9 shrink-0"
disabled={!targetReady || loading || busy}
onClick={() => void loadCandidates()}
>
<Search data-icon="inline-start" />
查询候选
</Button>
</div>
{validation != null && <p className="text-xs text-destructive">{validation}</p>}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在查询待合并变更集…
</div>
) : candidates == null ? (
<div className="flex h-32 items-center justify-center px-6 text-center text-sm text-muted-foreground">
输入目标路径并点击「查询候选」,查看可合并的变更集
</div>
) : candidates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
没有待合并的变更集,两个分支已同步
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map((entry) => (
<TableRow
key={entry.changeset}
className={cn(
"cursor-pointer select-none",
selected === entry.changeset && "bg-primary/10 hover:bg-primary/10",
)}
onClick={() =>
setSelected((prev) => (prev === entry.changeset ? null : entry.changeset))
}
>
<TableCell className="py-1.5 font-mono text-xs">{entry.changeset}</TableCell>
<TableCell className="py-1.5 text-xs">{entry.changeType}</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(entry.date)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
{entry.comment || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
<p className="shrink-0 text-xs text-muted-foreground">
合并只产生挂起更改,不会直接写入服务器;出现冲突会进入冲突处理弹窗,签入前可撤销。
</p>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="outline"
disabled={!targetReady || busy || candidates == null || candidates.length === 0 || selected == null}
onClick={() => handleMerge(selected ?? undefined)}
>
合并所选({selected != null ? `C${selected}` : "未选择"})
</Button>
<Button
disabled={!targetReady || busy || candidates == null || candidates.length === 0}
onClick={() => handleMerge(undefined)}
>
{busy ? "正在合并…" : "全部合并"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,186 @@
import { useEffect, useState } from "react"
import { Check, Copy, Loader2 } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { api } from "~/lib/api"
import type { ItemInfo } from "~/lib/api"
import type { FileTarget } from "~/lib/tfs"
import { statusBadgeClass, statusLabel } from "~/lib/tfs"
import { formatDateTime } from "~/lib/utils"
/**
* 把字节数格式化为可读大小(B / KB / MB)。
*/
function formatSize(size: number | null): string {
if (size == null) {
return "—"
}
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / 1024 / 1024).toFixed(2)} MB`
}
/**
* 对象属性弹窗:展示服务器路径、本地路径、映射与挂起状态,
* 以及服务器侧最新版本信息(changeset / 时间 / 提交人),文件附带大小与编码。
*/
export function PropertiesDialog({
target,
onClose,
}: {
target: FileTarget
onClose: () => void
}) {
const [info, setInfo] = useState<ItemInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.getItemInfo({ serverPath: target.serverPath })
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setError(result.errorMessage ?? "属性加载失败")
return
}
setError(null)
setInfo(result.data.item)
})()
return () => {
active = false
}
}, [target.serverPath])
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>属性</DialogTitle>
<DialogDescription className="font-mono text-xs">
{target.serverPath}
</DialogDescription>
</DialogHeader>
<div className="space-y-1 text-sm">
<PropertyRow label="对象类型" value={target.folder ? "目录" : "文件"} />
<PropertyRow label="服务器路径" value={target.serverPath} mono copyable />
<PropertyRow
label="本地路径"
value={target.localPath ?? "未映射"}
mono={target.localPath != null}
copyable={target.localPath != null}
muted={target.localPath == null}
/>
<PropertyRow
label="映射状态"
value={
<Badge variant="secondary" className={statusBadgeClass(target.mapped ? "mapped" : "notMapped")}>
{target.mapped ? (target.mappingRoot ? "映射根目录" : "已映射") : "未映射"}
</Badge>
}
/>
<PropertyRow
label="挂起状态"
value={
target.pendingStatus ? (
<Badge variant="secondary" className={statusBadgeClass(target.pendingStatus)}>
{statusLabel(target.pendingStatus)}
</Badge>
) : (
"无挂起更改"
)
}
muted={target.pendingStatus == null}
/>
<div className="my-2 border-t" />
{loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载服务器信息…
</div>
) : error ? (
<div className="flex h-24 items-center justify-center text-sm text-destructive">{error}</div>
) : info ? (
<>
<PropertyRow label="最新 Changeset" value={`C${info.changeset}`} mono />
<PropertyRow label="签入时间" value={formatDateTime(info.checkinDate)} />
<PropertyRow label="签入人" value={info.author || "—"} />
<PropertyRow label="签入备注" value={info.comment || "—"} muted={!info.comment} />
{!info.folder && (
<>
<PropertyRow label="文件大小" value={formatSize(info.size)} />
<PropertyRow label="编码" value={info.encoding ?? "—"} />
</>
)}
</>
) : null}
</div>
</DialogContent>
</Dialog>
)
}
/**
* 单行属性:左侧标签 + 右侧值,可选等宽字体与复制按钮。
*/
function PropertyRow({
label,
value,
mono,
copyable,
muted,
}: {
label: string
value: React.ReactNode
mono?: boolean
copyable?: boolean
muted?: boolean
}) {
const [copied, setCopied] = useState(false)
return (
<div className="flex items-start gap-3 py-0.5">
<span className="w-24 shrink-0 pt-0.5 text-xs text-muted-foreground">{label}</span>
<span
className={`min-w-0 flex-1 break-all ${mono ? "font-mono text-xs leading-5" : "text-sm"} ${muted ? "text-muted-foreground" : ""}`}
>
{value}
</span>
{copyable && typeof value === "string" && (
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
title="复制"
onClick={() => {
void navigator.clipboard.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}}
>
{copied ? <Check className="text-green-600" /> : <Copy />}
</Button>
)}
</div>
)
}
@@ -0,0 +1,113 @@
import { useMemo, useState } from "react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
// Windows / TFS 路径名非法字符。
const INVALID_NAME_PATTERN = /[\\/:*?"<>|]/
/**
* 重命名弹窗:同目录改名,校验非空、非法字符与同名;
* 确认后产生 rename 挂起更改(由编排层调用接口并刷新)。
*/
export function RenameDialog({
serverPath,
folder,
onConfirm,
onClose,
}: {
serverPath: string
folder: boolean
/** 确认重命名,由编排层执行 API 与刷新,返回是否成功。 */
onConfirm: (newName: string) => Promise<boolean>
onClose: () => void
}) {
const currentName = useMemo(
() => serverPath.slice(serverPath.lastIndexOf("/") + 1),
[serverPath],
)
const [name, setName] = useState(currentName)
const [busy, setBusy] = useState(false)
const trimmed = name.trim()
const validation =
trimmed.length === 0
? "名称不能为空"
: INVALID_NAME_PATTERN.test(trimmed)
? '名称不能包含 / \\ : * ? " < > | 字符'
: trimmed === currentName
? "新名称与当前名称相同"
: null
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名{folder ? "目录" : "文件"}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
onFocus={(event) => {
// 文件默认选中主名部分,便于直接输入新名称。
const dot = folder ? -1 : currentName.lastIndexOf(".")
event.target.setSelectionRange(0, dot > 0 ? dot : currentName.length)
}}
onKeyDown={(event) => {
if (event.key === "Enter" && validation == null && !busy) {
event.preventDefault()
void (async () => {
setBusy(true)
const ok = await onConfirm(trimmed)
setBusy(false)
if (ok) {
onClose()
}
})()
}
}}
/>
{validation != null && trimmed !== currentName && (
<p className="text-xs text-destructive">{validation}</p>
)}
<p className="text-xs text-muted-foreground">
重命名会产生挂起更改,签入后服务器才会生效;签入前可在挂起更改面板撤销。
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
disabled={validation != null || busy}
onClick={() => {
void (async () => {
setBusy(true)
const ok = await onConfirm(trimmed)
setBusy(false)
if (ok) {
onClose()
}
})()
}}
>
{busy ? "正在重命名…" : "重命名"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronRight, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api"
import type { MappingInfo, ServerItem } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import {
SERVER_ROOT_PATH,
getAncestorPaths,
makeFileTarget,
normalizeServerPath,
resolveLocalPath,
} from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
* 与中间列表共享当前路径;节点支持与中间列表同规则的右键菜单。
*/
export function SourceTreePanel({
collection,
mappings,
selectedServerPath,
onNavigate,
onFileAction,
}: {
collection: string
mappings: MappingInfo[]
selectedServerPath: string
onNavigate: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
// path -> 子目录列表(仅 folder),undefined 表示尚未加载。
const [childrenByPath, setChildrenByPath] = useState<Record<string, ServerItem[]>>({})
const [expanded, setExpanded] = useState<Record<string, boolean>>({
[SERVER_ROOT_PATH]: true,
})
const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(null)
// 手动刷新令牌:递增时清空缓存并重新加载。
const [reloadToken, setReloadToken] = useState(0)
// 已加载与加载中的路径集合,避免异步流程里读到过期 state。
const requestedRef = useRef<Set<string>>(new Set())
/**
* 懒加载指定目录的子目录列表,已加载或加载中的路径直接跳过。
*/
const ensureChildren = useCallback(
async (serverPath: string) => {
const target = normalizeServerPath(serverPath)
if (requestedRef.current.has(target)) {
return
}
requestedRef.current.add(target)
setLoadingPaths((prev) => ({ ...prev, [target]: true }))
const result = await api.getServerTree({ path: target, collection })
setLoadingPaths((prev) => ({ ...prev, [target]: false }))
if (!result.ok) {
requestedRef.current.delete(target)
setError(result.errorMessage ?? "目录加载失败")
return
}
setError(null)
const folders = (result.data?.items ?? []).filter((item) => item.folder)
setChildrenByPath((prev) => ({ ...prev, [target]: folders }))
},
[collection],
)
// 当前路径变化或手动刷新时,逐级加载并展开祖先链与自身。
useEffect(() => {
let active = true
void (async () => {
const chain = [...getAncestorPaths(selectedServerPath), normalizeServerPath(selectedServerPath)]
for (const path of chain) {
if (!active) {
return
}
await ensureChildren(path)
setExpanded((prev) => (prev[path] ? prev : { ...prev, [path]: true }))
}
})()
return () => {
active = false
}
}, [selectedServerPath, ensureChildren, reloadToken])
/**
* 手动刷新目录树:清空懒加载缓存并从根重新加载,保持当前选中路径的展开链。
*/
const handleRefresh = useCallback(() => {
requestedRef.current.clear()
setChildrenByPath({})
setExpanded({ [SERVER_ROOT_PATH]: true })
setError(null)
setReloadToken((token) => token + 1)
}, [])
/**
* 切换节点展开 / 收起,首次展开时触发懒加载。
*/
const toggleExpand = useCallback(
(serverPath: string) => {
setExpanded((prev) => {
const next = !prev[serverPath]
if (next) {
void ensureChildren(serverPath)
}
return { ...prev, [serverPath]: next }
})
},
[ensureChildren],
)
/**
* 点击节点名称:选中并导航到该目录,同时确保其展开。
*/
const handleSelect = useCallback(
(serverPath: string) => {
onNavigate(serverPath)
setExpanded((prev) => (prev[serverPath] ? prev : { ...prev, [serverPath]: true }))
void ensureChildren(serverPath)
},
[onNavigate, ensureChildren],
)
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<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>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={handleRefresh}
aria-label="刷新目录树"
title="刷新目录树"
>
<RefreshCw />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
{error && (
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
)}
<TreeNode
path={SERVER_ROOT_PATH}
name={SERVER_ROOT_PATH}
depth={0}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={normalizeServerPath(selectedServerPath)}
onToggle={toggleExpand}
onSelect={handleSelect}
onFileAction={onFileAction}
/>
</div>
</ScrollArea>
</aside>
)
}
/**
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
*/
function TreeNode({
path,
name,
depth,
mappings,
childrenByPath,
expanded,
loadingPaths,
selectedServerPath,
onToggle,
onSelect,
onFileAction,
}: {
path: string
name: string
depth: number
mappings: MappingInfo[]
childrenByPath: Record<string, ServerItem[]>
expanded: Record<string, boolean>
loadingPaths: Record<string, boolean>
selectedServerPath: string
onToggle: (serverPath: string) => void
onSelect: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const children = childrenByPath[path]
const isExpanded = expanded[path] === true
const isLoading = loadingPaths[path] === true
const isSelected = selectedServerPath === path
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
const isLeaf = children != null && children.length === 0
const isMapped = resolveLocalPath(mappings, path) != null
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
return (
<div>
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
)}
style={{ paddingLeft: `${depth * 14 + 2}px` }}
>
<span className="flex size-5 shrink-0 items-center justify-center">
{isLoading ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isLeaf ? null : (
<button
type="button"
aria-label={isExpanded ? "收起目录" : "展开目录"}
onClick={() => onToggle(path)}
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200 ease-out-quart",
isExpanded && "rotate-90",
)}
/>
</button>
)}
</span>
<button
type="button"
onClick={() => onSelect(path)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
<span
className={cn(
"truncate text-sm",
isSelected && "font-medium text-foreground",
)}
>
{name}
</span>
</button>
</div>
</FileTargetMenu>
{isExpanded && children != null && children.length > 0 && (
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
{children.map((child) => (
<TreeNode
key={child.serverPath}
path={child.serverPath}
name={child.name}
depth={depth + 1}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={selectedServerPath}
onToggle={onToggle}
onSelect={onSelect}
onFileAction={onFileAction}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useState } from "react"
import { FolderSearch, Loader2, Pencil, Plus, Trash2 } from "lucide-react"
import { ConfirmDialog } from "~/components/app/confirm-dialog"
import { MappingDialog } from "~/components/explorer/mapping-dialog"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { CheckMappingTargetResult, MappingInfo, WorkspaceInfo } from "~/lib/api"
import { isElectron, pathsExist, selectDirectory } from "~/lib/electron"
import type { WorkspaceSession } from "~/lib/tfs"
/**
* 工作区集中管理弹窗:展示 Workspace 信息与全部 Mapping,
* 支持新增、删除与修改本地路径(删旧 + 加新),对齐 VS 的 Edit Workspace。
*/
export function WorkspaceManageDialog({
session,
onClose,
onMappingsChanged,
}: {
session: WorkspaceSession
onClose: () => void
/** Mapping 集合变化(新增 / 删除 / 修改)后回调上层刷新。 */
onMappingsChanged: (mappings: MappingInfo[]) => void
}) {
const [info, setInfo] = useState<WorkspaceInfo | null>(null)
// 各 Mapping 本地目录存在性(null 表示无法检测,如非 Electron 环境)。
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
const [error, setError] = useState<string | null>(null)
// 新增映射:输入服务端路径后打开既有 MappingDialog。
const [addServerPath, setAddServerPath] = useState("")
const [addOpen, setAddOpen] = useState(false)
const [editMapping, setEditMapping] = useState<MappingInfo | null>(null)
const [confirmDelete, setConfirmDelete] = useState<MappingInfo | null>(null)
// Workspace 详细信息(Owner / Computer)通过 ensure 接口幂等获取。
useEffect(() => {
let active = true
void (async () => {
const result = await api.ensureWorkspace({})
if (!active) {
return
}
if (result.ok && result.data) {
setInfo(result.data.workspace)
}
})()
return () => {
active = false
}
}, [])
// Mapping 集合变化时重新检测本地目录存在性。
useEffect(() => {
let active = true
void (async () => {
const result = await pathsExist(session.mappings.map((mapping) => mapping.localPath))
if (active) {
setExistsMap(result)
}
})()
return () => {
active = false
}
}, [session.mappings])
/**
* 删除 Mapping(仅解除映射,不删除本地文件),成功后回调上层刷新。
*/
const handleDelete = useCallback(
async (mapping: MappingInfo) => {
const result = await api.deleteMapping({ serverPath: mapping.serverPath })
if (!result.ok) {
setError(result.errorMessage ?? "删除 Mapping 失败")
return
}
setError(null)
onMappingsChanged(result.data?.mappings ?? [])
},
[onMappingsChanged],
)
const trimmedAddPath = addServerPath.trim()
const addPathValid = trimmedAddPath.startsWith("$/") && trimmedAddPath.length > 2
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>工作区管理</DialogTitle>
<DialogDescription>查看 Workspace 信息,集中管理全部本地映射</DialogDescription>
</DialogHeader>
<div className="grid shrink-0 grid-cols-2 gap-x-6 gap-y-1 rounded-md border bg-muted/30 px-3 py-2 text-xs">
<InfoRow label="Workspace" value={session.workspace} mono />
<InfoRow label="服务器" value={session.serverUri} mono />
<InfoRow label="Collection" value={session.collection} />
<InfoRow label="Owner" value={info?.ownerName ?? "…"} />
<InfoRow label="Computer" value={info?.computer ?? "…"} />
<InfoRow label="Mapping 数量" value={String(session.mappings.length)} />
</div>
<div className="flex shrink-0 items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor="addServerPath" className="text-xs">
新增映射的服务端路径
</Label>
<Input
id="addServerPath"
value={addServerPath}
onChange={(event) => setAddServerPath(event.target.value)}
placeholder="$/Project/Folder"
className="h-8 font-mono text-xs"
/>
</div>
<Button
size="sm"
disabled={!addPathValid}
onClick={() => setAddOpen(true)}
title={addPathValid ? undefined : "请输入以 $/ 开头的服务端路径"}
>
<Plus data-icon="inline-start" />
新增映射…
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{session.mappings.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
当前 Workspace 还没有任何映射
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">本地目录</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground" />
</TableRow>
</TableHeader>
<TableBody>
{session.mappings.map((mapping) => {
const exists = existsMap?.[mapping.localPath]
return (
<TableRow key={mapping.serverPath} className="cursor-default select-none">
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={mapping.serverPath}
>
{mapping.serverPath}
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={mapping.localPath}
>
{mapping.localPath}
</TableCell>
<TableCell className="py-1.5">
{existsMap == null ? (
<span className="text-xs text-muted-foreground">无法检测</span>
) : exists ? (
<Badge variant="secondary" className="bg-muted text-foreground/80">
存在
</Badge>
) : (
<Badge
variant="secondary"
className="bg-red-500/10 text-red-700 dark:text-red-400"
>
不存在
</Badge>
)}
</TableCell>
<TableCell className="py-1.5">
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
title="修改本地路径…"
onClick={() => setEditMapping(mapping)}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:text-destructive"
title="删除映射…"
onClick={() => setConfirmDelete(mapping)}
>
<Trash2 />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
<p className="shrink-0 text-xs text-muted-foreground">
删除映射只解除服务端目录与本地目录的关联,不会删除本地已下载的文件。
</p>
{addOpen && (
<MappingDialog
serverPath={trimmedAddPath}
onClose={() => setAddOpen(false)}
onCreated={(mappings) => {
onMappingsChanged(mappings)
setAddOpen(false)
setAddServerPath("")
}}
/>
)}
{editMapping && (
<EditMappingDialog
mapping={editMapping}
onClose={() => setEditMapping(null)}
onChanged={(mappings) => {
onMappingsChanged(mappings)
setEditMapping(null)
}}
/>
)}
{confirmDelete && (
<ConfirmDialog
title="删除映射"
description={
<>
<p>
将解除 <span className="font-mono text-xs">{confirmDelete.serverPath}</span>{" "}
与本地目录的映射。
</p>
<p className="mt-2 text-muted-foreground">
本地已下载的文件不会被删除;该目录下的挂起更改将无法继续签入。
</p>
</>
}
confirmLabel="删除映射"
danger
onConfirm={async () => {
await handleDelete(confirmDelete)
setConfirmDelete(null)
}}
onClose={() => setConfirmDelete(null)}
/>
)}
</DialogContent>
</Dialog>
)
}
/**
* 工作区信息单行展示。
*/
function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex min-w-0 items-center gap-2">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`} title={value}>
{value}
</span>
</div>
)
}
/**
* 修改 Mapping 本地路径弹窗:选择新的本地父目录并预校验,
* 确认后按「删旧 + 加新」方式调整映射(不迁移本地内容)。
*/
function EditMappingDialog({
mapping,
onClose,
onChanged,
}: {
mapping: MappingInfo
onClose: () => void
onChanged: (mappings: MappingInfo[]) => void
}) {
const [parentPath, setParentPath] = useState("")
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
const [checking, setChecking] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* 通过系统目录选择器选择新的本地父目录。
*/
const handleBrowse = useCallback(async () => {
const selected = await selectDirectory()
if (selected) {
setParentPath(selected)
}
}, [])
// 与创建映射一致:父目录变化时由后端预校验最终目标路径。
useEffect(() => {
const trimmed = parentPath.trim()
if (trimmed.length === 0) {
setCheck(null)
return
}
let active = true
setChecking(true)
const timer = setTimeout(() => {
void (async () => {
const result = await api.checkMappingTarget({
serverPath: mapping.serverPath,
localParentPath: trimmed,
})
if (!active) {
return
}
setChecking(false)
if (!result.ok || !result.data) {
setCheck(null)
setError(result.errorMessage ?? "目标路径校验失败")
return
}
setError(null)
setCheck(result.data)
})()
}, 300)
return () => {
active = false
clearTimeout(timer)
}
}, [parentPath, mapping.serverPath])
const exists = check?.exists === true
const canConfirm = check != null && !exists && !busy && !checking
/**
* 删旧 + 加新调整映射本地路径,成功后回调最新 mappings。
*/
const handleConfirm = useCallback(async () => {
if (!check) {
return
}
setBusy(true)
setError(null)
const removed = await api.deleteMapping({ serverPath: mapping.serverPath })
if (!removed.ok) {
setBusy(false)
setError(removed.errorMessage ?? "删除原映射失败")
return
}
const added = await api.addMapping({
serverPath: mapping.serverPath,
localPath: check.targetPath,
getLatest: false,
})
setBusy(false)
if (!added.ok) {
setError(`${added.errorMessage ?? "创建新映射失败"}(原映射已删除,请重新映射)`)
return
}
const mappings = await api.listMappings()
onChanged(mappings.data?.mappings ?? [])
}, [check, mapping.serverPath, onChanged])
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>修改本地路径</DialogTitle>
<DialogDescription className="font-mono text-xs">{mapping.serverPath}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>当前本地路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{mapping.localPath}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="newParentPath">新的本地父目录</Label>
<div className="flex gap-2">
<Input
id="newParentPath"
placeholder="/Users/me/tfs"
value={parentPath}
onChange={(event) => setParentPath(event.target.value)}
disabled={busy}
className="font-mono text-xs"
/>
{isElectron() && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => void handleBrowse()}
disabled={busy}
aria-label="浏览本地目录"
>
<FolderSearch />
</Button>
)}
</div>
</div>
{checking && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
正在校验目标路径…
</p>
)}
{check && !checking && (
<div className="space-y-1.5">
<Label>新映射路径</Label>
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
{check.targetPath}
</p>
{exists && (
<p className="text-xs font-medium text-destructive">目标目录已存在,禁止映射</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
修改方式为「删除原映射 + 创建新映射」:原本地目录内容保留但不再与服务端关联,
新目录需重新执行获取最新。
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
{busy ? "正在修改…" : "修改本地路径"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,259 @@
import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import { ScrollArea } from "~/components/ui/scroll-area"
import { Textarea } from "~/components/ui/textarea"
import type { MappingInfo, PendingChange } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。
*/
export function ChangesPanel({
mappings,
pendingChanges,
excludedKeys,
loading,
error,
checkinBusy,
onToggleExcluded,
onCheckin,
onFileAction,
onRefresh,
}: {
mappings: MappingInfo[]
pendingChanges: PendingChange[]
excludedKeys: Set<string>
loading: boolean
error: string | null
checkinBusy: boolean
onToggleExcluded: (serverPath: string) => void
onCheckin: (paths: string[], comment: string) => Promise<boolean>
onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void
}) {
const [comment, setComment] = useState("")
const included = useMemo(
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const excluded = useMemo(
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
/**
* 提交签入:只提交 Included 项,成功后清空注释。
*/
const handleCheckin = useCallback(async () => {
const success = await onCheckin(
included.map((change) => change.serverPath),
comment.trim(),
)
if (success) {
setComment("")
}
}, [onCheckin, included, comment])
return (
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
挂起更改
</span>
{pendingChanges.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
)}
<Button
variant="ghost"
size="icon-xs"
className={pendingChanges.length > 0 ? "" : "ml-auto"}
onClick={onRefresh}
disabled={loading}
aria-label="刷新挂起更改"
title="刷新挂起更改"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在加载挂起更改…
</div>
) : error ? (
<div className="flex flex-1 items-center justify-center p-4 text-xs text-destructive">
{error}
</div>
) : pendingChanges.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex flex-col items-center gap-2 text-center">
<ListChecks className="size-6 text-muted-foreground/60" />
<p className="text-xs leading-5 text-muted-foreground">当前没有挂起更改</p>
</div>
</div>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
<GroupHeader label="Included" count={included.length} />
{included.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
没有要签入的更改,勾选下方 Excluded 项可移回
</p>
)}
{included.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
{excluded.length > 0 && (
<>
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
{excluded.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included={false}
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
</>
)}
</div>
</ScrollArea>
)}
<div className="shrink-0 space-y-2 border-t p-3">
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="签入注释(必填)"
rows={3}
disabled={checkinBusy || pendingChanges.length === 0}
className="resize-none text-sm"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
将签入 {included.length} 项
</span>
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
{checkinBusy ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
正在签入…
</>
) : (
"签入"
)}
</Button>
</div>
{comment.trim().length === 0 && pendingChanges.length > 0 && (
<p className="text-xs text-muted-foreground">填写注释后才能签入</p>
)}
</div>
</aside>
)
}
/**
* 分组标题行。
*/
function GroupHeader({
label,
count,
className,
}: {
label: string
count: number
className?: string
}) {
return (
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}>
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-xs text-muted-foreground/70">{count}</span>
</div>
)
}
/**
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。
*/
function PendingRow({
change,
included,
mappings,
onToggleExcluded,
onFileAction,
}: {
change: PendingChange
included: boolean
mappings: MappingInfo[]
onToggleExcluded: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const target = makeFileTarget({
source: "pending",
folder: change.folder,
serverPath: change.serverPath,
mappings,
pendingStatus: change.status,
})
return (
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted",
!included && "opacity-60",
)}
title={change.serverPath}
>
<Checkbox
checked={included}
onCheckedChange={() => onToggleExcluded(change.serverPath)}
aria-label={included ? "移到 Excluded" : "移回 Included"}
/>
{change.folder ? (
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{change.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
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>
</div>
</FileTargetMenu>
)
}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react"
import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api"
import type { OperationLogEntry } from "~/lib/api"
import { cn, formatDateTime } from "~/lib/utils"
/**
* 把毫秒耗时格式化为可读文本。
*/
function formatDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs} ms`
}
return `${(durationMs / 1000).toFixed(1)} s`
}
/**
* 底部 Operation Console:展示最近操作日志(操作、摘要、起止时间、耗时、结果),
* 随主要操作完成自动刷新,长操作执行期间显示执行中提示。
*/
export function ConsolePanel({
refreshToken,
busy,
busyText,
}: {
refreshToken: number
busy: boolean
busyText: string | null
}) {
const [logs, setLogs] = useState<OperationLogEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* 拉取最近操作日志。
*/
const loadLogs = useCallback(async () => {
setLoading(true)
const result = await api.getLogs()
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "操作日志加载失败")
return
}
setError(null)
setLogs(result.data?.logs ?? [])
}, [])
useEffect(() => {
void loadLogs()
}, [loadLogs, refreshToken])
// 最新的操作排在最上面。
const ordered = useMemo(
() => [...logs].sort((a, b) => b.startedAt - a.startedAt),
[logs],
)
return (
<footer className="flex h-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">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
操作日志
</span>
{busy && (
<span className="flex items-center gap-1.5 text-xs text-primary">
<Loader2 className="size-3 animate-spin" />
{busyText ?? "正在执行…"}
</span>
)}
<span className="ml-auto text-xs text-muted-foreground">
{ordered.length > 0 && `${ordered.length} 条`}
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void loadLogs()}
disabled={loading}
aria-label="刷新日志"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
{error ? (
<p className="px-3 py-2 text-xs text-destructive">{error}</p>
) : ordered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{loading ? "正在加载操作日志…" : "暂无操作日志"}
</p>
) : (
<ul className="px-2 py-1">
{ordered.map((entry, index) => (
<li
key={`${entry.startedAt}-${entry.operation}-${index}`}
className="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}
>
{entry.success ? (
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
) : (
<XCircle className="size-3.5 shrink-0 text-destructive" />
)}
<span className="w-36 shrink-0 truncate font-mono text-muted-foreground">
{entry.operation}
</span>
<span className="min-w-0 flex-1 truncate">
{entry.summary}
{!entry.success && entry.errorMessage && (
<span className="text-destructive"> {entry.errorMessage}</span>
)}
</span>
<span className="w-32 shrink-0 text-right text-muted-foreground">
{formatDateTime(entry.startedAt)}
</span>
<span className="w-16 shrink-0 text-right font-mono text-muted-foreground">
{formatDuration(entry.durationMs)}
</span>
</li>
))}
</ul>
)}
</ScrollArea>
</footer>
)
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+3 -3
View File
@@ -5,15 +5,15 @@ import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
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: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+261
View File
@@ -0,0 +1,261 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+166
View File
@@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/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
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"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
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-semibold tracking-tight",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "~/lib/utils"
/**
* 基础文本输入框,统一 macOS 工作台风格的尺寸、边框与聚焦态。
*/
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
className,
)}
{...props}
/>
)
}
export { Input }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
/**
* 表单字段标签,保持轻量并禁止选中,贴合工具型界面。
*/
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-1 text-sm font-medium text-foreground select-none",
className,
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+192
View File
@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react"
import { AlertCircle, CheckCircle2, Loader2 } 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 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
const KIND_VISUALS: Record<
ToastKind,
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
> = {
success: {
icon: CheckCircle2,
iconClass: "text-emerald-600 dark:text-emerald-400",
barClass: "bg-emerald-500",
borderClass: "border-emerald-500/35",
},
error: {
icon: AlertCircle,
iconClass: "text-destructive",
barClass: "bg-destructive",
borderClass: "border-destructive/40",
},
loading: {
icon: Loader2,
iconClass: "animate-spin text-primary",
barClass: "bg-primary",
borderClass: "border-primary/35",
},
}
/**
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
*/
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-3 z-[60] flex w-[380px] flex-col gap-2">
{items.map((item) => {
const visual = KIND_VISUALS[item.kind]
const Icon = visual.icon
return (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className={cn(
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
visual.borderClass,
)}
>
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
<span className="min-w-0 break-words">{item.text}</span>
</button>
)
})}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+562
View File
@@ -0,0 +1,562 @@
import { useCallback, useState } from "react"
import type { DiffRequest } from "~/components/explorer/diff-dialog"
import { api } from "~/lib/api"
import type { MappingInfo } from "~/lib/api"
import { isElectron, revealPath } from "~/lib/electron"
import type { FileActionId, FileTarget } from "~/lib/tfs"
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认 / 属性 / 获取特定版本。
export type WorkspaceDialogState =
| { kind: "mapping"; serverPath: string }
| { kind: "history"; serverPath: string; folder: boolean }
| { kind: "compare"; serverPath: string }
| { kind: "viewFile"; serverPath: string; localPath: string | null; changeset?: number }
| { kind: "diff"; request: DiffRequest }
| { kind: "conflicts"; serverPath: string }
| { kind: "confirmForceGet"; serverPath: string; folder: boolean }
| { kind: "properties"; target: FileTarget }
| { kind: "getVersion"; serverPath: string; folder: boolean }
| { kind: "rename"; serverPath: string; folder: boolean }
| { kind: "branch"; serverPath: string }
| { kind: "merge"; serverPath: string }
| { kind: "addFiles"; serverPath: string }
| null
// 顶部细条通知:信息(操作摘要)或错误。
export interface ActionNotice {
kind: "info" | "error"
text: string
}
// 动作编排所需的上层依赖:mappings 更新与各类刷新入口。
interface FileActionsDeps {
onMappingsChanged: (mappings: MappingInfo[]) => void
refreshPendingChanges: () => Promise<void>
refreshItems: () => void
refreshLogs: () => void
}
/**
* 对象动作编排 hook:集中持有弹窗开关、通知与忙碌状态,
* 把右键菜单动作映射为 API 执行或弹窗打开,并按统一规则刷新(含操作日志)。
*/
export function useFileActions({
onMappingsChanged,
refreshPendingChanges,
refreshItems,
refreshLogs,
}: FileActionsDeps) {
const [dialog, setDialog] = useState<WorkspaceDialogState>(null)
const [notice, setNotice] = useState<ActionNotice | null>(null)
const [actionBusy, setActionBusy] = useState(false)
const [checkinBusy, setCheckinBusy] = useState(false)
/**
* 执行 Get Latest(目录递归),返回是否产生冲突;摘要写入顶部通知。
* 默认安全模式:本地改动不会被覆盖而是产生冲突并自动进入冲突弹窗;
* force=true 为强制覆盖本地(仅经确认弹窗触发)。
*/
const runGetLatest = useCallback(
async (target: FileTarget, force = false): Promise<boolean> => {
const result = await api.getLatest({
serverPath: target.serverPath,
recursive: target.folder,
force,
})
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "获取最新失败" })
return false
}
const summary = result.data.result
const parts = [`更新 ${summary.updated} 项`]
if (summary.conflicts > 0) {
parts.push(`冲突 ${summary.conflicts} 项`)
}
if (summary.failures > 0) {
parts.push(`跳过 / 失败 ${summary.failures} 项`)
}
setNotice({
kind: summary.conflicts > 0 ? "error" : "info",
text: `${force ? "强制获取" : "获取最新"}完成:${parts.join(",")}`,
})
refreshItems()
if (summary.conflicts > 0) {
setDialog({ kind: "conflicts", serverPath: target.serverPath })
return true
}
return false
},
[refreshItems, refreshLogs],
)
/**
* 获取指定 changeset 版本(覆盖本地),由历史弹窗中的确认操作触发。
*/
const runGetVersion = useCallback(
async (serverPath: string, changeset: number, folder: boolean): Promise<boolean> => {
setNotice({ kind: "info", text: `正在获取版本 C${changeset}…` })
const result = await api.getVersion({ serverPath, changeset, recursive: folder })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "获取指定版本失败" })
return false
}
const summary = result.data.result
setNotice({
kind: "info",
text: `已获取版本 C${changeset}:更新 ${summary.updated} 项(本地已被该版本覆盖)`,
})
refreshItems()
await refreshPendingChanges()
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 执行变更集回滚(产生挂起更改,不直接入库),由历史弹窗中的确认操作触发;
* 出现冲突时进入统一冲突弹窗,完成后刷新挂起更改与目录列表。
*/
const runRollback = useCallback(
async (
serverPath: string,
mode: "single" | "toVersion",
changeset: number,
): Promise<boolean> => {
setNotice({
kind: "info",
text: mode === "single" ? `正在回滚变更集 C${changeset}…` : `正在回滚到 C${changeset}…`,
})
const result = await api.rollback({ serverPath, mode, changeset })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "回滚失败" })
return false
}
const summary = result.data.result
const parts = [`产生挂起更改 ${summary.operations} 项`]
if (summary.conflicts > 0) {
parts.push(`冲突 ${summary.conflicts} 项`)
}
if (summary.failures > 0) {
parts.push(`失败 ${summary.failures} 项`)
}
setNotice({
kind: summary.conflicts > 0 || summary.failures > 0 ? "error" : "info",
text: `回滚完成:${parts.join(",")},请在挂起更改面板审查后签入`,
})
await refreshPendingChanges()
refreshItems()
if (summary.conflicts > 0) {
setDialog({ kind: "conflicts", serverPath })
}
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 执行 Checkout:先 Get Latest,出现冲突时进入统一冲突弹窗;
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
*/
const runCheckout = useCallback(
async (target: FileTarget) => {
const hadConflicts = await runGetLatest(target)
if (hadConflicts) {
setNotice({ kind: "error", text: "获取最新存在冲突,请先处理冲突后再签出" })
return
}
const result = await api.checkout({
paths: [target.serverPath],
recursive: target.folder,
})
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "签出失败" })
return
}
const summary = result.data.result
const failureText =
summary.failures.length > 0
? `,跳过 ${summary.failures.length} 项(${summary.failures.slice(0, 3).join(";")}${summary.failures.length > 3 ? "…" : ""})`
: ""
setNotice({
kind: summary.failures.length > 0 ? "error" : "info",
text: `签出完成:${summary.affected} 项${failureText}`,
})
await refreshPendingChanges()
},
[runGetLatest, refreshPendingChanges, refreshLogs],
)
/**
* 执行 挂起删除 / 撤销更改 两类文件操作并按规则刷新。
*/
const runFileOperation = useCallback(
async (target: FileTarget, action: "delete" | "undo") => {
const labels =
action === "delete"
? { doing: "正在挂起删除…", fail: "挂起删除失败", done: "已挂起删除" }
: { doing: "正在撤销更改…", fail: "撤销更改失败", done: "已撤销" }
setNotice({ kind: "info", text: labels.doing })
const call = action === "delete" ? api.deleteFiles : api.undoFiles
const result = await call({ paths: [target.serverPath], recursive: target.folder })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? labels.fail })
return
}
setNotice({ kind: "info", text: `${labels.done} ${result.data.result.affected} 项` })
await refreshPendingChanges()
refreshItems()
},
[refreshPendingChanges, refreshItems, refreshLogs],
)
/**
* 对象右键菜单动作统一分发器。
* unmap / getLatest / checkout / delete / undo 直接执行;
* map / history / compare / viewFile / diff 打开对应弹窗。
*/
const handleFileAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
setNotice(null)
switch (action) {
case "unmap": {
const result = await api.deleteMapping({ serverPath: target.serverPath })
refreshLogs()
if (!result.ok) {
setNotice({ kind: "error", text: result.errorMessage ?? "取消映射失败" })
return
}
// 不跳转当前浏览位置,仅更新 mappings 让动作可用性与状态列刷新。
onMappingsChanged(result.data?.mappings ?? [])
break
}
case "getLatest":
setActionBusy(true)
setNotice({ kind: "info", text: "正在获取最新…" })
await runGetLatest(target)
setActionBusy(false)
break
case "forceGetLatest":
// 覆盖类危险操作:先弹确认,确认后才执行。
setDialog({
kind: "confirmForceGet",
serverPath: target.serverPath,
folder: target.folder,
})
break
case "getSpecificVersion":
// 覆盖类危险操作:弹窗内选择 changeset 并确认后执行。
setDialog({
kind: "getVersion",
serverPath: target.serverPath,
folder: target.folder,
})
break
case "properties":
setDialog({ kind: "properties", target })
break
case "rename":
setDialog({ kind: "rename", serverPath: target.serverPath, folder: target.folder })
break
case "branch":
setDialog({ kind: "branch", serverPath: target.serverPath })
break
case "merge":
setDialog({ kind: "merge", serverPath: target.serverPath })
break
case "revealInFinder": {
if (!target.localPath) {
return
}
if (!isElectron()) {
setNotice({ kind: "error", text: "「在访达中打开」仅桌面应用支持" })
return
}
const revealed = await revealPath(target.localPath, target.folder)
if (!revealed) {
setNotice({ kind: "error", text: "本地路径不存在,请先获取最新" })
}
break
}
case "addLocalFiles":
// 扫描式添加:弹窗内目录对比筛出本地新增项,勾选后批量 pend add。
if (!target.mapped) {
return
}
setDialog({ kind: "addFiles", serverPath: target.serverPath })
break
case "checkout":
setActionBusy(true)
setNotice({ kind: "info", text: "正在签出…" })
await runCheckout(target)
setActionBusy(false)
break
case "delete":
case "undo":
setActionBusy(true)
await runFileOperation(target, action)
setActionBusy(false)
break
case "add": {
// pend add 需要本地路径(localOnly 项服务端尚不存在)。
if (!target.localPath) {
return
}
setActionBusy(true)
setNotice({ kind: "info", text: "正在加入版本控制…" })
const result = await api.addFiles({ paths: [target.localPath], recursive: target.folder })
refreshLogs()
setActionBusy(false)
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "加入版本控制失败" })
return
}
setNotice({ kind: "info", text: `已加入版本控制 ${result.data.result.affected} 项` })
await refreshPendingChanges()
refreshItems()
break
}
case "map":
setDialog({ kind: "mapping", serverPath: target.serverPath })
break
case "history":
setDialog({ kind: "history", serverPath: target.serverPath, folder: target.folder })
break
case "compare":
setDialog({ kind: "compare", serverPath: target.serverPath })
break
case "viewFile":
setDialog({
kind: "viewFile",
serverPath: target.serverPath,
localPath: target.localPath,
})
break
case "diffLocalLatest":
if (target.localPath) {
setDialog({
kind: "diff",
request: {
mode: "localLatest",
serverPath: target.serverPath,
localPath: target.localPath,
},
})
}
break
default:
break
}
},
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs, refreshItems, refreshPendingChanges],
)
/**
* 提交签入:只提交 Included 项,成功后展示 changeset 并刷新挂起更改与当前目录。
*/
const handleCheckin = useCallback(
async (paths: string[], comment: string): Promise<boolean> => {
setCheckinBusy(true)
setNotice({ kind: "info", text: "正在签入…" })
const result = await api.checkin({ paths, comment })
setCheckinBusy(false)
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "签入失败" })
return false
}
const summary = result.data.checkin
setNotice({
kind: "info",
text: `签入成功:changeset ${summary.changeset},提交 ${summary.submittedChanges} 项`,
})
await refreshPendingChanges()
refreshItems()
return true
},
[refreshPendingChanges, refreshItems, refreshLogs],
)
/**
* Mapping 创建成功:刷新 mappings 与当前目录列表,保持浏览位置并关闭弹窗。
*/
const handleMappingCreated = useCallback(
(mappings: MappingInfo[]) => {
onMappingsChanged(mappings)
refreshItems()
setDialog(null)
},
[onMappingsChanged, refreshItems],
)
/**
* 冲突取舍完成:关闭弹窗并刷新当前目录与挂起更改。
*/
const handleConflictsResolved = useCallback(() => {
setDialog(null)
refreshItems()
void refreshPendingChanges()
refreshLogs()
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
}, [refreshItems, refreshPendingChanges, refreshLogs])
/**
* 重命名确认后执行:产生 rename 挂起更改并刷新目录列表与挂起更改。
*/
const handleRenameConfirmed = useCallback(
async (serverPath: string, newName: string): Promise<boolean> => {
setNotice({ kind: "info", text: "正在重命名…" })
const result = await api.renameFile({ serverPath, newName })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "重命名失败" })
return false
}
setNotice({
kind: "info",
text: `已挂起重命名为「${newName}」,签入后服务器生效`,
})
await refreshPendingChanges()
refreshItems()
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 分支确认后执行:产生 branch 挂起更改并刷新挂起更改与目录列表。
*/
const handleBranchConfirmed = useCallback(
async (
sourceServerPath: string,
targetServerPath: string,
changeset: number | undefined,
): Promise<boolean> => {
setNotice({ kind: "info", text: "正在创建分支…" })
const result = await api.branch({ sourceServerPath, targetServerPath, changeset })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "创建分支失败" })
return false
}
setNotice({
kind: "info",
text: `已挂起分支 ${result.data.result.affected} 项(${targetServerPath}),请在挂起更改面板审查后签入`,
})
await refreshPendingChanges()
refreshItems()
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 合并确认后执行:产生挂起更改并刷新,冲突自动进入冲突弹窗(针对目标路径)。
*/
const handleMergeConfirmed = useCallback(
async (
sourceServerPath: string,
targetServerPath: string,
changeset: number | undefined,
): Promise<boolean> => {
setNotice({
kind: "info",
text: changeset == null ? "正在合并全部候选变更集…" : `正在合并变更集 C${changeset}…`,
})
const result = await api.merge({ sourceServerPath, targetServerPath, changeset })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "合并失败" })
return false
}
const summary = result.data.result
const parts = [`产生挂起更改 ${summary.operations} 项`]
if (summary.conflicts > 0) {
parts.push(`冲突 ${summary.conflicts} 项`)
}
if (summary.failures > 0) {
parts.push(`失败 ${summary.failures} 项`)
}
setNotice({
kind: summary.conflicts > 0 || summary.failures > 0 ? "error" : "info",
text: `合并完成:${parts.join(",")},请在挂起更改面板审查后签入`,
})
await refreshPendingChanges()
refreshItems()
if (summary.conflicts > 0) {
setDialog({ kind: "conflicts", serverPath: targetServerPath })
}
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 添加本地文件确认后执行:批量 pend add 并刷新挂起更改与目录列表。
*/
const handleAddFilesConfirmed = useCallback(
async (localPaths: string[]): Promise<boolean> => {
setNotice({ kind: "info", text: "正在添加到版本控制…" })
const result = await api.addFiles({ paths: localPaths })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "添加失败" })
return false
}
setNotice({
kind: "info",
text: `已加入版本控制 ${result.data.result.affected} 项,请在挂起更改面板审查后签入`,
})
await refreshPendingChanges()
refreshItems()
return true
},
[refreshItems, refreshLogs, refreshPendingChanges],
)
/**
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
*/
const handleForceGetConfirmed = useCallback(
async (serverPath: string, folder: boolean) => {
setActionBusy(true)
const target: FileTarget = {
source: "list",
folder,
serverPath,
localPath: null,
mapped: true,
mappingRoot: false,
pendingStatus: null,
}
await runGetLatest(target, true)
setActionBusy(false)
setDialog(null)
},
[runGetLatest],
)
return {
dialog,
setDialog,
notice,
actionBusy,
checkinBusy,
handleFileAction,
handleCheckin,
handleMappingCreated,
handleConflictsResolved,
handleForceGetConfirmed,
handleRenameConfirmed,
handleBranchConfirmed,
handleMergeConfirmed,
handleAddFilesConfirmed,
runGetVersion,
runRollback,
}
}
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { api } from "~/lib/api"
import type { PendingChange } from "~/lib/api"
/**
* 挂起更改共享状态:列表加载 / 刷新、Included-Excluded 维护,
* 以及供菜单可用性判断的 serverPath -> 状态 映射。
*/
export function usePendingChanges() {
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Excluded 的挂起更改 serverPath 集合(不持久化),其余默认 Included。
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set())
/**
* 刷新当前 Workspace 的挂起更改列表,并清理悬挂的 Excluded 键。
*/
const refresh = useCallback(async () => {
setLoading(true)
const result = await api.getPendingChanges()
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "挂起更改加载失败")
return
}
setError(null)
const list = result.data?.pendingChanges ?? []
setPendingChanges(list)
setExcludedKeys((prev) => {
const valid = new Set(list.map((change) => change.serverPath))
const next = new Set([...prev].filter((key) => valid.has(key)))
return next.size === prev.size ? prev : next
})
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/**
* 切换某条挂起更改的 Included / Excluded 归属。
*/
const toggleExcluded = useCallback((serverPath: string) => {
setExcludedKeys((prev) => {
const next = new Set(prev)
if (next.has(serverPath)) {
next.delete(serverPath)
} else {
next.add(serverPath)
}
return next
})
}, [])
// serverPath -> 挂起状态,供文件列表生成菜单时控制可用性。
const pendingByServerPath = useMemo(() => {
const map: Record<string, string> = {}
for (const change of pendingChanges) {
map[change.serverPath] = change.status
}
return map
}, [pendingChanges])
return {
pendingChanges,
loading,
error,
excludedKeys,
pendingByServerPath,
refresh,
toggleExcluded,
}
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react"
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
export type ThemePreference = "system" | "light" | "dark"
const STORAGE_KEY = "mactfs.theme"
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
/**
* 当前系统外观是否为暗色。
*/
function systemPrefersDark(): boolean {
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
}
/**
* 由偏好推导是否应启用暗色。
*/
function resolveIsDark(preference: ThemePreference): boolean {
return preference === "dark" || (preference === "system" && systemPrefersDark())
}
/**
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
*/
function loadPreference(): ThemePreference {
if (typeof window === "undefined") {
return "system"
}
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw === "light" || raw === "dark" ? raw : "system"
}
/**
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
*/
function applyTheme(preference: ThemePreference) {
const root = document.documentElement
root.classList.add("theme-transition")
root.classList.toggle("dark", resolveIsDark(preference))
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
}
/**
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
* 跟随系统时监听系统外观变化自动切换。
*/
export function useTheme() {
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
useEffect(() => {
applyTheme(preference)
if (preference !== "system") {
return
}
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = () => applyTheme("system")
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [preference])
/**
* 设置主题偏好并持久化。
*/
const setPreference = useCallback((next: ThemePreference) => {
setPreferenceState(next)
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
// 本地存储不可用时仅在当前会话生效
}
}, [])
/**
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
*/
const cycle = useCallback(() => {
const index = CYCLE_ORDER.indexOf(preference)
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
}, [preference, setPreference])
return { preference, setPreference, cycle }
}
+117
View File
@@ -0,0 +1,117 @@
import { getApiBaseUrl, getToken } from "~/lib/electron"
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
/**
* 拼接查询参数到请求路径,自动跳过空值。
*/
function buildUrl(
baseUrl: string,
path: string,
query?: ApiRequestOptions["query"],
): string {
const url = new URL(path, baseUrl)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value))
}
}
}
return url.toString()
}
/**
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
*/
function resolveErrorMessage(
result: ApiResult<unknown> | null,
fallback: string,
): string {
if (result) {
return result.errorMessage || result.message || fallback
}
return fallback
}
/**
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
*/
async function request<T = Record<string, unknown>>(
method: string,
path: string,
options: ApiRequestOptions = {},
): Promise<ApiResponse<T>> {
const baseUrl = await getApiBaseUrl()
const token = await getToken()
const headers: Record<string, string> = {
Accept: "application/json",
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const init: RequestInit = { method, headers, signal: options.signal }
if (options.body !== undefined) {
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
let response: Response
try {
response = await fetch(buildUrl(baseUrl, path, options.query), init)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
status: 0,
result: null,
data: null,
errorMessage: `无法连接本地服务:${message}`,
}
}
let result: ApiResult<T> | null = null
try {
result = (await response.json()) as ApiResult<T>
} catch {
result = null
}
const ok = response.ok && result?.success === true
return {
ok,
status: response.status,
result,
data: result?.data ?? null,
errorMessage: ok
? null
: resolveErrorMessage(result, `请求失败(HTTP ${response.status})`),
}
}
// 统一 API 客户端,组件只通过本对象访问后端,不直接 fetch、不直接读 token。
export const apiClient = {
/**
* 发起 GET 请求。
*/
get<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("GET", path, options)
},
/**
* 发起 POST 请求。
*/
post<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("POST", path, options)
},
/**
* 发起 PUT 请求。
*/
put<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("PUT", path, options)
},
/**
* 发起 DELETE 请求。
*/
delete<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("DELETE", path, options)
},
}
+286
View File
@@ -0,0 +1,286 @@
import { apiClient } from "./client"
import type {
AppConfig,
CheckMappingTargetResult,
CheckinResult,
Collection,
ConflictInfo,
ConflictResolution,
ConflictResolutionResult,
ConnectResult,
FileContent,
FileOperationResult,
FolderDiffItem,
GetLatestResult,
HistoryEntry,
ItemInfo,
MappingInfo,
OperationLogEntry,
PendingChange,
ServerItem,
TextDiff,
WorkspaceContext,
WorkspaceInfo,
} from "./types"
// 文件类操作(checkout / add / delete / undo)的统一请求体。
type FileActionBody = { paths: string[]; recursive?: boolean }
/**
* 读取服务端持久化配置。
*/
export function getConfig() {
return apiClient.get<{ config: AppConfig }>("/api/config")
}
/**
* 保存服务端持久化配置。
*/
export function saveConfig(config: AppConfig) {
return apiClient.put<{ config: AppConfig }>("/api/config", { body: config })
}
/**
* 使用给定连接信息连接 TFS,成功后服务端会保存配置并建立会话。
*/
export function connect(config: Partial<AppConfig> = {}) {
return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
}
/**
* 列出当前账号可见的 Collection。
*/
export function listCollections() {
return apiClient.get<{ collections: Collection[] }>("/api/collections")
}
/**
* 浏览服务端目录树节点。
*/
export function getServerTree(params: { path?: string; collection?: string } = {}) {
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-tree", { query: params })
}
/**
* 读取当前目录下一级文件与文件夹列表。
*/
export function getFolderItems(params: { path?: string; collection?: string } = {}) {
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-folder/items", { query: params })
}
/**
* 读取当前 Workspace 名称与 Mapping 列表。
*/
export function getWorkspace() {
return apiClient.get<{ workspace: string; mappings: MappingInfo[] }>("/api/workspace")
}
/**
* 确保当前 Collection 的默认 Workspace 存在,必要时创建。
*/
export function ensureWorkspace(body: { collection?: string; workspace?: string; comment?: string } = {}) {
return apiClient.post<{ workspace: WorkspaceInfo }>("/api/workspace/ensure", { body })
}
/**
* 读取工作台固定上下文(serverUri / collection / workspace / mappings)。
*/
export function getWorkspaceContext() {
return apiClient.get<{ context: WorkspaceContext }>("/api/workspace/context")
}
/**
* 读取当前 Workspace 的 Mapping 列表。
*/
export function listMappings() {
return apiClient.get<{ mappings: MappingInfo[] }>("/api/mappings")
}
/**
* 预校验 Mapping 目标路径,返回最终本地目标路径及是否已存在。
*/
export function checkMappingTarget(body: { serverPath: string; localParentPath: string }) {
return apiClient.post<CheckMappingTargetResult>("/api/mappings/check-target", { body })
}
/**
* 创建 Mapping,可选立即 Get Latest。
*/
export function addMapping(body: { serverPath: string; localPath: string; getLatest?: boolean }) {
return apiClient.post<{ mapping: MappingInfo; getLatest?: GetLatestResult }>("/api/mappings", { body })
}
/**
* 删除 Mapping,仅解除映射不删除本地文件。
*/
export function deleteMapping(body: { serverPath?: string; localPath?: string }) {
return apiClient.delete<{ mappings: MappingInfo[] }>("/api/mappings", { body })
}
/**
* 获取指定服务端路径的最新文件。
* force=false(默认)为安全模式,本地改动会产生冲突而不被覆盖;force=true 强制覆盖本地。
*/
export function getLatest(body: { serverPath?: string; recursive?: boolean; force?: boolean } = {}) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-latest", { body })
}
/**
* 获取指定 changeset 版本并覆盖本地(对应 VS 的 Get Specific Version + Overwrite)。
*/
export function getVersion(body: { serverPath: string; changeset: number; recursive?: boolean }) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-version", { body })
}
/**
* 对已存在文件或目录执行 checkout。
*/
export function checkout(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/checkout", { body })
}
/**
* 将本地新增文件加入 pending add。
*/
export function addFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/add", { body })
}
/**
* 对已版本控制文件或目录执行 pending delete。
*/
export function deleteFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/delete", { body })
}
/**
* 撤销指定路径上的挂起更改。
*/
export function undoFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
}
/**
* 重命名文件或目录(同目录改名),产生 rename 挂起更改。
*/
export function renameFile(body: { serverPath: string; newName: string }) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/rename", { body })
}
/**
* 回滚变更集(只产生挂起更改):single 仅反做该 changeset,toVersion 反做其后全部改动。
*/
export function rollback(body: { serverPath: string; mode: "single" | "toVersion"; changeset: number }) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/rollback", { body })
}
/**
* 分支:把源路径分叉到目标路径(产生 branch 挂起更改),changeset 缺省为 latest。
*/
export function branch(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/branch", { body })
}
/**
* 查询源 → 目标的待合并变更集候选列表。
*/
export function mergeCandidates(params: { sourceServerPath: string; targetServerPath: string }) {
return apiClient.get<{ candidates: HistoryEntry[] }>("/api/files/merge-candidates", { query: params })
}
/**
* 合并:源 → 目标(产生挂起更改,冲突走冲突处理),changeset 缺省为合并全部候选。
*/
export function merge(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/merge", { body })
}
/**
* 读取当前 Workspace 的挂起更改。
*/
export function getPendingChanges(params: { serverPath?: string } = {}) {
return apiClient.get<{ pendingChanges: PendingChange[] }>("/api/pending-changes", { query: params })
}
/**
* 提交带注释的签入,paths 为空时签入全部挂起更改。
*/
export function checkin(body: { paths?: string[]; comment: string }) {
return apiClient.post<{ checkin: CheckinResult }>("/api/checkin", { body })
}
/**
* 读取服务端对象(文件或目录)的属性信息,供属性弹窗展示。
*/
export function getItemInfo(params: { serverPath: string }) {
return apiClient.get<{ item: ItemInfo }>("/api/items/info", { query: params })
}
/**
* 读取服务器内容(latest 或指定 changeset),或读取本地映射文件内容。
*/
export function getFileContent(params: { serverPath?: string; localPath?: string; changeset?: number }) {
return apiClient.get<{ content: FileContent }>("/api/files/content", { query: params })
}
/**
* 对已映射目录执行目录对比。
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
*/
export function compareFolder(body: {
serverPath: string
localPath?: string
recursive?: boolean
includeLocalOnly?: boolean
}) {
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
}
/**
* 查询文件或目录历史。
*/
export function getHistory(params: { path: string; folder?: boolean }) {
return apiClient.get<{ history: HistoryEntry[] }>("/api/history", { query: params })
}
/**
* 查询指定 changeset 影响的文件列表。
*/
export function getChangesetFiles(params: { changeset: number }) {
return apiClient.get<{ files: HistoryEntry[] }>("/api/history/changeset", { query: params })
}
/**
* 生成本地文件与服务器 latest 的文本 Diff。
*/
export function diffLocalLatest(body: { serverPath: string; localPath: string }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/local-latest", { body })
}
/**
* 生成同一服务端文件两个 changeset 之间的文本 Diff。
*/
export function diffRevisions(body: { serverPath: string; sourceChangeset: number; targetChangeset: number }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/revisions", { body })
}
/**
* 查询当前 Workspace 的冲突明细。
*/
export function listConflicts(params: { serverPath?: string; recursive?: boolean } = {}) {
return apiClient.get<{ conflicts: ConflictInfo[] }>("/api/conflicts", { query: params })
}
/**
* 对单个冲突应用取舍(采用服务器版本或保留本地版本)。
*/
export function applyConflict(body: { conflictId: number; resolution: ConflictResolution }) {
return apiClient.post<{ resolution: ConflictResolutionResult }>("/api/conflicts/apply", { body })
}
/**
* 读取最近操作日志。
*/
export function getLogs() {
return apiClient.get<{ logs: OperationLogEntry[] }>("/api/logs")
}
+29
View File
@@ -0,0 +1,29 @@
export type {
ApiRequestOptions,
ApiResponse,
ApiResult,
AppConfig,
CheckMappingTargetResult,
CheckinResult,
Collection,
ConflictInfo,
ConflictResolution,
ConflictResolutionResult,
ConnectResult,
FileContent,
FileOperationResult,
FolderDiffItem,
GetLatestResult,
HistoryEntry,
ItemInfo,
MappingConfig,
MappingInfo,
OperationLogEntry,
PendingChange,
ServerItem,
TextDiff,
WorkspaceContext,
WorkspaceInfo,
} from "./types"
export { apiClient } from "./client"
export * as api from "./endpoints"
+227
View File
@@ -0,0 +1,227 @@
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message?: string
errorMessage?: string
operation?: string
startedAt?: number
endedAt?: number
durationMs?: number
logs?: string[]
data: T
}
// API 客户端对外返回结构,统一封装网络层与业务层成功/失败。
export interface ApiResponse<T = Record<string, unknown>> {
ok: boolean
status: number
result: ApiResult<T> | null
data: T | null
errorMessage: string | null
}
// API 请求选项。
export interface ApiRequestOptions {
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
signal?: AbortSignal
}
// 单条 Mapping 配置,对应服务端 MappingConfig。
export interface MappingConfig {
serverPath: string
localPath: string
}
// 服务端持久化配置,对应 MacTfsServer.AppConfig。
export interface AppConfig {
serverUri?: string
authType?: string
domain?: string
username?: string
password?: string
collection?: string
workspace?: string
mappings: MappingConfig[]
}
// 连接结果摘要,对应 /api/session/connect 返回的概要字段。
export interface ConnectResult {
serverUri?: string
collectionCount?: number
}
// Collection 信息,对应 core TfsCollectionInfo。
export interface Collection {
name: string
id: string
}
// 服务端目录项,对应服务端 serverItems 序列化结构。
export interface ServerItem {
name: string
path: string
serverPath: string
type: "folder" | "file"
folder: boolean
latestVersion: number
checkinDate: number | null
}
// Workspace Mapping 信息,对应 core TfsMappingInfo。
export interface MappingInfo {
serverPath: string
localPath: string
}
// Workspace 概要,对应 core TfsWorkspaceInfo。
export interface WorkspaceInfo {
name: string
ownerName: string
computer: string
comment: string
created: boolean
mappings: MappingInfo[]
}
// 工作台固定上下文,对应 /api/workspace/context 的 context 字段。
export interface WorkspaceContext {
serverUri?: string
authType?: string
collection?: string
workspace?: string
connected: boolean
mappings: MappingInfo[]
}
// Mapping 目标预校验结果,对应 /api/mappings/check-target。
export interface CheckMappingTargetResult {
serverPath: string
localParentPath: string
name: string
targetPath: string
exists: boolean
allowed: boolean
}
// Get Latest 结果,对应 core TfsGetLatestResult。
export interface GetLatestResult {
updated: number
operations: number
conflicts: number
failures: number
}
// 文件操作结果,对应 core TfsFileOperationResult。
export interface FileOperationResult {
operation: string
affected: number
failures: string[]
}
// 挂起更改项,对应 core TfsPendingChangeInfo。
export interface PendingChange {
serverPath: string
localPath: string
name: string
folder: boolean
status: string
changeType: string
version: number
/** rename 挂起更改的原服务端路径,其余为 null。 */
sourceServerPath: string | null
}
// 签入结果,对应 core TfsCheckinResult。
export interface CheckinResult {
changeset: number
submittedChanges: number
}
// 目录对比项,对应 core TfsFolderDiffItem。
export interface FolderDiffItem {
serverPath: string
localPath: string
name: string
folder: boolean
status: string
localVersion: number
latestVersion: number
}
// 历史记录项,对应 core TfsHistoryEntry。
export interface HistoryEntry {
serverPath: string
name: string
changeType: string
itemType: string
changeset: number
author: string
date: number | null
comment: string
}
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
export interface ItemInfo {
serverPath: string
folder: boolean
changeset: number
checkinDate: number | null
author: string
comment: string
/** 文件大小(字节),目录为 null。 */
size: number | null
/** 文件编码名称,目录为 null。 */
encoding: string | null
}
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
export interface FileContent {
serverPath: string
changeset: number
content: string
binary: boolean
size: number
tooLarge: boolean
}
// 文本 Diff,对应 core TfsTextDiff。
export interface TextDiff {
sourceLabel: string
targetLabel: string
lines: string[]
}
// 冲突明细,对应 core TfsConflictInfo。
export interface ConflictInfo {
conflictId: number
type: string
serverPath: string | null
localPath: string | null
yourServerItem: string | null
theirServerItem: string | null
baseServerItem: string | null
resolved: boolean
}
// 第一版支持的冲突取舍:采用服务器版本或保留本地版本。
export type ConflictResolution = "takeServer" | "keepLocal"
// 冲突应用结果,对应 core TfsConflictResolution。
export interface ConflictResolutionResult {
conflictId: number
resolution: string
resolved: boolean
remainingConflicts: number
}
// 操作日志项,对应服务端 OperationLogEntry。
export interface OperationLogEntry {
operation: string
summary: string
startedAt: number
endedAt: number
durationMs: number
success: boolean
errorMessage: string | null
}
+110
View File
@@ -0,0 +1,110 @@
import type { MactfsBridge, ServiceStatus } from "./types"
const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
/**
* 获取 Electron 暴露的 window.mactfs 桥接对象,非 Electron 环境(含 SSR)返回 null。
*/
export function getBridge(): MactfsBridge | null {
if (typeof window === "undefined") {
return null
}
return window.mactfs ?? null
}
/**
* 判断当前是否运行在 Electron 桥接环境中。
*/
export function isElectron(): boolean {
return getBridge() != null
}
/**
* 获取本地 API 基础地址,非 Electron 环境回退到默认本地地址。
*/
export async function getApiBaseUrl(): Promise<string> {
const bridge = getBridge()
if (!bridge) {
return DEFAULT_BASE_URL
}
return bridge.getApiBaseUrl()
}
/**
* 获取本地服务 Bearer Token,非 Electron 环境返回 null。
*/
export async function getToken(): Promise<string | null> {
const bridge = getBridge()
if (!bridge) {
return null
}
return bridge.getToken()
}
/**
* 查询本地服务状态,非 Electron 环境返回未就绪状态并说明原因。
*/
export async function getServiceStatus(): Promise<ServiceStatus> {
const bridge = getBridge()
if (!bridge) {
return unavailableStatus()
}
return bridge.getServiceStatus()
}
/**
* 拉起并等待本地服务就绪,非 Electron 环境返回未就绪状态并说明原因。
*/
export async function startService(): Promise<ServiceStatus> {
const bridge = getBridge()
if (!bridge) {
return unavailableStatus()
}
return bridge.startService()
}
/**
* 打开系统目录选择器,非 Electron 环境返回 null。
*/
export async function selectDirectory(): Promise<string | null> {
const bridge = getBridge()
if (!bridge) {
return null
}
return bridge.selectDirectory()
}
/**
* 批量检测本地绝对路径是否存在,非 Electron 环境返回 null 表示无法检测。
*/
export async function pathsExist(paths: string[]): Promise<Record<string, boolean> | null> {
const bridge = getBridge()
if (!bridge || paths.length === 0) {
return bridge ? {} : null
}
return bridge.pathsExist(paths)
}
/**
* 在访达中打开本地路径(目录直接进入,文件定位选中),非 Electron 环境返回 false。
*/
export async function revealPath(targetPath: string, isFolder: boolean): Promise<boolean> {
const bridge = getBridge()
if (!bridge) {
return false
}
return bridge.revealPath(targetPath, isFolder)
}
/**
* 构造非 Electron 环境下的占位服务状态。
*/
function unavailableStatus(): ServiceStatus {
return {
running: false,
baseUrl: DEFAULT_BASE_URL,
tokenAvailable: false,
health: null,
error: "当前不在 Electron 桌面环境,无法访问本地服务",
}
}
+12
View File
@@ -0,0 +1,12 @@
export type { MactfsBridge, ServiceHealth, ServiceStatus } from "./types"
export {
getApiBaseUrl,
getBridge,
getServiceStatus,
getToken,
isElectron,
pathsExist,
revealPath,
selectDirectory,
startService,
} from "./bridge"
+36
View File
@@ -0,0 +1,36 @@
// 本地 API 服务健康信息,对应服务端 /api/health 的 data 字段。
export interface ServiceHealth {
status: string
host: string
port: number
tokenFile: string
configFile: string
connected: boolean
}
// 渲染进程感知的本地服务状态,由 Electron 主进程计算返回。
export interface ServiceStatus {
running: boolean
baseUrl: string
tokenAvailable: boolean
health: ServiceHealth | null
error: string | null
}
// preload 暴露到 window.mactfs 的窄接口契约。
export interface MactfsBridge {
isElectron: true
getApiBaseUrl(): Promise<string>
getToken(): Promise<string | null>
getServiceStatus(): Promise<ServiceStatus>
startService(): Promise<ServiceStatus>
selectDirectory(): Promise<string | null>
pathsExist(paths: string[]): Promise<Record<string, boolean>>
revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
}
declare global {
interface Window {
mactfs?: MactfsBridge
}
}
+103
View File
@@ -0,0 +1,103 @@
// Monaco Editor 客户端引导模块:注册各语言 worker 后导出 monaco 实例。
// 该模块体积大,只允许在浏览器端通过动态 import 加载(如 Diff 弹窗内)。
import * as monaco from "monaco-editor"
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
if (label === "json") {
return new JsonWorker()
}
if (label === "css" || label === "scss" || label === "less") {
return new CssWorker()
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new HtmlWorker()
}
if (label === "typescript" || label === "javascript") {
return new TsWorker()
}
return new EditorWorker()
},
}
// 自定义主题:贴合应用本身的中性灰底色与红绿差异配色,弱化 Monaco 默认的高饱和蓝。
monaco.editor.defineTheme("mactfs-light", {
base: "vs",
inherit: true,
rules: [],
colors: {
"editor.background": "#ffffff",
"editor.foreground": "#1f1f1f",
"editor.lineHighlightBackground": "#00000005",
"editorLineNumber.foreground": "#a3a3a3",
"editorLineNumber.activeForeground": "#525252",
"editorCursor.foreground": "#4f46e5",
"focusBorder": "#00000000",
"diffEditor.insertedTextBackground": "#22c55e2e",
"diffEditor.insertedLineBackground": "#22c55e14",
"diffEditor.removedTextBackground": "#ef44442e",
"diffEditor.removedLineBackground": "#ef444414",
"diffEditorGutter.insertedLineBackground": "#22c55e21",
"diffEditorGutter.removedLineBackground": "#ef444421",
"diffEditorOverview.insertedForeground": "#22c55e99",
"diffEditorOverview.removedForeground": "#ef444499",
"diffEditor.unchangedRegionBackground": "#fafafa",
"diffEditor.unchangedRegionForeground": "#737373",
"scrollbarSlider.background": "#0000001f",
"scrollbarSlider.hoverBackground": "#00000033",
"scrollbarSlider.activeBackground": "#00000040",
},
})
monaco.editor.defineTheme("mactfs-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#1b1b1b",
"editor.foreground": "#fafafa",
"editor.lineHighlightBackground": "#ffffff08",
"editorLineNumber.foreground": "#6b6b6b",
"editorLineNumber.activeForeground": "#a3a3a3",
"editorCursor.foreground": "#818cf8",
"focusBorder": "#00000000",
"diffEditor.insertedTextBackground": "#22c55e30",
"diffEditor.insertedLineBackground": "#22c55e17",
"diffEditor.removedTextBackground": "#ef444430",
"diffEditor.removedLineBackground": "#ef444417",
"diffEditorGutter.insertedLineBackground": "#22c55e24",
"diffEditorGutter.removedLineBackground": "#ef444424",
"diffEditorOverview.insertedForeground": "#22c55e99",
"diffEditorOverview.removedForeground": "#ef444499",
"diffEditor.unchangedRegionBackground": "#222222",
"diffEditor.unchangedRegionForeground": "#9c9c9c",
"scrollbarSlider.background": "#ffffff1f",
"scrollbarSlider.hoverBackground": "#ffffff33",
"scrollbarSlider.activeBackground": "#ffffff40",
},
})
/**
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
*/
export function detectLanguage(path: string): string {
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase()
const dot = name.lastIndexOf(".")
const ext = dot >= 0 ? name.slice(dot) : ""
if (ext.length === 0) {
return "plaintext"
}
for (const language of monaco.languages.getLanguages()) {
if (language.extensions?.some((item) => item.toLowerCase() === ext)) {
return language.id
}
}
return "plaintext"
}
export { monaco }
+11
View File
@@ -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")
}
+47
View File
@@ -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
}
+341
View File
@@ -0,0 +1,341 @@
// 对象动作统一模型:左侧树、中间列表、Pending Changes、目录对比结果
// 都转换成 FileTarget,再由 buildFileMenu 按状态生成同一套菜单与置灰规则。
import type { MappingInfo } from "~/lib/api"
import { resolveLocalPath } from "./mapping"
import { normalizeServerPath } from "./path"
// 对象来源:决定菜单里展示哪一组动作。
export type FileTargetSource = "tree" | "list" | "pending" | "compare"
// 统一的对象目标描述。
export interface FileTarget {
source: FileTargetSource
folder: boolean
serverPath: string
/** 依据 Mapping 推导出的本地路径,未映射为 null。 */
localPath: string | null
/** 是否处于某条 Mapping 子树内。 */
mapped: boolean
/** serverPath 本身是否就是一条 Mapping 的根。 */
mappingRoot: boolean
/** 挂起更改状态(pendingEdit / pendingAdd / ...),无挂起为 null。 */
pendingStatus: string | null
/** 目录对比结果状态(localOnly / remoteChanged / ...),非对比来源为 null。 */
compareStatus?: string | null
}
// 全部对象动作。
export type FileActionId =
| "map"
| "unmap"
| "getLatest"
| "forceGetLatest"
| "getSpecificVersion"
| "checkout"
| "rename"
| "delete"
| "undo"
| "add"
| "compare"
| "history"
| "viewFile"
| "diffLocalLatest"
| "branch"
| "merge"
| "revealInFinder"
| "addLocalFiles"
| "properties"
// 单个菜单项:是否可用与置灰原因。
export interface FileMenuItem {
id: FileActionId
label: string
enabled: boolean
danger?: boolean
/** 置灰原因,展示为菜单项后缀说明。 */
reason?: string
}
/**
* 由来源数据构造统一 FileTarget,本地路径与映射状态从 mappings 推导。
*/
export function makeFileTarget(input: {
source: FileTargetSource
folder: boolean
serverPath: string
mappings: MappingInfo[]
pendingStatus?: string | null
compareStatus?: string | null
}): FileTarget {
const serverPath = normalizeServerPath(input.serverPath)
const localPath = resolveLocalPath(input.mappings, serverPath)
const mappingRoot = input.mappings.some(
(mapping) => normalizeServerPath(mapping.serverPath) === serverPath,
)
return {
source: input.source,
folder: input.folder,
serverPath,
localPath,
mapped: localPath != null,
mappingRoot,
pendingStatus: input.pendingStatus ?? null,
compareStatus: input.compareStatus ?? null,
}
}
const NOT_MAPPED_REASON = "未映射到本地"
/**
* 按对象状态生成右键菜单分组:同一路径在树和列表中产出完全一致的动作与置灰规则。
* Checkin、全局设置等不进入对象菜单。
*/
export function buildFileMenu(target: FileTarget): FileMenuItem[][] {
// 目录对比中的 localOnly 项:服务端不存在,只保留本地查看与加入版本控制。
if (target.source === "compare" && target.compareStatus === "localOnly") {
return buildLocalOnlyMenu(target)
}
return target.folder ? buildFolderMenu(target) : buildFileItemMenu(target)
}
/**
* 仅本地存在(localOnly)的对比结果项菜单:加入版本控制 + 本地查看。
*/
function buildLocalOnlyMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
if (!target.folder) {
sections.push([{ id: "viewFile", label: "查看文件", enabled: true }])
}
sections.push([
{
id: "add",
label: "加入版本控制",
enabled: target.localPath != null,
reason: target.localPath != null ? undefined : "缺少本地路径",
},
])
return sections
}
/**
* 目录菜单:本地工作区动作(未映射置灰)+ Mapping 动作 + 服务端动作。
*/
function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
const workspaceActions: FileMenuItem[] = [
{
id: "getLatest",
label: "获取最新",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "forceGetLatest",
label: "强制获取最新…",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "checkout",
label: "签出编辑",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "rename",
label: "重命名…",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "delete",
label: "挂起删除",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "undo",
label: "撤销更改",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
]
sections.push(workspaceActions)
const mappingActions: FileMenuItem[] = []
if (!target.mapped) {
mappingActions.push({ id: "map", label: "映射到本地", enabled: true })
}
if (target.mappingRoot) {
mappingActions.push({ id: "unmap", label: "取消映射", enabled: true, danger: true })
}
if (mappingActions.length > 0) {
sections.push(mappingActions)
}
sections.push([
{
id: "revealInFinder",
label: "在访达中打开",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "addLocalFiles",
label: "添加本地文件…",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
])
sections.push([
{
id: "compare",
label: "目录对比",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{ id: "history", label: "查看历史", enabled: true },
{ id: "branch", label: "分支…", enabled: true },
{ id: "merge", label: "合并…", enabled: true },
])
sections.push(buildAdvancedSection(target))
return sections
}
/**
* 高级动作分组:获取特定版本(按 changeset 覆盖本地)与属性弹窗。
*/
function buildAdvancedSection(target: FileTarget): FileMenuItem[] {
return [
{
id: "getSpecificVersion",
label: "获取特定版本…",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{ id: "properties", label: "属性…", enabled: true },
]
}
/**
* 文件菜单:查看 / 比较 / 历史 + 本地工作区动作,按挂起状态控制可用性。
* pendingAdd 文件不展示服务器比较与历史入口;pendingEdit 禁止直接 Get Latest 覆盖。
*/
function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
const pending = target.pendingStatus
const isPendingAdd = pending === "pendingAdd"
const viewActions: FileMenuItem[] = [
{ id: "viewFile", label: "查看文件", enabled: true },
]
if (!isPendingAdd) {
viewActions.push({
id: "diffLocalLatest",
label: "与最新版本比较",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
})
viewActions.push({ id: "history", label: "查看历史", enabled: true })
}
viewActions.push({
id: "revealInFinder",
label: "在访达中显示",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
})
sections.push(viewActions)
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
if (target.source === "pending") {
sections.push([
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
])
// pendingAdd 项服务端尚不存在,属性查询无意义。
if (!isPendingAdd) {
sections.push([{ id: "properties", label: "属性…", enabled: true }])
}
return sections
}
const workspaceActions: FileMenuItem[] = [
{
// 安全模式获取:本地改动会产生冲突而不会被覆盖,挂起状态下也允许执行。
id: "getLatest",
label: "获取最新",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "forceGetLatest",
label: "强制获取最新…",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "checkout",
label: "签出编辑",
enabled: target.mapped && pending == null,
reason: !target.mapped
? NOT_MAPPED_REASON
: pending === "pendingEdit"
? "已签出"
: pending != null
? "存在挂起更改"
: undefined,
},
{
id: "rename",
label: "重命名…",
enabled: target.mapped && pending !== "pendingAdd" && pending !== "pendingDelete" && pending !== "pendingRename",
reason: !target.mapped
? NOT_MAPPED_REASON
: pending === "pendingAdd"
? "挂起新增,签入后才能重命名"
: pending === "pendingDelete"
? "已挂起删除"
: pending === "pendingRename"
? "已挂起重命名"
: undefined,
},
{
id: "delete",
label: "挂起删除",
enabled: target.mapped && pending !== "pendingDelete",
danger: true,
reason: !target.mapped
? NOT_MAPPED_REASON
: pending === "pendingDelete"
? "已挂起删除"
: undefined,
},
{
id: "undo",
label: "撤销更改",
enabled: pending != null,
reason: pending == null ? "没有挂起更改" : undefined,
},
]
sections.push(workspaceActions)
// pendingAdd 文件服务端尚不存在,不提供分支、合并、属性与获取特定版本。
if (!isPendingAdd) {
sections.push([
{ id: "branch", label: "分支…", enabled: true },
{ id: "merge", label: "合并…", enabled: true },
])
sections.push(buildAdvancedSection(target))
}
return sections
}
+24
View File
@@ -0,0 +1,24 @@
export { SERVER_ROOT_PATH } from "./session"
export type { WorkspaceSession } from "./session"
export {
getAncestorPaths,
getParentPath,
isSameOrUnderPath,
normalizeServerPath,
} from "./path"
export { findMapping, resolveLocalPath } from "./mapping"
export {
STATUS_BADGE_CLASSES,
STATUS_LABELS,
resolveItemLocalState,
statusBadgeClass,
statusLabel,
} from "./status"
export type { ItemLocalState } from "./status"
export { buildFileMenu, makeFileTarget } from "./actions"
export type {
FileActionId,
FileMenuItem,
FileTarget,
FileTargetSource,
} from "./actions"
+45
View File
@@ -0,0 +1,45 @@
import type { MappingInfo } from "~/lib/api"
import { isSameOrUnderPath, normalizeServerPath } from "./path"
/**
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
*/
export function findMapping(
mappings: MappingInfo[],
serverPath: string,
): MappingInfo | null {
const target = normalizeServerPath(serverPath)
let matched: MappingInfo | null = null
for (const mapping of mappings) {
if (isSameOrUnderPath(mapping.serverPath, target)) {
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
matched = mapping
}
}
}
return matched
}
/**
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
*/
export function resolveLocalPath(
mappings: MappingInfo[],
serverPath: string,
): string | null {
const target = normalizeServerPath(serverPath)
const mapping = findMapping(mappings, target)
if (!mapping) {
return null
}
const base = normalizeServerPath(mapping.serverPath)
if (target === base) {
return mapping.localPath
}
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
const localBase = mapping.localPath.endsWith("/")
? mapping.localPath.slice(0, -1)
: mapping.localPath
return `${localBase}/${suffix}`
}
+52
View File
@@ -0,0 +1,52 @@
import { SERVER_ROOT_PATH } from "./session"
/**
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
*/
export function normalizeServerPath(serverPath: string): string {
let result = serverPath.trim()
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
result = result.slice(0, -1)
}
return result.length === 0 ? SERVER_ROOT_PATH : result
}
/**
* 取服务端路径的父路径,根路径返回自身。
*/
export function getParentPath(serverPath: string): string {
const normalized = normalizeServerPath(serverPath)
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
return SERVER_ROOT_PATH
}
const index = normalized.lastIndexOf("/")
const parent = normalized.slice(0, index)
return parent === "$" ? SERVER_ROOT_PATH : parent
}
/**
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
*/
export function getAncestorPaths(serverPath: string): string[] {
const normalized = normalizeServerPath(serverPath)
const ancestors: string[] = []
let current = normalized
while (current !== SERVER_ROOT_PATH) {
current = getParentPath(current)
ancestors.unshift(current)
}
return ancestors
}
/**
* 判断 childPath 是否等于 basePath 或位于其子树内。
*/
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
const base = normalizeServerPath(basePath)
const child = normalizeServerPath(childPath)
if (base === child) {
return true
}
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
return child.startsWith(prefix)
}
+12
View File
@@ -0,0 +1,12 @@
import type { MappingInfo } from "~/lib/api"
// 服务端目录树根路径,工作台导航的默认起点。
export const SERVER_ROOT_PATH = "$/"
// 工作台进入后固定的共享上下文,serverUri / collection / workspace 不再在台内切换。
export interface WorkspaceSession {
serverUri: string
collection: string
workspace: string
mappings: MappingInfo[]
}
+73
View File
@@ -0,0 +1,73 @@
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
// 业务状态对应的中文文案。
export const STATUS_LABELS: Record<string, string> = {
localModified: "本地已修改",
remoteChanged: "服务器已更新",
bothChanged: "两端都有修改",
localOnly: "仅本地存在",
remoteOnly: "仅服务器存在",
notDownloaded: "未下载",
localDeleted: "本地已删除",
pendingEdit: "签出编辑",
pendingAdd: "挂起新增",
pendingDelete: "挂起删除",
pendingRename: "挂起重命名",
pending: "挂起更改",
upToDate: "最新",
notMapped: "未映射",
mapped: "已映射",
}
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
export const STATUS_BADGE_CLASSES: Record<string, string> = {
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
upToDate: "bg-muted text-muted-foreground",
notMapped: "bg-muted text-muted-foreground",
mapped: "bg-muted text-foreground/80",
}
/**
* 取状态的中文文案,未知状态原样返回。
*/
export function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status
}
/**
* 取状态 badge 的配色 class,未知状态用中性灰。
*/
export function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
}
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
/**
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
*/
export function resolveItemLocalState(
mapped: boolean,
localExists: boolean | null,
): ItemLocalState {
if (!mapped) {
return "notMapped"
}
if (localExists === false) {
return "notDownloaded"
}
return "mapped"
}
+12
View File
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 把毫秒时间戳格式化为 `YYYY-MM-DD HH:mm`,空值返回占位符。
*/
export function formatDateTime(value: number | null | undefined): string {
if (value == null || value <= 0) {
return "—"
}
const date = new Date(value)
const pad = (input: number) => String(input).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+6
View File
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏前标记 vibrancy 环境并应用主题偏好,避免暗色闪白 */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy");var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
}}
/>
<Meta />
<Links />
</head>
+112 -9
View File
@@ -1,15 +1,118 @@
import { Button } from "~/components/ui/button"
import { useCallback, useEffect, useState } from "react"
import { ConnectView } from "~/components/app/connect-view"
import { WorkspaceShell } from "~/components/app/workspace-shell"
import { Button } from "~/components/ui/button"
import { getServiceStatus, isElectron, startService } from "~/lib/electron"
import type { ServiceStatus } from "~/lib/electron"
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
import type { WorkspaceSession } from "~/lib/tfs/session"
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
/**
* 页面标题(同时作为 Electron 窗口标题)。
*/
export function meta() {
return [{ title: "MacTFS" }]
}
/**
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
*/
export default function Home() {
const [phase, setPhase] = useState<AppPhase>("checking")
const [status, setStatus] = useState<ServiceStatus | null>(null)
const [session, setSession] = useState<WorkspaceSession | null>(null)
const [selectedServerPath, setSelectedServerPath] = useState(SERVER_ROOT_PATH)
/**
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
*/
const bootstrap = useCallback(async () => {
setPhase("checking")
let next = await getServiceStatus()
if (!next.running && isElectron()) {
next = await startService()
}
setStatus(next)
setPhase(next.running ? "connect" : "blocked")
}, [])
useEffect(() => {
void bootstrap()
}, [bootstrap])
/**
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
*/
const handleReady = useCallback((next: WorkspaceSession) => {
setSession(next)
setSelectedServerPath(SERVER_ROOT_PATH)
setPhase("workspace")
}, [])
/**
* 从工作台返回连接入口,重新走连接流程。
*/
const handleReconnect = useCallback(() => {
setSession(null)
setPhase("connect")
}, [])
/**
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
*/
const handleNavigate = useCallback((serverPath: string) => {
setSelectedServerPath(serverPath)
}, [])
/**
* Mapping 创建 / 取消后更新固定上下文中的映射列表,浏览位置保持不变。
*/
const handleMappingsChanged = useCallback((mappings: WorkspaceSession["mappings"]) => {
setSession((prev) => (prev ? { ...prev, mappings } : prev))
}, [])
if (phase === "workspace" && session) {
return (
<WorkspaceShell
session={session}
selectedServerPath={selectedServerPath}
onNavigate={handleNavigate}
onMappingsChanged={handleMappingsChanged}
onReconnect={handleReconnect}
/>
)
}
if (phase === "connect") {
return <ConnectView onReady={handleReady} />
}
return (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
</div>
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
<h1 className="text-base font-semibold">MacTFS</h1>
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
{phase === "checking" && (
<p className="mt-4 text-sm text-muted-foreground">正在检查本地服务…</p>
)}
{phase === "blocked" && (
<div className="mt-4 space-y-3 text-sm">
<p className="font-medium text-destructive">本地服务未就绪</p>
<p className="text-muted-foreground">
{status?.error ?? "无法连接本地 API 服务"}
</p>
<Button size="sm" onClick={() => void bootstrap()}>
重试连接
</Button>
</div>
)}
</div>
</div>
)
+327 -3
View File
@@ -1,13 +1,321 @@
const { app, BrowserWindow } = require("electron")
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, nativeTheme } = require("electron")
const path = require("node:path")
const fs = require("node:fs")
const os = require("node:os")
const http = require("node:http")
const { spawn } = require("node:child_process")
const API_HOST = "127.0.0.1"
const API_PORT = 38765
const API_BASE_URL = `http://${API_HOST}:${API_PORT}`
const SERVER_MAIN_CLASS = "com.mydev.mactfs.server.MacTfsServer"
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
const HEALTH_TIMEOUT_MS = 2000
const START_POLL_TIMES = 30
const START_POLL_INTERVAL_MS = 1000
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
let serverProcess = null
/**
* 计算项目根目录,main.cjs 位于 mactfsui/electron 下,上溯两级即为单仓库根目录。
*/
function resolveProjectRoot() {
return path.resolve(__dirname, "..", "..")
}
/**
* 解析运行服务端使用的 Java 可执行文件,优先项目内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
*/
function resolveJavaBin() {
const projectRoot = resolveProjectRoot()
const bundled = path.join(
projectRoot,
"zulu8.94.0.17-ca-jdk8.0.492-macosx_x64",
"Contents",
"Home",
"bin",
"java",
)
if (fs.existsSync(bundled)) {
return bundled
}
if (process.env.JAVA_HOME) {
const fromHome = path.join(process.env.JAVA_HOME, "bin", "java")
if (fs.existsSync(fromHome)) {
return fromHome
}
}
return "java"
}
/**
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
*/
function resolveServerClasspath() {
const installLib = path.join(
resolveProjectRoot(),
"mactfs",
"build",
"install",
"mactfs",
"lib",
)
if (fs.existsSync(installLib)) {
return path.join(installLib, "*")
}
return null
}
/**
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
*/
function resolveNativeDirectory() {
const projectRoot = resolveProjectRoot()
const installNative = path.join(
projectRoot,
"mactfs",
"build",
"install",
"mactfs",
"lib",
"native",
)
if (fs.existsSync(installNative)) {
return installNative
}
const libNative = path.join(projectRoot, "tfsIntegration", "lib", "native")
if (fs.existsSync(libNative)) {
return libNative
}
return null
}
/**
* 读取本地 Bearer Token,渲染进程不直接访问 token 文件,统一由主进程提供。
*/
function readToken() {
try {
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
return value.length > 0 ? value : null
} catch (error) {
return null
}
}
/**
* 延时辅助函数,用于轮询等待服务端就绪。
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 调用 /api/health 检查本地服务是否就绪,返回结构化服务状态。
*/
function checkHealth() {
return new Promise((resolve) => {
const token = readToken()
const base = {
running: false,
baseUrl: API_BASE_URL,
tokenAvailable: token != null,
health: null,
error: null,
}
if (!token) {
resolve({ ...base, error: "未找到本地服务 token,服务可能尚未启动" })
return
}
const request = http.request(
`${API_BASE_URL}/api/health`,
{
method: "GET",
headers: { Authorization: `Bearer ${token}` },
timeout: HEALTH_TIMEOUT_MS,
},
(response) => {
let raw = ""
response.on("data", (chunk) => {
raw += chunk
})
response.on("end", () => {
if (response.statusCode !== 200) {
resolve({ ...base, error: `服务返回状态码 ${response.statusCode}` })
return
}
try {
const payload = JSON.parse(raw)
resolve({
...base,
running: payload.success === true,
health: payload.data || null,
})
} catch (error) {
resolve({ ...base, error: "服务健康检查响应解析失败" })
}
})
},
)
request.on("timeout", () => {
request.destroy()
resolve({ ...base, error: "服务健康检查超时" })
})
request.on("error", (error) => {
resolve({ ...base, error: `无法连接本地服务:${error.message}` })
})
request.end()
})
}
/**
* 按本地开发约定拉起 mactfs-server,并轮询等待服务就绪。
*/
async function startService() {
const current = await checkHealth()
if (current.running) {
return current
}
const classpath = resolveServerClasspath()
if (!classpath) {
return {
...current,
error:
"未找到服务端构建产物,请先在 mactfs 目录执行 ../tfsIntegration/gradlew installDist",
}
}
if (!serverProcess || serverProcess.exitCode !== null) {
const args = ["-cp", classpath]
const nativeDirectory = resolveNativeDirectory()
if (nativeDirectory) {
args.push(`-Dcom.microsoft.tfs.jni.native.base-directory=${nativeDirectory}`)
}
args.push(SERVER_MAIN_CLASS)
serverProcess = spawn(resolveJavaBin(), args, {
cwd: path.join(resolveProjectRoot(), "mactfs"),
stdio: "ignore",
})
serverProcess.on("exit", () => {
serverProcess = null
})
}
for (let attempt = 0; attempt < START_POLL_TIMES; attempt += 1) {
await delay(START_POLL_INTERVAL_MS)
const status = await checkHealth()
if (status.running) {
return status
}
}
return {
...current,
error: "服务启动超时,请检查 JDK 与服务端构建产物",
}
}
/**
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
*/
function pathsExist(paths) {
const result = {}
if (!Array.isArray(paths)) {
return result
}
for (const target of paths) {
if (typeof target === "string" && target.length > 0) {
result[target] = fs.existsSync(target)
}
}
return result
}
/**
* 注册渲染进程通过 preload 调用的窄接口,集中暴露 token、服务状态与目录选择能力。
*/
function registerIpcHandlers() {
ipcMain.handle("mactfs:get-api-base-url", () => API_BASE_URL)
ipcMain.handle("mactfs:get-token", () => readToken())
ipcMain.handle("mactfs:get-service-status", () => checkHealth())
ipcMain.handle("mactfs:start-service", () => startService())
ipcMain.handle("mactfs:select-directory", async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
ipcMain.handle("mactfs:reveal-path", async (event, targetPath, isFolder) => {
if (typeof targetPath !== "string" || targetPath.length === 0 || !fs.existsSync(targetPath)) {
return false
}
// 目录直接进入访达中的该目录,文件则在访达中定位并选中。
if (isFolder) {
const error = await shell.openPath(targetPath)
return error === ""
}
shell.showItemInFolder(targetPath)
return true
})
}
/**
* 解析应用图标路径:开发态用 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 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
*/
function createWindow() {
const iconPath = resolveAppIcon()
const isMac = process.platform === "darwin"
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
width: 1280,
height: 840,
minWidth: 980,
minHeight: 640,
title: "MacTFS",
// 预设背景并延迟显示,避免启动白闪;macOS 下背景透明以透出 vibrancy 毛玻璃。
show: false,
backgroundColor: isMac
? "#00000000"
: nativeTheme.shouldUseDarkColors
? "#222226"
: "#f9f9fa",
// macOS 隐藏系统标题栏 + 窗口级毛玻璃材质;其它平台回退系统标题栏与实色背景。
...(isMac
? {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 16 },
vibrancy: "sidebar",
visualEffectState: "followWindow",
}
: {}),
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
},
})
mainWindow.once("ready-to-show", () => {
// 启动即铺满当前屏幕可视区域(保留程序坞与菜单栏,非独立全屏 Space)。
mainWindow.maximize()
mainWindow.show()
})
if (process.env.NODE_ENV === "development") {
@@ -18,7 +326,15 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
}
app.setName("MacTFS")
app.whenReady().then(() => {
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
const iconPath = resolveAppIcon()
if (process.platform === "darwin" && iconPath && app.dock) {
app.dock.setIcon(nativeImage.createFromPath(iconPath))
}
registerIpcHandlers()
createWindow()
app.on("activate", () => {
@@ -33,3 +349,11 @@ app.on("window-all-closed", () => {
app.quit()
}
})
// 应用退出时回收由本进程拉起的服务端,避免遗留孤儿进程。
app.on("will-quit", () => {
if (serverProcess && serverProcess.exitCode === null) {
serverProcess.kill()
serverProcess = null
}
})
+48
View File
@@ -0,0 +1,48 @@
const { contextBridge, ipcRenderer } = require("electron")
// 仅向渲染进程暴露窄接口,渲染层不直接接触 fs、child_process 或 token 文件。
contextBridge.exposeInMainWorld("mactfs", {
isElectron: true,
/**
* 获取本地 API 基础地址。
*/
getApiBaseUrl() {
return ipcRenderer.invoke("mactfs:get-api-base-url")
},
/**
* 获取本地服务 Bearer Token。
*/
getToken() {
return ipcRenderer.invoke("mactfs:get-token")
},
/**
* 查询本地 API 服务当前状态。
*/
getServiceStatus() {
return ipcRenderer.invoke("mactfs:get-service-status")
},
/**
* 拉起本地 API 服务并等待就绪。
*/
startService() {
return ipcRenderer.invoke("mactfs:start-service")
},
/**
* 打开系统目录选择器,返回所选目录路径。
*/
selectDirectory() {
return ipcRenderer.invoke("mactfs:select-directory")
},
/**
* 批量检测本地绝对路径是否存在。
*/
pathsExist(paths) {
return ipcRenderer.invoke("mactfs:paths-exist", paths)
},
/**
* 在访达中打开:目录直接进入,文件定位并选中。
*/
revealPath(targetPath, isFolder) {
return ipcRenderer.invoke("mactfs:reveal-path", targetPath, isFolder)
},
})
+3 -2
View File
@@ -6,19 +6,19 @@
"build": "react-router build",
"dev": "react-router dev",
"electron": "electron electron/main.cjs",
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
"electron:dev": "concurrently -k \"pnpm dev\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
"start": "pnpm build && pnpm electron",
"typecheck": "react-router typegen && tsc",
"format": "prettier --write \"**/*.{ts,tsx}\""
},
"dependencies": {
"@fontsource-variable/roboto-slab": "^5.2.8",
"@react-router/node": "7.13.1",
"@react-router/serve": "7.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.36",
"lucide-react": "^1.16.0",
"monaco-editor": "^0.55.1",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -35,6 +35,7 @@
"@types/react-dom": "^19.2.3",
"concurrently": "^9.2.1",
"electron": "^42.2.0",
"playwright-core": "^1.60.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
+41 -8
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@fontsource-variable/roboto-slab':
specifier: ^5.2.8
version: 5.2.8
'@react-router/node':
specifier: 7.13.1
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
@@ -29,6 +26,9 @@ importers:
lucide-react:
specifier: ^1.16.0
version: 1.16.0(react@19.2.6)
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -72,6 +72,9 @@ importers:
electron:
specifier: ^42.2.0
version: 42.2.0
playwright-core:
specifier: ^1.60.0
version: 1.60.0
prettier:
specifier: ^3.8.1
version: 3.8.3
@@ -410,9 +413,6 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource-variable/roboto-slab@5.2.8':
resolution: {integrity: sha512-cdvOSwocP50xS01gXqnGxw7uFcCZQgn1IDlqiAmNuZP4uEVScYAKMDC+aWsbP1grn9QdMJ2frhcuVBNXniBVGA==}
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
engines: {node: '>=14.0.0'}
@@ -1556,6 +1556,9 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -1898,6 +1901,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
@@ -2494,6 +2500,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2564,6 +2575,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
morgan@1.10.1:
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
engines: {node: '>= 0.8.0'}
@@ -2739,6 +2753,11 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
@@ -3717,8 +3736,6 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@fontsource-variable/roboto-slab@5.2.8': {}
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -4857,6 +4874,9 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/validate-npm-package-name@4.0.2': {}
'@types/yauzl@2.10.3':
@@ -5172,6 +5192,10 @@ snapshots:
diff@8.0.4: {}
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.4.2: {}
dunder-proto@1.0.1:
@@ -5784,6 +5808,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -5829,6 +5855,11 @@ snapshots:
minimist@1.2.8: {}
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
morgan@1.10.1:
dependencies:
basic-auth: 2.0.1
@@ -5997,6 +6028,8 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.60.0: {}
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+127
View File
@@ -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)
+231
View File
@@ -0,0 +1,231 @@
// 临时 E2E 驱动:按《TFS-AI测试流程》用 playwright-core 驱动真实 Electron 应用,
// 连接真实 TFS,只在指定测试目录内做写操作,并截图留证。
import { _electron as electron } from "playwright-core"
import { mkdirSync } from "node:fs"
import path from "node:path"
const ROOT = process.cwd()
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
mkdirSync(SHOT_DIR, { recursive: true })
const TEST_SERVER_DIR = "$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs"
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
// 注意:/Users/fenghp/Desktop/DEV/mactfs-ai-workspace 已被旧测试 Workspace
// mactfs-ai-subapp-pm-mactfs 占用(TFS 不允许嵌套映射),UI 测试改用旁路独立目录。
const LOCAL_PARENT = "/Users/fenghp/Desktop/DEV/mactfs-ui-e2e"
const results = []
/** 记录一步结果并打印。 */
function record(step, ok, detail = "") {
results.push({ step, ok, detail })
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
}
/** 截图保存。 */
async function shot(win, name) {
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) })
}
const app = await electron.launch({
args: ["electron/main.cjs"],
cwd: ROOT,
env: { ...process.env, NODE_ENV: "development" },
})
try {
const win = await app.firstWindow()
win.setDefaultTimeout(90000)
// 1. 服务检测 → 连接页(Electron 会自动拉起本地服务,最长等 60s+)
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
const serverUri = await win.locator("#serverUri").inputValue()
record("1 服务就绪并进入连接页", true, `回填 serverUri=${serverUri}`)
await shot(win, "01-connect")
// 2. 连接真实 TFS → Collection 列表
await win.getByRole("button", { name: "连接 TFS" }).click()
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
const collections = await win.locator("ul button span.font-medium").allInnerTexts()
record("2 连接成功并加载 Collection", collections.includes("PKUSEHR"), collections.join(","))
await shot(win, "02-collections")
// 3. 选择 PKUSEHR → 确保 Workspace → 进入工作台
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
await win.getByRole("button", { name: "进入工作台" }).click()
await win.getByText("源码目录").waitFor({ timeout: 120000 })
const topbar = await win.locator("header").innerText()
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
await shot(win, "03-workspace")
// 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. 逐级展开目录树到指定测试目录(懒加载真实数据)
const tree = win.locator("aside").first()
for (const name of TEST_TREE_CHAIN) {
const node = tree.getByText(name, { exact: true }).first()
await node.waitFor({ timeout: 120000 })
await node.click()
await win.waitForTimeout(2500)
}
const pathBar = await win.locator("section").first().innerText()
record("4 树导航到测试目录", pathBar.includes(TEST_SERVER_DIR), "")
await shot(win, "04-test-folder")
// 5. 目录历史弹窗
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
await win.getByRole("menuitem", { name: "查看历史", exact: true }).click()
await win.locator("[data-slot=dialog-title]", { hasText: "历史记录" }).waitFor()
await win.waitForTimeout(5000)
const historyText = await win.locator("[data-slot=dialog-content]").innerText()
const historyOk = /Changeset/.test(historyText) && !historyText.includes("历史加载失败")
record("5 目录历史弹窗", historyOk, historyText.slice(0, 120).replace(/\n/g, " "))
await shot(win, "05-history")
await win.keyboard.press("Escape")
await win.waitForTimeout(500)
// 6. 文件查看(服务器 latest,未映射状态)
const fileRow = win.locator("section table tbody tr").filter({ hasText: ".txt" }).first()
await fileRow.click({ button: "right" })
await win.getByRole("menuitem", { name: "查看文件", exact: true }).click()
await win.locator("[data-slot=dialog-title]", { hasText: "查看文件" }).waitFor()
await win.waitForTimeout(4000)
const viewText = await win.locator("[data-slot=dialog-content]").innerText()
record("6 文件查看(服务器 latest)", viewText.includes("服务器 latest"), viewText.slice(0, 100).replace(/\n/g, " "))
await shot(win, "06-view-file")
await win.keyboard.press("Escape")
await win.waitForTimeout(500)
// 7. Mapping 弹窗:父目录 → 后端预校验 → 创建(勾选立即 Get Latest)
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
await win.getByRole("menuitem", { name: "映射到本地", exact: true }).click()
await win.locator("#parentPath").fill(LOCAL_PARENT)
await win.waitForTimeout(2500)
const mapDialog = await win.locator("[data-slot=dialog-content]").innerText()
const precheckOk = mapDialog.includes(`${LOCAL_PARENT}/mactfs`)
record("7a Mapping 预校验(最终路径由后端生成)", precheckOk, "")
await shot(win, "07-mapping-precheck")
await win.getByRole("button", { name: "创建映射" }).click()
// 立即 Get Latest,真实下载可能较慢
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 180000 }).catch(() => {})
await win.waitForTimeout(3000)
const afterMap = await win.locator("section").first().innerText()
const mapError = await win.locator("[data-slot=dialog-content]").count()
if (mapError > 0) {
const errText = await win.locator("[data-slot=dialog-content]").innerText()
record("7b 创建 Mapping + Get Latest", false, errText.slice(0, 160).replace(/\n/g, " "))
await shot(win, "07b-mapping-error")
await win.keyboard.press("Escape")
} else {
record("7b 创建 Mapping + Get Latest", afterMap.includes(LOCAL_PARENT), afterMap.split("\n").slice(0, 3).join(" "))
await shot(win, "07b-mapped")
}
// 8. 目录对比弹窗(真实对比)
await win.keyboard.press("Escape")
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
await win.waitForTimeout(800)
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
const compareItem = win.getByRole("menuitem", { name: "目录对比", exact: true })
const compareEnabled = (await compareItem.getAttribute("aria-disabled")) !== "true"
if (compareEnabled) {
await compareItem.click()
await win.locator("[data-slot=dialog-title]", { hasText: "目录对比" }).waitFor()
await win.waitForTimeout(8000)
const compareText = await win.locator("[data-slot=dialog-content]").innerText()
record("8 目录对比弹窗", !compareText.includes("目录对比失败"), compareText.slice(0, 120).replace(/\n/g, " "))
await shot(win, "08-compare")
await win.keyboard.press("Escape")
await win.waitForTimeout(500)
} else {
await win.keyboard.press("Escape")
record("8 目录对比弹窗", false, "目录未映射,菜单置灰")
}
// 9. 签出一个文件 → 右侧出现 pending → Diff → 撤销(不做 checkin,避免污染服务端)
const fileRow2 = win.locator("section table tbody tr").filter({ hasText: ".txt" }).first()
if ((await fileRow2.count()) > 0) {
await fileRow2.click({ button: "right" })
const checkoutItem = win.getByRole("menuitem", { name: "签出编辑", exact: true })
const checkoutEnabled = (await checkoutItem.getAttribute("aria-disabled")) !== "true"
if (checkoutEnabled) {
await checkoutItem.click()
await win.waitForTimeout(15000)
const pendingPanel = await win.locator("aside").nth(1).innerText()
const hasPending = pendingPanel.includes("签出编辑") || pendingPanel.includes("Included")
record("9a 签出文件产生挂起更改", hasPending, pendingPanel.slice(0, 120).replace(/\n/g, " "))
await shot(win, "09a-pending")
// 与最新版本比较(刚签出未改 → 应一致)
const pendingRow = win.locator("aside").nth(1).locator("[data-slot=checkbox]").first()
if (hasPending && (await pendingRow.count()) > 0) {
const row = win.locator("aside").nth(1).getByText(".txt").first()
await row.click({ button: "right" })
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
if ((await diffItem.count()) > 0) {
await diffItem.click()
await win.waitForTimeout(6000)
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
record("9b 本地 vs latest Diff", diffText.includes("一致") || diffText.includes("差异"), diffText.slice(0, 100).replace(/\n/g, " "))
await shot(win, "09b-diff")
await win.keyboard.press("Escape")
await win.waitForTimeout(500)
}
// 撤销
await row.click({ button: "right" })
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
await win.waitForTimeout(10000)
const afterUndo = await win.locator("aside").nth(1).innerText()
record("9c 撤销挂起更改", afterUndo.includes("当前没有挂起更改"), "")
await shot(win, "09c-undone")
}
} else {
await win.keyboard.press("Escape")
record("9a 签出文件产生挂起更改", false, "签出菜单置灰(可能未映射)")
}
}
// 10. 取消映射(清理现场,不跳转浏览位置)
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
const unmapItem = win.getByRole("menuitem", { name: "取消映射", exact: true })
if ((await unmapItem.count()) > 0) {
await unmapItem.click()
await win.waitForTimeout(8000)
const afterUnmap = await win.locator("section").first().innerText()
record("10 取消映射并停留当前位置", afterUnmap.includes("未映射到本地") && afterUnmap.includes(TEST_SERVER_DIR), "")
await shot(win, "10-unmapped")
} else {
await win.keyboard.press("Escape")
record("10 取消映射", false, "菜单中没有取消映射(mapping 未创建成功)")
}
// 11. 操作日志面板
const consoleText = await win.locator("footer").innerText()
record("11 操作日志记录", /getLatest|checkout|undo|connect|serverTree/i.test(consoleText), consoleText.split("\n").slice(0, 6).join(" | "))
await shot(win, "11-console")
} catch (error) {
record("EXCEPTION", false, String(error).slice(0, 300))
try {
const win = await app.firstWindow()
await shot(win, "99-exception")
} catch {}
} finally {
await app.close().catch(() => {})
}
console.log("\n==== RESULT SUMMARY ====")
for (const item of results) {
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
}
const failed = results.filter((item) => !item.ok).length
console.log(`\n${results.length - failed}/${results.length} passed`)
process.exit(failed > 0 ? 1 : 0)
+49
View File
@@ -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(() => {})
}
+9 -9
View File
@@ -35,19 +35,19 @@
- 常用 API 调试命令
- 保留必要的本地验证能力
## 阶段 4:Frontend
## 阶段 4:Frontend Workspace UI
目标:实现类 Visual Studio 的桌面 UI。
目标:实现以固定 Collection / Workspace 上下文为核心的桌面工作台。
输出:
- Electron 启动本地服务
- 登录配置
- 左侧 Collection 服务端目录树
- 中间目录文件列表
- 右侧挂起更改
- 底部操作日志
- Mapping、目录对比、文件操作、历史、Diff、签入
- Electron 启动本地服务、preload、API client
- 登录配置、Collection 选择、默认 Workspace 上下文
- 三栏工作台、底部操作台、左右与底部收起能力
- 左侧服务端目录树与中间目录文件列表同步导航
- 对象右键菜单与统一动作模型
- Mapping、History、目录对比、文件查看、Diff、冲突处理弹窗
- Pending Changes、Checkin、操作反馈和日志展示
## 阶段 5:Feature E2E
+9
View File
@@ -9,8 +9,17 @@ AI 执行任何任务前,必须读取:
- [task/AI-RULES.md](/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md)
- 当前任务文件
如果任务属于前端阶段,或任务会影响前端交互、布局、接口对接,还必须读取:
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
如果任务涉及源码,还必须读取相关模块现有代码,优先复用项目已有写法。
说明:
- `task/frontend` 是当前唯一有效的前端阶段任务目录。
- `task/frontend-refactor` 已并入 `task/frontend`,仅保留归档说明,不再作为当前任务来源。
## 二、任务状态更新
开始任务前:
+50 -17
View File
@@ -4,11 +4,17 @@
- 每次开发任务前,AI 必须先读取 [AI-RULES.md](/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md)。
- 每次开发任务前,AI 必须读取 [mactfs-api-product-prd.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-api-product-prd.md)。
- 前端相关任务还必须读取 [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)。
- 每次开发任务前,AI 必须读取当前任务文件。
- 开始任务时,将任务状态改为 `doing`。
- 完成任务后,将任务状态改为 `done`,填写完成记录,并更新本看板。
- 阻塞任务必须标记为 `blocked`,并写清阻塞原因和需要的输入。
说明:
- `task/frontend` 是当前唯一有效的前端阶段目录。
- `task/frontend-refactor` 已归档,设计结论已并入 `task/frontend` 和 `mactfsui/FRONTEND_SPEC.md`。
## 状态枚举
```text
@@ -25,18 +31,24 @@ blocked
| Core | 12 | 12 | 100% |
| Server API | 10 | 10 | 100% |
| CLI | 2 | 2 | 100% |
| Frontend | 0 | 13 | 0% |
| Frontend | 34 | 34 | 100% |
| Feature E2E | 0 | 5 | 0% |
| Release | 0 | 4 | 0% |
| Overall | 24 | 46 | 52% |
| Overall | 58 | 67 | 87% |
## 当前阶段
Frontend
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))
## 前端规范
- [mactfsui/FRONTEND_SPEC.md](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md)
## 下一步任务
- [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md)
- 两个 UI 分支对比体验后选定方案
- [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
@@ -81,19 +93,40 @@ Frontend
| 任务 | 状态 | 优先级 |
|---|---|---|
| [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md) | todo | P0 |
| [FE-002-实现登录配置页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-实现登录配置页.md) | todo | P0 |
| [FE-003-实现VS风格主布局.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现VS风格主布局.md) | todo | P0 |
| [FE-004-实现Collection服务端目录树.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现Collection服务端目录树.md) | todo | P0 |
| [FE-005-实现中间目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现中间目录文件列表.md) | todo | P0 |
| [FE-006-实现Mapping创建流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现Mapping创建流程.md) | todo | P0 |
| [FE-007-实现目录对比页面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md) | todo | P0 |
| [FE-008-实现右侧挂起更改面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md) | todo | P0 |
| [FE-009-实现文件操作交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md) | todo | P0 |
| [FE-010-实现签入流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md) | todo | P0 |
| [FE-011-实现历史记录界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md) | todo | P0 |
| [FE-012-实现文件Diff界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md) | todo | P0 |
| [FE-013-实现操作日志面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md) | todo | P1 |
| [FE-001-实现Electron启动服务Preload与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务Preload与API客户端.md) | done | P0 |
| [FE-002-补齐前端集成所需服务端接口与类型契约.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-补齐前端集成所需服务端接口与类型契约.md) | done | P0 |
| [FE-003-实现连接页Collection选择与Workspace上下文.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现连接页Collection选择与Workspace上下文.md) | done | P0 |
| [FE-004-实现工作台布局折叠面板与同步导航.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现工作台布局折叠面板与同步导航.md) | done | P0 |
| [FE-005-实现服务端目录树与当前目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现服务端目录树与当前目录文件列表.md) | done | P0 |
| [FE-006-实现对象右键菜单与通用动作模型.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现对象右键菜单与通用动作模型.md) | done | P0 |
| [FE-007-实现MappingHistory与目录对比弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现MappingHistory与目录对比弹窗.md) | done | P0 |
| [FE-008-实现文件查看与Diff弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现文件查看与Diff弹窗.md) | done | P0 |
| [FE-009-实现GetLatest与Checkout冲突处理.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现GetLatest与Checkout冲突处理.md) | done | P0 |
| [FE-010-实现PendingChanges与Checkin体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现PendingChanges与Checkin体验.md) | done | P0 |
| [FE-011-拆分FolderItemsPanel与收口动作编排.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-拆分FolderItemsPanel与收口动作编排.md) | done | P1 |
| [FE-012-实现操作日志面板与刷新反馈.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现操作日志面板与刷新反馈.md) | done | P1 |
| [FE-013-前端阶段联调验收基线.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-前端阶段联调验收基线.md) | done | P0 |
| [FE-014-实现安全获取与强制获取及指定版本.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-014-实现安全获取与强制获取及指定版本.md) | done | P0 |
| [FE-015-完善历史与对比结果的对象操作.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-015-完善历史与对比结果的对象操作.md) | done | P0 |
| [FE-016-优化Diff与历史弹窗体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-016-优化Diff与历史弹窗体验.md) | done | P1 |
| [FE-017-实现工作台手动刷新.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-017-实现工作台手动刷新.md) | done | P1 |
| [FE-018-Diff弹窗替换为MonacoDiffEditor.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-018-Diff弹窗替换为MonacoDiffEditor.md) | done | P1 |
| [FE-019-实现对象属性与获取特定版本.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-019-实现对象属性与获取特定版本.md) | done | P0 |
| [FE-020-实现重命名.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-020-实现重命名.md) | done | P0 |
| [FE-021-实现变更集回滚.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-021-实现变更集回滚.md) | done | P1 |
| [FE-022-实现工作区与Mapping集中管理.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-022-实现工作区与Mapping集中管理.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-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
@@ -14,7 +14,7 @@ feature
## 依赖任务
- FE-004
- FE-013
- SERVER-005
## 需求来源
@@ -23,14 +23,15 @@ feature
## 目标
验证从 UI 到 API 到 TFS SDK 的连接和目录浏览完整链路。
验证从登录页到固定 Collection / Workspace 上下文,再到目录树和当前目录列表的完整链路。
## 实现范围
- UI 输入账号密码连接
- 查询 Collection
- UI 输入账号密码并连接
- 连接后加载 Collection
- 选择 Collection 并自动确保 Workspace
- 展示左侧服务端目录树
- 点击目录展示下一级文件列表
- 点击目录展示中间当前目录列表
- 未映射目录也可浏览
## 不在范围
@@ -47,9 +48,10 @@ feature
## 验收标准
- 真实 TFS 环境可连接
- Collection 可见
- Collection 可见并可确认
- 默认 Workspace 可自动使用或创建
- 服务端目录树可展开
- 中间文件列表可展示
- 中间当前目录列表可展示
## 测试方式
@@ -15,7 +15,8 @@ feature
## 依赖任务
- FEATURE-001
- FE-006
- FE-007
- FE-009
- SERVER-006
- SERVER-007
@@ -25,15 +26,15 @@ feature
## 目标
验证 Mapping 创建和 Get Latest 的完整链路。
验证 Mapping 弹窗、目录选择、是否立即 Get Latest,以及后续独立执行 Get Latest 的完整链路。
## 实现范围
- 选择服务端目录
- 映射到本地目录
- 通过弹窗映射到本地目录
- 选择是否立即 Get Latest
- 对文件 / 目录 / Mapping 执行 Get Latest
- 展示下载结果
- 展示下载结果、跳过项和冲突反馈
## 不在范围
@@ -49,8 +50,9 @@ feature
## 验收标准
- Mapping 能保存到 Workspace
- 不立即下载时状态为 notDownloaded
- 不立即下载时状态为 `notDownloaded`
- 后续可单独下载下级文件或目录
- 有冲突或跳过项时前端反馈清晰
## 测试方式
@@ -15,6 +15,7 @@ feature
## 依赖任务
- FEATURE-002
- FE-006
- FE-007
- FE-009
- SERVER-008
@@ -25,22 +26,21 @@ feature
## 目标
验证目录对比和勾选差异文件处理完整链路。
验证目录对比弹窗、差异筛选、右键动作和冲突反馈链路。
## 实现范围
- 对已映射目录执行对比
- 展示差异文件
- 勾选 localModified 执行 checkout
- 勾选 localOnly 执行 add
- 勾选 notDownloaded 执行 Get Latest
- 勾选 pending change 执行 undo
- 通过右键菜单执行 checkout、add、get latest、undo
- 对冲突项进入统一冲突处理
- 验证目录对比结果刷新
## 不在范围
- 不做自动修复全部
- 不做冲突解决
- 不做实时刷新
- 不做手动冲突块编辑
- 不做实时 watcher
## 涉及文件
@@ -49,9 +49,10 @@ feature
## 验收标准
- 目录对比不显示 upToDate
- 目录对比默认不显示 `upToDate`
- 差异状态准确
- 用户可勾选文件执行对应操作
- 用户可通过右键菜单执行对应操作
- 有冲突时能进入统一冲突反馈流程
## 测试方式

Some files were not shown because too many files have changed in this diff Show More