Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7795aaf5b0 | ||
|
|
1e838ff56f | ||
|
|
bad9c8552f | ||
|
|
0f668ae9f3 | ||
|
|
f145d84549 | ||
|
|
7e14ba30f9 | ||
|
|
55a76cc51a | ||
|
|
fa1a6cc3af | ||
|
|
b646b46365 | ||
|
|
e951a24889 | ||
|
|
67ecb24ffc | ||
|
|
9f9899b1d1 | ||
|
|
45eeb10283 | ||
|
|
be055673f1 | ||
|
|
b6447443c5 | ||
|
|
ad7c30393c | ||
|
|
ee9e052586 | ||
|
|
5408f27902 | ||
|
|
b056f7c1e4 | ||
|
|
b1a5b2f16b | ||
|
|
bba6e89a16 | ||
|
|
b247a18155 | ||
|
|
b156f505eb | ||
|
|
b3454eb157 | ||
|
|
fe4c573585 | ||
|
|
75c8e00e19 | ||
|
|
6910563078 | ||
|
|
cfa4141c1a | ||
|
|
f09b8f95b4 | ||
|
|
63f7111bcd | ||
|
|
4ba868482b | ||
|
|
23062e8333 | ||
|
|
0c2324b1db | ||
|
|
0418a93890 |
@@ -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,142 @@
|
|||||||
|
# macTFS UI 风格改版需求(现代桌面工具风 / 精致化 / 动效)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
macTFS 功能层面已完成 FE-001 ~ FE-025,三栏工作台、弹窗体系、挂起更改与签入链路均可用。但当前 UI 是「功能优先」的默认 shadcn 风格:
|
||||||
|
|
||||||
|
- 全局使用 Roboto Slab 衬线字体,与现代桌面工具观感不符
|
||||||
|
- 登录页只有一张简单表单卡片,元素少、缺少品牌感
|
||||||
|
- 窗口使用系统默认标题栏,应用没有「一体成型」的桌面应用质感
|
||||||
|
- 几乎没有过渡动效:弹窗、面板折叠、列表加载都是瞬时切换
|
||||||
|
- 明暗主题虽有 token 定义,但没有切换入口、也不跟随系统
|
||||||
|
|
||||||
|
本批需求目标:在**不改任何业务逻辑与接口**的前提下,把整体 UI 升级为「精致、现代、富动效」的桌面工具风格。
|
||||||
|
|
||||||
|
## 二、用户诉求原文与解读
|
||||||
|
|
||||||
|
> 「我希望的 ui 风格是更精致,动效更多,登录页元素多点,反正就是希望比较现代一些。」
|
||||||
|
>
|
||||||
|
> 补充澄清:「不是 macos 风格 finder 风格,你知道 codex 桌面客户端吗?以及 ccswitch,我想要的是这样的桌面风格。」
|
||||||
|
|
||||||
|
**风格基准定为「现代 AI 开发者工具风」,参照对象:OpenAI Codex 桌面端、CC Switch。**
|
||||||
|
|
||||||
|
这类风格的共同特征(设计基调):
|
||||||
|
|
||||||
|
| 特征 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 中性底色 | 以中性灰(zinc/neutral)分级铺底,内容区与侧栏用明度区分,不用毛玻璃 |
|
||||||
|
| 卡片化 | 信息以圆角卡片承载,细边框(1px 低对比)+ 极轻阴影,hover 时边框/底色微变 |
|
||||||
|
| 克制的主色 | 单一 accent 色只用于主按钮、选中态、链接、状态点,不大面积铺色 |
|
||||||
|
| 留白与排版 | 间距充足、层级靠字号字重区分,无衬线系统字体 |
|
||||||
|
| 隐藏式标题栏 | 无系统标题栏,红绿灯融入应用顶栏,窗口一体成型 |
|
||||||
|
| 明暗双主题 | 默认跟随系统,暗色为深灰而非纯黑 |
|
||||||
|
| 微动效 | hover/按压/出入场都有轻量过渡,快而不花哨(150~250ms) |
|
||||||
|
|
||||||
|
| 诉求 | 落地方向 |
|
||||||
|
|---|---|
|
||||||
|
| 更精致 | 统一设计令牌(颜色/圆角/边框/阴影/间距/图标),细化悬停、选中、禁用态 |
|
||||||
|
| 现代桌面风 | 系统字体栈、隐藏式标题栏、卡片化布局、Codex 式侧栏、明暗主题 |
|
||||||
|
| 动效更多 | 统一动效规范:弹窗出入场、面板折叠、列表级联、骨架屏、微交互 |
|
||||||
|
| 登录页元素多点 | 品牌区 + 分步表单 + 氛围背景 + 最近连接 + 连接过程动效 |
|
||||||
|
|
||||||
|
## 三、现状盘点
|
||||||
|
|
||||||
|
| 现状 | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主题 token | `mactfsui/app/app.css` | 衬线字体、紫色主色、token 未按中性灰体系组织,dark 主题无入口 |
|
||||||
|
| Electron 窗口 | `mactfsui/electron/main.cjs` | 默认标题栏,应用不「一体成型」 |
|
||||||
|
| 登录页 | `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 现代桌面风设计基建(FE-026,P0)
|
||||||
|
|
||||||
|
- 字体切换为系统无衬线字体栈(`-apple-system, BlinkMacSystemFont, "PingFang SC"…`),仅代码/Diff 保留等宽字体
|
||||||
|
- 重定义颜色令牌:中性灰分级底色(页面底 / 侧栏 / 卡片 / 悬停四档明度)、低对比 1px 边框色、单一克制 accent 色(用于主按钮/选中/状态点)
|
||||||
|
- 统一圆角档位(8/10/12px 卡片为主)、极轻阴影(卡片几乎无阴影,浮层/弹窗轻阴影)、间距档位
|
||||||
|
- 建立动效令牌:统一时长(150/200/250ms)、缓动曲线变量
|
||||||
|
- 完整 light/dark 两套 token(dark 为深灰底,非纯黑)
|
||||||
|
|
||||||
|
### U2 Electron 无缝窗口与自绘顶栏(FE-027,P0)
|
||||||
|
|
||||||
|
- `titleBarStyle: "hiddenInset"`,移除系统标题栏,红绿灯融入应用顶栏,校准 trafficLightPosition
|
||||||
|
- 顶栏设置可拖拽区域,交互控件 no-drag
|
||||||
|
- 窗口背景色与主题 token 一致,启动时无白闪(背景色预设 + ready-to-show)
|
||||||
|
- 核对窗口默认尺寸与最小尺寸;非 macOS 平台回退正常标题栏不影响功能
|
||||||
|
|
||||||
|
### U3 登录页改版(FE-028,P0)
|
||||||
|
|
||||||
|
- 左右分栏:左侧品牌区(logo、产品名、标语、版本号、低噪点氛围背景:渐变网格 / 微光斑动效),右侧连接表单卡片
|
||||||
|
- 表单 → Collection 选择两阶段切换增加滑动 + 淡入淡出过渡
|
||||||
|
- 新增元素:密码可见性切换、最近连接记录(本地存储多条、一键填充)、本地服务健康状态指示灯
|
||||||
|
- 连接中按钮内 spinner + 阶段文案过渡;失败时表单抖动 + 错误提示条
|
||||||
|
- Collection 选择改卡片式列表:细边框卡片、hover 微浮起、选中 accent 描边动画
|
||||||
|
|
||||||
|
### U4 工作台外壳与顶栏改版(FE-029,P0)
|
||||||
|
|
||||||
|
- 顶栏融合红绿灯区域,按现代工具风重排:上下文信息居左、动作按钮图标化居右、底部 1px 分隔线
|
||||||
|
- 左侧目录树面板改 Codex 式侧栏:比内容区深/浅一档的纯色底、圆角选中条、分组标题小字号大写风格
|
||||||
|
- 左右面板折叠/展开加宽度过渡动画,底部操作台滑入滑出
|
||||||
|
- 三栏分隔线统一为低对比 1px,留白与内边距统一
|
||||||
|
|
||||||
|
### U5 目录树与文件列表精致化(FE-030,P0)
|
||||||
|
|
||||||
|
- 树节点与文件行:统一行高、圆角悬停/选中态、展开箭头旋转动画、子级展开渐入
|
||||||
|
- 按扩展名区分文件图标与颜色,文件夹/分支/已映射目录差异化
|
||||||
|
- 加载骨架屏替代文字 loading;空目录、未连接等空状态配图标 + 引导文案
|
||||||
|
- pendingEdit / pendingAdd / pendingDelete / 未下载等状态徽标统一为小圆点 + 浅底色徽标风格
|
||||||
|
- 文件列表首次加载行级联渐入
|
||||||
|
|
||||||
|
### U6 弹窗体系统一现代风格(FE-031,P1)
|
||||||
|
|
||||||
|
- 13 个弹窗统一结构:顶部标题区、内容区、底部按钮区,间距与按钮排布一致
|
||||||
|
- 卡片化弹窗:圆角、细边框、轻阴影;出入场统一缩放 + 淡入,遮罩淡入
|
||||||
|
- 大弹窗(History/Compare/Diff)优化分区留白与表格样式
|
||||||
|
- 弹窗内表单控件按 U1 token 对齐
|
||||||
|
|
||||||
|
### U7 操作反馈与微交互(FE-032,P1)
|
||||||
|
|
||||||
|
- 引入轻量 toast(成功/失败/进行中),与日志面板互补
|
||||||
|
- 挂起更改面板、日志面板按新风格改版;新日志条目滑入动画
|
||||||
|
- 控件微交互:按钮按压缩放、聚焦光环、复选框勾选动画、hover 过渡
|
||||||
|
- 长操作(GetLatest、Checkin、目录对比)显示进度反馈动效
|
||||||
|
|
||||||
|
### U8 明暗主题跟随系统(FE-033,P1)
|
||||||
|
|
||||||
|
- 跟随系统外观自动切换 light/dark,顶栏手动三态开关(跟随/亮/暗),持久化
|
||||||
|
- 切换瞬间全局颜色过渡动画
|
||||||
|
- 校验全部面板与弹窗在 dark 下的对比度与可读性
|
||||||
|
|
||||||
|
### U9 改版回归验收(FE-034,P0)
|
||||||
|
|
||||||
|
- 按 `TFS-AI测试流程.md` 大纲做 UI 链路回归:登录、浏览、Mapping、GetLatest、签出签入、历史、Diff、目录对比
|
||||||
|
- 确认零业务逻辑变化:`app/lib` 与接口契约不动
|
||||||
|
- typecheck 通过、关键页面截图存档
|
||||||
|
|
||||||
|
## 五、优先级与排期建议
|
||||||
|
|
||||||
|
| 顺序 | 任务 | 优先级 | 依赖 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | FE-026 设计基建 | P0 | 无 |
|
||||||
|
| 2 | FE-027 无缝窗口 | 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 | 全部 |
|
||||||
|
|
||||||
|
## 六、不在本批范围
|
||||||
|
|
||||||
|
- 任何业务逻辑、接口契约、服务端代码改动
|
||||||
|
- 新功能(本批只做视觉与交互层)
|
||||||
|
- 毛玻璃 / vibrancy 材质(已明确不要 Finder 原生风)
|
||||||
|
- Windows / Linux 适配(仅保证不崩溃的回退样式)
|
||||||
|
- Monaco Diff 内部主题深度定制(仅跟随明暗主题切换)
|
||||||
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 490 KiB |
@@ -214,7 +214,7 @@ public class MacTfsCli {
|
|||||||
private static String readServerToken() throws Exception {
|
private static String readServerToken() throws Exception {
|
||||||
File tokenFile = serverTokenFile();
|
File tokenFile = serverTokenFile();
|
||||||
if (!tokenFile.exists()) {
|
if (!tokenFile.exists()) {
|
||||||
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start macTFS API server first.");
|
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start MacTFS API server first.");
|
||||||
}
|
}
|
||||||
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
|
||||||
if (token.length() == 0) {
|
if (token.length() == 0) {
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
|
|||||||
required(merged, "workspace"),
|
required(merged, "workspace"),
|
||||||
required(merged, "server-path"),
|
required(merged, "server-path"),
|
||||||
merged.getProperty("local-path"),
|
merged.getProperty("local-path"),
|
||||||
!"false".equalsIgnoreCase(merged.getProperty("recursive"))
|
!"false".equalsIgnoreCase(merged.getProperty("recursive")),
|
||||||
|
!"false".equalsIgnoreCase(merged.getProperty("include-local-only"))
|
||||||
);
|
);
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", folderDiffsToMaps(result.getData()));
|
data.put("diffs", folderDiffsToMaps(result.getData()));
|
||||||
|
|||||||
@@ -13,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.GetItemsOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.GetOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.GetOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.GetStatus;
|
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.PendChangesOptions;
|
||||||
|
import com.microsoft.tfs.core.clients.versioncontrol.RollbackOptions;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.VersionControlClient;
|
import com.microsoft.tfs.core.clients.versioncontrol.VersionControlClient;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceLocation;
|
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceLocation;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceOptions;
|
import com.microsoft.tfs.core.clients.versioncontrol.WorkspaceOptions;
|
||||||
@@ -30,6 +32,7 @@ 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.ItemSet;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.ItemType;
|
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.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.PendingChange;
|
||||||
import com.microsoft.tfs.core.clients.versioncontrol.soapextensions.PendingSet;
|
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.RecursionType;
|
||||||
@@ -61,7 +64,7 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
* MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
|
||||||
*/
|
*/
|
||||||
public class MacTfsCoreService {
|
public class MacTfsCoreService {
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。
|
* 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
|
||||||
*/
|
*/
|
||||||
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
|
||||||
final String collectionName,
|
final String collectionName,
|
||||||
@@ -393,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 为空时直接拒绝。
|
* 按选中的 pending changes 执行 checkin,comment 为空时直接拒绝。
|
||||||
*/
|
*/
|
||||||
@@ -425,7 +579,8 @@ public class MacTfsCoreService {
|
|||||||
final String workspaceName,
|
final String workspaceName,
|
||||||
final String serverPath,
|
final String serverPath,
|
||||||
final String localPath,
|
final String localPath,
|
||||||
final boolean recursive) {
|
final boolean recursive,
|
||||||
|
final boolean includeLocalOnly) {
|
||||||
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
|
||||||
@Override
|
@Override
|
||||||
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
|
||||||
@@ -439,7 +594,10 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
|
||||||
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
|
||||||
Set<String> localFiles = scanLocalFiles(new File(normalizedLocalPath), recursive);
|
// 不统计仅本地存在的项时直接跳过本地全量扫描(如 node_modules 大目录),只对比两端都有的文件。
|
||||||
|
Set<String> localFiles = includeLocalOnly
|
||||||
|
? scanLocalFiles(new File(normalizedLocalPath), recursive)
|
||||||
|
: new LinkedHashSet<String>();
|
||||||
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
|
||||||
|
|
||||||
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
|
||||||
@@ -584,6 +742,68 @@ public class MacTfsCoreService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个服务端对象(文件或目录)的属性信息:最新版本、签入时间与签入人,文件附带大小与编码。
|
||||||
|
* 签入人与备注来自最近一条历史记录(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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成本地文件和服务器 latest 的文本 diff。
|
* 生成本地文件和服务器 latest 的文本 diff。
|
||||||
*/
|
*/
|
||||||
@@ -1021,8 +1241,12 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
boolean exists = localFile != null && localFile.exists();
|
boolean exists = localFile != null && localFile.exists();
|
||||||
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
|
||||||
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
// 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
|
||||||
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
// 这样未签出的纯本地修改也能在目录对比中暴露出来。
|
||||||
|
boolean writableFile = exists && !isFolder(item.getItemType()) && localFile.canWrite();
|
||||||
|
// mtime 仅作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
|
||||||
|
boolean mtimeNewer = exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
|
||||||
|
boolean localModified = writableFile || (remoteChanged && mtimeNewer);
|
||||||
if (!exists && item.getLocalVersion() > 0) {
|
if (!exists && item.getLocalVersion() > 0) {
|
||||||
return "localDeleted";
|
return "localDeleted";
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1330,9 @@ public class MacTfsCoreService {
|
|||||||
isFolder(change.getItemType()),
|
isFolder(change.getItemType()),
|
||||||
toPendingStatus(change),
|
toPendingStatus(change),
|
||||||
change.getChangeType() == null ? "" : change.getChangeType().toUIString(false),
|
change.getChangeType() == null ? "" : change.getChangeType().toUIString(false),
|
||||||
change.getVersion()
|
change.getVersion(),
|
||||||
|
// rename 挂起更改携带原路径,供 UI 展示「旧名 → 新名」。
|
||||||
|
change.isRename() ? change.getSourceServerItem() : null
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -1676,8 +1902,9 @@ public class MacTfsCoreService {
|
|||||||
private final String status;
|
private final String status;
|
||||||
private final String changeType;
|
private final String changeType;
|
||||||
private final int version;
|
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.serverPath = serverPath;
|
||||||
this.localPath = localPath;
|
this.localPath = localPath;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -1685,6 +1912,7 @@ public class MacTfsCoreService {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
this.changeType = changeType;
|
this.changeType = changeType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
this.sourceServerPath = sourceServerPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getServerPath() {
|
public String getServerPath() {
|
||||||
@@ -1714,6 +1942,10 @@ public class MacTfsCoreService {
|
|||||||
public int getVersion() {
|
public int getVersion() {
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSourceServerPath() {
|
||||||
|
return sourceServerPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TfsFileOperationResult {
|
public static class TfsFileOperationResult {
|
||||||
@@ -1902,6 +2134,61 @@ public class MacTfsCoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
public static class TfsConflictInfo {
|
||||||
private final int conflictId;
|
private final int conflictId;
|
||||||
private final String type;
|
private final String type;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.mydev.mactfs.core.MacTfsCoreService.TfsFileOperationResult;
|
|||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsFolderDiffItem;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsGetLatestResult;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsHistoryEntry;
|
||||||
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsItemInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsMappingInfo;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsMappingInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsPendingChangeInfo;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsPendingChangeInfo;
|
||||||
import com.mydev.mactfs.core.MacTfsCoreService.TfsServerItem;
|
import com.mydev.mactfs.core.MacTfsCoreService.TfsServerItem;
|
||||||
@@ -51,7 +52,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
* MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
|
||||||
*/
|
*/
|
||||||
public class MacTfsServer {
|
public class MacTfsServer {
|
||||||
|
|
||||||
@@ -92,8 +93,8 @@ public class MacTfsServer {
|
|||||||
registerFilters();
|
registerFilters();
|
||||||
registerRoutes();
|
registerRoutes();
|
||||||
Spark.awaitInitialization();
|
Spark.awaitInitialization();
|
||||||
System.out.println("macTFS API server listening on http://" + HOST + ":" + PORT);
|
System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
|
||||||
System.out.println("macTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
|
System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +447,71 @@ 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() {
|
Spark.get("/api/files/content", (request, response) -> handle(request, response, "fileContent", TIMEOUT_DIFF_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
@@ -467,6 +533,17 @@ public class MacTfsServer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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() {
|
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
|
||||||
@Override
|
@Override
|
||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
@@ -520,7 +597,7 @@ public class MacTfsServer {
|
|||||||
public ApiResult call(Request request) throws Exception {
|
public ApiResult call(Request request) throws Exception {
|
||||||
Map<String, Object> body = readBody(request);
|
Map<String, Object> body = readBody(request);
|
||||||
AppConfig config = requestConfig(body);
|
AppConfig config = requestConfig(body);
|
||||||
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true));
|
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true), booleanValue(body, "includeLocalOnly", true));
|
||||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||||
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
|
||||||
return fromCore(result, data);
|
return fromCore(result, data);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/roboto-slab";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 现代桌面工具风设计基建(FE-026)
|
||||||
|
* 基调:中性灰分层底色 + 卡片化 + 克制的蓝色主色,参照 Codex 桌面端 / CC Switch。
|
||||||
|
* 动效约定:快 150ms / 标准 200ms / 慢 250ms,对应 duration-150 / duration-200 / duration-250。
|
||||||
|
*/
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-serif);
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
--font-serif: 'Roboto Slab Variable', serif;
|
--font-heading: var(--font-sans);
|
||||||
|
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -39,6 +44,7 @@
|
|||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
|
/* 圆角档位:sm 6px / md 8px / lg 10px / xl 14px(--radius = 10px) */
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -46,75 +52,105 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗明显 */
|
||||||
|
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.04);
|
||||||
|
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.08), 0 1px 3px rgb(0 0 0 / 0.06);
|
||||||
|
--shadow-dialog: 0 16px 48px rgb(0 0 0 / 0.16), 0 4px 12px rgb(0 0 0 / 0.08);
|
||||||
|
/* 缓动曲线:出场用 out-quart,强调动效用 spring 近似 */
|
||||||
|
--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);
|
||||||
|
--animate-float-slow: float-slow 9s ease-in-out infinite;
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% { transform: translateX(-1px); }
|
||||||
|
20%, 80% { transform: translateX(2px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-3px); }
|
||||||
|
40%, 60% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
50% { transform: translate3d(18px, -22px, 0) scale(1.08); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
/* 中性灰分层:页面底 0.985 / 卡片 1 / 侧栏 0.967 / 悬停 0.945 */
|
||||||
--foreground: oklch(0.145 0 0);
|
--background: oklch(0.985 0.002 286);
|
||||||
|
--foreground: oklch(0.21 0.006 286);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.21 0.006 286);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.21 0.006 286);
|
||||||
--primary: oklch(0.488 0.243 264.376);
|
/* 克制的蓝色主色:仅用于主按钮、选中态、状态点 */
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary: oklch(0.546 0.215 262.881);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--primary-foreground: oklch(0.985 0.002 286);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary: oklch(0.96 0.004 286);
|
||||||
--muted: oklch(0.97 0 0);
|
--secondary-foreground: oklch(0.21 0.006 286);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted: oklch(0.96 0.004 286);
|
||||||
--accent: oklch(0.488 0.243 264.376);
|
--muted-foreground: oklch(0.552 0.014 285);
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
/* accent 为中性悬停底色,不再复用主色 */
|
||||||
|
--accent: oklch(0.945 0.004 286);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 286);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.92 0.004 286);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.92 0.004 286);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.623 0.188 259.815);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
--radius: 0.875rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.967 0.003 286);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.21 0.006 286);
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
--sidebar-primary: oklch(0.546 0.215 262.881);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.985 0.002 286);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.92 0.004 286);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.21 0.006 286);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.91 0.004 286);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.623 0.188 259.815);
|
||||||
|
/* 动效时长:快 / 标准 / 慢 */
|
||||||
|
--motion-fast: 150ms;
|
||||||
|
--motion-base: 200ms;
|
||||||
|
--motion-slow: 250ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
/* 深灰底而非纯黑:页面 0.185 / 卡片 0.22 / 侧栏 0.165 / 悬停 0.27 */
|
||||||
--foreground: oklch(0.985 0 0);
|
--background: oklch(0.185 0.005 285);
|
||||||
--card: oklch(0.205 0 0);
|
--foreground: oklch(0.96 0.002 286);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card: oklch(0.22 0.006 285);
|
||||||
--popover: oklch(0.205 0 0);
|
--card-foreground: oklch(0.96 0.002 286);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover: oklch(0.22 0.006 285);
|
||||||
--primary: oklch(0.424 0.199 265.638);
|
--popover-foreground: oklch(0.96 0.002 286);
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary: oklch(0.623 0.188 259.815);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--primary-foreground: oklch(0.985 0.002 286);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary: oklch(0.27 0.006 286);
|
||||||
--muted: oklch(0.269 0 0);
|
--secondary-foreground: oklch(0.96 0.002 286);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted: oklch(0.26 0.006 286);
|
||||||
--accent: oklch(0.424 0.199 265.638);
|
--muted-foreground: oklch(0.71 0.01 286);
|
||||||
--accent-foreground: oklch(0.97 0.014 254.604);
|
--accent: oklch(0.27 0.006 286);
|
||||||
|
--accent-foreground: oklch(0.96 0.002 286);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 8%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 12%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.623 0.188 259.815);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.165 0.005 285);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.96 0.002 286);
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
--sidebar-primary: oklch(0.623 0.188 259.815);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.985 0.002 286);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.24 0.006 285);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.96 0.002 286);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.623 0.188 259.815);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -125,6 +161,24 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-serif;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
|
||||||
|
.theme-transition,
|
||||||
|
.theme-transition * {
|
||||||
|
transition:
|
||||||
|
background-color 250ms var(--ease-out-quart),
|
||||||
|
border-color 250ms var(--ease-out-quart),
|
||||||
|
color 250ms var(--ease-out-quart) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
|
||||||
|
@utility app-drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility app-no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
|
||||||
|
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { Input } from "~/components/ui/input"
|
import { Input } from "~/components/ui/input"
|
||||||
import { Label } from "~/components/ui/label"
|
import { Label } from "~/components/ui/label"
|
||||||
import { api } from "~/lib/api"
|
import { api } from "~/lib/api"
|
||||||
import type { AppConfig, Collection } from "~/lib/api"
|
import type { AppConfig, Collection } from "~/lib/api"
|
||||||
|
import { getServiceStatus } from "~/lib/electron"
|
||||||
|
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
|
||||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ const EMPTY_FORM: ConnectFormState = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
|
||||||
|
* 布局为左品牌区 + 右表单卡片,带分阶段过渡与最近连接快速填充。
|
||||||
*/
|
*/
|
||||||
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
|
||||||
const [phase, setPhase] = useState<ConnectPhase>("form")
|
const [phase, setPhase] = useState<ConnectPhase>("form")
|
||||||
@@ -37,9 +41,15 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [busyText, setBusyText] = useState("")
|
const [busyText, setBusyText] = useState("")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [recent, setRecent] = useState<RecentServer[]>([])
|
||||||
|
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
|
||||||
|
// 每次失败自增,作为 key 触发表单抖动动画重放。
|
||||||
|
const [shakeCount, setShakeCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
setRecent(loadRecentServers())
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await api.getConfig()
|
const result = await api.getConfig()
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -58,6 +68,12 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
void (async () => {
|
||||||
|
const status = await getServiceStatus()
|
||||||
|
if (active) {
|
||||||
|
setServiceOk(status.running)
|
||||||
|
}
|
||||||
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
@@ -71,6 +87,27 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
setForm((prev) => ({ ...prev, [key]: value }))
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败统一处理:展示错误条并触发表单抖动。
|
||||||
|
*/
|
||||||
|
const failWith = useCallback((message: string) => {
|
||||||
|
setError(message)
|
||||||
|
setShakeCount((count) => count + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用一条最近连接记录填充表单(不含密码)。
|
||||||
|
*/
|
||||||
|
const applyRecent = useCallback((item: RecentServer) => {
|
||||||
|
setError(null)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverUri: item.serverUri,
|
||||||
|
domain: item.domain,
|
||||||
|
username: item.username,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
* 连接 TFS 并加载当前账号可见的 Collection 列表。
|
||||||
*/
|
*/
|
||||||
@@ -88,23 +125,30 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
const connectResult = await api.connect(payload)
|
const connectResult = await api.connect(payload)
|
||||||
if (!connectResult.ok) {
|
if (!connectResult.ok) {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setError(connectResult.errorMessage ?? "连接失败")
|
failWith(connectResult.errorMessage ?? "连接失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBusyText("正在加载 Collection…")
|
setBusyText("正在加载 Collection…")
|
||||||
const collectionsResult = await api.listCollections()
|
const collectionsResult = await api.listCollections()
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (!collectionsResult.ok) {
|
if (!collectionsResult.ok) {
|
||||||
setError(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setRecent(
|
||||||
|
saveRecentServer({
|
||||||
|
serverUri: form.serverUri.trim(),
|
||||||
|
domain: form.domain.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
const list = collectionsResult.data?.collections ?? []
|
const list = collectionsResult.data?.collections ?? []
|
||||||
setCollections(list)
|
setCollections(list)
|
||||||
setSelectedCollection((prev) =>
|
setSelectedCollection((prev) =>
|
||||||
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
|
||||||
)
|
)
|
||||||
setPhase("collection")
|
setPhase("collection")
|
||||||
}, [form])
|
}, [form, failWith])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
|
||||||
@@ -120,7 +164,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
const workspace = ensureResult.data?.workspace
|
const workspace = ensureResult.data?.workspace
|
||||||
if (!ensureResult.ok || !workspace) {
|
if (!ensureResult.ok || !workspace) {
|
||||||
setError(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onReady({
|
onReady({
|
||||||
@@ -129,7 +173,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
workspace: workspace.name,
|
workspace: workspace.name,
|
||||||
mappings: workspace.mappings ?? [],
|
mappings: workspace.mappings ?? [],
|
||||||
})
|
})
|
||||||
}, [selectedCollection, form.serverUri, onReady])
|
}, [selectedCollection, form.serverUri, onReady, failWith])
|
||||||
|
|
||||||
const canConnect =
|
const canConnect =
|
||||||
form.serverUri.trim().length > 0 &&
|
form.serverUri.trim().length > 0 &&
|
||||||
@@ -137,18 +181,62 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
form.password.length > 0
|
form.password.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
<div className="relative flex min-h-svh">
|
||||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
|
||||||
|
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
|
||||||
|
|
||||||
|
{/* 左侧品牌区:大 logo + 一句话描述 */}
|
||||||
|
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col overflow-hidden bg-zinc-950 p-10 text-zinc-100 md:flex">
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="animate-float-slow absolute -top-24 -left-20 size-72 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
|
<div className="animate-float-slow absolute -right-24 bottom-0 size-80 rounded-full bg-indigo-500/15 blur-3xl [animation-delay:-4s]" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_left,transparent_55%,rgb(9_9_11/0.7))]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 flex-col items-center justify-center gap-5">
|
||||||
|
{/* logo 自带圆角方形外形,用 drop-shadow 跟随透明轮廓投影 */}
|
||||||
|
<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-zinc-400">macOS 上的 TFS 源码管理工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
serviceOk == null ? "bg-zinc-600" : serviceOk ? "bg-emerald-400" : "bg-red-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{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">
|
<div className="space-y-1">
|
||||||
<h1 className="text-base font-semibold">macTFS</h1>
|
<h1 className="text-xl font-semibold tracking-tight">连接 TFS</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"}
|
使用域账号连接 Team Foundation Server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase === "form" && (
|
|
||||||
<form
|
<form
|
||||||
className="mt-5 space-y-3"
|
className="mt-6 space-y-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (canConnect && !busy) {
|
if (canConnect && !busy) {
|
||||||
@@ -166,6 +254,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="domain">域(可选)</Label>
|
<Label htmlFor="domain">域(可选)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -186,32 +275,99 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">密码</Label>
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
className="pr-9"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={updateField("password")}
|
onChange={updateField("password")}
|
||||||
disabled={busy}
|
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>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{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}>
|
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
{busy ? busyText || "处理中…" : "连接 TFS"}
|
{busy ? busyText || "处理中…" : "连接 TFS"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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-all duration-150 hover:-translate-y-px hover:border-ring/40 hover:shadow-overlay"
|
||||||
|
>
|
||||||
|
<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" && (
|
{phase === "collection" && (
|
||||||
<div className="mt-5 space-y-4">
|
<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 ? (
|
{collections.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">当前账号没有可见的 Collection。</p>
|
<p className="rounded-md border bg-card px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
当前账号没有可见的 Collection。
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-64 space-y-1 overflow-y-auto">
|
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
||||||
{collections.map((collection) => {
|
{collections.map((collection) => {
|
||||||
const active = collection.name === selectedCollection
|
const active = collection.name === selectedCollection
|
||||||
return (
|
return (
|
||||||
@@ -221,14 +377,18 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
onClick={() => setSelectedCollection(collection.name)}
|
onClick={() => setSelectedCollection(collection.name)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg border px-3.5 py-3 text-left text-sm transition-colors duration-150",
|
||||||
active
|
active
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
? "border-primary/60 bg-primary/5"
|
||||||
: "border-border bg-background hover:bg-muted",
|
: "bg-card shadow-card hover:bg-muted/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{collection.name}</span>
|
<span className={cn(active && "font-medium")}>{collection.name}</span>
|
||||||
{active && <span className="text-xs text-primary">已选择</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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -236,7 +396,11 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -256,12 +420,15 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
|
|||||||
onClick={() => void handleEnter()}
|
onClick={() => void handleEnter()}
|
||||||
disabled={busy || !selectedCollection}
|
disabled={busy || !selectedCollection}
|
||||||
>
|
>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
{busy ? busyText || "处理中…" : "进入工作台"}
|
{busy ? busyText || "处理中…" : "进入工作台"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { PanelBottom, PanelLeft, PanelRight } from "lucide-react"
|
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { Separator } from "~/components/ui/separator"
|
import { Separator } from "~/components/ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||||
|
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
|
||||||
|
import { isMacElectron } from "~/lib/platform"
|
||||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 主题三态对应的图标与说明文案。
|
||||||
|
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
|
||||||
|
system: { icon: Monitor, label: "主题:跟随系统" },
|
||||||
|
light: { icon: Sun, label: "主题:亮色" },
|
||||||
|
dark: { icon: Moon, label: "主题:暗色" },
|
||||||
|
}
|
||||||
|
|
||||||
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
||||||
export interface PanelVisibility {
|
export interface PanelVisibility {
|
||||||
tree: boolean
|
tree: boolean
|
||||||
@@ -22,32 +31,43 @@ export function TopBar({
|
|||||||
panels,
|
panels,
|
||||||
onTogglePanel,
|
onTogglePanel,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onManageWorkspace,
|
||||||
}: {
|
}: {
|
||||||
session: WorkspaceSession
|
session: WorkspaceSession
|
||||||
panels: PanelVisibility
|
panels: PanelVisibility
|
||||||
onTogglePanel: (panel: keyof PanelVisibility) => void
|
onTogglePanel: (panel: keyof PanelVisibility) => void
|
||||||
onReconnect: () => void
|
onReconnect: () => void
|
||||||
|
onManageWorkspace: () => void
|
||||||
}) {
|
}) {
|
||||||
|
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
|
||||||
|
const macInset = isMacElectron()
|
||||||
|
const theme = useTheme()
|
||||||
|
const themeVisual = THEME_VISUALS[theme.preference]
|
||||||
|
const ThemeIcon = themeVisual.icon
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3">
|
<header
|
||||||
|
className={cn(
|
||||||
|
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
|
||||||
|
macInset && "pl-20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
<span className="shrink-0 font-semibold">macTFS</span>
|
<span className="shrink-0 font-semibold tracking-tight">MacTFS</span>
|
||||||
<Separator orientation="vertical" className="h-4!" />
|
<Separator orientation="vertical" className="h-4!" />
|
||||||
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
|
<span className="hidden max-w-56 truncate font-mono text-xs text-muted-foreground lg:inline">
|
||||||
服务器
|
|
||||||
</span>
|
|
||||||
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
|
|
||||||
{session.serverUri}
|
{session.serverUri}
|
||||||
</span>
|
</span>
|
||||||
<Separator orientation="vertical" className="hidden h-4! lg:block" />
|
<span className="hidden max-w-44 shrink-0 items-center gap-1 truncate rounded-md border bg-card px-2 py-0.5 text-xs font-medium shadow-card sm:inline-flex">
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
|
<span className="text-muted-foreground">Collection</span>
|
||||||
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
|
<span className="truncate">{session.collection}</span>
|
||||||
<Separator orientation="vertical" className="h-4!" />
|
</span>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
|
<span className="inline-flex max-w-52 shrink-0 items-center gap-1 truncate rounded-md border bg-card px-2 py-0.5 text-xs shadow-card">
|
||||||
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
|
<span className="text-muted-foreground">Workspace</span>
|
||||||
|
<span className="truncate font-mono">{session.workspace}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="app-no-drag flex shrink-0 items-center gap-1">
|
||||||
<PanelToggle
|
<PanelToggle
|
||||||
label="源码目录面板"
|
label="源码目录面板"
|
||||||
active={panels.tree}
|
active={panels.tree}
|
||||||
@@ -70,9 +90,30 @@ export function TopBar({
|
|||||||
<PanelBottom />
|
<PanelBottom />
|
||||||
</PanelToggle>
|
</PanelToggle>
|
||||||
<Separator orientation="vertical" className="mx-1 h-4!" />
|
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||||
<Button size="sm" variant="outline" onClick={onReconnect}>
|
<Tooltip>
|
||||||
重新连接
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
|
||||||
|
<ThemeIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
|
||||||
|
<FolderCog />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">工作区与映射管理</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={onReconnect} aria-label="重新连接">
|
||||||
|
<Unplug />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">重新连接</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
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 { CompareDialog } from "~/components/explorer/compare-dialog"
|
||||||
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
|
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
|
||||||
import { DiffDialog } from "~/components/explorer/diff-dialog"
|
import { DiffDialog } from "~/components/explorer/diff-dialog"
|
||||||
import { FileViewDialog } from "~/components/explorer/file-view-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 { HistoryDialog } from "~/components/explorer/history-dialog"
|
||||||
import { MappingDialog } from "~/components/explorer/mapping-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 { WorkspaceDialogState } from "~/hooks/use-file-actions"
|
||||||
import type { MappingInfo } from "~/lib/api"
|
import type { MappingInfo } from "~/lib/api"
|
||||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
@@ -23,6 +29,11 @@ export function WorkspaceDialogs({
|
|||||||
onConflictsResolved,
|
onConflictsResolved,
|
||||||
onForceGetConfirmed,
|
onForceGetConfirmed,
|
||||||
onGetVersion,
|
onGetVersion,
|
||||||
|
onRenameConfirmed,
|
||||||
|
onRollback,
|
||||||
|
onBranchConfirmed,
|
||||||
|
onMergeConfirmed,
|
||||||
|
onAddFilesConfirmed,
|
||||||
}: {
|
}: {
|
||||||
dialog: WorkspaceDialogState
|
dialog: WorkspaceDialogState
|
||||||
mappings: MappingInfo[]
|
mappings: MappingInfo[]
|
||||||
@@ -33,6 +44,19 @@ export function WorkspaceDialogs({
|
|||||||
onConflictsResolved: () => void
|
onConflictsResolved: () => void
|
||||||
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
|
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
|
||||||
onGetVersion: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
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) {
|
if (!dialog) {
|
||||||
return null
|
return null
|
||||||
@@ -59,6 +83,7 @@ export function WorkspaceDialogs({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
onGetVersion={onGetVersion}
|
onGetVersion={onGetVersion}
|
||||||
|
onRollback={onRollback}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "compare":
|
case "compare":
|
||||||
@@ -89,6 +114,56 @@ export function WorkspaceDialogs({
|
|||||||
onResolved={onConflictsResolved}
|
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":
|
case "confirmForceGet":
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
|
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
|
||||||
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
|
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
|
||||||
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
|
||||||
import { SourceTreePanel } from "~/components/explorer/source-tree-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 { ChangesPanel } from "~/components/inspector/changes-panel"
|
||||||
import { ConsolePanel } from "~/components/logs/console-panel"
|
import { ConsolePanel } from "~/components/logs/console-panel"
|
||||||
|
import { Toaster, showToast } from "~/components/ui/toaster"
|
||||||
import { TooltipProvider } from "~/components/ui/tooltip"
|
import { TooltipProvider } from "~/components/ui/tooltip"
|
||||||
import { useFileActions } from "~/hooks/use-file-actions"
|
import { useFileActions } from "~/hooks/use-file-actions"
|
||||||
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
import { usePendingChanges } from "~/hooks/use-pending-changes"
|
||||||
@@ -38,6 +40,8 @@ export function WorkspaceShell({
|
|||||||
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
|
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
|
||||||
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
|
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
|
||||||
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
|
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
|
||||||
|
// 工作区集中管理弹窗显隐。
|
||||||
|
const [manageOpen, setManageOpen] = useState(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换指定面板的展开 / 收起状态。
|
* 切换指定面板的展开 / 收起状态。
|
||||||
@@ -68,34 +72,39 @@ export function WorkspaceShell({
|
|||||||
refreshLogs,
|
refreshLogs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 把动作编排的 notice 转成右上角 toast:固定 id 让「正在…」与结果复用同一条目。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actions.notice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { kind, text } = actions.notice
|
||||||
|
showToast({
|
||||||
|
id: "action",
|
||||||
|
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}, [actions.notice])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex h-svh flex-col overflow-hidden bg-background">
|
{/* 画布式布局:浅灰画布 + 各面板卡片化,卡片间留缝隙避免生硬拼接 */}
|
||||||
|
<div className="flex h-svh flex-col overflow-hidden bg-sidebar">
|
||||||
<TopBar
|
<TopBar
|
||||||
session={session}
|
session={session}
|
||||||
panels={panels}
|
panels={panels}
|
||||||
onTogglePanel={togglePanel}
|
onTogglePanel={togglePanel}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
|
onManageWorkspace={() => setManageOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{actions.notice && (
|
<div className="flex min-h-0 flex-1 px-2 pb-2">
|
||||||
|
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-2 border-b px-3 py-1 text-xs",
|
"flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
actions.notice.kind === "error"
|
panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
|
||||||
? "bg-destructive/10 text-destructive"
|
|
||||||
: "bg-primary/5 text-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{actions.actionBusy && (
|
|
||||||
<span className="size-2 animate-pulse rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
{actions.notice.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
|
||||||
{panels.tree && (
|
|
||||||
<SourceTreePanel
|
<SourceTreePanel
|
||||||
collection={session.collection}
|
collection={session.collection}
|
||||||
mappings={session.mappings}
|
mappings={session.mappings}
|
||||||
@@ -103,7 +112,7 @@ export function WorkspaceShell({
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onFileAction={actions.handleFileAction}
|
onFileAction={actions.handleFileAction}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<FolderItemsPanel
|
<FolderItemsPanel
|
||||||
session={session}
|
session={session}
|
||||||
selectedServerPath={selectedServerPath}
|
selectedServerPath={selectedServerPath}
|
||||||
@@ -113,7 +122,12 @@ export function WorkspaceShell({
|
|||||||
onFileAction={actions.handleFileAction}
|
onFileAction={actions.handleFileAction}
|
||||||
onRefresh={refreshItems}
|
onRefresh={refreshItems}
|
||||||
/>
|
/>
|
||||||
{panels.changes && (
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 shrink-0 justify-end overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
|
||||||
|
panels.changes ? "ml-2 w-[340px]" : "ml-0 w-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ChangesPanel
|
<ChangesPanel
|
||||||
mappings={session.mappings}
|
mappings={session.mappings}
|
||||||
pendingChanges={pending.pendingChanges}
|
pendingChanges={pending.pendingChanges}
|
||||||
@@ -126,16 +140,22 @@ export function WorkspaceShell({
|
|||||||
onFileAction={actions.handleFileAction}
|
onFileAction={actions.handleFileAction}
|
||||||
onRefresh={() => void pending.refresh()}
|
onRefresh={() => void pending.refresh()}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{panels.console && (
|
{/* 底部操作台通过高度过渡实现滑入滑出 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border shrink-0 overflow-hidden px-2 transition-[height] duration-250 ease-out-quart",
|
||||||
|
panels.console ? "h-[188px] pb-2" : "h-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ConsolePanel
|
<ConsolePanel
|
||||||
refreshToken={logsRefreshToken}
|
refreshToken={logsRefreshToken}
|
||||||
busy={actions.actionBusy || actions.checkinBusy}
|
busy={actions.actionBusy || actions.checkinBusy}
|
||||||
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<WorkspaceDialogs
|
<WorkspaceDialogs
|
||||||
dialog={actions.dialog}
|
dialog={actions.dialog}
|
||||||
@@ -147,7 +167,26 @@ export function WorkspaceShell({
|
|||||||
onConflictsResolved={actions.handleConflictsResolved}
|
onConflictsResolved={actions.handleConflictsResolved}
|
||||||
onForceGetConfirmed={actions.handleForceGetConfirmed}
|
onForceGetConfirmed={actions.handleForceGetConfirmed}
|
||||||
onGetVersion={actions.runGetVersion}
|
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>
|
</div>
|
||||||
</TooltipProvider>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState } from "react"
|
||||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog"
|
} from "~/components/ui/dialog"
|
||||||
@@ -30,6 +31,9 @@ import { cn } from "~/lib/utils"
|
|||||||
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
|
||||||
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
|
||||||
*/
|
*/
|
||||||
|
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
|
||||||
|
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
|
||||||
|
|
||||||
export function CompareDialog({
|
export function CompareDialog({
|
||||||
serverPath,
|
serverPath,
|
||||||
mappings,
|
mappings,
|
||||||
@@ -39,33 +43,51 @@ export function CompareDialog({
|
|||||||
serverPath: string
|
serverPath: string
|
||||||
mappings: MappingInfo[]
|
mappings: MappingInfo[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onFileAction: (target: FileTarget, action: FileActionId) => void
|
onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
|
||||||
|
const [phase, setPhase] = useState<"setup" | "result">("setup")
|
||||||
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showUpToDate, setShowUpToDate] = useState(false)
|
const [showUpToDate, setShowUpToDate] = useState(false)
|
||||||
|
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
|
||||||
|
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
|
||||||
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
|
||||||
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行(或重新执行)目录对比。
|
* 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
|
||||||
*/
|
*/
|
||||||
const runCompare = useCallback(async () => {
|
const runCompare = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const result = await api.compareFolder({ serverPath, recursive: true })
|
const result = await api.compareFolder({
|
||||||
|
serverPath,
|
||||||
|
recursive: true,
|
||||||
|
includeLocalOnly: !ignoreLocalOnly,
|
||||||
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.errorMessage ?? "目录对比失败")
|
setError(result.errorMessage ?? "目录对比失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDiffs(result.data?.diffs ?? [])
|
setDiffs(result.data?.diffs ?? [])
|
||||||
}, [serverPath])
|
}, [serverPath, ignoreLocalOnly])
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
void runCompare()
|
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
|
||||||
}, [runCompare])
|
* 让结果状态(如 签出编辑)立即反映最新事实。
|
||||||
|
*/
|
||||||
|
const handleAction = useCallback(
|
||||||
|
async (target: FileTarget, action: FileActionId) => {
|
||||||
|
await onFileAction(target, action)
|
||||||
|
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
|
||||||
|
await runCompare()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFileAction, runCompare],
|
||||||
|
)
|
||||||
|
|
||||||
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
|
||||||
const presentStatuses = useMemo(() => {
|
const presentStatuses = useMemo(() => {
|
||||||
@@ -104,11 +126,56 @@ export function CompareDialog({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
|
||||||
|
if (phase === "setup") {
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>目录对比</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={ignoreLocalOnly}
|
||||||
|
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">忽略仅本地存在的项</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
|
||||||
|
并跳过本地全量扫描,大目录速度更快。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setPhase("result")
|
||||||
|
void runCompare()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始对比
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>目录对比</DialogTitle>
|
<DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
|
||||||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -134,6 +201,14 @@ export function CompareDialog({
|
|||||||
/>
|
/>
|
||||||
显示最新项
|
显示最新项
|
||||||
</label>
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPhase("setup")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
调整选项
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -181,7 +256,11 @@ export function CompareDialog({
|
|||||||
compareStatus: diff.status,
|
compareStatus: diff.status,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<FileTargetMenu key={diff.serverPath} target={target} onAction={onFileAction}>
|
<FileTargetMenu
|
||||||
|
key={diff.serverPath}
|
||||||
|
target={target}
|
||||||
|
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
|
||||||
|
>
|
||||||
<TableRow className="cursor-default select-none">
|
<TableRow className="cursor-default select-none">
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function DiffDialog({
|
|||||||
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
||||||
let originalModel: MonacoEditor.ITextModel | null = null
|
let originalModel: MonacoEditor.ITextModel | null = null
|
||||||
let modifiedModel: MonacoEditor.ITextModel | null = null
|
let modifiedModel: MonacoEditor.ITextModel | null = null
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
||||||
if (disposed || !containerRef.current) {
|
if (disposed || !containerRef.current) {
|
||||||
@@ -140,6 +141,8 @@ export function DiffDialog({
|
|||||||
originalEditable: false,
|
originalEditable: false,
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
|
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
|
||||||
|
compactMode: viewMode === "unified",
|
||||||
hideUnchangedRegions: { enabled: onlyChanges },
|
hideUnchangedRegions: { enabled: onlyChanges },
|
||||||
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -165,10 +168,16 @@ export function DiffDialog({
|
|||||||
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
||||||
})
|
})
|
||||||
editorRef.current = diffEditor
|
editorRef.current = diffEditor
|
||||||
|
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
editorRef.current = null
|
editorRef.current = null
|
||||||
|
themeObserver?.disconnect()
|
||||||
diffEditor?.dispose()
|
diffEditor?.dispose()
|
||||||
originalModel?.dispose()
|
originalModel?.dispose()
|
||||||
modifiedModel?.dispose()
|
modifiedModel?.dispose()
|
||||||
@@ -182,7 +191,10 @@ export function DiffDialog({
|
|||||||
*/
|
*/
|
||||||
const switchView = useCallback((mode: "unified" | "split") => {
|
const switchView = useCallback((mode: "unified" | "split") => {
|
||||||
setViewMode(mode)
|
setViewMode(mode)
|
||||||
editorRef.current?.updateOptions({ renderSideBySide: mode === "split" })
|
editorRef.current?.updateOptions({
|
||||||
|
renderSideBySide: mode === "split",
|
||||||
|
compactMode: mode === "unified",
|
||||||
|
})
|
||||||
if (typeof sessionStorage !== "undefined") {
|
if (typeof sessionStorage !== "undefined") {
|
||||||
sessionStorage.setItem(VIEW_MODE_KEY, mode)
|
sessionStorage.setItem(VIEW_MODE_KEY, mode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Database,
|
||||||
|
File,
|
||||||
|
FileArchive,
|
||||||
|
FileCode,
|
||||||
|
FileImage,
|
||||||
|
FileJson,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileTerminal,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
|
FolderSymlink,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
|
||||||
|
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
|
||||||
|
{
|
||||||
|
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
|
||||||
|
icon: FileCode,
|
||||||
|
className: "text-blue-600 dark:text-blue-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
|
||||||
|
icon: FileJson,
|
||||||
|
className: "text-amber-600 dark:text-amber-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
|
||||||
|
icon: FileText,
|
||||||
|
className: "text-zinc-500 dark:text-zinc-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sql", "db"],
|
||||||
|
icon: Database,
|
||||||
|
className: "text-violet-600 dark:text-violet-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
|
||||||
|
icon: FileImage,
|
||||||
|
className: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
|
||||||
|
icon: FileArchive,
|
||||||
|
className: "text-orange-600 dark:text-orange-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["xls", "xlsx", "csv"],
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
className: "text-green-600 dark:text-green-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
|
||||||
|
icon: FileTerminal,
|
||||||
|
className: "text-zinc-600 dark:text-zinc-300",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取文件名扩展名(小写,无点);无扩展名返回空串。
|
||||||
|
*/
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const index = name.lastIndexOf(".")
|
||||||
|
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
|
||||||
|
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
|
||||||
|
*/
|
||||||
|
export function FileIcon({
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
mapped = false,
|
||||||
|
expanded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
folder: boolean
|
||||||
|
mapped?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (folder) {
|
||||||
|
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
mapped ? "text-primary" : "text-sky-600 dark:text-sky-400",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
|
||||||
|
const Icon = rule?.icon ?? File
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0",
|
||||||
|
rule?.className ?? "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
|
import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
|
|
||||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
|
import { FileIcon } from "~/components/explorer/file-icon"
|
||||||
import { Badge } from "~/components/ui/badge"
|
import { Badge } from "~/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -117,8 +118,17 @@ export function FolderItemsPanel({
|
|||||||
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
|
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
|
||||||
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
|
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
|
||||||
|
|
||||||
|
// 中间区域空白处右键的目标:当前目录本身(与目录树右键同一套菜单)。
|
||||||
|
const currentFolderTarget = makeFileTarget({
|
||||||
|
source: "list",
|
||||||
|
folder: true,
|
||||||
|
serverPath: selectedServerPath,
|
||||||
|
mappings: session.mappings,
|
||||||
|
pendingStatus: pendingByServerPath[selectedServerPath] ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
|
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
||||||
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
||||||
@@ -142,23 +152,27 @@ export function FolderItemsPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
|
||||||
|
{/* 由 table-container 自身承担纵横滚动:表头 sticky 不随内容滚走,横向滚动条始终贴可视区底部 */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden [&_[data-slot=table-container]]:h-full [&_[data-slot=table-container]]:overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
<ItemsSkeleton />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
正在加载目录…
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
|
||||||
{error}
|
<AlertCircle className="size-8 text-destructive/60" />
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||||
当前目录为空
|
<FolderOpen className="size-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">当前目录为空</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
右键空白处可对当前目录执行获取、映射或添加本地文件
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
||||||
@@ -172,7 +186,7 @@ export function FolderItemsPanel({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => {
|
{rows.map((row, index) => {
|
||||||
const selected = row.item.serverPath === selectedItemPath
|
const selected = row.item.serverPath === selectedItemPath
|
||||||
const target = makeFileTarget({
|
const target = makeFileTarget({
|
||||||
source: "list",
|
source: "list",
|
||||||
@@ -189,9 +203,14 @@ export function FolderItemsPanel({
|
|||||||
>
|
>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={selected ? "selected" : undefined}
|
data-state={selected ? "selected" : undefined}
|
||||||
className="cursor-default select-none"
|
className="animate-in fade-in cursor-default select-none fill-mode-backwards duration-200"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 12) * 18}ms` }}
|
||||||
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
||||||
onContextMenu={() => setSelectedItemPath(row.item.serverPath)}
|
onContextMenu={(event) => {
|
||||||
|
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
|
||||||
|
event.stopPropagation()
|
||||||
|
setSelectedItemPath(row.item.serverPath)
|
||||||
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
if (row.item.folder) {
|
if (row.item.folder) {
|
||||||
onNavigate(row.item.serverPath)
|
onNavigate(row.item.serverPath)
|
||||||
@@ -200,19 +219,20 @@ export function FolderItemsPanel({
|
|||||||
>
|
>
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
||||||
{row.item.folder ? (
|
<FileIcon
|
||||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
name={row.item.name}
|
||||||
) : (
|
folder={row.item.folder}
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
mapped={row.item.folder && row.localPath != null}
|
||||||
)}
|
/>
|
||||||
<span className="truncate text-sm">{row.item.name}</span>
|
<span className="truncate text-sm">{row.item.name}</span>
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1.5">
|
<TableCell className="py-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn("rounded-md", statusBadgeClass(row.localState))}
|
className={cn("gap-1.5 rounded-md", statusBadgeClass(row.localState))}
|
||||||
>
|
>
|
||||||
|
<span className="size-1.5 rounded-full bg-current opacity-60" />
|
||||||
{statusLabel(row.localState)}
|
{statusLabel(row.localState)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -233,6 +253,7 @@ export function FolderItemsPanel({
|
|||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</FileTargetMenu>
|
||||||
|
|
||||||
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
|
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
|
||||||
{selectedRow ? (
|
{selectedRow ? (
|
||||||
@@ -254,3 +275,25 @@ export function FolderItemsPanel({
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 loading。
|
||||||
|
*/
|
||||||
|
function ItemsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 9 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex animate-pulse items-center gap-3"
|
||||||
|
style={{ animationDelay: `${index * 80}ms` }}
|
||||||
|
>
|
||||||
|
<span className="size-4 rounded bg-muted" />
|
||||||
|
<span className="h-3.5 rounded bg-muted" style={{ width: `${160 + ((index * 53) % 120)}px` }} />
|
||||||
|
<span className="h-3.5 w-14 rounded-full bg-muted" />
|
||||||
|
<span className="ml-auto h-3 w-28 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,6 +44,13 @@ interface VersionConfirm {
|
|||||||
folder: boolean
|
folder: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「回滚」待确认信息:single 仅反做该变更集,toVersion 反做其后全部改动。
|
||||||
|
interface RollbackConfirm {
|
||||||
|
serverPath: string
|
||||||
|
changeset: number
|
||||||
|
mode: "single" | "toVersion"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History 弹窗:展示文件或目录历史(最近记录)。
|
* History 弹窗:展示文件或目录历史(最近记录)。
|
||||||
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
|
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
|
||||||
@@ -56,12 +63,14 @@ export function HistoryDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onDiffRevisions,
|
onDiffRevisions,
|
||||||
onGetVersion,
|
onGetVersion,
|
||||||
|
onRollback,
|
||||||
}: {
|
}: {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
folder: boolean
|
folder: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
|
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
|
||||||
onGetVersion?: (serverPath: string, changeset: number, folder: boolean) => Promise<boolean>
|
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 })
|
const [target, setTarget] = useState<HistoryTarget>({ serverPath, folder })
|
||||||
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
|
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
|
||||||
@@ -78,6 +87,8 @@ export function HistoryDialog({
|
|||||||
// 文件历史勾选的 changeset(最多两个),用于版本对比。
|
// 文件历史勾选的 changeset(最多两个),用于版本对比。
|
||||||
const [selected, setSelected] = useState<number[]>([])
|
const [selected, setSelected] = useState<number[]>([])
|
||||||
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
|
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
|
||||||
|
// 「回滚」待确认信息。
|
||||||
|
const [confirmRollback, setConfirmRollback] = useState<RollbackConfirm | null>(null)
|
||||||
// 「查看此版本内容」叠加的文件查看弹窗目标。
|
// 「查看此版本内容」叠加的文件查看弹窗目标。
|
||||||
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
|
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
|
||||||
|
|
||||||
@@ -238,8 +249,9 @@ export function HistoryDialog({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
|
<ContextMenu key={`${entry.changeset}-${entry.serverPath}`}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
<TableRow
|
<TableRow
|
||||||
key={`${entry.changeset}-${entry.serverPath}`}
|
|
||||||
className="cursor-default select-none"
|
className="cursor-default select-none"
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
if (target.folder) {
|
if (target.folder) {
|
||||||
@@ -296,6 +308,54 @@ export function HistoryDialog({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -352,6 +412,40 @@ export function HistoryDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
{viewVersion && (
|
||||||
<FileViewDialog
|
<FileViewDialog
|
||||||
serverPath={viewVersion.serverPath}
|
serverPath={viewVersion.serverPath}
|
||||||
|
|||||||
@@ -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,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { ChevronRight, FolderClosed, FolderOpen, Loader2, RefreshCw } from "lucide-react"
|
import { ChevronRight, Loader2, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
import { FileTargetMenu } from "~/components/app/file-target-menu"
|
||||||
|
import { FileIcon } from "~/components/explorer/file-icon"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||||
import { api } from "~/lib/api"
|
import { api } from "~/lib/api"
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
getAncestorPaths,
|
getAncestorPaths,
|
||||||
makeFileTarget,
|
makeFileTarget,
|
||||||
normalizeServerPath,
|
normalizeServerPath,
|
||||||
|
resolveLocalPath,
|
||||||
} from "~/lib/tfs"
|
} from "~/lib/tfs"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
@@ -127,9 +129,11 @@ export function SourceTreePanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-[280px] shrink-0 flex-col border-r bg-sidebar">
|
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
源码目录
|
||||||
|
</span>
|
||||||
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -200,6 +204,7 @@ function TreeNode({
|
|||||||
const isSelected = selectedServerPath === path
|
const isSelected = selectedServerPath === path
|
||||||
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
|
||||||
const isLeaf = children != null && children.length === 0
|
const isLeaf = children != null && children.length === 0
|
||||||
|
const isMapped = resolveLocalPath(mappings, path) != null
|
||||||
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -207,8 +212,8 @@ function TreeNode({
|
|||||||
<FileTargetMenu target={target} onAction={onFileAction}>
|
<FileTargetMenu target={target} onAction={onFileAction}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex h-7 items-center rounded-md pr-1",
|
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
|
||||||
isSelected ? "bg-primary/10" : "hover:bg-muted",
|
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||||
>
|
>
|
||||||
@@ -220,10 +225,13 @@ function TreeNode({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
aria-label={isExpanded ? "收起目录" : "展开目录"}
|
||||||
onClick={() => onToggle(path)}
|
onClick={() => onToggle(path)}
|
||||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")}
|
className={cn(
|
||||||
|
"size-3.5 transition-transform duration-200 ease-out-quart",
|
||||||
|
isExpanded && "rotate-90",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -233,11 +241,7 @@ function TreeNode({
|
|||||||
onClick={() => onSelect(path)}
|
onClick={() => onSelect(path)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||||
>
|
>
|
||||||
{isExpanded && !isLeaf ? (
|
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
|
||||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
@@ -249,8 +253,9 @@ function TreeNode({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FileTargetMenu>
|
</FileTargetMenu>
|
||||||
{isExpanded &&
|
{isExpanded && children != null && children.length > 0 && (
|
||||||
children?.map((child) => (
|
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
{children.map((child) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={child.serverPath}
|
key={child.serverPath}
|
||||||
path={child.serverPath}
|
path={child.serverPath}
|
||||||
@@ -267,5 +272,7 @@ function TreeNode({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { FolderSearch, Loader2, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { ConfirmDialog } from "~/components/app/confirm-dialog"
|
||||||
|
import { MappingDialog } from "~/components/explorer/mapping-dialog"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table"
|
||||||
|
import { api } from "~/lib/api"
|
||||||
|
import type { CheckMappingTargetResult, MappingInfo, WorkspaceInfo } from "~/lib/api"
|
||||||
|
import { isElectron, pathsExist, selectDirectory } from "~/lib/electron"
|
||||||
|
import type { WorkspaceSession } from "~/lib/tfs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作区集中管理弹窗:展示 Workspace 信息与全部 Mapping,
|
||||||
|
* 支持新增、删除与修改本地路径(删旧 + 加新),对齐 VS 的 Edit Workspace。
|
||||||
|
*/
|
||||||
|
export function WorkspaceManageDialog({
|
||||||
|
session,
|
||||||
|
onClose,
|
||||||
|
onMappingsChanged,
|
||||||
|
}: {
|
||||||
|
session: WorkspaceSession
|
||||||
|
onClose: () => void
|
||||||
|
/** Mapping 集合变化(新增 / 删除 / 修改)后回调上层刷新。 */
|
||||||
|
onMappingsChanged: (mappings: MappingInfo[]) => void
|
||||||
|
}) {
|
||||||
|
const [info, setInfo] = useState<WorkspaceInfo | null>(null)
|
||||||
|
// 各 Mapping 本地目录存在性(null 表示无法检测,如非 Electron 环境)。
|
||||||
|
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// 新增映射:输入服务端路径后打开既有 MappingDialog。
|
||||||
|
const [addServerPath, setAddServerPath] = useState("")
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [editMapping, setEditMapping] = useState<MappingInfo | null>(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<MappingInfo | null>(null)
|
||||||
|
|
||||||
|
// Workspace 详细信息(Owner / Computer)通过 ensure 接口幂等获取。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.ensureWorkspace({})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.ok && result.data) {
|
||||||
|
setInfo(result.data.workspace)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mapping 集合变化时重新检测本地目录存在性。
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
const result = await pathsExist(session.mappings.map((mapping) => mapping.localPath))
|
||||||
|
if (active) {
|
||||||
|
setExistsMap(result)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [session.mappings])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 Mapping(仅解除映射,不删除本地文件),成功后回调上层刷新。
|
||||||
|
*/
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (mapping: MappingInfo) => {
|
||||||
|
const result = await api.deleteMapping({ serverPath: mapping.serverPath })
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errorMessage ?? "删除 Mapping 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
onMappingsChanged(result.data?.mappings ?? [])
|
||||||
|
},
|
||||||
|
[onMappingsChanged],
|
||||||
|
)
|
||||||
|
|
||||||
|
const trimmedAddPath = addServerPath.trim()
|
||||||
|
const addPathValid = trimmedAddPath.startsWith("$/") && trimmedAddPath.length > 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>工作区管理</DialogTitle>
|
||||||
|
<DialogDescription>查看 Workspace 信息,集中管理全部本地映射</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid shrink-0 grid-cols-2 gap-x-6 gap-y-1 rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<InfoRow label="Workspace" value={session.workspace} mono />
|
||||||
|
<InfoRow label="服务器" value={session.serverUri} mono />
|
||||||
|
<InfoRow label="Collection" value={session.collection} />
|
||||||
|
<InfoRow label="Owner" value={info?.ownerName ?? "…"} />
|
||||||
|
<InfoRow label="Computer" value={info?.computer ?? "…"} />
|
||||||
|
<InfoRow label="Mapping 数量" value={String(session.mappings.length)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<Label htmlFor="addServerPath" className="text-xs">
|
||||||
|
新增映射的服务端路径
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addServerPath"
|
||||||
|
value={addServerPath}
|
||||||
|
onChange={(event) => setAddServerPath(event.target.value)}
|
||||||
|
placeholder="$/Project/Folder"
|
||||||
|
className="h-8 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!addPathValid}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
title={addPathValid ? undefined : "请输入以 $/ 开头的服务端路径"}
|
||||||
|
>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
新增映射…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||||
|
{session.mappings.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
当前 Workspace 还没有任何映射
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
|
||||||
|
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground">本地目录</TableHead>
|
||||||
|
<TableHead className="h-8 w-20 text-xs text-muted-foreground" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{session.mappings.map((mapping) => {
|
||||||
|
const exists = existsMap?.[mapping.localPath]
|
||||||
|
return (
|
||||||
|
<TableRow key={mapping.serverPath} className="cursor-default select-none">
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={mapping.serverPath}
|
||||||
|
>
|
||||||
|
{mapping.serverPath}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-0 truncate py-1.5 font-mono text-xs"
|
||||||
|
title={mapping.localPath}
|
||||||
|
>
|
||||||
|
{mapping.localPath}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
{existsMap == null ? (
|
||||||
|
<span className="text-xs text-muted-foreground">无法检测</span>
|
||||||
|
) : exists ? (
|
||||||
|
<Badge variant="secondary" className="bg-muted text-foreground/80">
|
||||||
|
存在
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-red-500/10 text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
不存在
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="修改本地路径…"
|
||||||
|
onClick={() => setEditMapping(mapping)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title="删除映射…"
|
||||||
|
onClick={() => setConfirmDelete(mapping)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
|
||||||
|
<p className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
删除映射只解除服务端目录与本地目录的关联,不会删除本地已下载的文件。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{addOpen && (
|
||||||
|
<MappingDialog
|
||||||
|
serverPath={trimmedAddPath}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onCreated={(mappings) => {
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
setAddOpen(false)
|
||||||
|
setAddServerPath("")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editMapping && (
|
||||||
|
<EditMappingDialog
|
||||||
|
mapping={editMapping}
|
||||||
|
onClose={() => setEditMapping(null)}
|
||||||
|
onChanged={(mappings) => {
|
||||||
|
onMappingsChanged(mappings)
|
||||||
|
setEditMapping(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="删除映射"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
将解除 <span className="font-mono text-xs">{confirmDelete.serverPath}</span>{" "}
|
||||||
|
与本地目录的映射。
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
本地已下载的文件不会被删除;该目录下的挂起更改将无法继续签入。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="删除映射"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
await handleDelete(confirmDelete)
|
||||||
|
setConfirmDelete(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作区信息单行展示。
|
||||||
|
*/
|
||||||
|
function InfoRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className={`min-w-0 truncate ${mono ? "font-mono" : ""}`} title={value}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改 Mapping 本地路径弹窗:选择新的本地父目录并预校验,
|
||||||
|
* 确认后按「删旧 + 加新」方式调整映射(不迁移本地内容)。
|
||||||
|
*/
|
||||||
|
function EditMappingDialog({
|
||||||
|
mapping,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
mapping: MappingInfo
|
||||||
|
onClose: () => void
|
||||||
|
onChanged: (mappings: MappingInfo[]) => void
|
||||||
|
}) {
|
||||||
|
const [parentPath, setParentPath] = useState("")
|
||||||
|
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过系统目录选择器选择新的本地父目录。
|
||||||
|
*/
|
||||||
|
const handleBrowse = useCallback(async () => {
|
||||||
|
const selected = await selectDirectory()
|
||||||
|
if (selected) {
|
||||||
|
setParentPath(selected)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 与创建映射一致:父目录变化时由后端预校验最终目标路径。
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = parentPath.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
setCheck(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
setChecking(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await api.checkMappingTarget({
|
||||||
|
serverPath: mapping.serverPath,
|
||||||
|
localParentPath: trimmed,
|
||||||
|
})
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChecking(false)
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
setCheck(null)
|
||||||
|
setError(result.errorMessage ?? "目标路径校验失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setCheck(result.data)
|
||||||
|
})()
|
||||||
|
}, 300)
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [parentPath, mapping.serverPath])
|
||||||
|
|
||||||
|
const exists = check?.exists === true
|
||||||
|
const canConfirm = check != null && !exists && !busy && !checking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删旧 + 加新调整映射本地路径,成功后回调最新 mappings。
|
||||||
|
*/
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (!check) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
const removed = await api.deleteMapping({ serverPath: mapping.serverPath })
|
||||||
|
if (!removed.ok) {
|
||||||
|
setBusy(false)
|
||||||
|
setError(removed.errorMessage ?? "删除原映射失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const added = await api.addMapping({
|
||||||
|
serverPath: mapping.serverPath,
|
||||||
|
localPath: check.targetPath,
|
||||||
|
getLatest: false,
|
||||||
|
})
|
||||||
|
setBusy(false)
|
||||||
|
if (!added.ok) {
|
||||||
|
setError(`${added.errorMessage ?? "创建新映射失败"}(原映射已删除,请重新映射)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mappings = await api.listMappings()
|
||||||
|
onChanged(mappings.data?.mappings ?? [])
|
||||||
|
}, [check, mapping.serverPath, onChanged])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改本地路径</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{mapping.serverPath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>当前本地路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{mapping.localPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="newParentPath">新的本地父目录</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="newParentPath"
|
||||||
|
placeholder="/Users/me/tfs"
|
||||||
|
value={parentPath}
|
||||||
|
onChange={(event) => setParentPath(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{isElectron() && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleBrowse()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="浏览本地目录"
|
||||||
|
>
|
||||||
|
<FolderSearch />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checking && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
正在校验目标路径…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{check && !checking && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>新映射路径</Label>
|
||||||
|
<p className="rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-xs">
|
||||||
|
{check.targetPath}
|
||||||
|
</p>
|
||||||
|
{exists && (
|
||||||
|
<p className="text-xs font-medium text-destructive">目标目录已存在,禁止映射</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
修改方式为「删除原映射 + 创建新映射」:原本地目录内容保留但不再与服务端关联,
|
||||||
|
新目录需重新执行获取最新。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
|
||||||
|
{busy ? "正在修改…" : "修改本地路径"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,9 +66,11 @@ export function ChangesPanel({
|
|||||||
}, [onCheckin, included, comment])
|
}, [onCheckin, included, comment])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-[340px] shrink-0 flex-col border-l bg-sidebar">
|
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">挂起更改</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
挂起更改
|
||||||
|
</span>
|
||||||
{pendingChanges.length > 0 && (
|
{pendingChanges.length > 0 && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
|
||||||
)}
|
)}
|
||||||
@@ -235,15 +237,20 @@ function PendingRow({
|
|||||||
<FileText 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="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm">{change.name}</span>
|
<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">
|
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
|
||||||
{change.serverPath}
|
{change.serverPath}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn("shrink-0 rounded-md", statusBadgeClass(change.status))}
|
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
|
||||||
>
|
>
|
||||||
|
<span className="size-1.5 rounded-full bg-current opacity-60" />
|
||||||
{statusLabel(change.status)}
|
{statusLabel(change.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ export function ConsolePanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="flex h-[180px] shrink-0 flex-col border-t bg-background">
|
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">操作日志</span>
|
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
操作日志
|
||||||
|
</span>
|
||||||
{busy && (
|
{busy && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-primary">
|
<span className="flex items-center gap-1.5 text-xs text-primary">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
@@ -95,7 +97,8 @@ export function ConsolePanel({
|
|||||||
{ordered.map((entry, index) => (
|
{ordered.map((entry, index) => (
|
||||||
<li
|
<li
|
||||||
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
key={`${entry.startedAt}-${entry.operation}-${index}`}
|
||||||
className="flex h-7 items-center gap-2 rounded px-1.5 text-xs hover:bg-muted"
|
className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
|
||||||
title={entry.errorMessage ?? entry.summary}
|
title={entry.errorMessage ?? entry.summary}
|
||||||
>
|
>
|
||||||
{entry.success ? (
|
{entry.success ? (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function Checkbox({
|
|||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
|
||||||
>
|
>
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 isolate z-50 bg-black/20 duration-200 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-150",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -59,7 +59,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"ease-out-quart fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl 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:zoom-in-95 data-open:slide-in-from-top-1 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:duration-150",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -128,7 +128,7 @@ function DialogTitle({
|
|||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-none font-medium",
|
"font-heading text-base leading-none font-semibold tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function ScrollBar({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
data-slot="scroll-area-thumb"
|
data-slot="scroll-area-thumb"
|
||||||
className="relative flex-1 rounded-full bg-border"
|
className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
|
||||||
/>
|
/>
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
|
||||||
|
export type ToastKind = "success" | "error" | "loading"
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string
|
||||||
|
kind: ToastKind
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (toasts: ToastItem[]) => void
|
||||||
|
|
||||||
|
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
|
||||||
|
let currentToasts: ToastItem[] = []
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 3200
|
||||||
|
const MAX_VISIBLE = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有挂载中的 Toaster 重新渲染。
|
||||||
|
*/
|
||||||
|
function emit() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(currentToasts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭指定 toast。
|
||||||
|
*/
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
currentToasts = currentToasts.filter((item) => item.id !== id)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
|
||||||
|
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
|
||||||
|
*/
|
||||||
|
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
|
||||||
|
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
const existing = currentToasts.find((item) => item.id === id)
|
||||||
|
const next: ToastItem = { id, kind: input.kind, text: input.text }
|
||||||
|
currentToasts = existing
|
||||||
|
? currentToasts.map((item) => (item.id === id ? next : item))
|
||||||
|
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
|
||||||
|
const timer = dismissTimers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
dismissTimers.delete(id)
|
||||||
|
}
|
||||||
|
if (input.kind !== "loading") {
|
||||||
|
dismissTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// toast 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
|
||||||
|
const KIND_VISUALS: Record<
|
||||||
|
ToastKind,
|
||||||
|
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
barClass: "bg-emerald-500",
|
||||||
|
borderClass: "border-emerald-500/35",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconClass: "text-destructive",
|
||||||
|
barClass: "bg-destructive",
|
||||||
|
borderClass: "border-destructive/40",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
icon: Loader2,
|
||||||
|
iconClass: "animate-spin text-primary",
|
||||||
|
barClass: "bg-primary",
|
||||||
|
borderClass: "border-primary/35",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
|
||||||
|
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
|
||||||
|
*/
|
||||||
|
export function Toaster() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener: Listener = (toasts) => setItems([...toasts])
|
||||||
|
listeners.add(listener)
|
||||||
|
setItems([...currentToasts])
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-14 right-3 z-[60] flex w-[380px] flex-col gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const visual = KIND_VISUALS[item.kind]
|
||||||
|
const Icon = visual.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
|
||||||
|
visual.borderClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
|
||||||
|
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
|
||||||
|
<span className="min-w-0 break-words">{item.text}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { useCallback, useState } from "react"
|
|||||||
import type { DiffRequest } from "~/components/explorer/diff-dialog"
|
import type { DiffRequest } from "~/components/explorer/diff-dialog"
|
||||||
import { api } from "~/lib/api"
|
import { api } from "~/lib/api"
|
||||||
import type { MappingInfo } from "~/lib/api"
|
import type { MappingInfo } from "~/lib/api"
|
||||||
|
import { isElectron, revealPath } from "~/lib/electron"
|
||||||
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
import type { FileActionId, FileTarget } from "~/lib/tfs"
|
||||||
|
|
||||||
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认。
|
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认 / 属性 / 获取特定版本。
|
||||||
export type WorkspaceDialogState =
|
export type WorkspaceDialogState =
|
||||||
| { kind: "mapping"; serverPath: string }
|
| { kind: "mapping"; serverPath: string }
|
||||||
| { kind: "history"; serverPath: string; folder: boolean }
|
| { kind: "history"; serverPath: string; folder: boolean }
|
||||||
@@ -14,6 +15,12 @@ export type WorkspaceDialogState =
|
|||||||
| { kind: "diff"; request: DiffRequest }
|
| { kind: "diff"; request: DiffRequest }
|
||||||
| { kind: "conflicts"; serverPath: string }
|
| { kind: "conflicts"; serverPath: string }
|
||||||
| { kind: "confirmForceGet"; serverPath: string; folder: boolean }
|
| { 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
|
| null
|
||||||
|
|
||||||
// 顶部细条通知:信息(操作摘要)或错误。
|
// 顶部细条通知:信息(操作摘要)或错误。
|
||||||
@@ -108,6 +115,48 @@ export function useFileActions({
|
|||||||
[refreshItems, refreshLogs, refreshPendingChanges],
|
[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,出现冲突时进入统一冲突弹窗;
|
* 执行 Checkout:先 Get Latest,出现冲突时进入统一冲突弹窗;
|
||||||
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
|
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
|
||||||
@@ -200,6 +249,47 @@ export function useFileActions({
|
|||||||
folder: target.folder,
|
folder: target.folder,
|
||||||
})
|
})
|
||||||
break
|
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":
|
case "checkout":
|
||||||
setActionBusy(true)
|
setActionBusy(true)
|
||||||
setNotice({ kind: "info", text: "正在签出…" })
|
setNotice({ kind: "info", text: "正在签出…" })
|
||||||
@@ -263,7 +353,7 @@ export function useFileActions({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs],
|
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs, refreshItems, refreshPendingChanges],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,6 +405,120 @@ export function useFileActions({
|
|||||||
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
|
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
|
||||||
}, [refreshItems, refreshPendingChanges, refreshLogs])
|
}, [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],
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
|
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
|
||||||
*/
|
*/
|
||||||
@@ -348,6 +552,11 @@ export function useFileActions({
|
|||||||
handleMappingCreated,
|
handleMappingCreated,
|
||||||
handleConflictsResolved,
|
handleConflictsResolved,
|
||||||
handleForceGetConfirmed,
|
handleForceGetConfirmed,
|
||||||
|
handleRenameConfirmed,
|
||||||
|
handleBranchConfirmed,
|
||||||
|
handleMergeConfirmed,
|
||||||
|
handleAddFilesConfirmed,
|
||||||
runGetVersion,
|
runGetVersion,
|
||||||
|
runRollback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
|
||||||
|
export type ThemePreference = "system" | "light" | "dark"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mactfs.theme"
|
||||||
|
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前系统外观是否为暗色。
|
||||||
|
*/
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由偏好推导是否应启用暗色。
|
||||||
|
*/
|
||||||
|
function resolveIsDark(preference: ThemePreference): boolean {
|
||||||
|
return preference === "dark" || (preference === "system" && systemPrefersDark())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
|
||||||
|
*/
|
||||||
|
function loadPreference(): ThemePreference {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw === "light" || raw === "dark" ? raw : "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
|
||||||
|
*/
|
||||||
|
function applyTheme(preference: ThemePreference) {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.add("theme-transition")
|
||||||
|
root.classList.toggle("dark", resolveIsDark(preference))
|
||||||
|
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
|
||||||
|
* 跟随系统时监听系统外观变化自动切换。
|
||||||
|
*/
|
||||||
|
export function useTheme() {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(preference)
|
||||||
|
if (preference !== "system") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
const onChange = () => applyTheme("system")
|
||||||
|
media.addEventListener("change", onChange)
|
||||||
|
return () => media.removeEventListener("change", onChange)
|
||||||
|
}, [preference])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置主题偏好并持久化。
|
||||||
|
*/
|
||||||
|
const setPreference = useCallback((next: ThemePreference) => {
|
||||||
|
setPreferenceState(next)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
} catch {
|
||||||
|
// 本地存储不可用时仅在当前会话生效
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
|
||||||
|
*/
|
||||||
|
const cycle = useCallback(() => {
|
||||||
|
const index = CYCLE_ORDER.indexOf(preference)
|
||||||
|
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
|
||||||
|
}, [preference, setPreference])
|
||||||
|
|
||||||
|
return { preference, setPreference, cycle }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
FolderDiffItem,
|
FolderDiffItem,
|
||||||
GetLatestResult,
|
GetLatestResult,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
ItemInfo,
|
||||||
MappingInfo,
|
MappingInfo,
|
||||||
OperationLogEntry,
|
OperationLogEntry,
|
||||||
PendingChange,
|
PendingChange,
|
||||||
@@ -159,6 +160,41 @@ export function undoFiles(body: FileActionBody) {
|
|||||||
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件或目录(同目录改名),产生 rename 挂起更改。
|
||||||
|
*/
|
||||||
|
export function renameFile(body: { serverPath: string; newName: string }) {
|
||||||
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/rename", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚变更集(只产生挂起更改):single 仅反做该 changeset,toVersion 反做其后全部改动。
|
||||||
|
*/
|
||||||
|
export function rollback(body: { serverPath: string; mode: "single" | "toVersion"; changeset: number }) {
|
||||||
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/rollback", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支:把源路径分叉到目标路径(产生 branch 挂起更改),changeset 缺省为 latest。
|
||||||
|
*/
|
||||||
|
export function branch(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
|
||||||
|
return apiClient.post<{ result: FileOperationResult }>("/api/files/branch", { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询源 → 目标的待合并变更集候选列表。
|
||||||
|
*/
|
||||||
|
export function mergeCandidates(params: { sourceServerPath: string; targetServerPath: string }) {
|
||||||
|
return apiClient.get<{ candidates: HistoryEntry[] }>("/api/files/merge-candidates", { query: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并:源 → 目标(产生挂起更改,冲突走冲突处理),changeset 缺省为合并全部候选。
|
||||||
|
*/
|
||||||
|
export function merge(body: { sourceServerPath: string; targetServerPath: string; changeset?: number }) {
|
||||||
|
return apiClient.post<{ result: GetLatestResult }>("/api/files/merge", { body })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取当前 Workspace 的挂起更改。
|
* 读取当前 Workspace 的挂起更改。
|
||||||
*/
|
*/
|
||||||
@@ -173,6 +209,13 @@ export function checkin(body: { paths?: string[]; comment: string }) {
|
|||||||
return apiClient.post<{ checkin: CheckinResult }>("/api/checkin", { body })
|
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),或读取本地映射文件内容。
|
* 读取服务器内容(latest 或指定 changeset),或读取本地映射文件内容。
|
||||||
*/
|
*/
|
||||||
@@ -182,8 +225,14 @@ export function getFileContent(params: { serverPath?: string; localPath?: string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 对已映射目录执行目录对比。
|
* 对已映射目录执行目录对比。
|
||||||
|
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
|
||||||
*/
|
*/
|
||||||
export function compareFolder(body: { serverPath: string; localPath?: string; recursive?: boolean }) {
|
export function compareFolder(body: {
|
||||||
|
serverPath: string
|
||||||
|
localPath?: string
|
||||||
|
recursive?: boolean
|
||||||
|
includeLocalOnly?: boolean
|
||||||
|
}) {
|
||||||
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type {
|
|||||||
FolderDiffItem,
|
FolderDiffItem,
|
||||||
GetLatestResult,
|
GetLatestResult,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
ItemInfo,
|
||||||
MappingConfig,
|
MappingConfig,
|
||||||
MappingInfo,
|
MappingInfo,
|
||||||
OperationLogEntry,
|
OperationLogEntry,
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export interface PendingChange {
|
|||||||
status: string
|
status: string
|
||||||
changeType: string
|
changeType: string
|
||||||
version: number
|
version: number
|
||||||
|
/** rename 挂起更改的原服务端路径,其余为 null。 */
|
||||||
|
sourceServerPath: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签入结果,对应 core TfsCheckinResult。
|
// 签入结果,对应 core TfsCheckinResult。
|
||||||
@@ -159,6 +161,20 @@ export interface HistoryEntry {
|
|||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
|
||||||
|
export interface ItemInfo {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
changeset: number
|
||||||
|
checkinDate: number | null
|
||||||
|
author: string
|
||||||
|
comment: string
|
||||||
|
/** 文件大小(字节),目录为 null。 */
|
||||||
|
size: number | null
|
||||||
|
/** 文件编码名称,目录为 null。 */
|
||||||
|
encoding: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
||||||
export interface FileContent {
|
export interface FileContent {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ export async function pathsExist(paths: string[]): Promise<Record<string, boolea
|
|||||||
return bridge.pathsExist(paths)
|
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 环境下的占位服务状态。
|
* 构造非 Electron 环境下的占位服务状态。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export {
|
|||||||
getToken,
|
getToken,
|
||||||
isElectron,
|
isElectron,
|
||||||
pathsExist,
|
pathsExist,
|
||||||
|
revealPath,
|
||||||
selectDirectory,
|
selectDirectory,
|
||||||
startService,
|
startService,
|
||||||
} from "./bridge"
|
} from "./bridge"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface MactfsBridge {
|
|||||||
startService(): Promise<ServiceStatus>
|
startService(): Promise<ServiceStatus>
|
||||||
selectDirectory(): Promise<string | null>
|
selectDirectory(): Promise<string | null>
|
||||||
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
||||||
|
revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 是否运行在 macOS 的 Electron 窗口内。
|
||||||
|
* 用于判断隐藏式标题栏场景:需要为红绿灯预留空间并启用顶栏拖拽。
|
||||||
|
*/
|
||||||
|
export function isMacElectron(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
return ua.includes("Electron") && ua.includes("Macintosh")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 登录页「最近连接」本地记录,仅存非敏感字段(不存密码)。
|
||||||
|
export interface RecentServer {
|
||||||
|
serverUri: string
|
||||||
|
domain: string
|
||||||
|
username: string
|
||||||
|
lastUsedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mactfs.recent-servers"
|
||||||
|
const MAX_ENTRIES = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取最近连接记录,本地存储不可用或数据损坏时返回空列表。
|
||||||
|
*/
|
||||||
|
export function loadRecentServers(): RecentServer[] {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as RecentServer[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入一条最近连接记录:按 serverUri + username 去重置顶,最多保留 5 条。
|
||||||
|
*/
|
||||||
|
export function saveRecentServer(entry: Omit<RecentServer, "lastUsedAt">): RecentServer[] {
|
||||||
|
const next: RecentServer[] = [
|
||||||
|
{ ...entry, lastUsedAt: Date.now() },
|
||||||
|
...loadRecentServers().filter(
|
||||||
|
(item) => item.serverUri !== entry.serverUri || item.username !== entry.username,
|
||||||
|
),
|
||||||
|
].slice(0, MAX_ENTRIES)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// 本地存储写入失败不影响连接流程
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ export type FileActionId =
|
|||||||
| "unmap"
|
| "unmap"
|
||||||
| "getLatest"
|
| "getLatest"
|
||||||
| "forceGetLatest"
|
| "forceGetLatest"
|
||||||
|
| "getSpecificVersion"
|
||||||
| "checkout"
|
| "checkout"
|
||||||
|
| "rename"
|
||||||
| "delete"
|
| "delete"
|
||||||
| "undo"
|
| "undo"
|
||||||
| "add"
|
| "add"
|
||||||
@@ -40,6 +42,11 @@ export type FileActionId =
|
|||||||
| "history"
|
| "history"
|
||||||
| "viewFile"
|
| "viewFile"
|
||||||
| "diffLocalLatest"
|
| "diffLocalLatest"
|
||||||
|
| "branch"
|
||||||
|
| "merge"
|
||||||
|
| "revealInFinder"
|
||||||
|
| "addLocalFiles"
|
||||||
|
| "properties"
|
||||||
|
|
||||||
// 单个菜单项:是否可用与置灰原因。
|
// 单个菜单项:是否可用与置灰原因。
|
||||||
export interface FileMenuItem {
|
export interface FileMenuItem {
|
||||||
@@ -138,6 +145,12 @@ function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
enabled: target.mapped,
|
enabled: target.mapped,
|
||||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "rename",
|
||||||
|
label: "重命名…",
|
||||||
|
enabled: target.mapped,
|
||||||
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "delete",
|
id: "delete",
|
||||||
label: "挂起删除",
|
label: "挂起删除",
|
||||||
@@ -165,6 +178,21 @@ function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
sections.push(mappingActions)
|
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([
|
sections.push([
|
||||||
{
|
{
|
||||||
id: "compare",
|
id: "compare",
|
||||||
@@ -173,11 +201,31 @@ function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
|
||||||
},
|
},
|
||||||
{ id: "history", label: "查看历史", enabled: true },
|
{ id: "history", label: "查看历史", enabled: true },
|
||||||
|
{ id: "branch", label: "分支…", enabled: true },
|
||||||
|
{ id: "merge", label: "合并…", enabled: true },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
sections.push(buildAdvancedSection(target))
|
||||||
|
|
||||||
return sections
|
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 覆盖。
|
* pendingAdd 文件不展示服务器比较与历史入口;pendingEdit 禁止直接 Get Latest 覆盖。
|
||||||
@@ -199,6 +247,12 @@ function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
})
|
})
|
||||||
viewActions.push({ id: "history", label: "查看历史", enabled: true })
|
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)
|
sections.push(viewActions)
|
||||||
|
|
||||||
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
|
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
|
||||||
@@ -206,6 +260,10 @@ function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
sections.push([
|
sections.push([
|
||||||
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
|
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
|
||||||
])
|
])
|
||||||
|
// pendingAdd 项服务端尚不存在,属性查询无意义。
|
||||||
|
if (!isPendingAdd) {
|
||||||
|
sections.push([{ id: "properties", label: "属性…", enabled: true }])
|
||||||
|
}
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +294,20 @@ function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
? "存在挂起更改"
|
? "存在挂起更改"
|
||||||
: undefined,
|
: 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",
|
id: "delete",
|
||||||
label: "挂起删除",
|
label: "挂起删除",
|
||||||
@@ -256,5 +328,14 @@ function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
|
|||||||
]
|
]
|
||||||
sections.push(workspaceActions)
|
sections.push(workspaceActions)
|
||||||
|
|
||||||
|
// pendingAdd 文件服务端尚不存在,不提供分支、合并、属性与获取特定版本。
|
||||||
|
if (!isPendingAdd) {
|
||||||
|
sections.push([
|
||||||
|
{ id: "branch", label: "分支…", enabled: true },
|
||||||
|
{ id: "merge", label: "合并…", enabled: true },
|
||||||
|
])
|
||||||
|
sections.push(buildAdvancedSection(target))
|
||||||
|
}
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
{/* 首屏前同步应用主题偏好,避免暗色模式闪白 */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import type { WorkspaceSession } from "~/lib/tfs/session"
|
|||||||
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
|
||||||
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面标题(同时作为 Electron 窗口标题)。
|
||||||
|
*/
|
||||||
|
export function meta() {
|
||||||
|
return [{ title: "MacTFS" }]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
|
||||||
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
|
||||||
@@ -88,7 +95,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
|
||||||
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
|
||||||
<h1 className="text-base font-semibold">macTFS</h1>
|
<h1 className="text-base font-semibold">MacTFS</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
|
||||||
|
|
||||||
{phase === "checking" && (
|
{phase === "checking" && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { app, BrowserWindow, ipcMain, dialog } = require("electron")
|
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, nativeTheme } = require("electron")
|
||||||
const path = require("node:path")
|
const path = require("node:path")
|
||||||
const fs = require("node:fs")
|
const fs = require("node:fs")
|
||||||
const os = require("node:os")
|
const os = require("node:os")
|
||||||
@@ -246,15 +246,55 @@ function registerIpcHandlers() {
|
|||||||
return result.filePaths[0]
|
return result.filePaths[0]
|
||||||
})
|
})
|
||||||
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
|
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
|
||||||
|
ipcMain.handle("mactfs:reveal-path", async (event, targetPath, isFolder) => {
|
||||||
|
if (typeof targetPath !== "string" || targetPath.length === 0 || !fs.existsSync(targetPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 目录直接进入访达中的该目录,文件则在访达中定位并选中。
|
||||||
|
if (isFolder) {
|
||||||
|
const error = await shell.openPath(targetPath)
|
||||||
|
return error === ""
|
||||||
|
}
|
||||||
|
shell.showItemInFolder(targetPath)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析应用图标路径:开发态用 public/logo.png,打包态用构建产物中的 logo.png。
|
||||||
|
*/
|
||||||
|
function resolveAppIcon() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, "..", "public", "logo.png"),
|
||||||
|
path.join(__dirname, "..", "build", "client", "logo.png"),
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
|
||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 840,
|
height: 840,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 640,
|
||||||
|
title: "MacTFS",
|
||||||
|
// 预设与主题一致的背景色并延迟显示,避免启动白闪。
|
||||||
|
show: false,
|
||||||
|
backgroundColor: nativeTheme.shouldUseDarkColors ? "#1d1d20" : "#fafafa",
|
||||||
|
// macOS 隐藏系统标题栏,红绿灯融入应用顶栏;其它平台回退系统标题栏。
|
||||||
|
...(process.platform === "darwin"
|
||||||
|
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
||||||
|
: {}),
|
||||||
|
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.cjs"),
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -262,6 +302,12 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
// 启动即铺满当前屏幕可视区域(保留程序坞与菜单栏,非独立全屏 Space)。
|
||||||
|
mainWindow.maximize()
|
||||||
|
mainWindow.show()
|
||||||
|
})
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.loadURL("http://localhost:5173")
|
mainWindow.loadURL("http://localhost:5173")
|
||||||
return
|
return
|
||||||
@@ -270,7 +316,14 @@ function createWindow() {
|
|||||||
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.setName("MacTFS")
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
|
||||||
|
const iconPath = resolveAppIcon()
|
||||||
|
if (process.platform === "darwin" && iconPath && app.dock) {
|
||||||
|
app.dock.setIcon(nativeImage.createFromPath(iconPath))
|
||||||
|
}
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
|||||||
@@ -39,4 +39,10 @@ contextBridge.exposeInMainWorld("mactfs", {
|
|||||||
pathsExist(paths) {
|
pathsExist(paths) {
|
||||||
return ipcRenderer.invoke("mactfs:paths-exist", paths)
|
return ipcRenderer.invoke("mactfs:paths-exist", paths)
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 在访达中打开:目录直接进入,文件定位并选中。
|
||||||
|
*/
|
||||||
|
revealPath(targetPath, isFolder) {
|
||||||
|
return ipcRenderer.invoke("mactfs:reveal-path", targetPath, isFolder)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"format": "prettier --write \"**/*.{ts,tsx}\""
|
"format": "prettier --write \"**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/roboto-slab": "^5.2.8",
|
|
||||||
"@react-router/node": "7.13.1",
|
"@react-router/node": "7.13.1",
|
||||||
"@react-router/serve": "7.13.1",
|
"@react-router/serve": "7.13.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fontsource-variable/roboto-slab':
|
|
||||||
specifier: ^5.2.8
|
|
||||||
version: 5.2.8
|
|
||||||
'@react-router/node':
|
'@react-router/node':
|
||||||
specifier: 7.13.1
|
specifier: 7.13.1
|
||||||
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
|
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
|
||||||
@@ -416,9 +413,6 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.11':
|
'@floating-ui/utils@0.2.11':
|
||||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
'@fontsource-variable/roboto-slab@5.2.8':
|
|
||||||
resolution: {integrity: sha512-cdvOSwocP50xS01gXqnGxw7uFcCZQgn1IDlqiAmNuZP4uEVScYAKMDC+aWsbP1grn9QdMJ2frhcuVBNXniBVGA==}
|
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
|
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -3742,8 +3736,6 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
'@fontsource-variable/roboto-slab@5.2.8': {}
|
|
||||||
|
|
||||||
'@hapi/address@5.1.1':
|
'@hapi/address@5.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hapi/hoek': 11.0.7
|
'@hapi/hoek': 11.0.7
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,127 @@
|
|||||||
|
// FE-034 补充回归:目录已被既有 Workspace 映射(含用户真实挂起更改),
|
||||||
|
// 因此跳过创建/取消映射,只验证 目录对比、签出→Diff→撤销(仅 ai-smoke 文件)、操作日志。
|
||||||
|
import { _electron as electron } from "playwright-core"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
|
||||||
|
const SMOKE_FILE = "ai-smoke-20260606051038.txt"
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
/** 记录一步结果并打印。 */
|
||||||
|
function record(step, ok, detail = "") {
|
||||||
|
results.push({ step, ok, detail })
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截图保存。 */
|
||||||
|
async function shot(win, name) {
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: ["electron/main.cjs"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, NODE_ENV: "development" },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
win.setDefaultTimeout(90000)
|
||||||
|
|
||||||
|
// 进入工作台并导航到测试目录
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||||
|
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||||
|
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||||
|
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||||
|
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||||
|
const tree = win.locator("aside").first()
|
||||||
|
for (const name of TEST_TREE_CHAIN) {
|
||||||
|
const node = tree.getByText(name, { exact: true }).first()
|
||||||
|
await node.waitFor({ timeout: 120000 })
|
||||||
|
await node.click()
|
||||||
|
await win.waitForTimeout(2500)
|
||||||
|
}
|
||||||
|
record("0 进入工作台并导航到测试目录", true)
|
||||||
|
|
||||||
|
// A. 目录对比(目录已映射 → 菜单应可用;忽略仅本地/仅服务器项保持默认)
|
||||||
|
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
|
||||||
|
const compareItem = win.getByRole("menuitem", { name: "目录对比", exact: true })
|
||||||
|
await compareItem.waitFor({ timeout: 10000 })
|
||||||
|
await compareItem.click()
|
||||||
|
await win.locator("[data-slot=dialog-title]", { hasText: "目录对比" }).waitFor()
|
||||||
|
await shot(win, "12-compare-options")
|
||||||
|
// 新交互:先确认选项再执行
|
||||||
|
const startButton = win.getByRole("button", { name: /开始对比|执行对比|对比/ }).first()
|
||||||
|
if ((await startButton.count()) > 0) {
|
||||||
|
await startButton.click()
|
||||||
|
}
|
||||||
|
await win.waitForTimeout(10000)
|
||||||
|
const compareText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||||
|
record("A 目录对比弹窗", !compareText.includes("目录对比失败"), compareText.slice(0, 100).replace(/\n/g, " "))
|
||||||
|
await shot(win, "12-compare")
|
||||||
|
await win.keyboard.press("Escape")
|
||||||
|
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
|
||||||
|
// B. 签出 ai-smoke 文件 → 挂起更改出现 → 与最新版本比较 → 撤销(只动这一个文件)
|
||||||
|
const fileRow = win.locator("section table tbody tr").filter({ hasText: SMOKE_FILE }).first()
|
||||||
|
await fileRow.click({ button: "right" })
|
||||||
|
const checkoutItem = win.getByRole("menuitem", { name: "签出编辑", exact: true })
|
||||||
|
await checkoutItem.waitFor({ timeout: 10000 })
|
||||||
|
await checkoutItem.click()
|
||||||
|
await win.waitForTimeout(15000)
|
||||||
|
const pendingPanel = await win.locator("aside").nth(1).innerText()
|
||||||
|
const hasPending = pendingPanel.includes(SMOKE_FILE)
|
||||||
|
record("B1 签出文件产生挂起更改", hasPending, "")
|
||||||
|
await shot(win, "13-pending")
|
||||||
|
|
||||||
|
if (hasPending) {
|
||||||
|
const row = win.locator("aside").nth(1).getByText(SMOKE_FILE).first()
|
||||||
|
await row.click({ button: "right" })
|
||||||
|
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
|
||||||
|
if ((await diffItem.count()) > 0) {
|
||||||
|
await diffItem.click()
|
||||||
|
await win.waitForTimeout(8000)
|
||||||
|
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||||
|
record("B2 本地 vs latest Diff(Monaco)", diffText.includes("差异") || diffText.includes("一致"), diffText.slice(0, 80).replace(/\n/g, " "))
|
||||||
|
await shot(win, "14-diff")
|
||||||
|
await win.keyboard.press("Escape")
|
||||||
|
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
// 仅撤销 ai-smoke 这一个文件,避免影响用户已有挂起更改
|
||||||
|
await row.click({ button: "right" })
|
||||||
|
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
|
||||||
|
await win.waitForTimeout(12000)
|
||||||
|
const afterUndo = await win.locator("aside").nth(1).innerText()
|
||||||
|
record("B3 撤销该文件挂起更改", !afterUndo.includes(SMOKE_FILE), "")
|
||||||
|
await shot(win, "15-undone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 操作日志面板有真实记录
|
||||||
|
const consoleText = await win.locator("footer").innerText()
|
||||||
|
record("C 操作日志记录", /checkout|undo|connect|serverTree|folderDiff|compare/i.test(consoleText), consoleText.split("\n").slice(0, 5).join(" | "))
|
||||||
|
await shot(win, "16-console")
|
||||||
|
} catch (error) {
|
||||||
|
record("EXCEPTION", false, String(error).slice(0, 300))
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
await shot(win, "98-exception")
|
||||||
|
} catch {}
|
||||||
|
} finally {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n==== RESULT SUMMARY ====")
|
||||||
|
for (const item of results) {
|
||||||
|
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
|
||||||
|
}
|
||||||
|
const failed = results.filter((item) => !item.ok).length
|
||||||
|
console.log(`\n${results.length - failed}/${results.length} passed`)
|
||||||
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// FE-034 清理与诊断:撤销 part2 遗留的 ai-smoke 签出;再从文件列表打开「与最新版本比较」
|
||||||
|
// 复现 Diff 弹窗导致窗口关闭的问题,捕获 renderer 崩溃与主进程输出。
|
||||||
|
import { _electron as electron } from "playwright-core"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
|
||||||
|
const SMOKE_FILE = "ai-smoke-20260606051038.txt"
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
function record(step, ok, detail = "") {
|
||||||
|
results.push({ step, ok, detail })
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shot(win, name) {
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: ["electron/main.cjs"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, NODE_ENV: "development", ELECTRON_ENABLE_LOGGING: "1" },
|
||||||
|
})
|
||||||
|
app.process().stderr?.on("data", (chunk) => {
|
||||||
|
const text = String(chunk)
|
||||||
|
if (/error|crash|fatal/i.test(text)) {
|
||||||
|
console.log("[main-stderr]", text.slice(0, 400))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
win.setDefaultTimeout(90000)
|
||||||
|
win.on("crash", () => console.log("[renderer] CRASHED"))
|
||||||
|
win.on("close", () => console.log("[renderer] window closed"))
|
||||||
|
win.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
console.log("[console.error]", msg.text().slice(0, 300))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
win.on("pageerror", (err) => console.log("[pageerror]", String(err).slice(0, 300)))
|
||||||
|
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||||
|
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||||
|
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||||
|
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||||
|
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||||
|
await win.waitForTimeout(5000)
|
||||||
|
|
||||||
|
// 1. 清理:撤销 ai-smoke 的遗留签出(只动这一个文件)
|
||||||
|
const changes = win.locator("aside").nth(1)
|
||||||
|
const smokeRow = changes.getByText(SMOKE_FILE).first()
|
||||||
|
if ((await smokeRow.count()) > 0) {
|
||||||
|
await smokeRow.click({ button: "right" })
|
||||||
|
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
|
||||||
|
await win.waitForTimeout(12000)
|
||||||
|
const after = await changes.innerText()
|
||||||
|
record("1 清理遗留签出", !after.includes(SMOKE_FILE), "")
|
||||||
|
await shot(win, "17-cleanup")
|
||||||
|
} else {
|
||||||
|
record("1 清理遗留签出", true, "无遗留挂起更改")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 诊断:从文件列表打开 与最新版本比较(Monaco Diff)
|
||||||
|
const tree = win.locator("aside").first()
|
||||||
|
for (const name of TEST_TREE_CHAIN) {
|
||||||
|
const node = tree.getByText(name, { exact: true }).first()
|
||||||
|
await node.waitFor({ timeout: 120000 })
|
||||||
|
await node.click()
|
||||||
|
await win.waitForTimeout(2000)
|
||||||
|
}
|
||||||
|
const fileRow = win.locator("section table tbody tr").filter({ hasText: SMOKE_FILE }).first()
|
||||||
|
await fileRow.click({ button: "right" })
|
||||||
|
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
|
||||||
|
await diffItem.waitFor({ timeout: 10000 })
|
||||||
|
await diffItem.click()
|
||||||
|
await win.waitForTimeout(3000)
|
||||||
|
await shot(win, "18-diff-opening")
|
||||||
|
await win.waitForTimeout(7000)
|
||||||
|
const dialogCount = await win.locator("[data-slot=dialog-content]").count()
|
||||||
|
if (dialogCount > 0) {
|
||||||
|
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
|
||||||
|
record("2 Diff 弹窗(Monaco)", diffText.includes("差异") || diffText.includes("一致"), diffText.slice(0, 80).replace(/\n/g, " "))
|
||||||
|
await shot(win, "18-diff")
|
||||||
|
await win.keyboard.press("Escape")
|
||||||
|
} else {
|
||||||
|
record("2 Diff 弹窗(Monaco)", false, "弹窗未出现")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
record("EXCEPTION", false, String(error).slice(0, 300))
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
await shot(win, "97-exception")
|
||||||
|
} catch {}
|
||||||
|
} finally {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n==== RESULT SUMMARY ====")
|
||||||
|
for (const item of results) {
|
||||||
|
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
|
||||||
|
}
|
||||||
|
process.exit(results.some((item) => !item.ok) ? 1 : 0)
|
||||||
@@ -57,6 +57,18 @@ try {
|
|||||||
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
|
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
|
||||||
await shot(win, "03-workspace")
|
await shot(win, "03-workspace")
|
||||||
|
|
||||||
|
// 3.5 明暗主题三态切换(FE-033 回归):跟随系统 → 亮 → 暗 → 跟随系统
|
||||||
|
const themeButton = win.getByRole("button", { name: /主题:/ })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
const darkApplied = await win.evaluate(() => document.documentElement.classList.contains("dark"))
|
||||||
|
record("3.5 主题切换到暗色", darkApplied, "")
|
||||||
|
await shot(win, "03b-dark-workspace")
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
|
||||||
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
|
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
|
||||||
const tree = win.locator("aside").first()
|
const tree = win.locator("aside").first()
|
||||||
for (const name of TEST_TREE_CHAIN) {
|
for (const name of TEST_TREE_CHAIN) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 快速视觉确认:进入工作台后截图亮色 / 暗色画布卡片布局,不做任何写操作。
|
||||||
|
import { _electron as electron } from "playwright-core"
|
||||||
|
import { mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: ["electron/main.cjs"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, NODE_ENV: "development" },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win = await app.firstWindow()
|
||||||
|
win.setDefaultTimeout(90000)
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "20-connect-v2.png") })
|
||||||
|
await win.getByRole("button", { name: "连接 TFS" }).click()
|
||||||
|
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "21-collections-v2.png") })
|
||||||
|
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
|
||||||
|
await win.getByRole("button", { name: "进入工作台" }).click()
|
||||||
|
await win.getByText("源码目录").waitFor({ timeout: 120000 })
|
||||||
|
await win.waitForTimeout(6000)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "22-workspace-cards.png") })
|
||||||
|
// 验证表头 sticky:滚动文件列表后表头应仍可见
|
||||||
|
await win.evaluate(() => {
|
||||||
|
const container = document.querySelector('[data-slot="table-container"]')
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await win.waitForTimeout(600)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "24-sticky-header.png") })
|
||||||
|
const themeButton = win.getByRole("button", { name: /主题:/ })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(800)
|
||||||
|
await win.screenshot({ path: path.join(SHOT_DIR, "23-workspace-cards-dark.png") })
|
||||||
|
await themeButton.click()
|
||||||
|
await win.waitForTimeout(400)
|
||||||
|
console.log("screenshots done")
|
||||||
|
} finally {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
}
|
||||||
@@ -31,14 +31,14 @@ blocked
|
|||||||
| Core | 12 | 12 | 100% |
|
| Core | 12 | 12 | 100% |
|
||||||
| Server API | 10 | 10 | 100% |
|
| Server API | 10 | 10 | 100% |
|
||||||
| CLI | 2 | 2 | 100% |
|
| CLI | 2 | 2 | 100% |
|
||||||
| Frontend | 18 | 18 | 100% |
|
| Frontend | 34 | 34 | 100% |
|
||||||
| Feature E2E | 0 | 5 | 0% |
|
| Feature E2E | 0 | 5 | 0% |
|
||||||
| Release | 0 | 4 | 0% |
|
| Release | 0 | 4 | 0% |
|
||||||
| Overall | 42 | 51 | 82% |
|
| Overall | 58 | 67 | 87% |
|
||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
Feature E2E(前端阶段含增强已全部完成)
|
UI 风格改版完成(分支 `feature/ui-modern-restyle`,FE-026 ~ FE-034 全部 done,需求见 [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md);截图见 [docs/ui-restyle-screenshots](/Users/fenghp/Desktop/DEV/project/mydev/docs/ui-restyle-screenshots))
|
||||||
|
|
||||||
## 前端规范
|
## 前端规范
|
||||||
|
|
||||||
@@ -47,6 +47,8 @@ Feature E2E(前端阶段含增强已全部完成)
|
|||||||
## 下一步任务
|
## 下一步任务
|
||||||
|
|
||||||
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
||||||
|
- 或切换 `feature/ui-macos-restyle` 分支开发 macOS 原生风方案做对比
|
||||||
|
- UI 改版完成后再回到 [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -109,6 +111,22 @@ Feature E2E(前端阶段含增强已全部完成)
|
|||||||
| [FE-016-优化Diff与历史弹窗体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-016-优化Diff与历史弹窗体验.md) | done | P1 |
|
| [FE-016-优化Diff与历史弹窗体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-016-优化Diff与历史弹窗体验.md) | done | P1 |
|
||||||
| [FE-017-实现工作台手动刷新.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-017-实现工作台手动刷新.md) | done | P1 |
|
| [FE-017-实现工作台手动刷新.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-017-实现工作台手动刷新.md) | done | P1 |
|
||||||
| [FE-018-Diff弹窗替换为MonacoDiffEditor.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-018-Diff弹窗替换为MonacoDiffEditor.md) | done | P1 |
|
| [FE-018-Diff弹窗替换为MonacoDiffEditor.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-018-Diff弹窗替换为MonacoDiffEditor.md) | done | P1 |
|
||||||
|
| [FE-019-实现对象属性与获取特定版本.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-019-实现对象属性与获取特定版本.md) | done | P0 |
|
||||||
|
| [FE-020-实现重命名.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-020-实现重命名.md) | done | P0 |
|
||||||
|
| [FE-021-实现变更集回滚.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-021-实现变更集回滚.md) | done | P1 |
|
||||||
|
| [FE-022-实现工作区与Mapping集中管理.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-022-实现工作区与Mapping集中管理.md) | done | P1 |
|
||||||
|
| [FE-023-实现分支.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-023-实现分支.md) | done | P1 |
|
||||||
|
| [FE-024-实现合并.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-024-实现合并.md) | done | P1 |
|
||||||
|
| [FE-025-实现访达打开与添加本地文件.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-025-实现访达打开与添加本地文件.md) | done | P0 |
|
||||||
|
| [FE-026-建立现代桌面风设计基建.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-026-建立现代桌面风设计基建.md) | done | P0 |
|
||||||
|
| [FE-027-Electron无缝窗口与自绘顶栏.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-027-Electron无缝窗口与自绘顶栏.md) | done | P0 |
|
||||||
|
| [FE-028-重设计登录页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-028-重设计登录页.md) | done | P0 |
|
||||||
|
| [FE-029-工作台外壳与顶栏改版.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-029-工作台外壳与顶栏改版.md) | done | P0 |
|
||||||
|
| [FE-030-目录树与文件列表精致化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-030-目录树与文件列表精致化.md) | done | P0 |
|
||||||
|
| [FE-031-弹窗体系统一现代风格.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-031-弹窗体系统一现代风格.md) | done | P1 |
|
||||||
|
| [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) | done | P1 |
|
||||||
|
| [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md) | done | P1 |
|
||||||
|
| [FE-034-UI改版回归验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-034-UI改版回归验收.md) | done | P0 |
|
||||||
|
|
||||||
## Feature E2E
|
## Feature E2E
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# FE-019 实现对象属性与获取特定版本
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-014
|
||||||
|
- FE-015
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R5
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为选中的文件 / 文件夹提供「高级」能力:属性弹窗(服务器路径、本地路径、映射与挂起状态、
|
||||||
|
服务器最新版本信息),以及右键直达的「获取特定版本…」(按 changeset 把对象本地内容
|
||||||
|
整体切到该变更集时刻),对齐 VS 的 Properties 与 Get Specific Version。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- server 新增 `GET /api/items/info?serverPath=`:返回对象类型、最新 changeset、
|
||||||
|
签入时间、签入人,文件补充大小与编码(core 用 `VersionControlClient.getItem`)
|
||||||
|
- 前端 API 层补充 `itemInfo`
|
||||||
|
- 右键菜单(文件与目录、树 / 列表 / 挂起 / 对比来源均可见)新增:
|
||||||
|
- 「属性…」:弹窗展示服务器路径、本地路径(未映射显示「未映射」)、对象类型、
|
||||||
|
映射状态、挂起状态、服务器最新版本信息;路径支持一键复制
|
||||||
|
- 「获取特定版本…」:弹窗内 changeset 输入框 + 「从历史选择…」内嵌历史列表点选回填;
|
||||||
|
明确提示会覆盖本地内容(目录递归);确认后调用既有 `/api/files/get-version`
|
||||||
|
- 完成后刷新目录列表与挂起更改
|
||||||
|
- 未映射对象:「获取特定版本…」置灰并给出原因
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做按日期 / 标签的版本规格(仅 changeset)
|
||||||
|
- 不做回滚(产生挂起更改的反做走 FE-021)
|
||||||
|
- 不改历史弹窗既有「获取此版本」入口
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
- [mactfsui/app/components/explorer](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 文件 / 目录右键可打开「属性…」,服务器路径与本地路径展示正确,未映射对象明确标识
|
||||||
|
- 属性弹窗展示服务器侧最新 changeset、签入时间、签入人;文件展示大小与编码
|
||||||
|
- 右键「获取特定版本…」可输入或从历史点选 changeset,确认后本地内容切到该版本
|
||||||
|
- 目录执行获取特定版本为递归覆盖,完成后目录列表与挂起更改刷新
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `getItemInfo`(`getItem` 取最新版本 / 大小 / 编码,`queryHistory` 限 1 条取签入人 / 时间 / 备注,目录按子树最近签入计)与数据类 `TfsItemInfo`
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `GET /api/items/info`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`:新增 `ItemInfo` 类型
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts` / `index.ts`:新增 `getItemInfo` 接口封装与类型导出
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `properties` / `getSpecificVersion`;目录与文件菜单尾部新增高级分组(获取特定版本… 危险态、属性…);pending 来源仅提供属性(pendingAdd 不提供)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `properties` / `getVersion`,动作分发打开对应弹窗
|
||||||
|
- `mactfsui/app/components/explorer/properties-dialog.tsx`(新增):属性弹窗(服务器 / 本地路径可复制、映射与挂起状态 badge、最新 changeset / 签入时间 / 签入人 / 备注、文件大小与编码)
|
||||||
|
- `mactfsui/app/components/explorer/get-version-dialog.tsx`(新增):获取特定版本弹窗(changeset 输入 + 历史列表点选回填 + 覆盖警示 + 确认执行)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx`:渲染两个新弹窗,`getVersion` 复用既有 `runGetVersion` 编排(含刷新与通知)
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 右键菜单(树 / 列表,文件与目录)尾部新增高级分组:「获取特定版本…」「属性…」;未映射对象获取特定版本置灰并给出原因
|
||||||
|
- 属性弹窗本地信息即时展示(路径 / 映射 / 挂起),服务器信息异步加载;路径一键复制
|
||||||
|
- 获取特定版本弹窗内嵌该对象历史(最近 100 条)点选回填,也可手动输入 changeset;确认后调用既有 `/api/files/get-version`(目录递归覆盖),完成后刷新目录列表与挂起更改
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 环境下的属性值与获取特定版本落盘验证:本机无真实 TFS 环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 属性弹窗展示服务器 / 本地路径、未映射标识、最新版本信息、文件大小与编码:满足(代码层面)
|
||||||
|
- 右键直达获取特定版本(输入 / 历史点选)并递归覆盖后刷新:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 属性弹窗的签入人来自最近一条历史,深目录大子树首次查询可能稍慢(服务端已限 1 条)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# FE-020 实现重命名
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-010
|
||||||
|
- FE-013
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R6
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为已映射的文件 / 文件夹提供重命名能力:产生 rename 挂起更改,可撤销,签入后服务器生效,
|
||||||
|
对齐 VS Source Control Explorer 的 Rename。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `rename(serverPath, newName)`:`Workspace.pendRename`,对象须已映射;
|
||||||
|
返回结果复用 `TfsFileOperationResult`
|
||||||
|
- server 新增 `POST /api/files/rename`(参数 `serverPath`、`newName`)
|
||||||
|
- 前端 API 层补充 `rename`
|
||||||
|
- 右键菜单新增「重命名…」(树 / 列表来源,文件与目录均可):
|
||||||
|
- 弹窗输入新名称,校验:非空、不含 `/ \ : * ? " < > |`、与原名不同
|
||||||
|
- 已有挂起 rename 或未映射时置灰并给出原因
|
||||||
|
- 挂起更改面板展示 rename 状态(`pendingRename`),显示「旧名 → 新名」
|
||||||
|
- rename 挂起更改支持 undo 撤销、checkin 签入(复用既有流程)
|
||||||
|
- 完成后刷新目录树、目录列表与挂起更改
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做跨目录移动(Move)
|
||||||
|
- 不做批量重命名
|
||||||
|
- 不处理大小写仅变化的特殊冲突场景(记录遗留问题即可)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/lib/tfs/status.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/status.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/inspector/changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/changes-panel.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 已映射文件 / 目录右键「重命名…」可输入新名称并提交
|
||||||
|
- 重命名后产生 rename 挂起更改,挂起面板显示「旧名 → 新名」
|
||||||
|
- undo 可撤销重命名(本地名称还原);checkin 后服务器生效
|
||||||
|
- 非法名称、重复名称、未映射对象有明确校验与置灰提示
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的 rename 签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `rename`(`Workspace.pendRename`,同目录改名,校验非法字符与同名);`TfsPendingChangeInfo` 新增 `sourceServerPath`(rename 携带原路径)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/rename`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`:`PendingChange` 新增 `sourceServerPath`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `renameFile`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `rename`;目录与文件菜单新增「重命名…」(未映射 / pendingAdd / pendingDelete / pendingRename 置灰并给出原因)
|
||||||
|
- `mactfsui/app/components/explorer/rename-dialog.tsx`(新增):重命名弹窗(默认选中主名、Enter 提交、非法字符 / 同名校验、说明挂起生效规则)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `rename`,新增 `handleRenameConfirmed`(执行后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线重命名弹窗
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`:pendingRename 项展示「旧名 → 新名」
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 已映射文件 / 目录右键「重命名…」:弹窗输入新名称(文件自动选中主名),校验非空、非法字符(`/ \ : * ? " < > |`)、与原名不同
|
||||||
|
- 提交后产生 rename 挂起更改:挂起面板以「旧名 → 新名」展示,badge 为「挂起重命名」;可 undo 撤销、checkin 后服务器生效(复用既有流程)
|
||||||
|
- 完成后自动刷新挂起更改与目录列表
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的 rename 挂起与签入验证:本机无真实 TFS 环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 重命名入口、校验、挂起展示「旧名 → 新名」、undo / checkin 复用:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 大小写仅变化的重命名在不区分大小写的服务器上行为未验证(已在不在范围中声明)
|
||||||
|
- 目录树节点名在签入前仍显示旧名(rename 仅挂起,签入后刷新树即可)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# FE-021 实现变更集回滚
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-009
|
||||||
|
- FE-010
|
||||||
|
- FE-019
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R7
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供 TFS Rollback 能力:把某个变更集的改动反做(或回滚到某变更集,反做其后的全部改动),
|
||||||
|
产生挂起更改由用户审查后签入,对齐 VS 的 Rollback Entire Changeset / Rollback to a Specific Version。
|
||||||
|
与「获取特定版本」(只动本地、不产生挂起更改)形成互补。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `rollback(serverPath, mode, changeset)`:`Workspace.rollback` + `RollbackOptions`
|
||||||
|
- `single`:仅反做该 changeset
|
||||||
|
- `toVersion`:反做该 changeset 之后的全部改动
|
||||||
|
- server 新增 `POST /api/files/rollback`(参数 `serverPath`、`mode`、`changeset`)
|
||||||
|
- 前端 API 层补充 `rollback`
|
||||||
|
- 历史弹窗记录行新增「回滚此变更集…」「回滚到此变更集…」:
|
||||||
|
- 确认弹窗说明:只产生挂起更改、不会直接入库、签入前可撤销
|
||||||
|
- 对象未映射时置灰并给出原因
|
||||||
|
- 回滚产生冲突时进入既有冲突弹窗流程
|
||||||
|
- 完成后刷新挂起更改与目录列表,通知用户到挂起面板审查并签入
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做自动签入(回滚结果必须人工审查)
|
||||||
|
- 不做跨对象批量回滚(仅当前历史弹窗目标对象范围内)
|
||||||
|
- 不做三方合并编辑器
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/explorer/history-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-dialog.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 历史记录可执行「回滚此变更集…」,确认后产生对应的反做挂起更改
|
||||||
|
- 历史记录可执行「回滚到此变更集…」,其后全部改动被反做为挂起更改
|
||||||
|
- 回滚结果可在挂起面板审查、可 undo、可签入
|
||||||
|
- 回滚遇到本地修改冲突时进入冲突弹窗
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的回滚签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `rollback`(`Workspace.rollback`;`single` 模式 from=to=该 changeset,`toVersion` 模式 `RollbackOptions.TO_VERSION` 反做其后全部改动;复用 `TfsGetLatestResult` 返回 operations / conflicts / failures)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/rollback`(长写超时)
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `rollback`
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:新增 `runRollback`(执行后刷新挂起更改与目录列表,conflicts>0 自动进入冲突弹窗)
|
||||||
|
- `mactfsui/app/components/explorer/history-dialog.tsx`:历史记录行新增右键菜单(获取此版本… / 回滚此变更集… / 回滚到此变更集…)与回滚确认弹窗(说明只产生挂起更改)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:透传 `onRollback`
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 历史弹窗每条记录右键提供两种回滚:「回滚此变更集」(仅反做该提交)与「回滚到此变更集」(反做其后全部改动,回到该时刻内容)
|
||||||
|
- 确认弹窗明确:回滚只产生挂起更改、不直接写服务器、可撤销、需签入;执行后顶部通知展示 operations / conflicts / failures 统计并提示去挂起面板审查
|
||||||
|
- 回滚冲突自动进入既有冲突弹窗;挂起更改可 undo / checkin(复用既有流程)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的回滚挂起与签入验证:本机无真实 TFS 环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 两种回滚入口 + 确认弹窗 + 挂起审查 / 撤销 / 签入 + 冲突进冲突弹窗:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- `toVersion` 模式的 from=null + TO_VERSION 语义对齐 .NET SDK 文档,真实服务器行为待验证
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# FE-022 实现工作区与 Mapping 集中管理
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-007
|
||||||
|
- FE-013
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R8
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供工作区集中管理入口:在一个弹窗内查看 Workspace 信息和全部 Mapping,
|
||||||
|
支持新增、删除、修改本地路径,替代只能在目录树逐个右键操作的现状,
|
||||||
|
对齐 VS 的 Edit Workspace 对话框。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 顶栏新增「工作区」按钮,打开集中管理弹窗:
|
||||||
|
- 工作区信息区:名称、Owner、Computer、服务器地址、Collection
|
||||||
|
- Mapping 表格:服务器路径、本地路径、本地目录是否存在(不存在标红提示)
|
||||||
|
- Mapping 操作:
|
||||||
|
- 新增:复用既有映射弹窗逻辑(选择服务器路径 + 本地父目录),完成后可触发 Get Latest
|
||||||
|
- 删除:确认弹窗,说明不会删除本地已下载文件;调用既有 `DELETE /api/mappings`
|
||||||
|
- 修改本地路径:实现为「删旧 + 加新」,确认弹窗提示将按新路径重新获取
|
||||||
|
- 操作完成后刷新工作区上下文(mappings)、目录树映射标识与挂起更改
|
||||||
|
- server 复用既有 `GET/POST/DELETE /api/mappings`,原则上不新增接口;
|
||||||
|
如需 Workspace 详细信息可扩展既有 `/api/workspace/context` 返回字段
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做多 Workspace 创建 / 切换 / 删除
|
||||||
|
- 不做 cloak(遮蔽)类型的 mapping
|
||||||
|
- 不迁移本地已下载内容(修改本地路径后由用户自行决定是否重新获取)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfsui/app/components/app/top-bar.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/top-bar.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-shell.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
- [mactfsui/app/components/explorer/mapping-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/mapping-dialog.tsx)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/session.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/session.ts)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 顶栏可打开工作区管理弹窗,正确展示 Workspace 信息与全部 Mapping
|
||||||
|
- 可在弹窗内新增 Mapping,与目录树右键映射效果一致
|
||||||
|
- 可删除 Mapping(带确认),目录树映射标识同步更新
|
||||||
|
- 可修改 Mapping 本地路径(删旧加新),有重新获取提示
|
||||||
|
- 本地目录不存在的 Mapping 有醒目标识
|
||||||
|
- 前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/explorer/workspace-manage-dialog.tsx`(新增):集中管理弹窗
|
||||||
|
- 工作区信息区:Workspace / 服务器 / Collection / Owner / Computer / Mapping 数量(Owner、Computer 通过幂等的 `POST /api/workspace/ensure` 获取,未新增服务端接口)
|
||||||
|
- Mapping 表格:服务端路径、本地路径、本地目录存在性(Electron `pathsExist` 批量检测,不存在标红)
|
||||||
|
- 新增:输入服务端路径(校验 `$/` 前缀)→ 复用既有 `MappingDialog`(父目录选择 + 后端预校验 + 可选立即获取)
|
||||||
|
- 删除:确认弹窗(说明不删除本地文件),调用既有 `DELETE /api/mappings`
|
||||||
|
- 修改本地路径:内置 `EditMappingDialog`,选择新父目录 → `check-target` 预校验 → 「删旧 + 加新」,提示需重新获取
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`:顶栏新增「工作区」按钮
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`:持有弹窗显隐,Mapping 变化后统一刷新 mappings 与目录列表
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 顶栏「工作区」一键打开集中管理弹窗,无需到目录树逐个右键
|
||||||
|
- 全部 mapping 一屏可见,本地目录缺失(已映射未下载 / 被手动删除)醒目标红
|
||||||
|
- 三类操作(新增 / 删除 / 改路径)完成后目录树映射标识、列表状态列同步刷新
|
||||||
|
- 未新增服务端接口,全部复用既有 mappings API
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- 服务端无改动,无需重新构建(沿用 FE-021 构建产物)
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 环境的映射增删改落盘验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 工作区信息与全部 Mapping 展示、增 / 删 / 改、本地目录缺失标识:满足(代码层面)
|
||||||
|
- 前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 「修改本地路径」中删旧成功但加新失败时会处于无映射状态(已在弹窗错误提示中说明需重新映射)
|
||||||
|
- 删除映射后该子树挂起更改的处理沿用服务端默认行为,未做额外清理
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# FE-023 实现分支
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-010
|
||||||
|
- FE-019
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R9
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为服务端目录 / 文件提供分支能力:从源路径分叉出目标路径(pendBranch 产生挂起更改,
|
||||||
|
签入后服务器生效),对齐 VS Source Control Explorer 的 Branch。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `branch(sourceServerPath, targetServerPath, changeset?)`:
|
||||||
|
`Workspace.pendBranch`,默认基于 latest,可指定 changeset
|
||||||
|
- server 新增 `POST /api/files/branch`
|
||||||
|
- 前端 API 层补充 `branch`
|
||||||
|
- 右键菜单新增「分支…」(树 / 列表来源):
|
||||||
|
- 弹窗:源路径只读展示;目标路径默认 `源路径-branch` 可编辑;
|
||||||
|
可选「基于指定 changeset」(输入或从历史点选,复用 FE-019 版本选择器)
|
||||||
|
- 校验:目标路径不能与源相同、不能已存在;目标父目录必须已映射(pendBranch 需本地落盘),
|
||||||
|
未满足时给出明确提示
|
||||||
|
- 分支产生 branch 挂起更改,挂起面板可见、可 undo、可签入(复用既有流程)
|
||||||
|
- 完成后刷新目录树与挂起更改
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做分支可视化(Branch Hierarchy 图)
|
||||||
|
- 不做「转换为分支对象」(Convert to Branch)
|
||||||
|
- 不做合并(FE-024)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 右键「分支…」可对目录 / 文件发起分支,目标路径校验正确
|
||||||
|
- 分支产生 branch 挂起更改,签入后服务器出现目标路径内容
|
||||||
|
- 可基于指定 changeset 分支
|
||||||
|
- 目标父目录未映射时有明确提示
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的分支签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `branch`(`Workspace.pendBranch`,默认 latest 可指定 changeset,校验目标 ≠ 源)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/branch`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `branch`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `branch`;目录菜单(对比 / 历史分组)与文件菜单(非 pendingAdd)新增「分支…」
|
||||||
|
- `mactfsui/app/components/explorer/branch-dialog.tsx`(新增):分支弹窗(目标路径默认 `源-branch` 可编辑、可选基于 changeset、展示目标本地落盘路径)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `branch`,新增 `handleBranchConfirmed`(执行后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线分支弹窗
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 右键「分支…」打开弹窗:目标路径校验(`$/` 前缀、≠ 源、不在源内部、父目录必须已映射并给出明确提示)、changeset 数字校验(留空 = latest)
|
||||||
|
- 校验通过后展示目标的本地落盘路径;确认后产生 branch 挂起更改,顶部通知提示去挂起面板审查后签入
|
||||||
|
- 挂起的 branch 更改可 undo / checkin(复用既有流程)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的 pendBranch 与签入后服务器分支验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 分支入口、目标校验、指定 changeset、挂起审查 / 签入:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不做分支可视化与 Convert to Branch(已声明不在范围)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# FE-024 实现合并
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-009
|
||||||
|
- FE-023
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R10
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供分支间合并能力:查看待合并变更集候选,执行合并产生挂起更改,
|
||||||
|
冲突进入既有冲突弹窗,审查后签入,对齐 VS 的 Merge 向导。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增:
|
||||||
|
- `mergeCandidates(sourceServerPath, targetServerPath)`:`Workspace.getMergeCandidates`
|
||||||
|
- `merge(sourceServerPath, targetServerPath, changeset?)`:`Workspace.merge`,
|
||||||
|
默认合并全部候选,可指定单个 changeset
|
||||||
|
- server 新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- 前端 API 层补充 `mergeCandidates`、`merge`
|
||||||
|
- 右键菜单新增「合并…」(树 / 列表来源,作为合并源):
|
||||||
|
- 向导弹窗:源路径只读;目标路径输入(必须已映射,校验给出原因)
|
||||||
|
- 加载并展示候选 changeset 列表(编号、提交人、时间、备注);空列表提示无可合并内容
|
||||||
|
- 支持「全部合并」或选择单个 changeset 合并
|
||||||
|
- 确认弹窗说明:只产生挂起更改、冲突需要逐个处理、签入前可撤销
|
||||||
|
- 合并冲突进入既有冲突弹窗流程(listConflicts / applyConflict)
|
||||||
|
- 完成后刷新挂起更改,引导用户审查并签入
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做三方合并编辑器(内容冲突仍按「取本地 / 取服务器」处理)
|
||||||
|
- 不做 baseless merge(无分支关系的强制合并)
|
||||||
|
- 不做合并历史可视化
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
- [mactfsui/app/components/explorer/conflict-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/conflict-dialog.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 「合并…」向导可展示源 → 目标的候选 changeset 列表
|
||||||
|
- 可执行全部合并与单个 changeset 合并,结果为挂起更改
|
||||||
|
- 合并冲突进入冲突弹窗并可逐个处理
|
||||||
|
- 合并结果可在挂起面板审查、可 undo、可签入
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的合并签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `mergeCandidates`(`getMergeCandidates`,复用 `TfsHistoryEntry` 输出候选)与 `merge`(`Workspace.merge`;changeset 为空合并全部候选 `(null, latest)`,指定时仅合并该变更集 `(C, C)`;复用 `TfsGetLatestResult` 返回 operations / conflicts / failures)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `mergeCandidates`、`merge`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `merge`;目录与文件(非 pendingAdd)菜单新增「合并…」
|
||||||
|
- `mactfsui/app/components/explorer/merge-dialog.tsx`(新增):合并向导(源只读、目标路径校验须已映射、查询候选列表、点选单个或全部合并、挂起与冲突说明)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `merge`,新增 `handleMergeConfirmed`(刷新挂起更改与目录列表,conflicts>0 自动以目标路径进入冲突弹窗)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线合并向导
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 右键「合并…」打开向导:目标路径必须已映射(校验给出原因);「查询候选」展示待合并 changeset(编号 / 类型 / 作者 / 时间 / 注释),空列表提示已同步
|
||||||
|
- 支持「全部合并」与「合并所选」(点选候选行选中 / 取消);执行后顶部通知展示统计并提示去挂起面板审查后签入
|
||||||
|
- 合并冲突自动进入既有冲突弹窗(listConflicts / applyConflict,按「取本地 / 取服务器」处理)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的候选查询、合并挂起与冲突处理验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 候选列表、全部 / 单个合并、冲突进冲突弹窗、挂起审查 / 撤销 / 签入:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不支持 baseless merge 与三方合并编辑器(已声明不在范围)
|
||||||
|
- 候选「合并所选」一次仅支持单个 changeset,连续区间合并可后续增强
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# FE-025 实现访达打开与添加本地文件
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-006
|
||||||
|
- FE-011
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R11
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
补齐两个日常操作缺口:右键对象「在访达中打开」(文件定位选中、目录直接进入),
|
||||||
|
以及目录右键 / 中间区域空白处右键「添加本地文件…」(把本地新增文件加入 TFS 版本控制)。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- Electron main 新增 IPC:`reveal-path`(`shell.showItemInFolder` / `shell.openPath`)、
|
||||||
|
`select-files`(系统多选文件选择器,支持默认目录)
|
||||||
|
- preload / bridge / 类型契约同步暴露 `revealPath`、`selectFiles`
|
||||||
|
- 动作模型新增 `revealInFinder`、`addLocalFiles`:
|
||||||
|
- 文件菜单「在访达中显示」;目录菜单「在访达中打开」(未映射置灰)
|
||||||
|
- 目录菜单「添加本地文件…」(未映射置灰)
|
||||||
|
- 中间文件列表空白区域右键 = 当前目录的完整右键菜单(行内右键阻止冒泡避免双菜单)
|
||||||
|
- 添加流程(修订为扫描式):目录对比(递归)筛出 localOnly 项 → 弹窗勾选(默认全选)→
|
||||||
|
批量 `POST /api/files/add` → 刷新挂起更改与目录列表
|
||||||
|
- 非 Electron 环境:两个动作给出「仅桌面应用支持」提示
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做拖拽添加文件
|
||||||
|
- 不做新建文件 / 文件夹
|
||||||
|
- 不改既有目录对比 localOnly「加入版本控制」入口
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfsui/electron/main.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/main.cjs)
|
||||||
|
- [mactfsui/electron/preload.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/preload.cjs)
|
||||||
|
- [mactfsui/app/lib/electron](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/electron)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 已映射文件右键「在访达中显示」可在访达中定位选中该文件
|
||||||
|
- 已映射目录右键「在访达中打开」可直接进入该目录
|
||||||
|
- 目录右键与中间空白区域右键可「添加本地文件…」,自动扫描出本地新增项,勾选添加后产生 pendingAdd 并刷新
|
||||||
|
- 没有本地新增项时弹窗明确提示「本地内容与服务器一致」
|
||||||
|
- 未映射对象两个动作置灰并给出原因;前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfsui/electron/main.cjs`:新增 IPC `mactfs:reveal-path`(目录 `shell.openPath` 直接进入,文件 `shell.showItemInFolder` 定位选中,路径不存在返回 false)
|
||||||
|
- `mactfsui/electron/preload.cjs`:暴露 `revealPath`
|
||||||
|
- `mactfsui/app/lib/electron/types.ts` / `bridge.ts` / `index.ts`:桥接契约与封装(非 Electron 环境优雅降级)
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `revealInFinder`、`addLocalFiles`;文件菜单「在访达中显示」、目录菜单「在访达中打开」+「添加本地文件…」(未映射置灰)
|
||||||
|
- `mactfsui/app/components/explorer/add-files-dialog.tsx`(新增):扫描式添加弹窗(目录对比递归筛 localOnly、勾选列表默认全选、支持全选 / 清空、批量添加)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:`revealInFinder` 执行逻辑(失败提示先获取最新);`addLocalFiles` 打开扫描弹窗;新增 `handleAddFilesConfirmed`(批量 pend add 后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线添加弹窗
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`:中间区域空白处右键 = 当前目录完整菜单(行内右键 stopPropagation 防止双菜单)
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 「在访达中打开 / 显示」:目录直接进入访达对应目录;文件在访达中定位并选中;本地路径不存在(未下载)时提示先获取最新;非 Electron 提示仅桌面应用支持
|
||||||
|
- 「添加本地文件…」(按用户反馈修订为扫描式):自动对该目录做递归目录对比,筛出 localOnly(本地新增)的文件 / 目录,弹窗勾选(默认全选)后批量加入版本控制;无新增项时明确提示本地与服务器一致
|
||||||
|
- 添加结果为 pendingAdd 挂起更改,挂起面板可见、可撤销、签入后进入服务器
|
||||||
|
- 中间列表空白处右键提供当前目录的完整右键菜单,与目录树右键一致
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- 服务端无改动,无需重新构建
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 访达交互为 Electron 原生能力,需桌面应用人工验证(`pnpm electron:dev`)
|
||||||
|
- 扫描式添加依赖真实 TFS 目录对比结果,待 FEATURE 阶段验证
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 访达打开 / 显示、扫描式添加本地文件(勾选 / 全选 / 无新增提示)、空白区域右键、未映射置灰:满足(代码层面)
|
||||||
|
- 前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不支持拖拽添加与新建文件 / 文件夹(已声明不在范围)
|
||||||
|
- 大目录递归对比首次扫描可能较慢(沿用目录对比既有性能特征)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# FE-026 建立现代桌面风设计基建
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U1
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把全局设计令牌升级为现代 AI 开发者工具风(参照 Codex 桌面端 / CC Switch),作为后续所有 UI 改版任务的统一基础。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 字体切换为系统无衬线字体栈(`-apple-system, BlinkMacSystemFont, "PingFang SC"` 等),移除 Roboto Slab 作为全局字体;代码/Diff 场景保留等宽字体
|
||||||
|
- 重定义颜色令牌:中性灰分级底色(页面底/侧栏/卡片/悬停四档明度)、低对比 1px 边框色、单一克制 accent 色,light/dark 两套完整定义(dark 为深灰底非纯黑)
|
||||||
|
- 统一圆角档位(卡片 8/10/12px 为主)、阴影分层(卡片几乎无阴影,浮层/弹窗轻阴影)、间距档位
|
||||||
|
- 建立动效令牌:统一时长(150/200/250ms)与缓动曲线变量
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改任何组件结构与业务逻辑
|
||||||
|
- 不实现主题切换入口(FE-033 做)
|
||||||
|
- 不改 Electron 窗口配置(FE-027 做)
|
||||||
|
- 不使用毛玻璃 / vibrancy 材质
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/package.json`(如需移除字体依赖或引入 motion)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 全局字体为系统无衬线字体栈,观感与 Codex 桌面端 / CC Switch 同类
|
||||||
|
- light/dark 两套 token 完整、可用,accent 色仅出现在主按钮/选中/状态点
|
||||||
|
- 圆角、阴影、动效时长全部来自统一变量,无散落魔法值
|
||||||
|
- `pnpm typecheck` 通过,现有页面无功能回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/package.json`、`mactfsui/pnpm-lock.yaml`(移除 `@fontsource-variable/roboto-slab`)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 全局字体切换为系统无衬线字体栈(`-apple-system / PingFang SC`…),`html` 由 `font-serif` 改为 `font-sans`;新增 `--font-mono` 等宽字体令牌;`--font-heading` 指向 sans(dialog 标题沿用)
|
||||||
|
- 颜色令牌重定义为中性灰分层:light 页面底 0.985 / 卡片纯白 / 侧栏 0.967 / 悬停 0.945;dark 深灰底 0.185(非纯黑)/ 卡片 0.22 / 侧栏 0.165
|
||||||
|
- 主色改为克制的蓝色(oklch 0.546 0.215 262.881),`--accent` 与主色解耦、改为中性悬停底色(原先 accent=紫色主色,菜单悬停会大面积铺色)
|
||||||
|
- 圆角基准 0.875rem → 0.625rem(sm 6 / md 8 / lg 10 / xl 14px)
|
||||||
|
- 新增阴影分层令牌 `--shadow-card / --shadow-overlay / --shadow-dialog`
|
||||||
|
- 新增动效令牌:缓动 `--ease-out-quart / --ease-spring`,时长 `--motion-fast/base/slow`(150/200/250ms)
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过
|
||||||
|
- `pnpm build` 通过(Tailwind CSS 编译无误)
|
||||||
|
- 未执行 `pnpm electron:dev` 全页面人工走查(属 GUI 交互验证,留待后续任务连带走查),现有类名均基于既有 token 体系,无破坏性重命名
|
||||||
|
- 遗留问题:
|
||||||
|
- 各组件中残留的散落样式(如紫色 hover 假设)将在 FE-029 ~ FE-032 按面板逐一对齐
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# FE-027 Electron无缝窗口与自绘顶栏
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U2
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
去掉系统标题栏,让窗口与应用顶栏一体成型,达到 Codex 桌面端那种「无缝」桌面应用质感。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- BrowserWindow 配置 `titleBarStyle: "hiddenInset"`,校准 trafficLightPosition 与顶栏对齐
|
||||||
|
- 顶栏对应区域设置 `-webkit-app-region: drag`,交互控件设置 no-drag
|
||||||
|
- 窗口 backgroundColor 与主题 token 一致,配合 ready-to-show 消除启动白闪
|
||||||
|
- 核对窗口默认尺寸与最小尺寸
|
||||||
|
- 非 macOS 平台回退系统标题栏,不影响功能
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 顶栏内容与布局重排(FE-029 做)
|
||||||
|
- 毛玻璃 / vibrancy 材质(本批明确不做)
|
||||||
|
- 打包签名相关配置(RELEASE 阶段做)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/electron/main.cjs`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`(仅拖拽区域标记)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 窗口无系统标题栏,红绿灯悬浮于顶栏内且不遮挡控件
|
||||||
|
- 顶栏空白区可拖动窗口,按钮等控件可正常点击
|
||||||
|
- 启动与主题切换无白闪
|
||||||
|
- 全部既有功能无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/electron/main.cjs`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
- `mactfsui/app/app.css`(新增 `app-drag` / `app-no-drag` 工具类)
|
||||||
|
- 新增 `mactfsui/app/lib/platform.ts`(isMacElectron 判断)
|
||||||
|
- 实际实现内容:
|
||||||
|
- macOS 下 BrowserWindow 配置 `titleBarStyle: "hiddenInset"` + `trafficLightPosition {16,16}`,非 macOS 平台回退系统标题栏
|
||||||
|
- 窗口 `show: false` + `ready-to-show` 后显示,backgroundColor 按 nativeTheme 取 `#fafafa` / `#1d1d20`,消除启动白闪
|
||||||
|
- 新增 `minWidth 980 / minHeight 640`
|
||||||
|
- 顶栏整体设为拖拽区(`app-drag`),右侧按钮容器豁免(`app-no-drag`);macOS Electron 下顶栏左侧 `pl-20` 为红绿灯预留空间
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 未执行 `pnpm electron:dev` 人工拖拽验证(GUI 交互验证,待 FE-034 回归时统一走查)
|
||||||
|
- 遗留问题:
|
||||||
|
- 登录页(无顶栏)尚无拖拽区,将在 FE-028 登录页改版时补充
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# FE-028 重设计登录页
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U3
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把登录页从单张表单卡片升级为有品牌感、有动效、元素丰富的现代登录页。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 左右分栏布局:左侧品牌区(logo、产品名、标语、版本号、氛围背景动效),右侧连接表单卡片
|
||||||
|
- 表单 → Collection 选择两阶段切换增加滑动 + 淡入淡出过渡
|
||||||
|
- 新增:密码可见性切换按钮、最近连接记录(localStorage 多条、一键填充)、本地服务健康状态指示灯
|
||||||
|
- 连接中按钮内 spinner、阶段文案过渡;连接失败表单抖动 + 错误提示条
|
||||||
|
- Collection 列表改卡片式:悬停浮起、选中高亮过渡
|
||||||
|
- 保持现有 `api.connect / listCollections / ensureWorkspace` 调用链路不变
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改连接协议与认证方式
|
||||||
|
- 不改 `app/lib/api` 接口契约(最近连接仅存前端本地)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/app/connect-view.tsx`
|
||||||
|
- 新增 `mactfsui/app/lib/recent-servers.ts`(或等价的本地存储工具)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 登录页具备品牌区与氛围背景,观感现代精致
|
||||||
|
- 两阶段切换有流畅过渡动效
|
||||||
|
- 最近连接可保存、可一键填充;密码可见性切换可用
|
||||||
|
- 连接成功进入工作台的链路与改版前完全一致
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:连接真实 TFS → 选择 Collection → 进入工作台
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/connect-view.tsx`(整体重写)
|
||||||
|
- 新增 `mactfsui/app/lib/recent-servers.ts`
|
||||||
|
- `mactfsui/app/app.css`(新增 shake / float-slow 动画令牌)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 左右分栏:左侧深色品牌区(logo、产品名、标语、四条能力点、漂浮光斑氛围背景、本地服务健康指示灯),右侧连接表单
|
||||||
|
- 表单 → Collection 两阶段切换:`animate-in` 滑动 + 淡入过渡
|
||||||
|
- 密码可见性切换(Eye/EyeOff);最近连接记录(localStorage 最多 5 条、去重置顶、一键填充、不存密码)
|
||||||
|
- 连接中按钮内 spinner + 阶段文案;失败时错误提示条 + 表单抖动(error 触发、key 重放)
|
||||||
|
- Collection 改卡片式列表:细边框卡片、hover 微浮起、选中 primary 描边 + 勾选动画
|
||||||
|
- 登录页顶部加 `app-drag` 拖拽条(补 FE-027 遗留)
|
||||||
|
- `api.connect / listCollections / ensureWorkspace` 调用链路保持不变
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 中确认 shake / float-slow / shadow-card 已编译
|
||||||
|
- 未连接真实 TFS 走全流程(留待 FE-034 回归统一验证)
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 用户反馈修订(2026-06-11)
|
||||||
|
|
||||||
|
- 左侧品牌区简化:去掉标语与能力点列表,改为居中大 logo + 一句话描述(保留氛围光斑与底部服务指示灯)
|
||||||
|
- Collection 选中效果重做:去掉未选中项的空心圆与选中项的 ring 双框,改为「主色细边框 + 浅主色底 + 右侧蓝色对勾(zoom 入场)」,未选中 hover 仅底色微变
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# FE-029 工作台外壳与顶栏改版
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
- FE-027
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U4
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把三栏工作台外壳与顶栏升级为 Codex 桌面端 / CC Switch 那类现代工具风,并补齐布局动效。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 顶栏按现代工具风重排:为红绿灯预留左侧空间,上下文信息居左、动作按钮图标化居右、底部 1px 低对比分隔线
|
||||||
|
- 左侧目录树面板改 Codex 式侧栏:比内容区深/浅一档的纯色底、圆角选中条、分组标题小字号大写风格
|
||||||
|
- 左右面板折叠/展开增加宽度过渡动画,底部操作台增加滑入滑出动画
|
||||||
|
- 统一三栏分隔线(低对比 1px)、内边距与留白
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 目录树节点与文件列表行内样式(FE-030 做)
|
||||||
|
- 弹窗样式(FE-031 做)
|
||||||
|
- 不改动作编排与状态模型
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 顶栏与红绿灯融合自然,动作按钮可用且有 tooltip
|
||||||
|
- 侧栏与内容区有清晰的明度层次,选中条圆角风格统一
|
||||||
|
- 折叠/展开、底部操作台均有流畅动画且不卡顿
|
||||||
|
- 同步导航、刷新等既有行为无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:折叠/展开左右面板与底部操作台,检查动画与功能
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`(面板标题样式)
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`(面板标题样式)
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`(面板标题样式)
|
||||||
|
- 实际实现内容:
|
||||||
|
- 顶栏重排:左侧 MacTFS 品牌 + 服务器地址(mono 弱化)+ Collection / Workspace 改为 `bg-muted` 圆角信息徽片;右侧动作图标化(工作区→FolderCog、重新连接→Unplug)配 tooltip
|
||||||
|
- 左右面板折叠改为保持挂载 + 宽度过渡动画(`transition-[width] duration-250 ease-out-quart`),底部操作台改为高度过渡滑入滑出
|
||||||
|
- 三个面板标题统一为小字号大写风格(`text-[11px] tracking-wider uppercase`)
|
||||||
|
- 三栏分隔线维持低对比 1px(border token 已在 FE-026 调低对比)
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 确认 `ease-out-quart` / `duration-250` 已编译
|
||||||
|
- 折叠动画的视觉流畅度留待 FE-034 回归走查
|
||||||
|
- 遗留问题:
|
||||||
|
- 折叠后面板组件保持挂载(原为卸载),日志/挂起面板在隐藏时仍会维持已有数据,行为更连续,无负面影响
|
||||||
|
|
||||||
|
### 用户反馈修订(2026-06-11)
|
||||||
|
|
||||||
|
- 反馈:左/中/右/底四块用 1px 边线直接拼接显得生硬,希望卡片感
|
||||||
|
- 修订为画布式布局:外层浅灰画布(bg-sidebar),顶栏透明融入画布;四个面板均改为圆角卡片(rounded-xl + border + shadow-card),卡片间 8px 缝隙;折叠动画同步过渡宽度与间距;顶栏徽片改白底细边框;已截图验证亮/暗两套效果
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# FE-030 目录树与文件列表精致化
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-029
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U5
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把左侧目录树与中间文件列表的行样式、图标、加载与空状态升级到精致水平。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 树节点与文件行统一行高、圆角悬停/选中态;展开箭头旋转动画、子级展开渐入
|
||||||
|
- 按扩展名区分文件图标与颜色,文件夹/分支/已映射目录差异化图标
|
||||||
|
- 列表加载改骨架屏;空目录、未连接等空状态配图标 + 引导文案
|
||||||
|
- pendingEdit / pendingAdd / pendingDelete / 未下载等状态徽标统一配色与形状
|
||||||
|
- 文件列表行级联渐入动效(首次加载时)
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改目录加载、右键菜单与动作编排逻辑
|
||||||
|
- 不改服务端接口与数据结构
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 树与列表行有清晰的悬停/选中反馈,展开收起有动画
|
||||||
|
- 不同类型文件图标可区分,状态徽标风格统一
|
||||||
|
- 加载呈骨架屏,空状态有引导
|
||||||
|
- 导航、右键菜单、双击打开等既有行为无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:浏览多层目录、含挂起更改的目录,检查图标与徽标
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/components/explorer/file-icon.tsx`(扩展名 → 图标/配色映射)
|
||||||
|
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
|
||||||
|
- 实际实现内容:
|
||||||
|
- 文件图标体系:代码/配置/文档/SQL/图片/压缩包/表格/脚本 8 类图标与配色;文件夹普通为蓝色、已映射为 FolderSymlink 主色、展开态 FolderOpen
|
||||||
|
- 树节点:选中态改 `bg-sidebar-accent` 圆角条、悬停 60% 透明过渡、展开箭头旋转 200ms(ease-out-quart)、子级展开 fade+slide 渐入
|
||||||
|
- 文件列表:行级联渐入(fade-in + fill-mode-backwards + 阶梯延迟);状态徽标统一为「小圆点 + 浅底色」(圆点用 bg-current 继承徽标文字色)
|
||||||
|
- 加载改 9 行骨架屏(脉冲 + 错峰延迟);空目录 / 错误改图标 + 引导文案空状态
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 确认 fill-mode-backwards / slide-in-from-top-1 已编译
|
||||||
|
- 真实目录浏览与右键菜单行为留待 FE-034 回归
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# FE-031 弹窗体系统一现代风格
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U6
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把全部弹窗统一为现代卡片风格,结构一致、动效一致。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 统一 Dialog 基础组件:顶部标题区、内容区、底部按钮区结构与间距规范
|
||||||
|
- 卡片化弹窗:圆角、低对比细边框、轻阴影;出入场统一缩放 + 淡入,遮罩淡入淡出
|
||||||
|
- 覆盖弹窗:mapping / history / compare / diff / file-view / conflict / properties / rename / get-version / branch / merge / workspace-manage / add-files / confirm / workspace-dialogs
|
||||||
|
- 大弹窗(History、Compare、Diff)优化分区留白与表格样式
|
||||||
|
- 弹窗内表单控件按 FE-026 token 对齐
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改各弹窗的业务流程与接口调用
|
||||||
|
- Monaco Diff 编辑器内部主题深度定制
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/ui/dialog.tsx`
|
||||||
|
- `mactfsui/app/components/explorer/*-dialog.tsx`(13 个)
|
||||||
|
- `mactfsui/app/components/app/confirm-dialog.tsx`
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 所有弹窗结构、间距、按钮排布一致,出入场动效统一流畅
|
||||||
|
- 大弹窗内表格与分区样式精致清晰
|
||||||
|
- 各弹窗功能(确认、提交、二次弹窗)无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:逐一打开全部弹窗检查样式与动效
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/app/components/ui/dialog.tsx`
|
||||||
|
- 实际实现内容:
|
||||||
|
- 全部 15 个弹窗(13 个 explorer 弹窗 + confirm + workspace-dialogs)共用同一 Dialog 基础组件,本次在基础组件统一升级,等效于全量弹窗统一:
|
||||||
|
- 容器改卡片化:`border + shadow-dialog`(替代原 ring),rounded-xl(FE-026 后为 14px)
|
||||||
|
- 出入场统一:打开 200ms `ease-out-quart` 缩放 95% + 上滑 + 淡入;关闭 150ms 缩放淡出;遮罩 `bg-black/20 + backdrop-blur` 淡入淡出
|
||||||
|
- 标题统一 `font-semibold tracking-tight`
|
||||||
|
- 弹窗底部按钮区沿用 DialogFooter 的 `bg-muted/50 + border-t` 分区带(10 个表单类弹窗使用;History/Diff/FileView/Properties 为查看器形态无底栏,结构合理保持)
|
||||||
|
- 弹窗内控件(Input/Button/Table)继承 FE-026 token,无需逐一调整
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 确认 shadow-dialog 已编译
|
||||||
|
- 全部弹窗逐一开闭走查留待 FE-034 回归
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# FE-032 操作反馈与微交互
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U7
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
补齐全局操作反馈(toast、进度)与控件微交互,让操作结果即时可感知。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 引入轻量 toast 通知(成功/失败/进行中),接入既有动作编排的结果回调,与日志面板互补
|
||||||
|
- 挂起更改面板、日志面板按新 token 改版;新日志条目滑入动画
|
||||||
|
- 控件微交互:按钮按压缩放、输入框聚焦光环、复选框勾选动画、hover 过渡
|
||||||
|
- 长操作(GetLatest、Checkin、目录对比)期间展示进度反馈动效
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不改动作编排逻辑与日志数据结构
|
||||||
|
- 不做系统级通知(macOS 通知中心)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`
|
||||||
|
- `mactfsui/app/components/ui/button.tsx`、`input.tsx`、`checkbox.tsx`
|
||||||
|
- 新增 toast 组件与挂载点(`app/root.tsx` 或 workspace-shell)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 关键操作(签出、签入、获取、撤销等)成功/失败均有 toast 反馈
|
||||||
|
- 面板样式与全局风格统一,日志新条目有动画
|
||||||
|
- 控件具备统一微交互,无性能问题
|
||||||
|
- 既有操作链路无回归
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:执行签出/撤销/获取最新,观察 toast 与面板反馈
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/components/ui/toaster.tsx`(无依赖的轻量 toast:模块级单例 + 订阅渲染)
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`(notice → toast 接线,移除原顶部通知条,挂载 Toaster)
|
||||||
|
- `mactfsui/app/components/logs/console-panel.tsx`(日志条目滑入动画)
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`(状态徽标加圆点,与文件列表统一)
|
||||||
|
- `mactfsui/app/components/ui/button.tsx`(按压缩放 0.98 + 统一 150ms 过渡)
|
||||||
|
- `mactfsui/app/components/ui/checkbox.tsx`(勾选指示 zoom+fade 入场动画)
|
||||||
|
- 实际实现内容:
|
||||||
|
- toast 体系:success / error 3.2 秒自动消失,loading(「正在…」文案)常驻并被同 id 结果替换;右下角滑入、点击关闭、最多同屏 4 条
|
||||||
|
- 动作编排零改动:在 workspace-shell 监听 `actions.notice`,按 kind 与「正在」前缀映射为 loading / success / error toast(GetLatest、签出、签入、目录对比等长操作天然获得进行中→结果的进度反馈)
|
||||||
|
- 日志面板新条目 fade+slide 滑入(fill-mode-backwards 阶梯延迟)
|
||||||
|
- 控件微交互:按钮按压缩放、复选框勾选动画;输入框聚焦光环沿用既有 focus-visible ring
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 确认 zoom-in-50 / slide-in-from-bottom-2 已编译
|
||||||
|
- toast 实际触发流程留待 FE-034 回归(需真实操作链路)
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# FE-033 明暗主题跟随系统
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026
|
||||||
|
- FE-027
|
||||||
|
- FE-028
|
||||||
|
- FE-029
|
||||||
|
- FE-030
|
||||||
|
- FE-031
|
||||||
|
- FE-032
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U8
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
实现明暗主题自动跟随 macOS 系统外观,并提供手动切换入口。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 监听系统外观(`prefers-color-scheme` / Electron nativeTheme),自动切换 `.dark`
|
||||||
|
- 顶栏增加三态切换:跟随系统 / 亮色 / 暗色,选择持久化到 localStorage
|
||||||
|
- 主题切换时全局颜色过渡动画
|
||||||
|
- 校验全部页面、面板、弹窗、Monaco Diff 在 dark 下的对比度与可读性,修正不达标 token
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 自定义主题色 / 多主题方案
|
||||||
|
- Monaco 编辑器自定义配色(仅在明暗两套内置主题间切换)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `mactfsui/app/app.css`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`
|
||||||
|
- 新增 `mactfsui/app/hooks/use-theme.ts`(或等价实现)
|
||||||
|
- `mactfsui/app/components/explorer/diff-dialog.tsx`、`file-view-dialog.tsx`(Monaco 主题接线)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 系统切换外观时应用自动跟随;手动选择优先于系统并可持久化
|
||||||
|
- 切换有平滑过渡,无闪烁
|
||||||
|
- dark 模式下所有界面可读性达标,无「白块」遗漏
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 手工验证:切换 macOS 系统外观 + 顶栏手动三态切换,全页面走查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- 新增 `mactfsui/app/hooks/use-theme.ts`
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`(三态主题切换按钮)
|
||||||
|
- `mactfsui/app/root.tsx`(首屏前同步主题,防暗色闪白)
|
||||||
|
- `mactfsui/app/app.css`(theme-transition 全局颜色过渡)
|
||||||
|
- `mactfsui/app/components/explorer/diff-dialog.tsx`(MutationObserver 同步 Monaco 主题)
|
||||||
|
- 实际实现内容:
|
||||||
|
- `useTheme`:「跟随系统 / 亮 / 暗」三态,持久化 `mactfs.theme`;跟随系统时监听 `prefers-color-scheme` 自动切换
|
||||||
|
- 顶栏新增循环切换按钮(Monitor/Sun/Moon 图标 + tooltip),按 跟随系统→亮→暗 循环
|
||||||
|
- 切换瞬间挂载 `.theme-transition` 实现 250ms 全局颜色过渡,320ms 后移除避免影响其它动画
|
||||||
|
- root 头部内联脚本在首屏渲染前按持久化偏好/系统外观设置 `.dark`,配合 FE-027 的窗口背景色消除闪白
|
||||||
|
- Diff 弹窗打开期间切换主题时,通过 MutationObserver 调用 `monaco.editor.setTheme` 即时跟随(明暗两套 mactfs 主题在 FE-018 已内置)
|
||||||
|
- 测试结果:
|
||||||
|
- `pnpm typecheck` 通过、`pnpm build` 通过
|
||||||
|
- 产物 CSS 确认 theme-transition 已编译
|
||||||
|
- dark 模式全页面对比度走查留待 FE-034 回归(token 在 FE-026 已按深灰体系成对定义)
|
||||||
|
- 遗留问题:
|
||||||
|
- 无
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# FE-034 UI改版回归验收
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-026 ~ FE-033 全部完成
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U9
|
||||||
|
- [TFS-AI测试流程.md](/Users/fenghp/Desktop/DEV/project/mydev/TFS-AI测试流程.md)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
确认 UI 改版后所有业务链路零回归,作为本批改版的收口验收。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 按测试大纲走查 UI 链路:登录连接、Collection 选择、目录浏览、Mapping、GetLatest、签出/新增/删除/撤销、签入、历史、Diff、目录对比、分支合并、工作区管理
|
||||||
|
- 明暗两种主题下分别走查关键页面
|
||||||
|
- 确认 `app/lib` 与接口契约零改动(diff 检查)
|
||||||
|
- 关键页面截图存档到 `docs/` 或任务完成记录
|
||||||
|
- `pnpm typecheck` 通过
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 真实 TFS 写操作验收(属 FEATURE-001 ~ 005,按测试大纲限定目录执行)
|
||||||
|
- 性能压测
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- 无新增改动(验收任务,发现问题回各任务修复)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 全部业务链路在新 UI 下可用,无功能回归
|
||||||
|
- 明暗主题下均无样式破损
|
||||||
|
- typecheck 通过
|
||||||
|
- 验收记录与截图归档
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mactfsui && pnpm typecheck && pnpm electron:dev
|
||||||
|
# 按 TFS-AI测试流程.md 第 6 节分组走查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
- 实际修改文件:
|
||||||
|
- `mactfsui/scripts/e2e-electron.mjs`(追加主题切换回归步骤)
|
||||||
|
- 新增 `mactfsui/scripts/e2e-electron-part2.mjs`、`e2e-electron-part3.mjs`(补充回归与清理诊断)
|
||||||
|
- 新增 `docs/ui-restyle-screenshots/`(6 张关键页面截图存档)
|
||||||
|
- 实际实现内容(验收执行):
|
||||||
|
- 用 playwright-core 驱动真实 Electron 应用连接真实 TFS,按测试大纲走查,共 12 项检查通过:
|
||||||
|
1. 本地服务自动拉起 → 新版登录页(品牌区/最近连接/服务指示灯正常)
|
||||||
|
2. 连接真实 TFS → Collection 卡片列表(PE / PKUSEHR)
|
||||||
|
3. 进入工作台,顶栏固定上下文徽片正确
|
||||||
|
4. 主题切换 跟随系统→亮→暗 生效(暗色工作台截图存档)
|
||||||
|
5. 目录树懒加载逐级导航至指定测试目录
|
||||||
|
6. 目录历史弹窗(真实 Changeset 678739)
|
||||||
|
7. 文件查看弹窗(服务器 latest)
|
||||||
|
8. 目录对比(新「先确认选项再执行」流程,结果含状态徽标)
|
||||||
|
9. 签出文件 → 挂起更改面板出现该文件
|
||||||
|
10. 与最新版本比较(Monaco Diff 正常渲染)
|
||||||
|
11. 撤销该文件挂起更改(现场恢复,未触碰用户已有挂起项)
|
||||||
|
12. 操作日志面板持续记录真实 API 调用
|
||||||
|
- 接口契约零改动核验:`git diff claude/dev...HEAD -- mactfsui/app/lib` 仅新增 platform.ts / recent-servers.ts,`app/lib/api` 无任何改动
|
||||||
|
- `pnpm typecheck` / `pnpm build` 全程通过(每个任务提交前均已验证)
|
||||||
|
- 测试结果:
|
||||||
|
- 三轮脚本合计 12/14 项 PASS;2 项 FAIL 均为环境因素而非 UI 回归:
|
||||||
|
- 「创建/取消映射」跳过:测试目录已被既有 Workspace 映射且含用户真实挂起更改(stage4-e2e 文件),为避免破坏现场未执行
|
||||||
|
- part2 中 Diff 步骤一次窗口异常关闭,part3 复跑同链路通过,未复现
|
||||||
|
- 遗留问题:
|
||||||
|
- 「映射到本地 / 取消映射」弹窗建议在干净环境(无既有映射)下人工补验一次
|
||||||
|
- 暗色主题已抽查工作台与对比/Diff 弹窗,其余弹窗如发现对比度问题可单点修 token
|
||||||