10 Commits
Author SHA1 Message Date
fenghpandCursor fe4c573585 优化: 添加本地文件改为扫描式(FE-025修订),目录对比自动筛出本地新增项勾选添加
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:39:20 +08:00
fenghpandCursor 75c8e00e19 功能: 右键支持在访达中打开与添加本地文件到版本控制(FE-025),中间区域空白处提供当前目录菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:23:13 +08:00
fenghpandCursor 6910563078 功能: 实现分支间合并向导(FE-024),候选变更集可全部或单个合并,冲突走冲突弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:31:25 +08:00
fenghpandCursor cfa4141c1a 功能: 文件与目录支持分支(FE-023),pendBranch 产生挂起更改审查后签入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:26:12 +08:00
fenghpandCursor f09b8f95b4 功能: 顶栏新增工作区集中管理弹窗(FE-022),支持 Mapping 新增删除与修改本地路径
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:21:29 +08:00
fenghpandCursor 63f7111bcd 功能: 历史记录支持回滚变更集与回滚到变更集(FE-021),结果为挂起更改审查后签入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:17:24 +08:00
fenghpandCursor 4ba868482b 功能: 文件与目录支持重命名(FE-020),挂起面板展示旧名→新名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:11:55 +08:00
fenghpandCursor 23062e8333 功能: 右键新增属性弹窗与获取特定版本(FE-019),服务端新增 /api/items/info
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:58:11 +08:00
fenghpandCursor 0c2324b1db 需求: 梳理第二批工作台增强需求并拆分 FE-019~024 任务(属性/获取特定版本/重命名/回滚/工作区管理/分支合并)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:47:30 +08:00
fenghpandCursor 0418a93890 修复: Diff 统一视图下出现两列紧贴行号,启用 compactMode 仅保留新版行号
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:30:23 +08:00
35 changed files with 3419 additions and 12 deletions
@@ -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 跨目录),第一步只做同目录重命名
@@ -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;
@@ -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 为空时直接拒绝。
*/ */
@@ -584,6 +738,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。
*/ */
@@ -1106,7 +1322,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 +1894,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 +1904,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 +1934,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 +2126,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;
@@ -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 {
+5
View File
@@ -22,11 +22,13 @@ 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
}) { }) {
return ( return (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3"> <header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3">
@@ -70,6 +72,9 @@ export function TopBar({
<PanelBottom /> <PanelBottom />
</PanelToggle> </PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" /> <Separator orientation="vertical" className="mx-1 h-4!" />
<Button size="sm" variant="outline" onClick={onManageWorkspace}>
工作区
</Button>
<Button size="sm" variant="outline" onClick={onReconnect}> <Button size="sm" variant="outline" onClick={onReconnect}>
重新连接 重新连接
</Button> </Button>
@@ -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
@@ -4,6 +4,7 @@ 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 { TooltipProvider } from "~/components/ui/tooltip" import { TooltipProvider } from "~/components/ui/tooltip"
@@ -38,6 +39,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)
/** /**
* 切换指定面板的展开 / 收起状态。 * 切换指定面板的展开 / 收起状态。
@@ -76,6 +79,7 @@ export function WorkspaceShell({
panels={panels} panels={panels}
onTogglePanel={togglePanel} onTogglePanel={togglePanel}
onReconnect={onReconnect} onReconnect={onReconnect}
onManageWorkspace={() => setManageOpen(true)}
/> />
{actions.notice && ( {actions.notice && (
@@ -147,7 +151,24 @@ 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()
}}
/>
)}
</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>
)
}
@@ -140,6 +140,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,
@@ -182,7 +184,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)
} }
@@ -117,6 +117,15 @@ 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 bg-background">
<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">
@@ -142,6 +151,7 @@ export function FolderItemsPanel({
</Button> </Button>
</div> </div>
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
@@ -191,7 +201,11 @@ export function FolderItemsPanel({
data-state={selected ? "selected" : undefined} data-state={selected ? "selected" : undefined}
className="cursor-default select-none" className="cursor-default select-none"
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)
@@ -233,6 +247,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 ? (
@@ -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>
)
}
@@ -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>
)
}
@@ -235,7 +235,11 @@ 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>
+211 -2
View File
@@ -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,
} }
} }
+43
View File
@@ -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),或读取本地映射文件内容。
*/ */
+1
View File
@@ -15,6 +15,7 @@ export type {
FolderDiffItem, FolderDiffItem,
GetLatestResult, GetLatestResult,
HistoryEntry, HistoryEntry,
ItemInfo,
MappingConfig, MappingConfig,
MappingInfo, MappingInfo,
OperationLogEntry, OperationLogEntry,
+16
View File
@@ -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
+11
View File
@@ -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 环境下的占位服务状态。
*/ */
+1
View File
@@ -6,6 +6,7 @@ export {
getToken, getToken,
isElectron, isElectron,
pathsExist, pathsExist,
revealPath,
selectDirectory, selectDirectory,
startService, startService,
} from "./bridge" } from "./bridge"
+1
View File
@@ -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 {
+81
View File
@@ -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
} }
+13 -1
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog } = require("electron") const { app, BrowserWindow, ipcMain, dialog, shell } = 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,6 +246,18 @@ 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
})
} }
/** /**
+6
View File
@@ -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)
},
}) })
+10 -3
View File
@@ -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 | 25 | 25 | 100% |
| Feature E2E | 0 | 5 | 0% | | Feature E2E | 0 | 5 | 0% |
| Release | 0 | 4 | 0% | | Release | 0 | 4 | 0% |
| Overall | 42 | 51 | 82% | | Overall | 49 | 58 | 84% |
## 当前阶段 ## 当前阶段
Feature E2E(前端阶段含增强已全部完成) Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真实 TFS 环境)
## 前端规范 ## 前端规范
@@ -109,6 +109,13 @@ 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 |
## 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 条)
+115
View File
@@ -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 通过:满足
### 遗留问题
- 「修改本地路径」中删旧成功但加新失败时会处于无映射状态(已在弹窗错误提示中说明需重新映射)
- 删除映射后该子树挂起更改的处理沿用服务端默认行为,未做额外清理
+111
View File
@@ -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(已声明不在范围)
+115
View File
@@ -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 通过:满足
### 遗留问题
- 不支持拖拽添加与新建文件 / 文件夹(已声明不在范围)
- 大目录递归对比首次扫描可能较慢(沿用目录对比既有性能特征)