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