59 Commits
Author SHA1 Message Date
fenghpandCursor 627adc907e docs: README 补充未签名应用首次打开方法(xattr 去隔离 + 隐私与安全性)
新增「下载安装」章节,指向 GitHub Releases 并区分 arm64/x64 安装包;
说明 Gatekeeper 拦截后的两种解除方式(系统设置仍要打开 / xattr 去隔离属性)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:17:56 +08:00
fenghpandCursor b2b2f49db6 chore: 将 main 整棵树对齐到 feature 分支(移除并行分支遗留文件与重复声明,确保可构建)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 14:37:39 +08:00
fenghpandCursor 1dfe081713 fix: 移除合并产生的大小写重复文件 AGENTS.md(保留 Agents.md)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 14:35:32 +08:00
fenghp a107ec1f2c Merge feature/ui-macos-restyle into main
合并 macOS 原生风 UI 改版分支,冲突以 feature 分支为准
(feature 为当前维护主线,取代 main 上的并行前端尝试)。

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	task/frontend/FE-001-实现Electron启动服务与API客户端.md
#	task/frontend/FE-002-实现登录配置页.md
#	task/frontend/FE-003-实现VS风格主布局.md
#	task/frontend/FE-004-实现Collection服务端目录树.md
#	task/frontend/FE-005-实现中间目录文件列表.md
#	task/frontend/FE-006-实现Mapping创建流程.md
#	task/frontend/FE-007-实现目录对比页面.md
#	task/frontend/FE-008-实现右侧挂起更改面板.md
#	task/frontend/FE-009-实现文件操作交互.md
#	task/frontend/FE-010-实现签入流程.md
#	task/frontend/FE-011-实现历史记录界面.md
#	task/frontend/FE-012-实现文件Diff界面.md
#	task/frontend/FE-013-实现操作日志面板.md
2026-06-16 14:33:47 +08:00
fenghpandCursor 4681ff914f docs: 新增项目 README(含 AI 生成声明与亮色演示截图)
- 根目录新增 README:介绍、架构、环境、快速开始、用法,并声明纯 AI 生成
- docs/screenshots 新增 5 张亮色演示截图(假数据,非真实服务器)
- 精简 mactfsui/README 默认模板为指向根 README 的说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 14:30:17 +08:00
fenghpandCursor e1a5793a62 release: Electron打包(electron-builder) + JDK运行时按需从Azul下载
- mactfsui 增加 electron-builder 配置与 dist/prepare:runtime 脚本,打包 universal DMG,服务端随 extraResources 内置 x64 JRE 与 jar
- main.cjs 打包态解析 jre-x64/server/lib,新增 app:// 自定义协议加载前端静态资源避免白屏
- prepare-runtime.mjs 本地无 JDK 时自动从 Azul 下载并校验 sha256 后解压,避免把 206M JDK 入库
- 整理 .gitignore:忽略 dist-app/runtime/icon.iconset 及 aarch64 JDK

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 12:37:10 +08:00
fenghpandCursor fcf7952ee3 体验: 顶栏改程序名+Collection胶囊,画布卡片层次强化,修复侧栏长文本截断无省略号
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 20:46:40 +08:00
fenghpandCursor 08bb38b188 体验: toast改macOS通知风圆徽毛玻璃卡片,移除明暗主题切换仅保留亮色
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 20:15:53 +08:00
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
fenghp acb388d22d 完成阶段5前端重构并固定后端JDK 2026-06-09 15:09:25 +08:00
fenghp 1bc913c54e Merge codex/frontend 2026-06-09 10:08:06 +08:00
194 changed files with 17485 additions and 8638 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 秒
+4
View File
@@ -32,3 +32,7 @@ workspace/
# Local JDK
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
zulu8.94.0.17-ca-jdk8.0.492-macosx_aarch64/
# E2E 截图
**/.e2e-shots/
+237
View File
@@ -0,0 +1,237 @@
# macTFS
> 🤖 **本项目由 Claude(Anthropic AI)生成**:从产品需求、前后端代码,到这份 README 和界面截图,均为纯 AI 生成的实验性作品。
>
> 🤖 _Built entirely by Claude (Anthropic's AI). Requirements, code, this README, and screenshots are all AI-generated._
> macOS 上的 TFS(Azure DevOps Server)版本控制客户端 —— 脱离 IDE 宿主,独立运行的桌面应用 + 本地 HTTP API。
macTFS 让你在 **macOS(含 Apple Silicon)** 上直接对 **TFS / TFVC** 仓库做日常版本控制:获取最新、签出、签入、查看历史、目录与文件对比……不再依赖 Windows + Visual Studio,也不用为了一个 TFS 插件背着一整个 IntelliJ。
它由一个 **Electron 桌面前端** 和一个 **常驻本地 Java 服务** 组成。Java 服务复用微软官方的 TFS Java SDK 与 TFS 通信,并把能力封装成 `127.0.0.1` 上的 REST API —— 所以除了图形界面,你也可以用 `curl` / 脚本 / CI 直接调用。
---
## 截图
> 以下为演示数据,非真实服务器内容。
### 连接 TFS
| 填写连接信息 | 选择 Collection |
| :---: | :---: |
| ![连接页](docs/screenshots/connect.png) | ![选择 Collection](docs/screenshots/collections.png) |
### 工作台
三栏布局:左侧服务端目录树、中间文件列表、右侧挂起更改与签入、底部操作日志。
![工作台](docs/screenshots/workspace.png)
### 对比与 Diff
| 目录对比 | 文件 Diff(Monaco) |
| :---: | :---: |
| ![目录对比](docs/screenshots/compare.png) | ![文件 Diff](docs/screenshots/diff.png) |
---
## 下载安装
普通用户无需自行构建,直接到 [GitHub Releases](https://github.com/Farewell0375/MacTFS/releases/latest) 下载对应芯片的安装包:
| 安装包 | 适用机型 |
| --- | --- |
| `MacTFS-<版本>-arm64.dmg` | Apple Silicon(M 系列芯片) |
| `MacTFS-<版本>-x64.dmg` | Intel 芯片 |
打开 DMG,把 `MacTFS.app` 拖进「应用程序」即可。
### 首次打开:应用未签名怎么办
本应用**未做 Apple 代码签名 / 公证**,首次打开 macOS 的 Gatekeeper 会拦截,提示「无法验证开发者」或「"MacTFS" 已损坏,无法打开」。这是未签名应用的正常现象,二选一解除即可:
**方法一 · 图形界面**
1. 双击 `MacTFS.app`,弹窗里先点「完成 / 取消」。
2. 打开 **系统设置 → 隐私与安全性**,下拉到「安全性」区域,会看到一条「已阻止使用 MacTFS」的提示,点 **仍要打开**。
3. 再次确认,之后即可正常启动。
**方法二 · 终端去掉隔离属性(推荐,最省事)**
把应用拖进「应用程序」后,在终端执行下面这条命令,去掉 macOS 给下载文件打的隔离标记(quarantine):
```bash
xattr -dr com.apple.quarantine /Applications/MacTFS.app
```
执行后直接双击即可打开。若仍提示「已损坏」,再用这条(会清除全部扩展属性):
```bash
sudo xattr -cr /Applications/MacTFS.app
```
> 这些步骤只是绕过「未签名」拦截,应用本身不会因此被修改。介意签名/公证的话,需要 Apple 开发者账号自行签名。
---
## 为什么做它
- **macOS 上几乎没有顺手的 TFVC 客户端。** 微软官方的 Team Explorer Everywhere 只剩命令行 / 老 Eclipse 插件且基本停更;IntelliJ 的 TFS 插件又必须绑着 IDE。
- **想要一个能独立运行、界面现代、对 Apple Silicon 友好的桌面工具。** 这就是 macTFS 的目标——为自己/小团队打造的「天天能用」的工具。
- **顺带提供 HTTP API。** 让 TFS 操作能被脚本和 CI 复用,而不只是点界面。
---
## 核心功能
- **连接与认证**:配置 TFS 服务器地址、NTLM 账号密码,测试连接、保持会话。
- **Collection / Workspace / Mapping**:选择项目集合,自动确保默认工作区,集中管理服务端路径 ↔ 本地路径映射。
- **服务端目录浏览**:左侧目录树 + 中间文件列表,同步导航、右键菜单统一动作。
- **文件同步**:Get Latest、获取指定版本 / Changeset。
- **签出签入闭环**:Checkout / Checkin / Add / Delete / Undo,签入附注释。
- **本地变更管理**:扫描并展示 Pending Changes,分组签入。
- **历史与对比**:文件 / 路径历史、Changeset 详情、本地 vs 云端 Diff、版本间对比、目录对比。
- **macOS 原生观感**:仿 Finder 的工作台、Sheet 风格弹窗与轻量动效。
- **操作日志**:底部可折叠控制台实时展示操作与错误。
> 完整的需求与 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md)。
---
## 技术架构
```
┌──────────────────────────────────────────────────────────┐
│ 消费端:Electron UI │ curl │ 脚本 │ CI Pipeline │
└──────────────────────────┬───────────────────────────────┘
│ HTTP REST (localhost:38765)
▼
┌──────────────────────────────────────────────────────────┐
│ mactfs-server(Java 8 + SparkJava) │
│ HTTP 路由 · 会话管理 · 配置存储 · TFS 核心服务层 │
└──────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ Microsoft TFS SDK(tfsIntegration/lib) │
│ SOAP 通信 · NTLM 认证 · 版本控制 │
└──────────────────────────────────────────────────────────┘
```
| 层级 | 技术 |
| --- | --- |
| 前端 | Electron + React 19 + React Router 7 + shadcn/ui + Tailwind CSS 4 + Monaco |
| 后端 | Java 8 + SparkJava(内嵌 HTTP 服务) |
| TFS 内核 | Microsoft TFS Java SDK(SOAP / NTLM) |
| 运行环境 | Zulu JDK 8 **x86_64** + Rosetta 2(见下方说明) |
> ⚠️ **为什么固定 x64 JDK:** TFS SDK 的 JNI 原生库只含 i386/x86_64,没有 arm64 切片。因此服务端 JVM 固定走 x64,在 Apple Silicon 上经 Rosetta 2 运行;Electron 界面本身仍是原生。
---
## 环境要求
- macOS 12+(Intel 或 Apple Silicon;Apple Silicon 需安装 Rosetta 2)
- [pnpm](https://pnpm.io/)(前端包管理)
- 首次构建需联网(自动下载 x64 JDK,见下)
- 目标 TFS:TFS 2015 及以下(TFVC)
---
## 快速开始
### 1. 克隆
```bash
git clone <你的仓库地址> mactfs
cd mactfs/mactfsui
pnpm install
```
### 2. 准备 x64 JDK(自动)
服务端需要一个 x64 的 JDK 8。仓库**不**收录这个 200M+ 的二进制,而是按需从 Azul 官方下载并校验:
```bash
cd mactfsui
pnpm prepare:runtime
```
脚本会检测本地是否已有 `zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/`,没有就从 Azul 下载 → 校验 sha256 → 解压到仓库根目录。`pnpm dist` 会自动先跑这一步。
### 3. 构建后端
```bash
cd mactfs
../tfsIntegration/gradlew installDist
```
> 后端从 `mactfs/build/install/mactfs/lib/` 加载运行,改了 Java 代码必须重新 `installDist`。
### 4. 开发模式运行(推荐)
```bash
cd mactfsui
pnpm electron:dev
```
会同时拉起 vite 开发服务器和 Electron 窗口;Electron 发现后端没运行时会自动拉起它,退出时自动回收。
### 5. 打包成桌面应用(.app / .dmg)
```bash
cd mactfsui
pnpm dist
```
产物输出到 `mactfsui/dist-app/`(universal DMG,内置精简后的 x64 JRE 与服务端 jar)。
> 启动 / 停止 / 重启 / 排错的完整说明见 [`服务启停指南.md`](服务启停指南.md)。
---
## 使用流程
1. **连接**:填入 TFS 服务器地址、认证方式(NTLM)、账号 / 域 / 密码,点测试连接。
2. **选择 Collection**:登录成功后选择要操作的项目集合,系统自动确保默认 Workspace。
3. **建立映射**:在目录树或文件列表上「映射到本地」,把服务端路径绑到本地目录(可选立即 Get Latest)。
4. **同步文件**:对映射执行 Get Latest,或获取指定版本。
5. **改 → 签出 → 签入**:编辑文件后在右侧 Changes 面板查看 Pending Changes,填注释签入。
6. **历史 / 对比**:右键查看历史、Changeset 详情,或做本地 vs 云端、版本间、目录级的 Diff。
### 也可以直接调 API
服务监听 `127.0.0.1:38765`,token 存在 `~/.mactfs/server-token`:
```bash
TOKEN=$(cat ~/.mactfs/server-token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:38765/api/health
```
完整 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md) 第四章。
---
## 目录结构
```
mydev/
├── mactfs/ # Java 后端:CLI + 本地 HTTP API 服务
├── mactfsui/ # Electron + React 桌面前端
│ ├── app/ # 前端源码(页面、组件、API client)
│ ├── electron/ # Electron 主进程 / preload
│ └── scripts/ # 打包辅助脚本(prepare-runtime.mjs 等)
├── tfsIntegration/ # 微软 TFS Java SDK 及原生库(构建依赖)
├── docs/ # PRD、需求、截图等文档
├── task/ # 分阶段开发任务单
└── testing/ # 端到端 / 接口测试报告
```
---
## 第三方依赖与许可
- 本项目通过 `tfsIntegration/` 下的 **Microsoft TFS Java SDK** 与 TFS 通信,其分发受微软相应许可约束。
- x64 JDK 由 [Azul Zulu](https://www.azul.com/downloads/) 提供,按需下载,不随仓库分发。
> macTFS 是面向个人 / 小团队的工具型项目,主要解决「在 Mac 上用老 TFS」这一具体痛点。
+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 内部主题深度定制(仅跟随明暗主题切换)
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

+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
@@ -4,11 +4,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 保存最近一次成功连接与映射结果,供第一阶段 CLI 复用输入。
@@ -16,7 +12,6 @@ import java.util.regex.Pattern;
public class LocalConfigStore {
private final File configFile;
private final File serverConfigFile;
public LocalConfigStore() {
File configDirectory = new File(System.getProperty("user.home"), ".mactfs");
@@ -24,26 +19,20 @@ public class LocalConfigStore {
configDirectory.mkdirs();
}
this.configFile = new File(configDirectory, "phase-one.properties");
this.serverConfigFile = new File(configDirectory, "config.json");
}
/**
* 读取第一阶段配置;本地 API 配置存在时优先使用 config.json 的当前连接上下文。
*/
public Properties load() throws IOException {
Properties properties = new Properties();
if (configFile.exists()) {
if (!configFile.exists()) {
return properties;
}
FileInputStream inputStream = new FileInputStream(configFile);
try {
properties.load(inputStream);
} finally {
inputStream.close();
}
}
if (serverConfigFile.exists()) {
mergeServerConfig(properties);
}
return properties;
}
@@ -59,46 +48,4 @@ public class LocalConfigStore {
public File getConfigFile() {
return configFile;
}
/**
* 将第二阶段本地 API 配置转换为旧 CLI 可复用的属性名。
*/
private void mergeServerConfig(Properties properties) throws IOException {
String json = new String(Files.readAllBytes(serverConfigFile.toPath()), StandardCharsets.UTF_8);
copyJsonString(json, properties, "serverUri", "server-uri");
copyJsonString(json, properties, "authType", "auth-type");
copyJsonString(json, properties, "domain", "domain");
copyJsonString(json, properties, "username", "username");
copyJsonString(json, properties, "password", "password");
copyJsonString(json, properties, "collection", "collection");
copyJsonString(json, properties, "workspace", "workspace");
copyJsonString(json, properties, "serverPath", "server-path");
copyJsonString(json, properties, "localPath", "local-path");
}
/**
* 复制 JSON 字符串字段,保持旧 CLI 配置读取逻辑不依赖服务端模块。
*/
private void copyJsonString(String json, Properties properties, String jsonKey, String propertyKey) {
String value = jsonString(json, jsonKey);
if (!isBlank(value)) {
properties.setProperty(propertyKey, value);
}
}
/**
* 从简单配置 JSON 中提取字符串字段。
*/
private String jsonString(String json, String key) {
Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
Matcher matcher = pattern.matcher(json);
return matcher.find() ? matcher.group(1).replace("\\\"", "\"") : null;
}
/**
* 判断字符串是否为空。
*/
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -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);
int sourceIndex = 0;
for (AbstractDelta<String> delta : patch.getDeltas()) {
int deltaStart = delta.getSource().getPosition();
while (sourceIndex < deltaStart) {
lines.add(" " + sourceLines.get(sourceIndex));
sourceIndex++;
}
if (target != null) {
lines.add("+" + target);
for (String removed : delta.getSource().getLines()) {
lines.add("-" + removed);
}
for (String added : delta.getTarget().getLines()) {
lines.add("+" + added);
}
sourceIndex += delta.getSource().size();
}
while (sourceIndex < sourceLines.size()) {
lines.add(" " + sourceLines.get(sourceIndex));
sourceIndex++;
}
return new TfsTextDiff(sourceLabel, targetLabel, lines);
}
@@ -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,10 +284,33 @@ 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 {
AppConfig config = requestConfig(queryConfig(request));
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"));
if (result.isSuccess()) {
config.mappings = mappingConfigs(result.getData());
@@ -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,11 +447,107 @@ 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 {
Map<String, Object> query = queryConfig(request);
AppConfig config = requestConfig(query);
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
CoreOperationResult<List<TfsPendingChangeInfo>> result = coreService.listPendingChanges(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("pendingChanges", result.getData() == null ? Collections.emptyList() : result.getData());
@@ -409,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);
@@ -571,26 +754,6 @@ public class MacTfsServer {
return normalizeConfig(config);
}
/**
* 提取 GET 请求中的配置覆盖字段,避免 UI 切换 Collection 时读接口使用旧配置。
*/
private Map<String, Object> queryConfig(Request request) {
Map<String, Object> query = new LinkedHashMap<String, Object>();
copyQuery(request, query, "collection");
copyQuery(request, query, "workspace");
return query;
}
/**
* 将非空 query 参数复制到请求配置覆盖对象。
*/
private void copyQuery(Request request, Map<String, Object> query, String key) {
String value = request.queryParams(key);
if (!isBlank(value)) {
query.put(key, value);
}
}
/**
* 把服务端配置转换为 core 层连接配置。
*/
@@ -711,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 四种字段。
*/
+5
View File
@@ -5,3 +5,8 @@
# React Router
/.react-router/
/build/
# electron-builder 打包产物与生成的运行时(体积大,不入库)
/dist-app/
/runtime/
/build-resources/icon.iconset/
+440 -523
View File
File diff suppressed because it is too large Load Diff
+8 -15
View File
@@ -1,21 +1,14 @@
# React Router + shadcn/ui
# mactfsui
This is a template for a new React Router project with React, TypeScript, and shadcn/ui.
macTFS 的 Electron + React 桌面前端。完整的项目介绍、截图、环境要求与使用说明请见仓库根目录的 [README](../README.md)。
## Adding components
To add components to your app, run the following command:
## 常用命令
```bash
npx shadcn@latest add button
pnpm install # 安装依赖
pnpm electron:dev # 开发模式(vite + Electron,自动拉起后端)
pnpm prepare:runtime # 准备 x64 JDK(本地缺失时自动从 Azul 下载)
pnpm dist # 打包 macOS 桌面应用到 dist-app/
```
This will place the ui components in the `components` directory.
## Using components
To use the components in your app, import them as follows:
```tsx
import { Button } from "@/components/ui/button";
```
启动 / 停止 / 重启 / 排错见根目录的 [`服务启停指南.md`](../服务启停指南.md)。
+81 -64
View File
@@ -2,11 +2,19 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* 应用只有亮色主题:保留 class 方案的 dark 变体定义,使组件库模板中残留的
dark: 工具类永远不会命中(根节点不再挂 .dark),避免回退到系统媒体查询。 */
@custom-variant dark (&:is(.dark *));
/*
* macOS 原生风设计基建(FE-026)
* 基调:系统字体 + macOS 蓝主色 + 中性灰分级 + 半透明材质(vibrancy)变量。
* 动效约定:快 150ms / 标准 220ms / 慢 320ms,缓动 ease-out-quart / spring 近似。
*/
@theme inline {
--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-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
--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);
@@ -38,6 +46,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);
@@ -45,75 +54,63 @@
--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 0 0 0.5px rgb(0 0 0 / 0.03), 0 1px 1px rgb(0 0 0 / 0.04), 0 4px 14px rgb(0 0 0 / 0.06);
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06);
--shadow-dialog: 0 18px 50px rgb(0 0 0 / 0.22), 0 4px 12px rgb(0 0 0 / 0.1);
/* 缓动曲线 */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
/* 反馈动效:表单错误抖动 */
--animate-shake: shake 0.4s var(--ease-out-quart);
@keyframes shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-3px); }
40%, 60% { transform: translateX(3px); }
}
}
:root {
--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);
}
.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);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--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;
}
@layer base {
@@ -127,3 +124,23 @@
@apply font-sans;
}
}
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
html.vibrancy,
html.vibrancy body {
background: transparent;
}
/* 工作台画布底色:比卡片深一档的中性灰,让白色卡片浮起;vibrancy 下半透明透出窗口毛玻璃。 */
@utility canvas-surface {
background-color: oklch(0.934 0.004 286 / 0.85);
}
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
@utility app-drag {
-webkit-app-region: drag;
}
@utility app-no-drag {
-webkit-app-region: no-drag;
}
-210
View File
@@ -1,210 +0,0 @@
import type { ReactNode } from "react"
import {
CheckCircle2,
Cloud,
Download,
FolderTree,
GitPullRequest,
History,
RefreshCw,
TriangleAlert,
} from "lucide-react"
import { OperationLogPanel } from "~/components/app/operation-log-panel"
import { Button } from "~/components/ui/button"
import type { ApiRequestLogEntry } from "~/lib/api/client"
interface AppShellProps {
connected: boolean
serviceReady: boolean
serviceLoading: boolean
serverUri: string
username: string
collectionCount?: number
serviceBaseUrl?: string
tokenFile?: string
serviceMessage: string
requestStatus: string
operationLogs: ApiRequestLogEntry[]
operationLogsLoading: boolean
workspaceGetLatestEnabled: boolean
workspaceHistoryEnabled: boolean
sourceList?: ReactNode
inspector?: ReactNode
onRefreshService(): void
onRefreshOperationLogs(): void
onWorkspaceGetLatest(): void
onWorkspaceHistory(): void
children: ReactNode
}
/**
* 渲染 macOS Source Workspace 主框架,承载顶部工具栏、Source List、主内容、Inspector 和 Console。
*/
export function AppShell({
connected,
serviceReady,
serviceLoading,
serverUri,
username,
collectionCount,
serviceBaseUrl,
tokenFile,
serviceMessage,
requestStatus,
operationLogs,
operationLogsLoading,
workspaceGetLatestEnabled,
workspaceHistoryEnabled,
sourceList,
inspector,
onRefreshService,
onRefreshOperationLogs,
onWorkspaceGetLatest,
onWorkspaceHistory,
children,
}: AppShellProps) {
return (
<div className="flex h-[calc(100svh-32px)] min-h-[640px] flex-col overflow-hidden rounded-[8px] border bg-background text-sm shadow-sm">
<header className="flex h-13 shrink-0 items-center justify-between gap-3 border-b bg-muted/30 px-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-7 items-center justify-center rounded-[7px] border bg-background text-primary">
<Cloud className="size-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">macTFS</span>
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-xs text-muted-foreground">
{connected ? "已连接" : "未连接"}
</span>
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{serverUri || "http://127.0.0.1:38765"}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!workspaceGetLatestEnabled}
onClick={onWorkspaceGetLatest}
>
<Download />
Get Latest
</Button>
<Button
variant="outline"
size="sm"
disabled={!workspaceHistoryEnabled}
onClick={onWorkspaceHistory}
>
<History />
History
</Button>
<Button
variant="ghost"
size="sm"
disabled={serviceLoading}
onClick={onRefreshService}
>
<RefreshCw className={serviceLoading ? "animate-spin" : ""} />
刷新
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[280px_minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,1fr)_340px]">
<aside className="flex min-h-[220px] min-w-0 flex-col border-b bg-muted/25 md:min-h-0 md:border-r md:border-b-0">
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
<FolderTree className="size-4" />
Source List
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{sourceList || (
<>
<div className="rounded-[6px] bg-background px-2 py-1.5 font-mono text-xs">
$/
</div>
<div className="mt-2 px-2 text-xs text-muted-foreground">
Collection:{collectionCount ?? "-"}
</div>
</>
)}
</div>
</aside>
<main className="flex min-h-[320px] min-w-0 flex-col">
<div className="flex h-9 items-center justify-between gap-3 border-b px-3">
<div className="min-w-0 truncate font-medium">Source Workspace</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="sm" disabled>
Compare
</Button>
<Button variant="ghost" size="sm" disabled>
Mapping
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</main>
<aside className="flex min-h-[260px] min-w-0 flex-col border-t bg-background md:col-span-2 lg:col-span-1 lg:border-t-0 lg:border-l">
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
<GitPullRequest className="size-4" />
Inspector
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
<div className="grid gap-4">
{inspector || (
<section className="grid gap-2">
<div className="flex items-center justify-between">
<span className="font-medium">Pending Changes</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
0 项
</span>
</div>
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
暂无挂起更改
</div>
</section>
)}
<section className="grid gap-2">
<div className="font-medium">本地服务</div>
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/30 px-3 py-2">
{serviceReady ? (
<CheckCircle2 className="size-4 text-green-600" />
) : (
<TriangleAlert className="size-4 text-amber-600" />
)}
<span>{serviceReady ? "服务可用" : "服务未就绪"}</span>
</div>
<div className="grid gap-1 text-xs text-muted-foreground">
<div className="truncate font-mono">
{serviceBaseUrl || "http://127.0.0.1:38765"}
</div>
<div className="truncate font-mono">
{tokenFile || "~/.mactfs/server-token"}
</div>
</div>
</section>
</div>
</div>
</aside>
</div>
<footer className="h-40 shrink-0 border-t bg-muted/20">
<OperationLogPanel
logs={operationLogs}
loading={operationLogsLoading}
serviceMessage={serviceMessage}
requestStatus={requestStatus}
username={username}
onRefresh={onRefreshOperationLogs}
/>
</footer>
</div>
)
}
@@ -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}
</>
)
}
@@ -1,169 +0,0 @@
import {
CheckCircle2,
Loader2,
RefreshCw,
Terminal,
TriangleAlert,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import type { ApiRequestLogEntry } from "~/lib/api/client"
interface OperationLogPanelProps {
logs: ApiRequestLogEntry[]
loading: boolean
serviceMessage: string
requestStatus: string
username: string
onRefresh(): void
}
/**
* 渲染底部操作日志面板,展示 API 操作状态、耗时、路径摘要和错误信息。
*/
export function OperationLogPanel({
logs,
loading,
serviceMessage,
requestStatus,
username,
onRefresh,
}: OperationLogPanelProps) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs font-medium text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<Terminal className="size-4" />
<span>Console</span>
<span className="rounded-[6px] border bg-background px-1.5 py-0.5">
{logs.length} 条
</span>
</div>
<Button
size="icon-xs"
variant="ghost"
title="刷新服务端操作日志"
disabled={loading}
onClick={onRefresh}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
{logs.length === 0 ? (
<div className="grid gap-1 p-3 font-mono text-xs text-muted-foreground">
<div>[service] {serviceMessage}</div>
<div>[request] {requestStatus}</div>
<div>[session] {username || "anonymous"}</div>
<div>[logs] 暂无 API 操作日志</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-[920px]">
<div className="sticky top-0 z-10 grid h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] border-b bg-muted px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">操作</div>
<div className="flex items-center">状态</div>
<div className="flex items-center">开始</div>
<div className="flex items-center">结束</div>
<div className="flex items-center">耗时</div>
<div className="flex items-center">路径摘要</div>
<div className="flex items-center">错误信息</div>
</div>
{logs.map((log) => (
<div
key={log.id}
className="grid min-h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] items-center border-b px-3 font-mono text-xs"
>
<div className="truncate">{log.operation}</div>
<div>
<span
className={`inline-flex max-w-full items-center gap-1 rounded-[6px] border px-1.5 py-0.5 ${statusClassName(log)}`}
>
{log.status === "success" ? (
<CheckCircle2 className="size-3" />
) : log.status === "running" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<TriangleAlert className="size-3" />
)}
{statusLabel(log)}
</span>
</div>
<div className="truncate text-muted-foreground">
{formatLogTime(log.startedAt)}
</div>
<div className="truncate text-muted-foreground">
{log.status === "running"
? "执行中"
: formatLogTime(log.endedAt)}
</div>
<div className="text-muted-foreground">
{formatDuration(log)}
</div>
<div className="truncate text-muted-foreground">
{log.summary || "-"}
</div>
<div
className={`truncate ${
log.errorMessage
? "text-destructive"
: "text-muted-foreground"
}`}
>
{log.status === "running"
? "请求执行中"
: log.errorMessage || "-"}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
/**
* 按日志状态返回中文状态文案。
*/
function statusLabel(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "执行中"
}
return log.success ? "成功" : "失败"
}
/**
* 按日志状态返回 badge 样式。
*/
function statusClassName(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "border-amber-200 bg-amber-50 text-amber-700"
}
return log.success
? "border-green-200 bg-green-50 text-green-700"
: "border-red-200 bg-red-50 text-red-700"
}
/**
* 格式化操作日志时间戳。
*/
function formatLogTime(value?: number) {
return value ? new Date(value).toLocaleString() : "-"
}
/**
* 格式化操作耗时,执行中的请求保持明确状态。
*/
function formatDuration(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "执行中"
}
if (log.durationMs === undefined) {
return "-"
}
return log.durationMs < 1000
? `${log.durationMs} ms`
: `${(log.durationMs / 1000).toFixed(1)} s`
}
+141
View File
@@ -0,0 +1,141 @@
import { FolderCog, PanelBottom, PanelLeft, PanelRight, 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 { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility {
tree: boolean
changes: boolean
console: boolean
}
/**
* 顶部上下文栏:仿 Finder 的居中上下文胶囊展示 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()
return (
<header
className={cn(
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
macInset && "pl-20",
)}
>
{/* 左侧:程序名 + Collection 胶囊;服务器地址与工作区收进悬停提示 */}
<div className="flex min-w-0 items-center gap-2.5">
<span className="shrink-0 text-sm font-medium">MacTFS</span>
<Tooltip>
<TooltipTrigger asChild>
<div className="app-no-drag flex h-7 min-w-0 items-center rounded-lg bg-foreground/5 px-3 text-xs text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground">
<span className="truncate">{session.collection}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="space-y-0.5">
<p>
服务器 <span className="font-mono">{session.serverUri}</span>
</p>
<p>
工作区 <span className="font-mono">{session.workspace}</span>
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<div 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={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="canvas-surface flex h-svh flex-col overflow-hidden">
<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">
{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,342 @@
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") }
}
/**
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
*/
export function DiffDialog({
request,
onClose,
}: {
request: DiffRequest
onClose: () => void
}) {
const [diff, setDiff] = useState<TextDiff | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyChanges, setOnlyChanges] = useState(false)
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
return "unified"
}
return "split"
})
// 差异块总数与当前导航到的差异块序号。
const [blockCount, setBlockCount] = useState(0)
const [blockIndex, setBlockIndex] = useState(-1)
const containerRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
useEffect(() => {
let active = true
setLoading(true)
setError(null)
void (async () => {
const result =
request.mode === "localLatest"
? await api.diffLocalLatest({
serverPath: request.serverPath,
localPath: request.localPath,
})
: await api.diffRevisions({
serverPath: request.serverPath,
sourceChangeset: request.sourceChangeset,
targetChangeset: request.targetChangeset,
})
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setError(result.errorMessage ?? "Diff 加载失败")
return
}
setDiff(result.data.diff)
})()
return () => {
active = false
}
}, [request])
const changedCount = useMemo(
() => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length,
[diff],
)
// 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。
useEffect(() => {
if (!diff) {
return
}
let disposed = false
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
let originalModel: MonacoEditor.ITextModel | null = null
let modifiedModel: MonacoEditor.ITextModel | null = null
void (async () => {
const { monaco, detectLanguage } = await import("~/lib/monaco")
if (disposed || !containerRef.current) {
return
}
const { original, modified } = splitDiffSides(diff.lines)
const language = detectLanguage(request.serverPath)
originalModel = monaco.editor.createModel(original, language)
modifiedModel = monaco.editor.createModel(modified, language)
diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
readOnly: true,
originalEditable: false,
automaticLayout: true,
renderSideBySide: viewMode === "split",
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
compactMode: viewMode === "unified",
hideUnchangedRegions: { enabled: onlyChanges },
theme: "mactfs-light",
fontSize: 12,
lineHeight: 20,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderOverviewRuler: true,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
diffWordWrap: "off",
diffAlgorithm: "advanced",
renderLineHighlight: "none",
folding: false,
glyphMargin: false,
guides: { indentation: false },
padding: { top: 8, bottom: 8 },
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
renderMarginRevertIcon: false,
})
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
diffEditor.onDidUpdateDiff(() => {
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
})
editorRef.current = diffEditor
})()
return () => {
disposed = true
editorRef.current = null
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
setBlockCount(0)
setBlockIndex(-1)
}
}, [diff, request.serverPath])
/**
* 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。
*/
const switchView = useCallback((mode: "unified" | "split") => {
setViewMode(mode)
editorRef.current?.updateOptions({
renderSideBySide: mode === "split",
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">
− {diff.sourceLabel}
</Badge>
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700">
+ {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>
)
}
@@ -1,185 +0,0 @@
import { useEffect, useState } from "react"
import { GitCompareArrows, Loader2, RefreshCw, X } from "lucide-react"
import { Button } from "~/components/ui/button"
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
import type { TfsTextDiff } from "~/lib/api/types"
export type DiffPanelRequest =
| {
type: "localLatest"
serverPath: string
localPath: string
label: string
}
| {
type: "revisions"
serverPath: string
sourceChangeset: number
targetChangeset: number
label: string
}
interface DiffPanelProps {
request: DiffPanelRequest
onClose(): void
}
interface DiffRow {
source?: string
target?: string
kind: "same" | "removed" | "added"
}
/**
* 展示文本 Diff 面板,支持本地 vs latest 和两个历史版本对比。
*/
export function DiffPanel({ request, onClose }: DiffPanelProps) {
const [diff, setDiff] = useState<TfsTextDiff>()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState("")
useEffect(() => {
loadDiff()
}, [request])
const rows = diff ? toDiffRows(diff.lines) : []
/**
* 根据当前请求类型加载文本 diff。
*/
async function loadDiff() {
setLoading(true)
setMessage("")
const result =
request.type === "localLatest"
? await diffLocalLatest({
serverPath: request.serverPath,
localPath: request.localPath,
})
: await diffRevisions({
serverPath: request.serverPath,
sourceChangeset: request.sourceChangeset,
targetChangeset: request.targetChangeset,
})
setLoading(false)
if (!result.success || !result.data.diff) {
setDiff(undefined)
setMessage(
result.errorMessage ||
result.message ||
"大文件、二进制或不支持类型无法展示文本 diff。"
)
return
}
setDiff(result.data.diff)
}
/**
* 将后端统一 diff lines 转为左右两栏展示行。
*/
function toDiffRows(lines: string[]): DiffRow[] {
return lines.map((line) => {
const prefix = line.charAt(0)
const content = line.slice(1)
if (prefix === "-") {
return { source: content, kind: "removed" }
}
if (prefix === "+") {
return { target: content, kind: "added" }
}
return { source: content, target: content, kind: "same" }
})
}
return (
<div className="border-b bg-background">
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div className="min-w-0">
<div className="flex items-center gap-1.5 font-medium">
<GitCompareArrows className="size-3.5" />
Diff
</div>
<div className="truncate font-mono text-muted-foreground">
{request.label}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
disabled={loading}
title="刷新 Diff"
onClick={loadDiff}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
<X />
</Button>
</div>
</div>
{message && (
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
{diff && (
<div className="border-t">
<div className="grid h-8 grid-cols-2 border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex min-w-0 items-center truncate font-mono">
{diff.sourceLabel}
</div>
<div className="flex min-w-0 items-center truncate border-l pl-3 font-mono">
{diff.targetLabel}
</div>
</div>
<div className="max-h-80 overflow-auto font-mono text-xs">
{rows.length === 0 ? (
<div className="px-3 py-4 text-muted-foreground">
无可展示 diff 内容。
</div>
) : (
rows.map((row, index) => (
<div
key={`${index}:${row.kind}`}
className="grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b"
>
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
{index + 1}
</div>
<div
className={`whitespace-pre-wrap px-2 py-1 ${
row.kind === "removed"
? "bg-red-50 text-red-700"
: ""
}`}
>
{row.source ?? ""}
</div>
<div className="border-l bg-muted/20 px-2 py-1 text-right text-muted-foreground">
{index + 1}
</div>
<div
className={`whitespace-pre-wrap px-2 py-1 ${
row.kind === "added"
? "bg-green-50 text-green-700"
: ""
}`}
>
{row.target ?? ""}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,111 @@
import {
Database,
File,
FileArchive,
FileCode,
FileImage,
FileJson,
FileSpreadsheet,
FileTerminal,
FileText,
FolderClosed,
FolderOpen,
FolderSymlink,
type LucideIcon,
} from "lucide-react"
import { cn } from "~/lib/utils"
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
{
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
icon: FileCode,
className: "text-blue-600",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600",
},
]
/**
* 取文件名扩展名(小写,无点);无扩展名返回空串。
*/
function extOf(name: string): string {
const index = name.lastIndexOf(".")
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
}
/**
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
*/
export function FileIcon({
name,
folder,
mapped = false,
expanded = false,
className,
}: {
name: string
folder: boolean
mapped?: boolean
expanded?: boolean
className?: string
}) {
if (folder) {
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
return (
<Icon
className={cn(
"size-3.5 shrink-0",
mapped ? "text-primary" : "text-sky-600",
className,
)}
/>
)
}
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
const Icon = rule?.icon ?? File
return (
<Icon
className={cn(
"size-3.5 shrink-0",
rule?.className ?? "text-muted-foreground",
className,
)}
/>
)
}
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
@@ -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>
)
}
@@ -1,322 +0,0 @@
import { useEffect, useState } from "react"
import {
FileText,
Folder,
GitCompareArrows,
Loader2,
RefreshCw,
X,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
import { queryChangesetFiles, queryHistory } from "~/lib/api/endpoints"
import type { TfsHistoryEntry } from "~/lib/api/types"
interface HistoryPanelProps {
path: string
folder: boolean
label: string
onOpenDiff(request: DiffPanelRequest): void
onClose(): void
}
/**
* 展示文件或目录历史,并支持目录 changeset 文件列表和历史 diff 入口。
*/
export function HistoryPanel({
path,
folder,
label,
onOpenDiff,
onClose,
}: HistoryPanelProps) {
const [history, setHistory] = useState<TfsHistoryEntry[]>([])
const [changesetFiles, setChangesetFiles] = useState<TfsHistoryEntry[]>([])
const [selectedChangeset, setSelectedChangeset] = useState<number>()
const [selectedRevisionKeys, setSelectedRevisionKeys] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [filesLoading, setFilesLoading] = useState(false)
const [message, setMessage] = useState("")
const [diffMessage, setDiffMessage] = useState("")
useEffect(() => {
loadHistory()
}, [path, folder])
const selectedRevisions = history.filter((entry) =>
selectedRevisionKeys.includes(toHistoryKey(entry))
)
/**
* 查询当前文件或目录最近 100 条历史记录。
*/
async function loadHistory() {
setLoading(true)
setMessage("")
setDiffMessage("")
setChangesetFiles([])
setSelectedChangeset(undefined)
setSelectedRevisionKeys([])
const result = await queryHistory(path, folder)
setLoading(false)
if (!result.success) {
setHistory([])
setMessage(result.errorMessage || result.message)
return
}
setHistory(result.data.history)
}
/**
* 查询目录历史中某个 changeset 影响的文件列表。
*/
async function loadChangesetFiles(changeset: number) {
setFilesLoading(true)
setMessage("")
setDiffMessage("")
setSelectedChangeset(changeset)
const result = await queryChangesetFiles(changeset)
setFilesLoading(false)
if (!result.success) {
setChangesetFiles([])
setMessage(result.errorMessage || result.message)
return
}
setChangesetFiles(result.data.files)
}
/**
* 使用 changeset 作为历史版本选择 key。
*/
function toHistoryKey(entry: TfsHistoryEntry) {
return String(entry.changeset)
}
/**
* 选择最多两个文件历史版本,供后续 Diff 面板使用。
*/
function toggleRevision(entry: TfsHistoryEntry, checked: boolean) {
const key = toHistoryKey(entry)
setSelectedRevisionKeys((currentKeys) => {
if (!checked) {
return currentKeys.filter((currentKey) => currentKey !== key)
}
if (currentKeys.includes(key)) {
return currentKeys
}
return [...currentKeys, key].slice(-2)
})
}
/**
* 格式化后端毫秒时间戳。
*/
function formatHistoryDate(date?: number | null) {
return date ? new Date(date).toLocaleString() : "-"
}
/**
* 打开用户选择的两个历史版本 diff。
*/
function enterHistoryDiff() {
if (selectedRevisions.length !== 2) {
return
}
onOpenDiff({
type: "revisions",
serverPath: path,
sourceChangeset: selectedRevisions[0].changeset,
targetChangeset: selectedRevisions[1].changeset,
label: `${path};C${selectedRevisions[0].changeset} ↔ C${selectedRevisions[1].changeset}`,
})
}
/**
* 记录 changeset 文件列表中的单文件 diff 入口。
*/
function enterChangesetFileDiff(file: TfsHistoryEntry) {
setDiffMessage(
`已选择 ${file.serverPath} @${file.changeset}。请打开该文件历史并选择两个版本进入 Diff。`
)
}
return (
<div className="border-b bg-background">
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div className="min-w-0">
<div className="font-medium">History</div>
<div className="truncate font-mono text-muted-foreground">
{label}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
disabled={loading}
title="刷新历史"
onClick={loadHistory}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
<X />
</Button>
</div>
</div>
{message && (
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
{diffMessage && (
<div className="border-t bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
{diffMessage}
</div>
)}
<div className="grid max-h-80 border-t md:grid-cols-[minmax(0,1fr)_300px]">
<div className="min-h-0 overflow-auto">
<div className="min-w-[760px]">
<div className="grid h-7 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">Diff</div>
<div className="flex items-center">Changeset</div>
<div className="flex items-center">变更</div>
<div className="flex items-center">作者</div>
<div className="flex items-center">时间</div>
<div className="flex items-center">Comment</div>
</div>
{history.length === 0 && !loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground">
暂无历史记录。
</div>
) : (
history.map((entry) => (
<div
key={toHistoryKey(entry)}
className={`grid min-h-8 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] items-center border-b px-3 text-xs ${
selectedChangeset === entry.changeset
? "bg-primary/10"
: ""
}`}
>
<label className="flex items-center justify-center">
<input
type="checkbox"
disabled={folder}
checked={selectedRevisionKeys.includes(
toHistoryKey(entry)
)}
aria-label={`选择 changeset ${entry.changeset}`}
onChange={(event) =>
toggleRevision(entry, event.target.checked)
}
/>
</label>
<div className="font-mono">{entry.changeset}</div>
<div className="truncate text-muted-foreground">
{entry.changeType || "-"}
</div>
<div className="truncate">{entry.author || "-"}</div>
<div className="truncate text-muted-foreground">
{formatHistoryDate(entry.date)}
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate">{entry.comment || "-"}</span>
{folder && (
<Button
size="xs"
variant="ghost"
onClick={() => loadChangesetFiles(entry.changeset)}
>
文件
</Button>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="min-h-0 border-t bg-muted/10 p-2 text-xs md:border-t-0 md:border-l">
{folder ? (
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">
Changeset {selectedChangeset ?? "-"}
</span>
{filesLoading && <Loader2 className="size-3 animate-spin" />}
</div>
{changesetFiles.length === 0 ? (
<div className="text-muted-foreground">
点击目录历史 changeset 查看影响文件。
</div>
) : (
<div className="grid max-h-64 gap-2 overflow-auto">
{changesetFiles.map((file) => (
<div
key={`${file.changeset}:${file.serverPath}`}
className="grid gap-1 border-b pb-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 items-center gap-1.5">
{file.itemType.toLowerCase().includes("folder") ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono">
{file.serverPath}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
{file.changeType || "-"}
</span>
<Button
size="xs"
variant="outline"
onClick={() => enterChangesetFileDiff(file)}
>
<GitCompareArrows />
Diff
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div className="grid gap-2">
<div className="font-medium">历史 Diff</div>
<div className="text-muted-foreground">
选择两个历史版本后进入 Diff。
</div>
<Button
size="xs"
variant="outline"
disabled={selectedRevisions.length !== 2}
onClick={enterHistoryDiff}
>
<GitCompareArrows />
Diff
</Button>
</div>
)}
</div>
</div>
</div>
)
}
@@ -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>
)
}
@@ -1,241 +0,0 @@
import { useEffect, useState } from "react"
import {
ChevronDown,
ChevronRight,
Database,
FileText,
Folder,
Loader2,
} from "lucide-react"
import { listCollections, listServerTree } from "~/lib/api/endpoints"
import type { TfsCollectionInfo, TfsServerItem } from "~/lib/api/types"
interface ServerTreePanelProps {
connected: boolean
preferredCollection: string
onCollectionSelect(collection: string): void
onPathSelect(path: string): void
}
/**
* 渲染 Collection 与服务端目录树,并在展开目录时按需请求子节点。
*/
export function ServerTreePanel({
connected,
preferredCollection,
onCollectionSelect,
onPathSelect,
}: ServerTreePanelProps) {
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
const [selectedCollection, setSelectedCollection] = useState("")
const [selectedPath, setSelectedPath] = useState("")
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
const [itemsByPath, setItemsByPath] = useState<
Record<string, TfsServerItem[]>
>({})
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
useEffect(() => {
if (!connected) {
setCollections([])
setSelectedCollection("")
setSelectedPath("")
setExpandedPaths([])
setItemsByPath({})
return
}
loadCollections()
}, [connected, preferredCollection])
/**
* 加载 Collection 列表,并优先展开当前配置保存的 Collection。
*/
async function loadCollections() {
setLoading(true)
setErrorMessage("")
const result = await listCollections()
setLoading(false)
if (!result.success) {
setErrorMessage(result.errorMessage || result.message)
return
}
setCollections(result.data.collections)
const collection =
result.data.collections.find(
(item) => item.name === preferredCollection
) || result.data.collections[0]
if (collection) {
selectCollection(collection)
}
}
/**
* 切换当前 Collection,并加载该 Collection 的服务端根目录。
*/
async function selectCollection(collection: TfsCollectionInfo) {
setSelectedCollection(collection.name)
setSelectedPath("$/")
setExpandedPaths(["$/"])
setItemsByPath({})
onCollectionSelect(collection.name)
onPathSelect("$/")
await loadServerItems(collection.name, "$/")
}
/**
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
*/
async function loadServerItems(collection: string, path: string) {
setLoading(true)
setErrorMessage("")
const result = await listServerTree(path, collection)
setLoading(false)
if (!result.success) {
setErrorMessage(result.errorMessage || result.message)
return
}
setItemsByPath((currentItems) => ({
...currentItems,
[path]: result.data.items,
}))
}
/**
* 选中节点并通知中间区域当前服务端路径已变化。
*/
function selectPath(path: string) {
setSelectedPath(path)
onPathSelect(path)
}
/**
* 展开或收起目录;首次展开时才加载子节点。
*/
async function toggleFolder(item: TfsServerItem) {
selectPath(item.serverPath)
if (!item.folder || !selectedCollection) {
return
}
if (expandedPaths.includes(item.serverPath)) {
setExpandedPaths((currentPaths) =>
currentPaths.filter((path) => path !== item.serverPath)
)
return
}
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
if (!itemsByPath[item.serverPath]) {
await loadServerItems(selectedCollection, item.serverPath)
}
}
/**
* 按层级渲染目录树节点。
*/
function renderItems(path: string, level = 0) {
const items = itemsByPath[path] || []
return items.map((item) => {
const expanded = expandedPaths.includes(item.serverPath)
const selected = selectedPath === item.serverPath
return (
<div key={item.serverPath}>
<button
className={`flex h-7 w-full items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
selected ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
style={{ paddingLeft: 8 + level * 14 }}
onClick={() => toggleFolder(item)}
>
{item.folder ? (
expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)
) : (
<span className="w-3.5 shrink-0" />
)}
{item.folder ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{item.name}</span>
</button>
{item.folder && expanded && renderItems(item.serverPath, level + 1)}
</div>
)
})
}
if (!connected) {
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
}
return (
<div className="grid gap-2">
{loading && (
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
加载中
</div>
)}
{errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{errorMessage}
</div>
)}
<div className="grid gap-1">
{collections.map((collection) => (
<button
key={collection.id || collection.name}
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
selectedCollection === collection.name
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
onClick={() => selectCollection(collection)}
>
<Database className="size-3.5 shrink-0" />
<span className="truncate">{collection.name}</span>
</button>
))}
</div>
<div className="grid gap-0.5 border-t pt-2">
<button
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left font-mono text-xs ${
selectedPath === "$/"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
onClick={() => {
selectPath("$/")
if (selectedCollection && !itemsByPath["$/"]) {
loadServerItems(selectedCollection, "$/")
}
}}
>
<Folder className="size-3.5 shrink-0 text-amber-600" />
$/
</button>
{renderItems("$/")}
</div>
</div>
)
}
@@ -0,0 +1,279 @@
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 bg-muted/40 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` }}
title={path}
>
<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"
>
不存在
</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 bg-muted/40 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>
)
}
@@ -1,441 +0,0 @@
import { useEffect, useState } from "react"
import {
ArrowDown,
ArrowUp,
FileText,
Folder,
Loader2,
RefreshCw,
RotateCcw,
Upload,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
import type {
PendingChangeStatus,
TfsPendingChangeInfo,
} from "~/lib/api/types"
interface PendingChangesPanelProps {
connected: boolean
collection: string
refreshKey: number
}
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
pendingEdit: "编辑",
pendingAdd: "新增",
pendingDelete: "删除",
pendingRename: "重命名",
pending: "挂起",
}
const PENDING_STATUS_CLASSES: Record<PendingChangeStatus, string> = {
pendingEdit: "border-blue-200 bg-blue-50 text-blue-700",
pendingAdd: "border-green-200 bg-green-50 text-green-700",
pendingDelete: "border-red-200 bg-red-50 text-red-700",
pendingRename: "border-violet-200 bg-violet-50 text-violet-700",
pending: "border-border bg-muted/30 text-muted-foreground",
}
/**
* 在 Inspector 中展示当前 Workspace 的 Included / Excluded Pending Changes。
*/
export function PendingChangesPanel({
connected,
collection,
refreshKey,
}: PendingChangesPanelProps) {
const [pendingChanges, setPendingChanges] = useState<
TfsPendingChangeInfo[]
>([])
const [includedKeys, setIncludedKeys] = useState<string[]>([])
const [excludedKeys, setExcludedKeys] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [operationKey, setOperationKey] = useState("")
const [checkinComment, setCheckinComment] = useState("")
const [checkinLoading, setCheckinLoading] = useState(false)
const [messageType, setMessageType] = useState<"success" | "error">(
"success"
)
const [message, setMessage] = useState("")
useEffect(() => {
if (!connected || !collection) {
setPendingChanges([])
setIncludedKeys([])
setExcludedKeys([])
setCheckinComment("")
setCheckinLoading(false)
setMessageType("success")
setMessage("")
return
}
loadPendingChanges()
}, [connected, collection, refreshKey])
const includedChanges = pendingChanges.filter((change) =>
includedKeys.includes(toPendingChangeKey(change))
)
const excludedChanges = pendingChanges.filter((change) =>
excludedKeys.includes(toPendingChangeKey(change))
)
const collectionReady = connected && Boolean(collection)
const canCheckin =
includedChanges.length > 0 &&
checkinComment.trim().length > 0 &&
!checkinLoading
/**
* 查询后端 pending changes,并保留当前会话内用户移动到 Excluded 的选择。
*/
async function loadPendingChanges() {
setLoading(true)
setMessage("")
const result = await listPendingChanges({ collection })
setLoading(false)
if (!result.success) {
setPendingChanges([])
setIncludedKeys([])
setExcludedKeys([])
setMessageType("error")
setMessage(result.errorMessage || result.message)
return
}
const nextChanges = result.data.pendingChanges
const nextKeys = nextChanges.map(toPendingChangeKey)
setPendingChanges(nextChanges)
setExcludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => nextKeys.includes(currentKey))
)
setIncludedKeys((currentKeys) => {
const knownKeys = new Set([...currentKeys, ...excludedKeys])
const excludedKeySet = new Set(excludedKeys)
const includedKeySet = new Set(currentKeys)
return nextKeys.filter((key) => {
if (excludedKeySet.has(key)) {
return false
}
return knownKeys.has(key) ? includedKeySet.has(key) : true
})
})
}
/**
* 使用服务端路径作为 Pending Change 的会话 key。
*/
function toPendingChangeKey(change: TfsPendingChangeInfo) {
return change.serverPath
}
/**
* 将单个挂起更改移动到 Excluded Changes。
*/
function moveToExcluded(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setIncludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => currentKey !== key)
)
setExcludedKeys((currentKeys) =>
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
)
}
/**
* 将单个挂起更改移回 Included Changes,后续签入只读取 Included。
*/
function moveToIncluded(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setExcludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => currentKey !== key)
)
setIncludedKeys((currentKeys) =>
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
)
}
/**
* 撤销指定 pending change,完成后刷新右侧挂起更改列表。
*/
async function undoPendingChange(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setOperationKey(key)
setMessage("")
const result = await undoFiles({
paths: [change.serverPath],
recursive: change.folder,
})
setOperationKey("")
if (!result.success) {
setMessage(result.errorMessage || result.message)
setMessageType("error")
return
}
setMessageType("success")
setMessage(`Undo 完成,影响 ${result.data.result?.affected ?? 0} 项。`)
await loadPendingChanges()
}
/**
* 提交 Included Changes,成功后清空 Included 并保留 Excluded 会话状态。
*/
async function submitIncludedChanges() {
const comment = checkinComment.trim()
if (!comment) {
setMessageType("error")
setMessage("Comment 必填。")
return
}
if (includedChanges.length === 0) {
setMessageType("error")
setMessage("没有可签入的 Included Changes。")
return
}
setCheckinLoading(true)
setMessage("")
const result = await checkin({
serverPaths: includedChanges.map((change) => change.serverPath),
comment,
})
setCheckinLoading(false)
if (!result.success) {
setMessageType("error")
setMessage(result.errorMessage || result.message)
return
}
setCheckinComment("")
await loadPendingChanges()
setIncludedKeys([])
setMessageType("success")
setMessage(
`Checkin 成功,changeset ${result.data.checkin?.changeset ?? "-"},提交 ${result.data.checkin?.submittedChanges ?? 0} 项。`
)
}
return (
<section className="grid gap-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-medium">Pending Changes</div>
<div className="text-xs text-muted-foreground">
签入范围:Included Changes
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
{pendingChanges.length} 项
</span>
<Button
size="icon-xs"
variant="ghost"
disabled={!collectionReady || loading || checkinLoading}
title="刷新挂起更改"
onClick={loadPendingChanges}
>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<RefreshCw />
)}
</Button>
</div>
</div>
{!collectionReady ? (
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
{connected ? "请选择 Collection" : "未连接"}
</div>
) : (
<>
{message && (
<div
className={`rounded-[6px] border px-3 py-2 text-xs ${
messageType === "error"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "bg-muted/20 text-muted-foreground"
}`}
>
{message}
</div>
)}
<PendingChangeGroup
title="Included Changes"
changes={includedChanges}
emptyText={loading ? "正在查询挂起更改" : "暂无 Included Changes"}
actionTitle="移到 Excluded"
actionIcon="down"
operationKey={operationKey}
disabled={checkinLoading}
onUndo={undoPendingChange}
onMove={moveToExcluded}
/>
<PendingChangeGroup
title="Excluded Changes"
changes={excludedChanges}
emptyText="暂无 Excluded Changes"
actionTitle="移回 Included"
actionIcon="up"
operationKey={operationKey}
disabled={checkinLoading}
onUndo={undoPendingChange}
onMove={moveToIncluded}
/>
<section className="grid gap-2">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-muted-foreground">Checkin</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
Included {includedChanges.length}
</span>
</div>
<textarea
className="min-h-20 rounded-[6px] border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={checkinComment}
placeholder="Comment"
disabled={checkinLoading}
onChange={(event) => setCheckinComment(event.target.value)}
/>
<Button
size="sm"
disabled={!canCheckin}
onClick={submitIncludedChanges}
>
{checkinLoading ? (
<Loader2 className="animate-spin" />
) : (
<Upload />
)}
Checkin
</Button>
<div className="text-xs text-muted-foreground">
只提交 Included Changes,Excluded 保留在当前 UI 会话。
</div>
</section>
</>
)}
</section>
)
}
interface PendingChangeGroupProps {
title: string
changes: TfsPendingChangeInfo[]
emptyText: string
actionTitle: string
actionIcon: "up" | "down"
operationKey: string
disabled: boolean
onUndo(change: TfsPendingChangeInfo): void
onMove(change: TfsPendingChangeInfo): void
}
/**
* 渲染 Pending Changes 单个分组,并提供组间移动入口。
*/
function PendingChangeGroup({
title,
changes,
emptyText,
actionTitle,
actionIcon,
operationKey,
disabled,
onUndo,
onMove,
}: PendingChangeGroupProps) {
return (
<section className="grid gap-2">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-muted-foreground">{title}</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
{changes.length}
</span>
</div>
{changes.length === 0 ? (
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{emptyText}
</div>
) : (
<div className="grid max-h-56 gap-2 overflow-auto">
{changes.map((change) => (
<div
key={change.serverPath}
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{change.folder ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium">{change.name}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
title="Undo"
disabled={disabled || operationKey === change.serverPath}
onClick={() => onUndo(change)}
>
{operationKey === change.serverPath ? (
<Loader2 className="animate-spin" />
) : (
<RotateCcw />
)}
</Button>
<Button
size="icon-xs"
variant="ghost"
title={actionTitle}
disabled={disabled || operationKey === change.serverPath}
onClick={() => onMove(change)}
>
{actionIcon === "down" ? <ArrowDown /> : <ArrowUp />}
</Button>
</div>
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{change.serverPath}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1">
<span
className={`rounded-[6px] border px-1.5 py-0.5 ${PENDING_STATUS_CLASSES[change.status]}`}
>
{change.status} / {PENDING_STATUS_LABELS[change.status]}
</span>
{change.changeType && (
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-muted-foreground">
{change.changeType}
</span>
)}
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 font-mono text-muted-foreground">
v{change.version}
</span>
</div>
</div>
))}
</div>
)}
</section>
)
}
@@ -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 bg-muted/40 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,55 @@
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"
// Radix 默认给内容包一层 display:table + min-width:100% 的 div,会让内容按
// 自身宽度撑开导致 truncate 失效、文字被面板边缘硬切;改为 block 占满视口宽度。
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:block! [&>div]:w-full [&>div]:min-w-0!"
>
{children}
</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-primary/8 data-[state=selected]:hover:bg-primary/10",
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 }
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "~/lib/utils"
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
export type ToastKind = "success" | "error" | "loading"
export interface ToastItem {
id: string
kind: ToastKind
text: string
}
type Listener = (toasts: ToastItem[]) => void
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
let currentToasts: ToastItem[] = []
const listeners = new Set<Listener>()
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
const AUTO_DISMISS_MS = 3200
const MAX_VISIBLE = 4
/**
* 通知所有挂载中的 Toaster 重新渲染。
*/
function emit() {
for (const listener of listeners) {
listener(currentToasts)
}
}
/**
* 关闭指定 toast。
*/
export function dismissToast(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
currentToasts = currentToasts.filter((item) => item.id !== id)
emit()
}
/**
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
*/
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const existing = currentToasts.find((item) => item.id === id)
const next: ToastItem = { id, kind: input.kind, text: input.text }
currentToasts = existing
? currentToasts.map((item) => (item.id === id ? next : item))
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
if (input.kind !== "loading") {
dismissTimers.set(
id,
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
)
}
emit()
return id
}
// toast 类型对应的状态徽标:macOS 通知风的彩色小圆徽(成功绿 / 失败红),loading 用旋转图标。
const KIND_BADGES: Record<"success" | "error", { icon: typeof Check; chipClass: string }> = {
success: { icon: Check, chipClass: "bg-emerald-500" },
error: { icon: X, chipClass: "bg-destructive" },
}
/**
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
* 视觉对齐 macOS 系统通知:白色毛玻璃圆角卡片 + 彩色状态圆徽 + 柔和投影,无彩色边框。
*/
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([])
useEffect(() => {
const listener: Listener = (toasts) => setItems([...toasts])
listeners.add(listener)
setItems([...currentToasts])
return () => {
listeners.delete(listener)
}
}, [])
if (items.length === 0) {
return null
}
return (
<div className="pointer-events-none fixed top-14 right-4 z-[60] flex flex-col items-end gap-2.5">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className="animate-in fade-in slide-in-from-top-2 ease-out-quart pointer-events-auto flex w-fit max-w-[360px] min-w-[200px] items-center gap-2.5 rounded-xl border border-black/8 bg-popover/90 py-2.5 pr-4 pl-3 text-left shadow-[0_8px_24px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.06)] backdrop-blur-xl duration-300"
>
{item.kind === "loading" ? (
<Loader2 className="size-4.5 shrink-0 animate-spin text-primary" />
) : (
<ToastBadge kind={item.kind} />
)}
<span className="min-w-0 text-[13px] leading-snug font-medium break-words text-foreground">
{item.text}
</span>
</button>
))}
</div>
)
}
/**
* 成功 / 失败的彩色圆形徽标:实色圆底 + 白色粗描边图标。
*/
function ToastBadge({ kind }: { kind: "success" | "error" }) {
const badge = KIND_BADGES[kind]
const Icon = badge.icon
return (
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full",
badge.chipClass,
)}
>
<Icon className="size-3 text-white" strokeWidth={3.2} />
</span>
)
}
+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,
}
}
+94 -193
View File
@@ -1,216 +1,117 @@
import { getMactfsBridge } from "~/lib/electron/bridge"
import type { ApiResult } from "./types"
const API_BASE_URL = "http://127.0.0.1:38765"
interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
body?: unknown
headers?: HeadersInit
}
export type ApiRequestLogStatus = "running" | "success" | "error"
export interface ApiRequestLogEntry {
id: string
operation: string
summary: string
startedAt: number
endedAt?: number
durationMs?: number
success?: boolean
errorMessage?: string
status: ApiRequestLogStatus
logs?: string[]
}
type ApiRequestLogListener = (entry: ApiRequestLogEntry) => void
const apiRequestLogListeners = new Set<ApiRequestLogListener>()
let apiRequestLogIndex = 0
import { getApiBaseUrl, getToken } from "~/lib/electron"
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
/**
* 订阅统一 API client 的请求日志,供底部 Console 展示每次操作状态。
* 拼接查询参数到请求路径,自动跳过空值。
*/
export function subscribeApiRequestLogs(listener: ApiRequestLogListener) {
apiRequestLogListeners.add(listener)
return () => {
apiRequestLogListeners.delete(listener)
}
}
/**
* 向所有日志订阅方发布一条请求状态更新。
*/
function notifyApiRequestLog(entry: ApiRequestLogEntry) {
apiRequestLogListeners.forEach((listener) => listener(entry))
}
/**
* 生成本次请求的前端日志 id,用于 running 和完成态更新同一行。
*/
function nextApiRequestLogId(apiPath: string) {
apiRequestLogIndex += 1
return `${Date.now()}:${apiRequestLogIndex}:${apiPath}`
}
/**
* 从请求体或 query 中提取路径摘要,便于 Console 快速定位操作对象。
*/
function summarizeApiRequest(apiPath: string, body?: unknown) {
if (body && typeof body === "object" && !Array.isArray(body)) {
const bodySummary = summarizeRequestBody(body as Record<string, unknown>)
if (bodySummary) {
return bodySummary
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))
}
}
const query = apiPath.split("?")[1]
return query || apiPath
}
/**
* 按后端常用字段生成请求体摘要。
*/
function summarizeRequestBody(body: Record<string, unknown>) {
return [
"serverPath",
"path",
"localPath",
"paths",
"serverPaths",
"changeset",
"sourceChangeset",
"targetChangeset",
]
.filter((key) => body[key] !== undefined && body[key] !== "")
.map((key) => `${key}=${formatSummaryValue(body[key])}`)
.join(" | ")
}
/**
* 将摘要字段值转换成单行文本。
*/
function formatSummaryValue(value: unknown) {
return Array.isArray(value) ? value.join(", ") : String(value)
}
/**
* 创建前端统一失败响应,供网络或 Electron 桥接不可用时展示明确提示。
*/
function createFailureResult<TData>(
message: string,
startedAt = Date.now()
): ApiResult<TData> {
const endedAt = Date.now()
return {
success: false,
message,
errorMessage: message,
operation: "frontendRequest",
startedAt,
endedAt,
durationMs: endedAt - startedAt,
logs: [],
data: {} as TData,
}
return url.toString()
}
/**
* 将统一 API 响应转换为底部 Console 可展示的日志行。
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
*/
function toApiRequestLogEntry(
id: string,
summary: string,
fallbackStartedAt: number,
result: ApiResult<unknown>
): ApiRequestLogEntry {
return {
id,
operation: result.operation,
summary,
startedAt: result.startedAt || fallbackStartedAt,
endedAt: result.endedAt,
durationMs: result.durationMs,
success: result.success,
errorMessage: result.errorMessage || (result.success ? "" : result.message),
status: result.success ? "success" : "error",
logs: result.logs,
function resolveErrorMessage(
result: ApiResult<unknown> | null,
fallback: string,
): string {
if (result) {
return result.errorMessage || result.message || fallback
}
return fallback
}
/**
* 通过统一 API client 调用本地 Java API,并自动附加 Bearer token。
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
*/
export async function apiRequest<TData>(
apiPath: string,
options: ApiRequestOptions = {}
): Promise<ApiResult<TData>> {
const startedAt = Date.now()
const logId = nextApiRequestLogId(apiPath)
const summary = summarizeApiRequest(apiPath, options.body)
notifyApiRequestLog({
id: logId,
operation: apiPath,
summary,
startedAt,
status: "running",
})
const bridge = getMactfsBridge()
if (!bridge) {
const result = createFailureResult<TData>(
"未检测到 Electron preload,无法读取本地 API token。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
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",
}
const token = await bridge.getToken()
if (!token) {
const result = createFailureResult<TData>(
"未找到本地 API token,请确认本地服务已启动。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
if (token) {
headers.Authorization = `Bearer ${token}`
}
try {
const headers = new Headers(options.headers)
headers.set("Authorization", `Bearer ${token}`)
const init: RequestInit = { method, headers, signal: options.signal }
if (options.body !== undefined) {
headers.set("Content-Type", "application/json")
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
...options,
headers,
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
})
const result = (await response.json()) as ApiResult<TData>
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
let response: Response
try {
response = await fetch(buildUrl(baseUrl, path, options.query), init)
} catch (error) {
const result = createFailureResult<TData>(
error instanceof Error ? error.message : "本地 API 请求失败。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
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)
},
}
+198 -149
View File
@@ -1,237 +1,286 @@
import { apiRequest } from "./client"
import { apiClient } from "./client"
import type {
AppConfig,
AddMappingData,
AddMappingRequest,
CheckinData,
CheckinRequest,
ChangesetFilesData,
CompareFolderData,
CompareFolderRequest,
CollectionsData,
FileOperationData,
FileOperationRequest,
ConfigData,
ConnectData,
DiffLocalLatestRequest,
DiffRevisionsRequest,
GetLatestData,
GetLatestRequest,
HistoryData,
MappingsData,
MappingsRequest,
OperationLogsData,
PendingChangesData,
PendingChangesRequest,
ServerTreeData,
TextDiffData,
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 apiRequest<ConfigData>("/api/config")
return apiClient.get<{ config: AppConfig }>("/api/config")
}
/**
* 提交连接配置给后端;连接成功时后端会保存为默认配置。
* 保存服务端持久化配置。
*/
export function connectSession(config: AppConfig) {
return apiRequest<ConnectData>("/api/session/connect", {
method: "POST",
body: config,
})
export function saveConfig(config: AppConfig) {
return apiClient.put<{ config: AppConfig }>("/api/config", { body: config })
}
/**
* 加载当前连接可见的 TFS Collection 列表。
* 使用给定连接信息连接 TFS,成功后服务端会保存配置并建立会话。
*/
export function connect(config: Partial<AppConfig> = {}) {
return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
}
/**
* 列出当前账号可见的 Collection。
*/
export function listCollections() {
return apiRequest<CollectionsData>("/api/collections")
return apiClient.get<{ collections: Collection[] }>("/api/collections")
}
/**
* 加载指定 Collection 和服务端路径下的目录节点。
* 浏览服务端目录树节点。
*/
export function listServerTree(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(`/api/server-tree?${params.toString()}`)
export function getServerTree(params: { path?: string; collection?: string } = {}) {
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-tree", { query: params })
}
/**
* 加载当前 Workspace 的 Mapping 列表,用于判断服务端项是否已映射到本地。
* 读取当前目录下一级文件与文件夹列表。
*/
export function listMappings(request: MappingsRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
const query = params.toString()
return apiRequest<MappingsData>(
query ? `/api/mappings?${query}` : "/api/mappings"
)
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 listServerFolderItems(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(
`/api/server-folder/items?${params.toString()}`
)
export function getWorkspace() {
return apiClient.get<{ workspace: string; mappings: MappingInfo[] }>("/api/workspace")
}
/**
* 创建服务端路径到本地目录的 Mapping,可选择保存后立即 Get Latest。
* 确保当前 Collection 的默认 Workspace 存在,必要时创建。
*/
export function addMapping(request: AddMappingRequest) {
return apiRequest<AddMappingData>("/api/mappings", {
method: "POST",
body: request,
})
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 compareFolder(request: CompareFolderRequest) {
return apiRequest<CompareFolderData>("/api/compare/folder", {
method: "POST",
body: request,
})
export function getWorkspaceContext() {
return apiClient.get<{ context: WorkspaceContext }>("/api/workspace/context")
}
/**
* 查询当前 Workspace 的挂起更改列表,供右侧 Inspector 分组展示。
* 读取当前 Workspace 的 Mapping 列表。
*/
export function listPendingChanges(request: PendingChangesRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
if (request.serverPath) {
params.set("serverPath", request.serverPath)
}
const query = params.toString()
return apiRequest<PendingChangesData>(
query ? `/api/pending-changes?${query}` : "/api/pending-changes"
)
export function listMappings() {
return apiClient.get<{ mappings: MappingInfo[] }>("/api/mappings")
}
/**
* 获取指定服务端路径的最新版本,目录按 recursive 参数递归。
* 预校验 Mapping 目标路径,返回最终本地目标路径及是否已存在。
*/
export function getLatest(request: GetLatestRequest) {
return apiRequest<GetLatestData>("/api/files/get-latest", {
method: "POST",
body: request,
})
export function checkMappingTarget(body: { serverPath: string; localParentPath: string }) {
return apiClient.post<CheckMappingTargetResult>("/api/mappings/check-target", { body })
}
/**
* 对已映射且本地存在的文件或目录执行 checkout。
* 创建 Mapping,可选立即 Get Latest。
*/
export function checkoutFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/checkout", {
method: "POST",
body: request,
})
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(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/add", {
method: "POST",
body: request,
})
export function addFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/add", { body })
}
/**
* 对已版本控制文件或目录执行 pending delete。
*/
export function deleteFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/delete", {
method: "POST",
body: request,
})
export function deleteFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/delete", { body })
}
/**
* 撤销指定路径上的 pending changes。
* 撤销指定路径上的挂起更改。
*/
export function undoFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/undo", {
method: "POST",
body: request,
})
export function undoFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
}
/**
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
* 重命名文件或目录(同目录改名),产生 rename 挂起更改。
*/
export function checkin(request: CheckinRequest) {
return apiRequest<CheckinData>("/api/checkin", {
method: "POST",
body: request,
})
export function renameFile(body: { serverPath: string; newName: string }) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/rename", { body })
}
/**
* 查询文件或目录最近历史记录。
* 回滚变更集(只产生挂起更改):single 仅反做该 changeset,toVersion 反做其后全部改动。
*/
export function queryHistory(path: string, folder: boolean) {
const params = new URLSearchParams({ path, folder: String(folder) })
return apiRequest<HistoryData>(`/api/history?${params.toString()}`)
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 queryChangesetFiles(changeset: number) {
const params = new URLSearchParams({ changeset: String(changeset) })
return apiRequest<ChangesetFilesData>(
`/api/history/changeset?${params.toString()}`
)
export function getChangesetFiles(params: { changeset: number }) {
return apiClient.get<{ files: HistoryEntry[] }>("/api/history/changeset", { query: params })
}
/**
* 生成本地文件和服务器 latest 之间的文本 diff。
* 生成本地文件与服务器 latest 的文本 Diff。
*/
export function diffLocalLatest(request: DiffLocalLatestRequest) {
return apiRequest<TextDiffData>("/api/diff/local-latest", {
method: "POST",
body: request,
})
export function diffLocalLatest(body: { serverPath: string; localPath: string }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/local-latest", { body })
}
/**
* 生成同一服务端文件两个历史版本之间的文本 diff。
* 生成同一服务端文件两个 changeset 之间的文本 Diff。
*/
export function diffRevisions(request: DiffRevisionsRequest) {
return apiRequest<TextDiffData>("/api/diff/revisions", {
method: "POST",
body: request,
})
export function diffRevisions(body: { serverPath: string; sourceChangeset: number; targetChangeset: number }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/revisions", { body })
}
/**
* 读取本地 API 服务最近操作日志,供底部 Console 手动刷新。
* 查询当前 Workspace 的冲突明细。
*/
export function listOperationLogs() {
return apiRequest<OperationLogsData>("/api/logs")
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"
+129 -153
View File
@@ -1,34 +1,39 @@
export interface ApiResult<TData = Record<string, unknown>> {
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message: string
message?: string
errorMessage?: string
operation: string
startedAt: number
endedAt: number
durationMs: number
logs: string[]
data: TData
operation?: string
startedAt?: number
endedAt?: number
durationMs?: number
logs?: string[]
data: T
}
export interface HealthData {
status: string
host: string
port: number
tokenFile: string
configFile: string
connected: boolean
// 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
}
export interface TfsMappingInfo {
serverPath: string
localPath: string
}
// 服务端持久化配置,对应 MacTfsServer.AppConfig。
export interface AppConfig {
serverUri?: string
authType?: string
@@ -37,215 +42,186 @@ export interface AppConfig {
password?: string
collection?: string
workspace?: string
mappings?: MappingConfig[]
mappings: MappingConfig[]
}
export interface ConfigData {
config: AppConfig
}
export interface ConnectData {
// 连接结果摘要,对应 /api/session/connect 返回的概要字段。
export interface ConnectResult {
serverUri?: string
collectionCount?: number
}
export interface TfsCollectionInfo {
// Collection 信息,对应 core TfsCollectionInfo。
export interface Collection {
name: string
id: string
}
export interface TfsServerItem {
// 服务端目录项,对应服务端 serverItems 序列化结构。
export interface ServerItem {
name: string
path: string
serverPath: string
type: "folder" | "file"
folder: boolean
latestVersion: number
checkinDate?: number | null
checkinDate: number | null
}
export interface CollectionsData {
collections: TfsCollectionInfo[]
}
export interface ServerTreeData {
path: string
items: TfsServerItem[]
}
export interface MappingsData {
mappings: TfsMappingInfo[]
}
export interface MappingsRequest {
collection?: string
}
export interface AddMappingRequest {
// Workspace Mapping 信息,对应 core TfsMappingInfo。
export interface MappingInfo {
serverPath: string
localPath: string
getLatest: boolean
}
export interface AddMappingData {
mapping?: TfsMappingInfo
getLatest?: unknown
}
export type FolderDiffStatus =
| "localModified"
| "remoteChanged"
| "bothChanged"
| "localOnly"
| "remoteOnly"
| "notDownloaded"
| "localDeleted"
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "upToDate"
export interface CompareFolderRequest {
serverPath: string
localPath: string
recursive: boolean
}
export interface FolderDiffItem {
serverPath: string
localPath: string
// Workspace 概要,对应 core TfsWorkspaceInfo。
export interface WorkspaceInfo {
name: string
folder: boolean
status: FolderDiffStatus
localVersion: number
latestVersion: number
ownerName: string
computer: string
comment: string
created: boolean
mappings: MappingInfo[]
}
export interface CompareFolderData {
diffs: FolderDiffItem[]
}
export type PendingChangeStatus =
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "pendingRename"
| "pending"
export interface TfsPendingChangeInfo {
serverPath: string
localPath: string
name: string
folder: boolean
status: PendingChangeStatus
changeType: string
version: number
}
export interface PendingChangesData {
pendingChanges: TfsPendingChangeInfo[]
}
export interface PendingChangesRequest {
// 工作台固定上下文,对应 /api/workspace/context 的 context 字段。
export interface WorkspaceContext {
serverUri?: string
authType?: string
collection?: string
serverPath?: string
workspace?: string
connected: boolean
mappings: MappingInfo[]
}
export interface GetLatestRequest {
// Mapping 目标预校验结果,对应 /api/mappings/check-target。
export interface CheckMappingTargetResult {
serverPath: string
recursive: boolean
localParentPath: string
name: string
targetPath: string
exists: boolean
allowed: boolean
}
export interface TfsGetLatestResult {
// Get Latest 结果,对应 core TfsGetLatestResult。
export interface GetLatestResult {
updated: number
operations: number
conflicts: number
failures: number
}
export interface GetLatestData {
result?: TfsGetLatestResult
}
export interface FileOperationRequest {
paths: string[]
recursive?: boolean
}
export interface TfsFileOperationResult {
// 文件操作结果,对应 core TfsFileOperationResult。
export interface FileOperationResult {
operation: string
affected: number
failures: string[]
}
export interface FileOperationData {
result?: TfsFileOperationResult
// 挂起更改项,对应 core TfsPendingChangeInfo。
export interface PendingChange {
serverPath: string
localPath: string
name: string
folder: boolean
status: string
changeType: string
version: number
/** rename 挂起更改的原服务端路径,其余为 null。 */
sourceServerPath: string | null
}
export interface CheckinRequest {
serverPaths: string[]
comment: string
}
export interface TfsCheckinResult {
// 签入结果,对应 core TfsCheckinResult。
export interface CheckinResult {
changeset: number
submittedChanges: number
}
export interface CheckinData {
checkin?: TfsCheckinResult
// 目录对比项,对应 core TfsFolderDiffItem。
export interface FolderDiffItem {
serverPath: string
localPath: string
name: string
folder: boolean
status: string
localVersion: number
latestVersion: number
}
export interface TfsHistoryEntry {
// 历史记录项,对应 core TfsHistoryEntry。
export interface HistoryEntry {
serverPath: string
name: string
changeType: string
itemType: string
changeset: number
author: string
date?: number | null
date: number | null
comment: string
}
export interface HistoryData {
history: TfsHistoryEntry[]
}
export interface ChangesetFilesData {
files: TfsHistoryEntry[]
}
export interface DiffLocalLatestRequest {
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
export interface ItemInfo {
serverPath: string
localPath: string
folder: boolean
changeset: number
checkinDate: number | null
author: string
comment: string
/** 文件大小(字节),目录为 null。 */
size: number | null
/** 文件编码名称,目录为 null。 */
encoding: string | null
}
export interface DiffRevisionsRequest {
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
export interface FileContent {
serverPath: string
sourceChangeset: number
targetChangeset: number
changeset: number
content: string
binary: boolean
size: number
tooLarge: boolean
}
export interface TfsTextDiff {
// 文本 Diff,对应 core TfsTextDiff。
export interface TextDiff {
sourceLabel: string
targetLabel: string
lines: string[]
}
export interface TextDiffData {
diff?: TfsTextDiff
// 冲突明细,对应 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 interface TfsOperationLogEntry {
// 第一版支持的冲突取舍:采用服务器版本或保留本地版本。
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
}
export interface OperationLogsData {
logs: TfsOperationLogEntry[]
errorMessage: string | null
}
+102 -20
View File
@@ -1,28 +1,110 @@
export interface ServiceStatus {
baseUrl: string
running: boolean
connected: boolean
message: string
tokenFile: string
configFile?: string
started?: boolean
}
import type { MactfsBridge, ServiceStatus } from "./types"
export interface MacTfsBridge {
getToken(): Promise<string>
getServiceStatus(): Promise<ServiceStatus>
selectDirectory(): Promise<string | null>
}
const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
declare global {
interface Window {
mactfs?: MacTfsBridge
/**
* 获取 Electron 暴露的 window.mactfs 桥接对象,非 Electron 环境(含 SSR)返回 null。
*/
export function getBridge(): MactfsBridge | null {
if (typeof window === "undefined") {
return null
}
return window.mactfs ?? null
}
/**
* 返回 preload 暴露的 macTFS 桥接对象,避免组件直接依赖 Electron 细节。
* 判断当前是否运行在 Electron 桥接环境中。
*/
export function getMactfsBridge() {
return window.mactfs
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
}
}
+75
View File
@@ -0,0 +1,75 @@
// 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 已注册语言,匹配不到时回退纯文本。
*/
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",
remoteChanged: "bg-violet-500/10 text-violet-700",
bothChanged: "bg-red-500/15 text-red-700",
localOnly: "bg-green-500/10 text-green-700",
remoteOnly: "bg-violet-500/10 text-violet-700",
notDownloaded: "bg-amber-500/10 text-amber-700",
localDeleted: "bg-red-500/10 text-red-700",
pendingEdit: "bg-blue-500/10 text-blue-700",
pendingAdd: "bg-green-500/10 text-green-700",
pendingDelete: "bg-red-500/10 text-red-700",
pendingRename: "bg-blue-500/10 text-blue-700",
pending: "bg-blue-500/10 text-blue-700",
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 环境(macOS Electron 下透出窗口毛玻璃) */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy")}catch(e){}})()`,
}}
/>
<Meta />
<Links />
</head>
+79 -368
View File
@@ -1,408 +1,119 @@
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { AppShell } from "~/components/app/app-shell"
import { ConnectView } from "~/components/app/connect-view"
import { WorkspaceShell } from "~/components/app/workspace-shell"
import { Button } from "~/components/ui/button"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
import type { ApiRequestLogEntry } from "~/lib/api/client"
import {
connectSession,
getConfig,
listOperationLogs,
} from "~/lib/api/endpoints"
import type {
ApiResult,
AppConfig,
ConnectData,
HealthData,
TfsOperationLogEntry,
} from "~/lib/api/types"
import type { ServiceStatus } from "~/lib/electron/bridge"
import { getMactfsBridge } from "~/lib/electron/bridge"
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"
interface ServiceViewState {
loading: boolean
serviceStatus?: ServiceStatus
healthResult?: ApiResult<HealthData>
errorMessage?: string
}
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
interface ConnectionForm {
serverUri: string
authType: string
domain: string
username: string
password: string
}
const EMPTY_CONNECTION_FORM: ConnectionForm = {
serverUri: "",
authType: "ntlm-explicit",
domain: "",
username: "",
password: "",
/**
* 页面标题(同时作为 Electron 窗口标题)。
*/
export function meta() {
return [{ title: "MacTFS" }]
}
/**
* 将后端配置转换为表单可直接使用的字符串结构,避免受缺省字段影响。
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
*/
function toConnectionForm(config?: AppConfig): ConnectionForm {
return {
serverUri: config?.serverUri || "",
authType: config?.authType || "ntlm-explicit",
domain: config?.domain || "",
username: config?.username || "",
password: config?.password || "",
}
}
export default function Home() {
const [state, setState] = useState<ServiceViewState>({
loading: true,
})
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
const [connecting, setConnecting] = useState(false)
const [connected, setConnected] = useState(false)
const [connectResult, setConnectResult] = useState<ConnectData>()
const [preferredCollection, setPreferredCollection] = useState("")
const [selectedCollection, setSelectedCollection] = useState("")
const [selectedServerPath, setSelectedServerPath] = useState("")
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
const [workspaceActionKey, setWorkspaceActionKey] = useState("")
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
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)
/**
* 先确认服务状态,再读取默认配置并验证自动 token 请求。
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
*/
async function loadServiceStatus() {
setState((currentState) => ({
...currentState,
loading: true,
errorMessage: undefined,
}))
const bridge = getMactfsBridge()
if (!bridge) {
setState({
loading: false,
errorMessage:
"未检测到 Electron preload,请通过 Electron 启动 macTFS。",
})
return
const bootstrap = useCallback(async () => {
setPhase("checking")
let next = await getServiceStatus()
if (!next.running && isElectron()) {
next = await startService()
}
const serviceStatus = await bridge.getServiceStatus()
const healthResult = serviceStatus.running
? await apiRequest<HealthData>("/api/health")
: undefined
const configResult = serviceStatus.running ? await getConfig() : undefined
if (configResult?.success) {
setForm(toConnectionForm(configResult.data.config))
setPreferredCollection(configResult.data.config.collection || "")
}
setState({
loading: false,
serviceStatus,
healthResult,
errorMessage: configResult?.success
? undefined
: configResult?.errorMessage || configResult?.message,
})
setConnected(Boolean(healthResult?.data.connected))
}
useEffect(() => {
return subscribeApiRequestLogs(upsertOperationLog)
setStatus(next)
setPhase(next.running ? "connect" : "blocked")
}, [])
useEffect(() => {
loadServiceStatus()
void bootstrap()
}, [bootstrap])
/**
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
*/
const handleReady = useCallback((next: WorkspaceSession) => {
setSession(next)
setSelectedServerPath(SERVER_ROOT_PATH)
setPhase("workspace")
}, [])
useEffect(() => {
if (connected) {
loadOperationLogs()
}
}, [connected])
const serviceReady =
state.serviceStatus?.running && state.healthResult?.success
const canConnect = serviceReady && !state.loading && !connecting
const serviceMessage = serviceReady
? "本地 API 已连接。"
: state.serviceStatus?.message ||
state.errorMessage ||
"正在检查本地 API 服务。"
const requestStatus = state.healthResult?.success
? state.healthResult.message
: state.healthResult?.errorMessage || state.errorMessage || "等待检查"
/**
* 从工作台返回连接入口,重新走连接流程。
*/
const handleReconnect = useCallback(() => {
setSession(null)
setPhase("connect")
}, [])
/**
* 更新连接表单字段,保持输入状态与受控表单一致。
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
*/
function updateFormField(field: keyof ConnectionForm, value: string) {
setForm((currentForm) => ({
...currentForm,
[field]: value,
}))
}
const handleNavigate = useCallback((serverPath: string) => {
setSelectedServerPath(serverPath)
}, [])
/**
* 调用连接 API,成功后进入已连接工作台状态。
* Mapping 创建 / 取消后更新固定上下文中的映射列表,浏览位置保持不变。
*/
async function handleConnect(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setConnecting(true)
setConnectResult(undefined)
setState((currentState) => ({
...currentState,
errorMessage: undefined,
}))
const handleMappingsChanged = useCallback((mappings: WorkspaceSession["mappings"]) => {
setSession((prev) => (prev ? { ...prev, mappings } : prev))
}, [])
const result = await connectSession(form)
setConnecting(false)
if (!result.success) {
setState((currentState) => ({
...currentState,
errorMessage: result.errorMessage || result.message,
}))
return
}
setConnected(true)
setConnectResult(result.data)
}
/**
* 触发右侧 Pending Changes 面板重新查询当前 Workspace 状态。
*/
function refreshPendingChanges() {
setPendingRefreshKey((currentKey) => currentKey + 1)
}
/**
* 切换主工作区当前路径,并清理上一目录的选中项状态。
*/
function selectServerPath(path: string) {
setSelectedMappedItem(false)
setSelectedServerPath(path)
}
/**
* 触发中间工作区文件操作,顶部工具栏和底部操作区复用同一套执行逻辑。
*/
function runWorkspaceAction(action: string) {
setWorkspaceActionKey(`${action}:${Date.now()}`)
}
/**
* 合并统一 API client 推送的请求日志,保留最近 80 条。
*/
function upsertOperationLog(entry: ApiRequestLogEntry) {
setOperationLogs((currentLogs) =>
[entry, ...currentLogs.filter((log) => log.id !== entry.id)].slice(0, 80)
if (phase === "workspace" && session) {
return (
<WorkspaceShell
session={session}
selectedServerPath={selectedServerPath}
onNavigate={handleNavigate}
onMappingsChanged={handleMappingsChanged}
onReconnect={handleReconnect}
/>
)
}
/**
* 从服务端读取最近操作日志,补齐当前 UI 会话之前的 API 操作记录。
*/
async function loadOperationLogs() {
setOperationLogsLoading(true)
const result = await listOperationLogs()
setOperationLogsLoading(false)
if (!result.success) {
return
}
const serverLogs = result.data.logs.map(toOperationLog).reverse()
setOperationLogs((currentLogs) => [
...currentLogs.filter((log) => log.status === "running"),
...serverLogs,
].slice(0, 80))
}
/**
* 将后端操作日志转换成底部 Console 的展示结构。
*/
function toOperationLog(
entry: TfsOperationLogEntry,
index: number
): ApiRequestLogEntry {
return {
id: `server:${entry.startedAt}:${entry.operation}:${index}`,
operation: entry.operation,
summary: entry.summary || "-",
startedAt: entry.startedAt,
endedAt: entry.endedAt,
durationMs: entry.durationMs,
success: entry.success,
errorMessage: entry.errorMessage,
status: entry.success ? "success" : "error",
}
if (phase === "connect") {
return <ConnectView onReady={handleReady} />
}
return (
<div className="min-h-svh bg-muted/40 p-4 text-sm">
<AppShell
connected={connected}
serviceReady={Boolean(serviceReady)}
serviceLoading={state.loading}
serverUri={connectResult?.serverUri || form.serverUri}
username={form.username}
collectionCount={connectResult?.collectionCount}
serviceBaseUrl={state.serviceStatus?.baseUrl}
tokenFile={state.serviceStatus?.tokenFile}
serviceMessage={serviceMessage}
requestStatus={requestStatus}
operationLogs={operationLogs}
operationLogsLoading={operationLogsLoading}
workspaceGetLatestEnabled={connected && selectedMappedItem}
workspaceHistoryEnabled={connected && Boolean(selectedServerPath)}
sourceList={
<ServerTreePanel
connected={connected}
preferredCollection={preferredCollection}
onCollectionSelect={setSelectedCollection}
onPathSelect={selectServerPath}
/>
}
inspector={
<PendingChangesPanel
connected={connected}
collection={selectedCollection}
refreshKey={pendingRefreshKey}
/>
}
onRefreshService={loadServiceStatus}
onRefreshOperationLogs={loadOperationLogs}
onWorkspaceGetLatest={() => runWorkspaceAction("getLatest")}
onWorkspaceHistory={() => runWorkspaceAction("history")}
>
{connected ? (
<FolderItemsPanel
connected={connected}
collection={selectedCollection}
serverPath={selectedServerPath}
actionKey={workspaceActionKey}
onPendingChangesRefresh={refreshPendingChanges}
onPathEnter={selectServerPath}
onSelectedMappedItemChange={setSelectedMappedItem}
/>
) : (
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
<div className="min-w-0">
<h1 className="text-base font-medium">连接 TFS</h1>
<p className="mt-1 text-muted-foreground">
{serviceReady ? "本地 API 已就绪。" : serviceMessage}
</p>
</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>
<form className="grid gap-4" onSubmit={handleConnect}>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
TFS 地址
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.serverUri}
required
placeholder="http://tfs.example.com:8080/tfs/"
onChange={(event) =>
updateFormField("serverUri", event.target.value)
}
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
认证方式
</span>
<select
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.authType}
onChange={(event) =>
updateFormField("authType", event.target.value)
}
>
<option value="ntlm-explicit">NTLM 显式账号密码</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
域
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.domain}
placeholder="domain"
onChange={(event) =>
updateFormField("domain", event.target.value)
}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
用户名
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.username}
required
autoComplete="username"
onChange={(event) =>
updateFormField("username", event.target.value)
}
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
密码
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
type="password"
value={form.password}
required
autoComplete="current-password"
onChange={(event) =>
updateFormField("password", event.target.value)
}
/>
</label>
</div>
{state.errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{state.errorMessage}
</div>
{phase === "checking" && (
<p className="mt-4 text-sm text-muted-foreground">正在检查本地服务…</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={!canConnect}>
{connecting && <Loader2 className="animate-spin" />}
连接
{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>
</form>
</div>
)}
</AppShell>
</div>
</div>
)
}

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