5 Commits
12 changed files with 155 additions and 28 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -214,7 +214,7 @@ public class MacTfsCli {
private static String readServerToken() throws Exception { private static String readServerToken() throws Exception {
File tokenFile = serverTokenFile(); File tokenFile = serverTokenFile();
if (!tokenFile.exists()) { if (!tokenFile.exists()) {
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start macTFS API server first."); throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start MacTFS API server first.");
} }
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim(); String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
if (token.length() == 0) { if (token.length() == 0) {
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
required(merged, "workspace"), required(merged, "workspace"),
required(merged, "server-path"), required(merged, "server-path"),
merged.getProperty("local-path"), merged.getProperty("local-path"),
!"false".equalsIgnoreCase(merged.getProperty("recursive")) !"false".equalsIgnoreCase(merged.getProperty("recursive")),
!"false".equalsIgnoreCase(merged.getProperty("include-local-only"))
); );
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("diffs", folderDiffsToMaps(result.getData())); data.put("diffs", folderDiffsToMaps(result.getData()));
@@ -64,7 +64,7 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。 * MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
*/ */
public class MacTfsCoreService { public class MacTfsCoreService {
@@ -139,7 +139,7 @@ public class MacTfsCoreService {
} }
/** /**
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。 * 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
*/ */
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config, public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
final String collectionName, final String collectionName,
@@ -579,7 +579,8 @@ public class MacTfsCoreService {
final String workspaceName, final String workspaceName,
final String serverPath, final String serverPath,
final String localPath, final String localPath,
final boolean recursive) { final boolean recursive,
final boolean includeLocalOnly) {
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() { return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
@Override @Override
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception { public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
@@ -593,7 +594,10 @@ public class MacTfsCoreService {
} }
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive); Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive)); Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
Set<String> localFiles = scanLocalFiles(new File(normalizedLocalPath), recursive); // 不统计仅本地存在的项时直接跳过本地全量扫描(如 node_modules 大目录),只对比两端都有的文件。
Set<String> localFiles = includeLocalOnly
? scanLocalFiles(new File(normalizedLocalPath), recursive)
: new LinkedHashSet<String>();
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>(); Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) { for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
@@ -1237,8 +1241,12 @@ public class MacTfsCoreService {
} }
boolean exists = localFile != null && localFile.exists(); boolean exists = localFile != null && localFile.exists();
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion(); boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。 // 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis(); // 这样未签出的纯本地修改也能在目录对比中暴露出来。
boolean writableFile = exists && !isFolder(item.getItemType()) && localFile.canWrite();
// mtime 仅作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
boolean mtimeNewer = exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
boolean localModified = writableFile || (remoteChanged && mtimeNewer);
if (!exists && item.getLocalVersion() > 0) { if (!exists && item.getLocalVersion() > 0) {
return "localDeleted"; return "localDeleted";
} }
@@ -52,7 +52,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
/** /**
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。 * MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
*/ */
public class MacTfsServer { public class MacTfsServer {
@@ -93,8 +93,8 @@ public class MacTfsServer {
registerFilters(); registerFilters();
registerRoutes(); registerRoutes();
Spark.awaitInitialization(); Spark.awaitInitialization();
System.out.println("macTFS API server listening on http://" + HOST + ":" + PORT); System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
System.out.println("macTFS token file: " + tokenStore.getTokenFile().getAbsolutePath()); System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
} }
/** /**
@@ -597,7 +597,7 @@ public class MacTfsServer {
public ApiResult call(Request request) throws Exception { public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request); Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body); AppConfig config = requestConfig(body);
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true)); CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true), booleanValue(body, "includeLocalOnly", true));
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData()); data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
return fromCore(result, data); return fromCore(result, data);
+1 -1
View File
@@ -140,7 +140,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6"> <div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm"> <div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-base font-semibold">macTFS</h1> <h1 className="text-base font-semibold">MacTFS</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"} {phase === "form" ? "连接 TFS 服务器" : "选择 Collection"}
</p> </p>
+1 -1
View File
@@ -33,7 +33,7 @@ export function TopBar({
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">
<div className="flex min-w-0 items-center gap-2 text-sm"> <div className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 font-semibold">macTFS</span> <span className="shrink-0 font-semibold">MacTFS</span>
<Separator orientation="vertical" className="h-4!" /> <Separator orientation="vertical" className="h-4!" />
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline"> <span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
服务器 服务器
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react" import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
@@ -9,6 +9,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog" } from "~/components/ui/dialog"
@@ -30,6 +31,9 @@ import { cn } from "~/lib/utils"
* 目录对比弹窗:对已映射目录执行本地与服务端对比, * 目录对比弹窗:对已映射目录执行本地与服务端对比,
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。 * 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
*/ */
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
export function CompareDialog({ export function CompareDialog({
serverPath, serverPath,
mappings, mappings,
@@ -39,33 +43,51 @@ export function CompareDialog({
serverPath: string serverPath: string
mappings: MappingInfo[] mappings: MappingInfo[]
onClose: () => void onClose: () => void
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
}) { }) {
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
const [phase, setPhase] = useState<"setup" | "result">("setup")
const [diffs, setDiffs] = useState<FolderDiffItem[]>([]) const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showUpToDate, setShowUpToDate] = useState(false) const [showUpToDate, setShowUpToDate] = useState(false)
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。 // 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set()) const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
/** /**
* 执行(或重新执行)目录对比。 * 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
*/ */
const runCompare = useCallback(async () => { const runCompare = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
const result = await api.compareFolder({ serverPath, recursive: true }) const result = await api.compareFolder({
serverPath,
recursive: true,
includeLocalOnly: !ignoreLocalOnly,
})
setLoading(false) setLoading(false)
if (!result.ok) { if (!result.ok) {
setError(result.errorMessage ?? "目录对比失败") setError(result.errorMessage ?? "目录对比失败")
return return
} }
setDiffs(result.data?.diffs ?? []) setDiffs(result.data?.diffs ?? [])
}, [serverPath]) }, [serverPath, ignoreLocalOnly])
useEffect(() => { /**
void runCompare() * 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
}, [runCompare]) * 让结果状态(如 签出编辑)立即反映最新事实。
*/
const handleAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
await onFileAction(target, action)
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
await runCompare()
}
},
[onFileAction, runCompare],
)
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。 // 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
const presentStatuses = useMemo(() => { const presentStatuses = useMemo(() => {
@@ -104,11 +126,56 @@ export function CompareDialog({
}) })
}, []) }, [])
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
if (phase === "setup") {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>目录对比</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={ignoreLocalOnly}
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
className="mt-0.5"
/>
<span>
<span className="font-medium">忽略仅本地存在的项</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
并跳过本地全量扫描,大目录速度更快。
</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
取消
</Button>
<Button
onClick={() => {
setPhase("result")
void runCompare()
}}
>
开始对比
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return ( return (
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]"> <DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
<DialogHeader> <DialogHeader>
<DialogTitle>目录对比</DialogTitle> <DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription> <DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -134,6 +201,14 @@ export function CompareDialog({
/> />
显示最新项 显示最新项
</label> </label>
<Button
variant="outline"
size="sm"
onClick={() => setPhase("setup")}
disabled={loading}
>
调整选项
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -181,7 +256,11 @@ export function CompareDialog({
compareStatus: diff.status, compareStatus: diff.status,
}) })
return ( return (
<FileTargetMenu key={diff.serverPath} target={target} onAction={onFileAction}> <FileTargetMenu
key={diff.serverPath}
target={target}
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
>
<TableRow className="cursor-default select-none"> <TableRow className="cursor-default select-none">
<TableCell className="py-1.5"> <TableCell className="py-1.5">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
+7 -1
View File
@@ -225,8 +225,14 @@ export function getFileContent(params: { serverPath?: string; localPath?: string
/** /**
* 对已映射目录执行目录对比。 * 对已映射目录执行目录对比。
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
*/ */
export function compareFolder(body: { serverPath: string; localPath?: string; recursive?: boolean }) { export function compareFolder(body: {
serverPath: string
localPath?: string
recursive?: boolean
includeLocalOnly?: boolean
}) {
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body }) return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
} }
+8 -1
View File
@@ -11,6 +11,13 @@ import type { WorkspaceSession } from "~/lib/tfs/session"
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。 // 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
type AppPhase = "checking" | "blocked" | "connect" | "workspace" type AppPhase = "checking" | "blocked" | "connect" | "workspace"
/**
* 页面标题(同时作为 Electron 窗口标题)。
*/
export function meta() {
return [{ title: "MacTFS" }]
}
/** /**
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程, * 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。 * 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
@@ -88,7 +95,7 @@ export default function Home() {
return ( return (
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6"> <div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm"> <div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
<h1 className="text-base font-semibold">macTFS</h1> <h1 className="text-base font-semibold">MacTFS</h1>
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p> <p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
{phase === "checking" && ( {phase === "checking" && (
+27 -1
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron") const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage } = 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")
@@ -260,13 +260,32 @@ function registerIpcHandlers() {
}) })
} }
/**
* 解析应用图标路径:开发态用 public/logo.png,打包态用构建产物中的 logo.png。
*/
function resolveAppIcon() {
const candidates = [
path.join(__dirname, "..", "public", "logo.png"),
path.join(__dirname, "..", "build", "client", "logo.png"),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return null
}
/** /**
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。 * 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
*/ */
function createWindow() { function createWindow() {
const iconPath = resolveAppIcon()
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1280, width: 1280,
height: 840, height: 840,
title: "MacTFS",
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.cjs"), preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true, contextIsolation: true,
@@ -282,7 +301,14 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html")) mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
} }
app.setName("MacTFS")
app.whenReady().then(() => { app.whenReady().then(() => {
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
const iconPath = resolveAppIcon()
if (process.platform === "darwin" && iconPath && app.dock) {
app.dock.setIcon(nativeImage.createFromPath(iconPath))
}
registerIpcHandlers() registerIpcHandlers()
createWindow() createWindow()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB