Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4c573585 | ||
|
|
75c8e00e19 | ||
|
|
6910563078 | ||
|
|
cfa4141c1a | ||
|
|
f09b8f95b4 | ||
|
|
63f7111bcd | ||
|
|
4ba868482b | ||
|
|
23062e8333 | ||
|
|
0c2324b1db | ||
|
|
0418a93890 |
@@ -0,0 +1,156 @@
|
|||||||
|
# macTFS 工作台增强需求·第二批(属性 / 重命名 / 回滚 / 工作区管理 / 分支合并)
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
第一批增强(FE-014 ~ FE-017,见 [mactfs-ui-enhancement-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements.md))
|
||||||
|
完成后,用户继续按 Visual Studio Source Control Explorer 的日常用法试用工作台,
|
||||||
|
提出第二批功能缺口。本文档收口这批需求,作为 `task/frontend` 中 FE-019 ~ FE-024 的需求来源。
|
||||||
|
|
||||||
|
## 二、用户反馈原文与解读
|
||||||
|
|
||||||
|
### 2.1 选中文件 / 文件夹缺少「高级」能力
|
||||||
|
|
||||||
|
- 原文:选中文件和文件夹的时候,需要一个高级功能。高级功能至少有属性:显示服务器地址、本地地址。
|
||||||
|
还有获取特定版本功能,根据历史变更集,获取这个变更集的内容(相当于整体代码回滚到这个变更集提交的时候)。
|
||||||
|
- 解读:
|
||||||
|
- 「属性」对应 VS 的 Properties 面板:展示服务器路径、本地路径、映射状态、挂起状态、最新版本号等
|
||||||
|
- 「获取特定版本」对应 VS 的 Get Specific Version 对话框:不必先打开历史弹窗,
|
||||||
|
右键即可选择 / 输入 changeset,把该对象(含目录递归)的本地内容切到那个版本
|
||||||
|
- 与 FE-014 已实现的「历史弹窗 → 获取此版本」互补:那是从历史入口走,这次要求从对象右键直达,
|
||||||
|
并提供 changeset 选择器
|
||||||
|
|
||||||
|
### 2.2 缺少重命名、回滚
|
||||||
|
|
||||||
|
- 原文:缺少重命名、回滚等功能。
|
||||||
|
- 解读:
|
||||||
|
- 重命名 = TFS pendRename:产生 rename 挂起更改,签入后服务器生效
|
||||||
|
- 回滚 = TFS Rollback:把某个 changeset 的改动反做(或回滚到某 changeset 之后的全部改动反做),
|
||||||
|
产生挂起更改,由用户审查后签入,区别于 2.1 的「获取特定版本」(后者只动本地工作区不产生挂起更改)
|
||||||
|
|
||||||
|
### 2.3 缺少工作区集中管理
|
||||||
|
|
||||||
|
- 原文:缺少管理工作区的功能。一个工作区下有很多 mapping,肯定是需要一个集中管理的地方,比如删除或者修改。
|
||||||
|
- 解读:对应 VS 的 Manage Workspaces / Edit Workspace 对话框。当前 mapping 只能在目录树逐个右键
|
||||||
|
「映射 / 取消映射」,需要一个集中入口:查看 workspace 信息 + 全部 mapping 列表,支持新增、删除、修改本地路径。
|
||||||
|
|
||||||
|
### 2.4 缺少分支与合并
|
||||||
|
|
||||||
|
- 原文:缺少分支和合并的功能。
|
||||||
|
- 解读:对应 VS 的 Branch / Merge:
|
||||||
|
- 分支:把源路径在服务器上分叉出目标路径(pendBranch + 签入)
|
||||||
|
- 合并:把源分支的 changeset 合并到目标分支,冲突进入冲突处理,结果是挂起更改,审查后签入
|
||||||
|
|
||||||
|
## 三、可行性盘点(现状 → 需要补什么)
|
||||||
|
|
||||||
|
| 能力 | TFS SDK 支持 | core 现状 | server 现状 | 前端现状 | 结论 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 属性(服务器/本地路径等) | `VersionControlClient.getItem` | 路径推导已有(mapping.ts) | 缺 item 详情接口 | 缺属性弹窗 | 主要是前端 + 1 个轻量接口 |
|
||||||
|
| 获取特定版本 | `ChangesetVersionSpec` | 已有 `getVersion`(FE-014) | 已有 `/api/files/get-version` | 仅历史弹窗入口 | 纯前端(右键直达 + 版本选择器) |
|
||||||
|
| 重命名 | `Workspace.pendRename` | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 回滚 | `Workspace.rollback`(RollbackOptions) | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 工作区管理 | `Workspace.update` / 现有 mapping API | mapping 增删已有 | `GET/POST/DELETE /api/mappings` 已有 | 仅目录树右键 | 主要是前端集中管理弹窗 |
|
||||||
|
| 分支 | `Workspace.pendBranch` / `createBranch` | 缺 | 缺 | 缺 | 全链路新增 |
|
||||||
|
| 合并 | `Workspace.merge` + `getMergeCandidates`;冲突复用 `listConflicts/applyConflict` | 缺 merge | 缺 | 冲突弹窗已有 | 全链路新增,冲突流程复用 |
|
||||||
|
|
||||||
|
SDK 均为 `com.microsoft.tfs.sdk-14.0.1.jar` 已验证存在的公开方法,无协议层风险。
|
||||||
|
|
||||||
|
## 四、需求定义
|
||||||
|
|
||||||
|
### R5 对象属性与获取特定版本(FE-019,P0)
|
||||||
|
|
||||||
|
- 右键菜单新增「属性…」与「获取特定版本…」(文件与目录均有)
|
||||||
|
- 属性弹窗内容:
|
||||||
|
- 服务器路径、本地路径(未映射明确显示「未映射」)
|
||||||
|
- 对象类型(文件 / 目录)、映射状态、挂起状态
|
||||||
|
- 服务器侧最新 changeset、签入时间、签入人(来自新增 item 详情接口)
|
||||||
|
- 文件补充:大小、编码(SDK item 信息自带)
|
||||||
|
- 获取特定版本弹窗:
|
||||||
|
- changeset 输入框 + 「从历史选择…」(内嵌该对象历史列表,点选回填)
|
||||||
|
- 明确提示:会覆盖该对象本地内容(目录为递归覆盖),等价整体切到该变更集时刻
|
||||||
|
- 确认后调用既有 `/api/files/get-version`,完成后刷新目录列表与挂起更改
|
||||||
|
- server 新增 `GET /api/items/info?serverPath=`(item 详情,供属性弹窗)
|
||||||
|
|
||||||
|
### R6 重命名(FE-020,P0)
|
||||||
|
|
||||||
|
- core 新增 `rename(serverPath, newName)`:`Workspace.pendRename`,要求对象已映射
|
||||||
|
- server 新增 `POST /api/files/rename`
|
||||||
|
- 前端:
|
||||||
|
- 右键「重命名…」弹窗:输入新名称,前端校验(非空、不含 `/ \ : * ? " < > |`、与原名不同)
|
||||||
|
- 产生 rename 挂起更改:挂起面板显示「旧名 → 新名」,可 undo 撤销,checkin 后生效
|
||||||
|
- 完成后刷新目录树、列表与挂起更改
|
||||||
|
|
||||||
|
### R7 回滚(FE-021,P1)
|
||||||
|
|
||||||
|
- core 新增 `rollback(serverPath, mode, changeset)`:
|
||||||
|
- `single`:仅反做该 changeset(VS Rollback Entire Changeset)
|
||||||
|
- `toVersion`:反做该 changeset 之后的全部改动(VS Rollback to a Specific Version)
|
||||||
|
- server 新增 `POST /api/files/rollback`
|
||||||
|
- 前端:
|
||||||
|
- 历史弹窗记录行新增「回滚此变更集…」「回滚到此变更集…」(带确认弹窗,说明只产生挂起更改不会直接入库)
|
||||||
|
- 回滚产生冲突时进入既有冲突弹窗
|
||||||
|
- 完成后刷新挂起更改,由用户审查并签入
|
||||||
|
|
||||||
|
### R8 工作区与 Mapping 集中管理(FE-022,P1)
|
||||||
|
|
||||||
|
- 顶栏新增「工作区」入口,打开集中管理弹窗:
|
||||||
|
- 工作区信息:名称、Owner、Computer、服务器、Collection
|
||||||
|
- Mapping 列表:服务器路径、本地路径、本地目录是否存在
|
||||||
|
- 操作:新增(复用既有映射弹窗逻辑)、删除(确认弹窗,说明不会删除本地文件)、
|
||||||
|
修改本地路径(实现为删旧 + 加新,提示将重新获取)
|
||||||
|
- server 复用既有 `GET/POST/DELETE /api/mappings`,不强制新增接口
|
||||||
|
|
||||||
|
### R9 分支(FE-023,P1)
|
||||||
|
|
||||||
|
- core 新增 `branch(sourceServerPath, targetServerPath, changeset?)`:`pendBranch`(默认 latest,可指定 changeset)
|
||||||
|
- server 新增 `POST /api/files/branch`
|
||||||
|
- 前端:
|
||||||
|
- 右键「分支…」弹窗:默认目标 `源路径-branch`,可编辑;可选基于指定 changeset
|
||||||
|
- 产生 branch 挂起更改,审查后签入;完成后刷新目录树
|
||||||
|
- 目标路径父目录必须已映射(pendBranch 需要本地落盘),未满足时弹窗给出明确提示
|
||||||
|
|
||||||
|
### R10 合并(FE-024,P1)
|
||||||
|
|
||||||
|
- core 新增:
|
||||||
|
- `mergeCandidates(source, target)`:`getMergeCandidates`,返回待合并 changeset 列表
|
||||||
|
- `merge(source, target, changeset?)`:默认合并全部候选,可指定单个 changeset
|
||||||
|
- server 新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- 前端:
|
||||||
|
- 右键「合并…」向导弹窗:源(当前对象)→ 目标路径(必须已映射);
|
||||||
|
展示候选 changeset 列表,支持「全部合并」或选择单个
|
||||||
|
- 冲突进入既有冲突弹窗;结果为挂起更改,审查后签入
|
||||||
|
- 完成后刷新挂起更改
|
||||||
|
|
||||||
|
### R11 访达打开与添加本地文件(FE-025,P0,第二批追加)
|
||||||
|
|
||||||
|
- 用户反馈原文:缺少右键文件或文件夹时「在访达中打开」(进入到访达的本目录);
|
||||||
|
右键文件夹或在中间区域右键时,缺少添加文件的功能(本地新增的文件加入 TFS 控制)。
|
||||||
|
- 在访达中打开:
|
||||||
|
- 文件:访达中显示并选中该文件(`shell.showItemInFolder`)
|
||||||
|
- 目录:直接进入该目录(`shell.openPath`)
|
||||||
|
- 仅 Electron 环境可用;未映射对象置灰
|
||||||
|
- 添加本地文件(修订:按用户反馈改为扫描式,不手动找文件):
|
||||||
|
- 目录右键与中间区域空白处右键提供「添加本地文件…」
|
||||||
|
- 自动对该目录执行目录对比(`POST /api/compare/folder` 递归),筛出 `localOnly`
|
||||||
|
(本地新增、服务器不存在)的文件 / 目录
|
||||||
|
- 弹窗列表勾选(默认全选,支持全选 / 清空),确认后批量
|
||||||
|
`POST /api/files/add` 产生 pendingAdd 挂起更改,完成后刷新
|
||||||
|
- 中间区域空白处右键 = 当前目录的完整右键菜单(与目录树右键一致)
|
||||||
|
|
||||||
|
## 五、优先级与排期建议
|
||||||
|
|
||||||
|
| 任务 | 内容 | 优先级 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| FE-019 | 属性 + 获取特定版本 | P0 | 用户明确点名;后端基本就绪,见效最快 |
|
||||||
|
| FE-020 | 重命名 | P0 | 日常高频操作,缺失影响基本可用性 |
|
||||||
|
| FE-021 | 回滚 | P1 | 重要但低频,依赖挂起更改/冲突链路已稳定 |
|
||||||
|
| FE-022 | 工作区管理 | P1 | 提升管理效率,API 已就绪纯前端 |
|
||||||
|
| FE-023 | 分支 | P1 | 团队工作流需要,先于合并 |
|
||||||
|
| FE-024 | 合并 | P1 | 依赖分支;冲突流程复用既有弹窗 |
|
||||||
|
|
||||||
|
## 六、不在本批范围
|
||||||
|
|
||||||
|
- Shelve / Unshelve
|
||||||
|
- Work Item 关联、Check-in Policy
|
||||||
|
- 多 Workspace 创建 / 切换 / 删除(仅展示与管理当前 Workspace 的 Mapping)
|
||||||
|
- 二进制三方合并编辑器(合并冲突仍走既有「取本地 / 取服务器」冲突弹窗)
|
||||||
|
- 移动(Move 跨目录),第一步只做同目录重命名
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),或读取本地映射文件内容。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type {
|
|||||||
FolderDiffItem,
|
FolderDiffItem,
|
||||||
GetLatestResult,
|
GetLatestResult,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
ItemInfo,
|
||||||
MappingConfig,
|
MappingConfig,
|
||||||
MappingInfo,
|
MappingInfo,
|
||||||
OperationLogEntry,
|
OperationLogEntry,
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export interface PendingChange {
|
|||||||
status: string
|
status: string
|
||||||
changeType: string
|
changeType: string
|
||||||
version: number
|
version: number
|
||||||
|
/** rename 挂起更改的原服务端路径,其余为 null。 */
|
||||||
|
sourceServerPath: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签入结果,对应 core TfsCheckinResult。
|
// 签入结果,对应 core TfsCheckinResult。
|
||||||
@@ -159,6 +161,20 @@ export interface HistoryEntry {
|
|||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
|
||||||
|
export interface ItemInfo {
|
||||||
|
serverPath: string
|
||||||
|
folder: boolean
|
||||||
|
changeset: number
|
||||||
|
checkinDate: number | null
|
||||||
|
author: string
|
||||||
|
comment: string
|
||||||
|
/** 文件大小(字节),目录为 null。 */
|
||||||
|
size: number | null
|
||||||
|
/** 文件编码名称,目录为 null。 */
|
||||||
|
encoding: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
|
||||||
export interface FileContent {
|
export interface FileContent {
|
||||||
serverPath: string
|
serverPath: string
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ export async function pathsExist(paths: string[]): Promise<Record<string, boolea
|
|||||||
return bridge.pathsExist(paths)
|
return bridge.pathsExist(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在访达中打开本地路径(目录直接进入,文件定位选中),非 Electron 环境返回 false。
|
||||||
|
*/
|
||||||
|
export async function revealPath(targetPath: string, isFolder: boolean): Promise<boolean> {
|
||||||
|
const bridge = getBridge()
|
||||||
|
if (!bridge) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bridge.revealPath(targetPath, isFolder)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造非 Electron 环境下的占位服务状态。
|
* 构造非 Electron 环境下的占位服务状态。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export {
|
|||||||
getToken,
|
getToken,
|
||||||
isElectron,
|
isElectron,
|
||||||
pathsExist,
|
pathsExist,
|
||||||
|
revealPath,
|
||||||
selectDirectory,
|
selectDirectory,
|
||||||
startService,
|
startService,
|
||||||
} from "./bridge"
|
} from "./bridge"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface MactfsBridge {
|
|||||||
startService(): Promise<ServiceStatus>
|
startService(): Promise<ServiceStatus>
|
||||||
selectDirectory(): Promise<string | null>
|
selectDirectory(): Promise<string | null>
|
||||||
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
||||||
|
revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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 条)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# FE-020 实现重命名
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-010
|
||||||
|
- FE-013
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R6
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为已映射的文件 / 文件夹提供重命名能力:产生 rename 挂起更改,可撤销,签入后服务器生效,
|
||||||
|
对齐 VS Source Control Explorer 的 Rename。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `rename(serverPath, newName)`:`Workspace.pendRename`,对象须已映射;
|
||||||
|
返回结果复用 `TfsFileOperationResult`
|
||||||
|
- server 新增 `POST /api/files/rename`(参数 `serverPath`、`newName`)
|
||||||
|
- 前端 API 层补充 `rename`
|
||||||
|
- 右键菜单新增「重命名…」(树 / 列表来源,文件与目录均可):
|
||||||
|
- 弹窗输入新名称,校验:非空、不含 `/ \ : * ? " < > |`、与原名不同
|
||||||
|
- 已有挂起 rename 或未映射时置灰并给出原因
|
||||||
|
- 挂起更改面板展示 rename 状态(`pendingRename`),显示「旧名 → 新名」
|
||||||
|
- rename 挂起更改支持 undo 撤销、checkin 签入(复用既有流程)
|
||||||
|
- 完成后刷新目录树、目录列表与挂起更改
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做跨目录移动(Move)
|
||||||
|
- 不做批量重命名
|
||||||
|
- 不处理大小写仅变化的特殊冲突场景(记录遗留问题即可)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/lib/tfs/status.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/status.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/inspector/changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/changes-panel.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 已映射文件 / 目录右键「重命名…」可输入新名称并提交
|
||||||
|
- 重命名后产生 rename 挂起更改,挂起面板显示「旧名 → 新名」
|
||||||
|
- undo 可撤销重命名(本地名称还原);checkin 后服务器生效
|
||||||
|
- 非法名称、重复名称、未映射对象有明确校验与置灰提示
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的 rename 签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `rename`(`Workspace.pendRename`,同目录改名,校验非法字符与同名);`TfsPendingChangeInfo` 新增 `sourceServerPath`(rename 携带原路径)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/rename`
|
||||||
|
- `mactfsui/app/lib/api/types.ts`:`PendingChange` 新增 `sourceServerPath`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `renameFile`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `rename`;目录与文件菜单新增「重命名…」(未映射 / pendingAdd / pendingDelete / pendingRename 置灰并给出原因)
|
||||||
|
- `mactfsui/app/components/explorer/rename-dialog.tsx`(新增):重命名弹窗(默认选中主名、Enter 提交、非法字符 / 同名校验、说明挂起生效规则)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `rename`,新增 `handleRenameConfirmed`(执行后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线重命名弹窗
|
||||||
|
- `mactfsui/app/components/inspector/changes-panel.tsx`:pendingRename 项展示「旧名 → 新名」
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 已映射文件 / 目录右键「重命名…」:弹窗输入新名称(文件自动选中主名),校验非空、非法字符(`/ \ : * ? " < > |`)、与原名不同
|
||||||
|
- 提交后产生 rename 挂起更改:挂起面板以「旧名 → 新名」展示,badge 为「挂起重命名」;可 undo 撤销、checkin 后服务器生效(复用既有流程)
|
||||||
|
- 完成后自动刷新挂起更改与目录列表
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的 rename 挂起与签入验证:本机无真实 TFS 环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 重命名入口、校验、挂起展示「旧名 → 新名」、undo / checkin 复用:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 大小写仅变化的重命名在不区分大小写的服务器上行为未验证(已在不在范围中声明)
|
||||||
|
- 目录树节点名在签入前仍显示旧名(rename 仅挂起,签入后刷新树即可)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# FE-021 实现变更集回滚
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-009
|
||||||
|
- FE-010
|
||||||
|
- FE-019
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R7
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供 TFS Rollback 能力:把某个变更集的改动反做(或回滚到某变更集,反做其后的全部改动),
|
||||||
|
产生挂起更改由用户审查后签入,对齐 VS 的 Rollback Entire Changeset / Rollback to a Specific Version。
|
||||||
|
与「获取特定版本」(只动本地、不产生挂起更改)形成互补。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `rollback(serverPath, mode, changeset)`:`Workspace.rollback` + `RollbackOptions`
|
||||||
|
- `single`:仅反做该 changeset
|
||||||
|
- `toVersion`:反做该 changeset 之后的全部改动
|
||||||
|
- server 新增 `POST /api/files/rollback`(参数 `serverPath`、`mode`、`changeset`)
|
||||||
|
- 前端 API 层补充 `rollback`
|
||||||
|
- 历史弹窗记录行新增「回滚此变更集…」「回滚到此变更集…」:
|
||||||
|
- 确认弹窗说明:只产生挂起更改、不会直接入库、签入前可撤销
|
||||||
|
- 对象未映射时置灰并给出原因
|
||||||
|
- 回滚产生冲突时进入既有冲突弹窗流程
|
||||||
|
- 完成后刷新挂起更改与目录列表,通知用户到挂起面板审查并签入
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做自动签入(回滚结果必须人工审查)
|
||||||
|
- 不做跨对象批量回滚(仅当前历史弹窗目标对象范围内)
|
||||||
|
- 不做三方合并编辑器
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/explorer/history-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-dialog.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 历史记录可执行「回滚此变更集…」,确认后产生对应的反做挂起更改
|
||||||
|
- 历史记录可执行「回滚到此变更集…」,其后全部改动被反做为挂起更改
|
||||||
|
- 回滚结果可在挂起面板审查、可 undo、可签入
|
||||||
|
- 回滚遇到本地修改冲突时进入冲突弹窗
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的回滚签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `rollback`(`Workspace.rollback`;`single` 模式 from=to=该 changeset,`toVersion` 模式 `RollbackOptions.TO_VERSION` 反做其后全部改动;复用 `TfsGetLatestResult` 返回 operations / conflicts / failures)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/rollback`(长写超时)
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `rollback`
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:新增 `runRollback`(执行后刷新挂起更改与目录列表,conflicts>0 自动进入冲突弹窗)
|
||||||
|
- `mactfsui/app/components/explorer/history-dialog.tsx`:历史记录行新增右键菜单(获取此版本… / 回滚此变更集… / 回滚到此变更集…)与回滚确认弹窗(说明只产生挂起更改)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:透传 `onRollback`
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 历史弹窗每条记录右键提供两种回滚:「回滚此变更集」(仅反做该提交)与「回滚到此变更集」(反做其后全部改动,回到该时刻内容)
|
||||||
|
- 确认弹窗明确:回滚只产生挂起更改、不直接写服务器、可撤销、需签入;执行后顶部通知展示 operations / conflicts / failures 统计并提示去挂起面板审查
|
||||||
|
- 回滚冲突自动进入既有冲突弹窗;挂起更改可 undo / checkin(复用既有流程)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的回滚挂起与签入验证:本机无真实 TFS 环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 两种回滚入口 + 确认弹窗 + 挂起审查 / 撤销 / 签入 + 冲突进冲突弹窗:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- `toVersion` 模式的 from=null + TO_VERSION 语义对齐 .NET SDK 文档,真实服务器行为待验证
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# FE-022 实现工作区与 Mapping 集中管理
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-007
|
||||||
|
- FE-013
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R8
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供工作区集中管理入口:在一个弹窗内查看 Workspace 信息和全部 Mapping,
|
||||||
|
支持新增、删除、修改本地路径,替代只能在目录树逐个右键操作的现状,
|
||||||
|
对齐 VS 的 Edit Workspace 对话框。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- 顶栏新增「工作区」按钮,打开集中管理弹窗:
|
||||||
|
- 工作区信息区:名称、Owner、Computer、服务器地址、Collection
|
||||||
|
- Mapping 表格:服务器路径、本地路径、本地目录是否存在(不存在标红提示)
|
||||||
|
- Mapping 操作:
|
||||||
|
- 新增:复用既有映射弹窗逻辑(选择服务器路径 + 本地父目录),完成后可触发 Get Latest
|
||||||
|
- 删除:确认弹窗,说明不会删除本地已下载文件;调用既有 `DELETE /api/mappings`
|
||||||
|
- 修改本地路径:实现为「删旧 + 加新」,确认弹窗提示将按新路径重新获取
|
||||||
|
- 操作完成后刷新工作区上下文(mappings)、目录树映射标识与挂起更改
|
||||||
|
- server 复用既有 `GET/POST/DELETE /api/mappings`,原则上不新增接口;
|
||||||
|
如需 Workspace 详细信息可扩展既有 `/api/workspace/context` 返回字段
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做多 Workspace 创建 / 切换 / 删除
|
||||||
|
- 不做 cloak(遮蔽)类型的 mapping
|
||||||
|
- 不迁移本地已下载内容(修改本地路径后由用户自行决定是否重新获取)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfsui/app/components/app/top-bar.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/top-bar.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-shell.tsx)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
- [mactfsui/app/components/explorer/mapping-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/mapping-dialog.tsx)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/session.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/session.ts)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 顶栏可打开工作区管理弹窗,正确展示 Workspace 信息与全部 Mapping
|
||||||
|
- 可在弹窗内新增 Mapping,与目录树右键映射效果一致
|
||||||
|
- 可删除 Mapping(带确认),目录树映射标识同步更新
|
||||||
|
- 可修改 Mapping 本地路径(删旧加新),有重新获取提示
|
||||||
|
- 本地目录不存在的 Mapping 有醒目标识
|
||||||
|
- 前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfsui/app/components/explorer/workspace-manage-dialog.tsx`(新增):集中管理弹窗
|
||||||
|
- 工作区信息区:Workspace / 服务器 / Collection / Owner / Computer / Mapping 数量(Owner、Computer 通过幂等的 `POST /api/workspace/ensure` 获取,未新增服务端接口)
|
||||||
|
- Mapping 表格:服务端路径、本地路径、本地目录存在性(Electron `pathsExist` 批量检测,不存在标红)
|
||||||
|
- 新增:输入服务端路径(校验 `$/` 前缀)→ 复用既有 `MappingDialog`(父目录选择 + 后端预校验 + 可选立即获取)
|
||||||
|
- 删除:确认弹窗(说明不删除本地文件),调用既有 `DELETE /api/mappings`
|
||||||
|
- 修改本地路径:内置 `EditMappingDialog`,选择新父目录 → `check-target` 预校验 → 「删旧 + 加新」,提示需重新获取
|
||||||
|
- `mactfsui/app/components/app/top-bar.tsx`:顶栏新增「工作区」按钮
|
||||||
|
- `mactfsui/app/components/app/workspace-shell.tsx`:持有弹窗显隐,Mapping 变化后统一刷新 mappings 与目录列表
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 顶栏「工作区」一键打开集中管理弹窗,无需到目录树逐个右键
|
||||||
|
- 全部 mapping 一屏可见,本地目录缺失(已映射未下载 / 被手动删除)醒目标红
|
||||||
|
- 三类操作(新增 / 删除 / 改路径)完成后目录树映射标识、列表状态列同步刷新
|
||||||
|
- 未新增服务端接口,全部复用既有 mappings API
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- 服务端无改动,无需重新构建(沿用 FE-021 构建产物)
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 环境的映射增删改落盘验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 工作区信息与全部 Mapping 展示、增 / 删 / 改、本地目录缺失标识:满足(代码层面)
|
||||||
|
- 前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 「修改本地路径」中删旧成功但加新失败时会处于无映射状态(已在弹窗错误提示中说明需重新映射)
|
||||||
|
- 删除映射后该子树挂起更改的处理沿用服务端默认行为,未做额外清理
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# FE-023 实现分支
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-010
|
||||||
|
- FE-019
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R9
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为服务端目录 / 文件提供分支能力:从源路径分叉出目标路径(pendBranch 产生挂起更改,
|
||||||
|
签入后服务器生效),对齐 VS Source Control Explorer 的 Branch。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增 `branch(sourceServerPath, targetServerPath, changeset?)`:
|
||||||
|
`Workspace.pendBranch`,默认基于 latest,可指定 changeset
|
||||||
|
- server 新增 `POST /api/files/branch`
|
||||||
|
- 前端 API 层补充 `branch`
|
||||||
|
- 右键菜单新增「分支…」(树 / 列表来源):
|
||||||
|
- 弹窗:源路径只读展示;目标路径默认 `源路径-branch` 可编辑;
|
||||||
|
可选「基于指定 changeset」(输入或从历史点选,复用 FE-019 版本选择器)
|
||||||
|
- 校验:目标路径不能与源相同、不能已存在;目标父目录必须已映射(pendBranch 需本地落盘),
|
||||||
|
未满足时给出明确提示
|
||||||
|
- 分支产生 branch 挂起更改,挂起面板可见、可 undo、可签入(复用既有流程)
|
||||||
|
- 完成后刷新目录树与挂起更改
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做分支可视化(Branch Hierarchy 图)
|
||||||
|
- 不做「转换为分支对象」(Convert to Branch)
|
||||||
|
- 不做合并(FE-024)
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 右键「分支…」可对目录 / 文件发起分支,目标路径校验正确
|
||||||
|
- 分支产生 branch 挂起更改,签入后服务器出现目标路径内容
|
||||||
|
- 可基于指定 changeset 分支
|
||||||
|
- 目标父目录未映射时有明确提示
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的分支签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `branch`(`Workspace.pendBranch`,默认 latest 可指定 changeset,校验目标 ≠ 源)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `POST /api/files/branch`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `branch`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `branch`;目录菜单(对比 / 历史分组)与文件菜单(非 pendingAdd)新增「分支…」
|
||||||
|
- `mactfsui/app/components/explorer/branch-dialog.tsx`(新增):分支弹窗(目标路径默认 `源-branch` 可编辑、可选基于 changeset、展示目标本地落盘路径)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `branch`,新增 `handleBranchConfirmed`(执行后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线分支弹窗
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 右键「分支…」打开弹窗:目标路径校验(`$/` 前缀、≠ 源、不在源内部、父目录必须已映射并给出明确提示)、changeset 数字校验(留空 = latest)
|
||||||
|
- 校验通过后展示目标的本地落盘路径;确认后产生 branch 挂起更改,顶部通知提示去挂起面板审查后签入
|
||||||
|
- 挂起的 branch 更改可 undo / checkin(复用既有流程)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的 pendBranch 与签入后服务器分支验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 分支入口、目标校验、指定 changeset、挂起审查 / 签入:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不做分支可视化与 Convert to Branch(已声明不在范围)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# FE-024 实现合并
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-009
|
||||||
|
- FE-023
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R10
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
提供分支间合并能力:查看待合并变更集候选,执行合并产生挂起更改,
|
||||||
|
冲突进入既有冲突弹窗,审查后签入,对齐 VS 的 Merge 向导。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- core 新增:
|
||||||
|
- `mergeCandidates(sourceServerPath, targetServerPath)`:`Workspace.getMergeCandidates`
|
||||||
|
- `merge(sourceServerPath, targetServerPath, changeset?)`:`Workspace.merge`,
|
||||||
|
默认合并全部候选,可指定单个 changeset
|
||||||
|
- server 新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- 前端 API 层补充 `mergeCandidates`、`merge`
|
||||||
|
- 右键菜单新增「合并…」(树 / 列表来源,作为合并源):
|
||||||
|
- 向导弹窗:源路径只读;目标路径输入(必须已映射,校验给出原因)
|
||||||
|
- 加载并展示候选 changeset 列表(编号、提交人、时间、备注);空列表提示无可合并内容
|
||||||
|
- 支持「全部合并」或选择单个 changeset 合并
|
||||||
|
- 确认弹窗说明:只产生挂起更改、冲突需要逐个处理、签入前可撤销
|
||||||
|
- 合并冲突进入既有冲突弹窗流程(listConflicts / applyConflict)
|
||||||
|
- 完成后刷新挂起更改,引导用户审查并签入
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做三方合并编辑器(内容冲突仍按「取本地 / 取服务器」处理)
|
||||||
|
- 不做 baseless merge(无分支关系的强制合并)
|
||||||
|
- 不做合并历史可视化
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java)
|
||||||
|
- [mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java)
|
||||||
|
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/app/workspace-dialogs.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/workspace-dialogs.tsx)
|
||||||
|
- [mactfsui/app/components/explorer/conflict-dialog.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/conflict-dialog.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 「合并…」向导可展示源 → 目标的候选 changeset 列表
|
||||||
|
- 可执行全部合并与单个 changeset 合并,结果为挂起更改
|
||||||
|
- 合并冲突进入冲突弹窗并可逐个处理
|
||||||
|
- 合并结果可在挂起面板审查、可 undo、可签入
|
||||||
|
- 服务端构建通过、前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
|
||||||
|
../tfsIntegration/gradlew --no-daemon build installDist
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 TFS 的合并签入验证须用户确认后执行。
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/core/MacTfsCoreService.java`:新增 `mergeCandidates`(`getMergeCandidates`,复用 `TfsHistoryEntry` 输出候选)与 `merge`(`Workspace.merge`;changeset 为空合并全部候选 `(null, latest)`,指定时仅合并该变更集 `(C, C)`;复用 `TfsGetLatestResult` 返回 operations / conflicts / failures)
|
||||||
|
- `mactfs/src/main/java/com/mydev/mactfs/server/MacTfsServer.java`:新增 `GET /api/files/merge-candidates`、`POST /api/files/merge`
|
||||||
|
- `mactfsui/app/lib/api/endpoints.ts`:新增 `mergeCandidates`、`merge`
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `merge`;目录与文件(非 pendingAdd)菜单新增「合并…」
|
||||||
|
- `mactfsui/app/components/explorer/merge-dialog.tsx`(新增):合并向导(源只读、目标路径校验须已映射、查询候选列表、点选单个或全部合并、挂起与冲突说明)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:弹窗状态新增 `merge`,新增 `handleMergeConfirmed`(刷新挂起更改与目录列表,conflicts>0 自动以目标路径进入冲突弹窗)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线合并向导
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 右键「合并…」打开向导:目标路径必须已映射(校验给出原因);「查询候选」展示待合并 changeset(编号 / 类型 / 作者 / 时间 / 注释),空列表提示已同步
|
||||||
|
- 支持「全部合并」与「合并所选」(点选候选行选中 / 取消);执行后顶部通知展示统计并提示去挂起面板审查后签入
|
||||||
|
- 合并冲突自动进入既有冲突弹窗(listConflicts / applyConflict,按「取本地 / 取服务器」处理)
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- `../tfsIntegration/gradlew --no-daemon build installDist`:通过
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 真实 TFS 的候选查询、合并挂起与冲突处理验证:本机无环境,待 FEATURE 阶段执行
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 候选列表、全部 / 单个合并、冲突进冲突弹窗、挂起审查 / 撤销 / 签入:满足(代码层面)
|
||||||
|
- 服务端构建与前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不支持 baseless merge 与三方合并编辑器(已声明不在范围)
|
||||||
|
- 候选「合并所选」一次仅支持单个 changeset,连续区间合并可后续增强
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# FE-025 实现访达打开与添加本地文件
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
P0
|
||||||
|
|
||||||
|
## 所属阶段
|
||||||
|
|
||||||
|
frontend
|
||||||
|
|
||||||
|
## 依赖任务
|
||||||
|
|
||||||
|
- FE-006
|
||||||
|
- FE-011
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
|
||||||
|
- [docs/mactfs-ui-enhancement-requirements-2.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-enhancement-requirements-2.md) R11
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
补齐两个日常操作缺口:右键对象「在访达中打开」(文件定位选中、目录直接进入),
|
||||||
|
以及目录右键 / 中间区域空白处右键「添加本地文件…」(把本地新增文件加入 TFS 版本控制)。
|
||||||
|
|
||||||
|
## 实现范围
|
||||||
|
|
||||||
|
- Electron main 新增 IPC:`reveal-path`(`shell.showItemInFolder` / `shell.openPath`)、
|
||||||
|
`select-files`(系统多选文件选择器,支持默认目录)
|
||||||
|
- preload / bridge / 类型契约同步暴露 `revealPath`、`selectFiles`
|
||||||
|
- 动作模型新增 `revealInFinder`、`addLocalFiles`:
|
||||||
|
- 文件菜单「在访达中显示」;目录菜单「在访达中打开」(未映射置灰)
|
||||||
|
- 目录菜单「添加本地文件…」(未映射置灰)
|
||||||
|
- 中间文件列表空白区域右键 = 当前目录的完整右键菜单(行内右键阻止冒泡避免双菜单)
|
||||||
|
- 添加流程(修订为扫描式):目录对比(递归)筛出 localOnly 项 → 弹窗勾选(默认全选)→
|
||||||
|
批量 `POST /api/files/add` → 刷新挂起更改与目录列表
|
||||||
|
- 非 Electron 环境:两个动作给出「仅桌面应用支持」提示
|
||||||
|
|
||||||
|
## 不在范围
|
||||||
|
|
||||||
|
- 不做拖拽添加文件
|
||||||
|
- 不做新建文件 / 文件夹
|
||||||
|
- 不改既有目录对比 localOnly「加入版本控制」入口
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- [mactfsui/electron/main.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/main.cjs)
|
||||||
|
- [mactfsui/electron/preload.cjs](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/electron/preload.cjs)
|
||||||
|
- [mactfsui/app/lib/electron](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/electron)
|
||||||
|
- [mactfsui/app/lib/tfs/actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/tfs/actions.ts)
|
||||||
|
- [mactfsui/app/hooks/use-file-actions.ts](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/hooks/use-file-actions.ts)
|
||||||
|
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 已映射文件右键「在访达中显示」可在访达中定位选中该文件
|
||||||
|
- 已映射目录右键「在访达中打开」可直接进入该目录
|
||||||
|
- 目录右键与中间空白区域右键可「添加本地文件…」,自动扫描出本地新增项,勾选添加后产生 pendingAdd 并刷新
|
||||||
|
- 没有本地新增项时弹窗明确提示「本地内容与服务器一致」
|
||||||
|
- 未映射对象两个动作置灰并给出原因;前端 typecheck 通过
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完成记录
|
||||||
|
|
||||||
|
### 实际修改文件
|
||||||
|
|
||||||
|
- `mactfsui/electron/main.cjs`:新增 IPC `mactfs:reveal-path`(目录 `shell.openPath` 直接进入,文件 `shell.showItemInFolder` 定位选中,路径不存在返回 false)
|
||||||
|
- `mactfsui/electron/preload.cjs`:暴露 `revealPath`
|
||||||
|
- `mactfsui/app/lib/electron/types.ts` / `bridge.ts` / `index.ts`:桥接契约与封装(非 Electron 环境优雅降级)
|
||||||
|
- `mactfsui/app/lib/tfs/actions.ts`:`FileActionId` 新增 `revealInFinder`、`addLocalFiles`;文件菜单「在访达中显示」、目录菜单「在访达中打开」+「添加本地文件…」(未映射置灰)
|
||||||
|
- `mactfsui/app/components/explorer/add-files-dialog.tsx`(新增):扫描式添加弹窗(目录对比递归筛 localOnly、勾选列表默认全选、支持全选 / 清空、批量添加)
|
||||||
|
- `mactfsui/app/hooks/use-file-actions.ts`:`revealInFinder` 执行逻辑(失败提示先获取最新);`addLocalFiles` 打开扫描弹窗;新增 `handleAddFilesConfirmed`(批量 pend add 后刷新挂起更改与目录列表)
|
||||||
|
- `mactfsui/app/components/app/workspace-dialogs.tsx` / `workspace-shell.tsx`:渲染并接线添加弹窗
|
||||||
|
- `mactfsui/app/components/explorer/folder-items-panel.tsx`:中间区域空白处右键 = 当前目录完整菜单(行内右键 stopPropagation 防止双菜单)
|
||||||
|
|
||||||
|
### 实际实现内容
|
||||||
|
|
||||||
|
- 「在访达中打开 / 显示」:目录直接进入访达对应目录;文件在访达中定位并选中;本地路径不存在(未下载)时提示先获取最新;非 Electron 提示仅桌面应用支持
|
||||||
|
- 「添加本地文件…」(按用户反馈修订为扫描式):自动对该目录做递归目录对比,筛出 localOnly(本地新增)的文件 / 目录,弹窗勾选(默认全选)后批量加入版本控制;无新增项时明确提示本地与服务器一致
|
||||||
|
- 添加结果为 pendingAdd 挂起更改,挂起面板可见、可撤销、签入后进入服务器
|
||||||
|
- 中间列表空白处右键提供当前目录的完整右键菜单,与目录树右键一致
|
||||||
|
|
||||||
|
### 已执行测试
|
||||||
|
|
||||||
|
- `pnpm typecheck`:通过
|
||||||
|
- 服务端无改动,无需重新构建
|
||||||
|
|
||||||
|
### 未执行测试及原因
|
||||||
|
|
||||||
|
- 访达交互为 Electron 原生能力,需桌面应用人工验证(`pnpm electron:dev`)
|
||||||
|
- 扫描式添加依赖真实 TFS 目录对比结果,待 FEATURE 阶段验证
|
||||||
|
|
||||||
|
### 是否满足验收标准
|
||||||
|
|
||||||
|
- 访达打开 / 显示、扫描式添加本地文件(勾选 / 全选 / 无新增提示)、空白区域右键、未映射置灰:满足(代码层面)
|
||||||
|
- 前端 typecheck 通过:满足
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
- 不支持拖拽添加与新建文件 / 文件夹(已声明不在范围)
|
||||||
|
- 大目录递归对比首次扫描可能较慢(沿用目录对比既有性能特征)
|
||||||
Reference in New Issue
Block a user