Merge feature/ui-macos-restyle into main

合并 macOS 原生风 UI 改版分支,冲突以 feature 分支为准
(feature 为当前维护主线,取代 main 上的并行前端尝试)。

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	task/frontend/FE-001-实现Electron启动服务与API客户端.md
#	task/frontend/FE-002-实现登录配置页.md
#	task/frontend/FE-003-实现VS风格主布局.md
#	task/frontend/FE-004-实现Collection服务端目录树.md
#	task/frontend/FE-005-实现中间目录文件列表.md
#	task/frontend/FE-006-实现Mapping创建流程.md
#	task/frontend/FE-007-实现目录对比页面.md
#	task/frontend/FE-008-实现右侧挂起更改面板.md
#	task/frontend/FE-009-实现文件操作交互.md
#	task/frontend/FE-010-实现签入流程.md
#	task/frontend/FE-011-实现历史记录界面.md
#	task/frontend/FE-012-实现文件Diff界面.md
#	task/frontend/FE-013-实现操作日志面板.md
This commit is contained in:
fenghp
2026-06-16 14:33:47 +08:00
158 changed files with 17212 additions and 4654 deletions
+81 -64
View File
@@ -2,11 +2,19 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* 应用只有亮色主题:保留 class 方案的 dark 变体定义,使组件库模板中残留的
dark: 工具类永远不会命中(根节点不再挂 .dark),避免回退到系统媒体查询。 */
@custom-variant dark (&:is(.dark *));
/*
* macOS 原生风设计基建(FE-026)
* 基调:系统字体 + macOS 蓝主色 + 中性灰分级 + 半透明材质(vibrancy)变量。
* 动效约定:快 150ms / 标准 220ms / 慢 320ms,缓动 ease-out-quart / spring 近似。
*/
@theme inline {
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-heading: var(--font-sans);
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -38,6 +46,7 @@
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
/* 圆角档位:macOS 常用 6/8/10/14px(--radius = 10px) */
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -45,75 +54,63 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗(Sheet)明显 */
--shadow-card: 0 0 0 0.5px rgb(0 0 0 / 0.03), 0 1px 1px rgb(0 0 0 / 0.04), 0 4px 14px rgb(0 0 0 / 0.06);
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06);
--shadow-dialog: 0 18px 50px rgb(0 0 0 / 0.22), 0 4px 12px rgb(0 0 0 / 0.1);
/* 缓动曲线 */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
/* 反馈动效:表单错误抖动 */
--animate-shake: shake 0.4s var(--ease-out-quart);
@keyframes shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-3px); }
40%, 60% { transform: translateX(3px); }
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.98 0.002 286);
--foreground: oklch(0.22 0.006 286);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.22 0.006 286);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.488 0.243 264.376);
--accent-foreground: oklch(0.97 0.014 254.604);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--popover-foreground: oklch(0.22 0.006 286);
/* macOS 系统蓝(≈ #007AFF),仅用于主按钮、选中态、状态点 */
--primary: oklch(0.585 0.205 256.5);
--primary-foreground: oklch(0.99 0.002 286);
--secondary: oklch(0.957 0.004 286);
--secondary-foreground: oklch(0.22 0.006 286);
--muted: oklch(0.957 0.004 286);
--muted-foreground: oklch(0.55 0.012 285);
/* accent 为中性悬停底色 */
--accent: oklch(0.94 0.004 286);
--accent-foreground: oklch(0.22 0.006 286);
--destructive: oklch(0.59 0.24 27.5);
--border: oklch(0.905 0.004 286);
--input: oklch(0.905 0.004 286);
--ring: oklch(0.62 0.19 256.5);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.875rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.424 0.199 265.638);
--accent-foreground: oklch(0.97 0.014 254.604);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--chart-2: oklch(0.7 0.165 254);
--chart-3: oklch(0.585 0.205 256.5);
--chart-4: oklch(0.5 0.21 259);
--chart-5: oklch(0.43 0.19 262);
--radius: 0.625rem;
/* 侧栏半透明材质:vibrancy 开启时透出窗口毛玻璃,关闭时回退近实色 */
--sidebar: oklch(0.962 0.003 286 / 72%);
--sidebar-foreground: oklch(0.22 0.006 286);
--sidebar-primary: oklch(0.585 0.205 256.5);
--sidebar-primary-foreground: oklch(0.99 0.002 286);
--sidebar-accent: oklch(0.88 0.005 286 / 80%);
--sidebar-border: oklch(0.89 0.004 286);
--sidebar-ring: oklch(0.62 0.19 256.5);
/* 动效时长:快 / 标准 / 慢 */
--motion-fast: 150ms;
--motion-base: 220ms;
--motion-slow: 320ms;
}
@layer base {
@@ -127,3 +124,23 @@
@apply font-sans;
}
}
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
html.vibrancy,
html.vibrancy body {
background: transparent;
}
/* 工作台画布底色:比卡片深一档的中性灰,让白色卡片浮起;vibrancy 下半透明透出窗口毛玻璃。 */
@utility canvas-surface {
background-color: oklch(0.934 0.004 286 / 0.85);
}
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
@utility app-drag {
-webkit-app-region: drag;
}
@utility app-no-drag {
-webkit-app-region: no-drag;
}
@@ -0,0 +1,63 @@
import { useState } from "react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
/**
* 通用确认弹窗:用于强制获取、获取指定版本等覆盖类危险操作的二次确认。
*/
export function ConfirmDialog({
title,
description,
confirmLabel,
danger,
onConfirm,
onClose,
}: {
title: string
description: React.ReactNode
confirmLabel: string
danger?: boolean
onConfirm: () => Promise<void> | void
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription asChild>
<div className="text-sm leading-6">{description}</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant={danger ? "destructive" : "default"}
disabled={busy}
onClick={() => {
void (async () => {
setBusy(true)
await onConfirm()
setBusy(false)
})()
}}
>
{busy ? "正在执行…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,427 @@
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { api } from "~/lib/api"
import type { AppConfig, Collection } from "~/lib/api"
import { getServiceStatus } from "~/lib/electron"
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 连接阶段:填写连接信息 / 确认 Collection。
type ConnectPhase = "form" | "collection"
// 连接表单字段,第一版仅支持显式 NTLM 账号密码。
interface ConnectFormState {
serverUri: string
domain: string
username: string
password: string
}
const EMPTY_FORM: ConnectFormState = {
serverUri: "",
domain: "",
username: "",
password: "",
}
/**
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
* macOS 风:左侧毛玻璃品牌区(大 logo + 一句话),右侧表单,带分阶段过渡与最近连接。
*/
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
const [phase, setPhase] = useState<ConnectPhase>("form")
const [form, setForm] = useState<ConnectFormState>(EMPTY_FORM)
const [collections, setCollections] = useState<Collection[]>([])
const [selectedCollection, setSelectedCollection] = useState("")
const [busy, setBusy] = useState(false)
const [busyText, setBusyText] = useState("")
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [recent, setRecent] = useState<RecentServer[]>([])
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
// 每次失败自增,作为 key 触发表单抖动动画重放。
const [shakeCount, setShakeCount] = useState(0)
useEffect(() => {
let active = true
setRecent(loadRecentServers())
void (async () => {
const result = await api.getConfig()
if (!active) {
return
}
const config = result.data?.config
if (config) {
setForm((prev) => ({
serverUri: config.serverUri ?? prev.serverUri,
domain: config.domain ?? prev.domain,
username: config.username ?? prev.username,
password: config.password ?? prev.password,
}))
if (config.collection) {
setSelectedCollection(config.collection)
}
}
})()
void (async () => {
const status = await getServiceStatus()
if (active) {
setServiceOk(status.running)
}
})()
return () => {
active = false
}
}, [])
/**
* 生成受控输入框的 onChange 处理器。
*/
const updateField = (key: keyof ConnectFormState) => (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target
setForm((prev) => ({ ...prev, [key]: value }))
}
/**
* 失败统一处理:展示错误条并触发表单抖动。
*/
const failWith = useCallback((message: string) => {
setError(message)
setShakeCount((count) => count + 1)
}, [])
/**
* 用一条最近连接记录填充表单(不含密码)。
*/
const applyRecent = useCallback((item: RecentServer) => {
setError(null)
setForm((prev) => ({
...prev,
serverUri: item.serverUri,
domain: item.domain,
username: item.username,
}))
}, [])
/**
* 连接 TFS 并加载当前账号可见的 Collection 列表。
*/
const handleConnect = useCallback(async () => {
setError(null)
setBusy(true)
setBusyText("正在连接 TFS…")
const payload: Partial<AppConfig> = {
serverUri: form.serverUri.trim(),
authType: "ntlm-explicit",
domain: form.domain.trim(),
username: form.username.trim(),
password: form.password,
}
const connectResult = await api.connect(payload)
if (!connectResult.ok) {
setBusy(false)
failWith(connectResult.errorMessage ?? "连接失败")
return
}
setBusyText("正在加载 Collection…")
const collectionsResult = await api.listCollections()
setBusy(false)
if (!collectionsResult.ok) {
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
return
}
setRecent(
saveRecentServer({
serverUri: form.serverUri.trim(),
domain: form.domain.trim(),
username: form.username.trim(),
}),
)
const list = collectionsResult.data?.collections ?? []
setCollections(list)
setSelectedCollection((prev) =>
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
)
setPhase("collection")
}, [form, failWith])
/**
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
*/
const handleEnter = useCallback(async () => {
if (!selectedCollection) {
return
}
setError(null)
setBusy(true)
setBusyText("正在确保默认 Workspace…")
const ensureResult = await api.ensureWorkspace({ collection: selectedCollection })
setBusy(false)
const workspace = ensureResult.data?.workspace
if (!ensureResult.ok || !workspace) {
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
return
}
onReady({
serverUri: form.serverUri.trim(),
collection: selectedCollection,
workspace: workspace.name,
mappings: workspace.mappings ?? [],
})
}, [selectedCollection, form.serverUri, onReady, failWith])
const canConnect =
form.serverUri.trim().length > 0 &&
form.username.trim().length > 0 &&
form.password.length > 0
return (
<div className="relative flex min-h-svh">
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
{/* 左侧品牌区:半透明材质(vibrancy 下呈毛玻璃),大 logo + 一句话 */}
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col border-r bg-sidebar md:flex">
<div className="flex flex-1 flex-col items-center justify-center gap-5 p-10">
<img src="/logo.png" alt="MacTFS" className="size-24 drop-shadow-2xl" />
<div className="text-center">
<p className="text-2xl font-semibold tracking-tight">MacTFS</p>
<p className="mt-1.5 text-sm text-muted-foreground">macOS 上的 TFS 源码管理工作台</p>
</div>
</div>
<div className="flex items-center gap-2 p-6 text-xs text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full",
serviceOk == null ? "bg-muted-foreground/50" : serviceOk ? "bg-emerald-500" : "bg-red-500",
)}
/>
{serviceOk == null
? "正在检测本地服务…"
: serviceOk
? "本地服务运行中 · 127.0.0.1:38765"
: "本地服务未就绪"}
</div>
</aside>
{/* 右侧表单区 */}
<main className="flex flex-1 items-center justify-center bg-background p-6">
<div className="w-full max-w-sm">
{phase === "form" && (
<div
key={`form-${shakeCount}`}
className={cn(
"animate-in fade-in slide-in-from-left-2 duration-200",
error != null && "animate-shake",
)}
>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">连接 TFS</h1>
<p className="text-sm text-muted-foreground">
使用域账号连接 Team Foundation Server
</p>
</div>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (canConnect && !busy) {
void handleConnect()
}
}}
>
<div className="space-y-1.5">
<Label htmlFor="serverUri">服务器地址</Label>
<Input
id="serverUri"
placeholder="http://tfs.example.com:8080/tfs"
value={form.serverUri}
onChange={updateField("serverUri")}
disabled={busy}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="domain">域(可选)</Label>
<Input
id="domain"
placeholder="DOMAIN"
value={form.domain}
onChange={updateField("domain")}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
autoComplete="username"
value={form.username}
onChange={updateField("username")}
disabled={busy}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
className="pr-9"
value={form.password}
onChange={updateField("password")}
disabled={busy}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
>
{showPassword ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</button>
</div>
</div>
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
{busy && <Loader2 className="size-4 animate-spin" />}
{busy ? busyText || "处理中…" : "连接 TFS"}
</Button>
</form>
{recent.length > 0 && (
<div className="mt-6 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock3 className="size-3.5" />
最近连接
</p>
<ul className="space-y-1.5">
{recent.map((item) => (
<li key={`${item.serverUri}-${item.username}`}>
<button
type="button"
onClick={() => applyRecent(item)}
disabled={busy}
className="flex w-full items-center justify-between gap-2 rounded-md border bg-card px-3 py-2 text-left shadow-card transition-colors duration-150 hover:bg-muted/60"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs">
{item.serverUri}
</span>
<span className="block text-xs text-muted-foreground">
{item.domain ? `${item.domain} \\ ` : ""}
{item.username}
</span>
</span>
<span className="shrink-0 text-xs text-primary">填充</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
{phase === "collection" && (
<div
key={`collection-${shakeCount}`}
className={cn(
"animate-in fade-in slide-in-from-right-4 duration-200",
error != null && "animate-shake",
)}
>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">选择 Collection</h1>
<p className="text-sm text-muted-foreground">
已连接 <span className="font-mono text-xs">{form.serverUri.trim()}</span>
</p>
</div>
<div className="mt-6 space-y-4">
{collections.length === 0 ? (
<p className="rounded-md border bg-card px-3 py-6 text-center text-sm text-muted-foreground">
当前账号没有可见的 Collection。
</p>
) : (
<ul className="max-h-72 space-y-2 overflow-y-auto">
{collections.map((collection) => {
const active = collection.name === selectedCollection
return (
<li key={collection.id || collection.name}>
<button
type="button"
onClick={() => setSelectedCollection(collection.name)}
disabled={busy}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-3.5 py-3 text-left text-sm transition-colors duration-150",
active
? "border-primary/60 bg-primary/5"
: "bg-card shadow-card hover:bg-muted/60",
)}
>
<span className={cn(active && "font-medium")}>{collection.name}</span>
{active && (
<span className="animate-in zoom-in-75 fade-in flex size-4.5 items-center justify-center rounded-full bg-primary text-primary-foreground duration-150">
<Check className="size-3" />
</span>
)}
</button>
</li>
)
})}
</ul>
)}
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setError(null)
setPhase("form")
}}
disabled={busy}
>
返回
</Button>
<Button
type="button"
onClick={() => void handleEnter()}
disabled={busy || !selectedCollection}
>
{busy && <Loader2 className="size-4 animate-spin" />}
{busy ? busyText || "处理中…" : "进入工作台"}
</Button>
</div>
</div>
</div>
)}
</div>
</main>
</div>
)
}
@@ -0,0 +1,70 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "~/components/ui/context-menu"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { buildFileMenu } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 对象右键菜单容器:包住任意触发区域,按统一动作模型渲染菜单分组,
* 置灰项展示原因后缀,点击可用项回调上层动作分发器。
*/
export function FileTargetMenu({
target,
onAction,
children,
}: {
target: FileTarget
onAction: (target: FileTarget, action: FileActionId) => void
children: React.ReactNode
}) {
const sections = buildFileMenu(target)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
{sections.map((items, index) => (
<Section key={index} withSeparator={index > 0}>
{items.map((item) => (
<ContextMenuItem
key={item.id}
disabled={!item.enabled}
variant={item.danger && item.enabled ? "destructive" : "default"}
onSelect={() => onAction(target, item.id)}
>
<span>{item.label}</span>
{!item.enabled && item.reason && (
<span className={cn("ml-auto pl-3 text-xs text-muted-foreground")}>
{item.reason}
</span>
)}
</ContextMenuItem>
))}
</Section>
))}
</ContextMenuContent>
</ContextMenu>
)
}
/**
* 菜单分组:非首组前自动插入分隔线。
*/
function Section({
withSeparator,
children,
}: {
withSeparator: boolean
children: React.ReactNode
}) {
return (
<>
{withSeparator && <ContextMenuSeparator />}
{children}
</>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { FolderCog, PanelBottom, PanelLeft, PanelRight, Unplug } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility {
tree: boolean
changes: boolean
console: boolean
}
/**
* 顶部上下文栏:仿 Finder 的居中上下文胶囊展示 Collection / Workspace
* (悬停查看服务器地址),右侧为面板折叠开关与重新连接入口,不承载对象级操作。
*/
export function TopBar({
session,
panels,
onTogglePanel,
onReconnect,
onManageWorkspace,
}: {
session: WorkspaceSession
panels: PanelVisibility
onTogglePanel: (panel: keyof PanelVisibility) => void
onReconnect: () => void
onManageWorkspace: () => void
}) {
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
const macInset = isMacElectron()
return (
<header
className={cn(
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
macInset && "pl-20",
)}
>
{/* 左侧:程序名 + Collection 胶囊;服务器地址与工作区收进悬停提示 */}
<div className="flex min-w-0 items-center gap-2.5">
<span className="shrink-0 text-sm font-medium">MacTFS</span>
<Tooltip>
<TooltipTrigger asChild>
<div className="app-no-drag flex h-7 min-w-0 items-center rounded-lg bg-foreground/5 px-3 text-xs text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground">
<span className="truncate">{session.collection}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="space-y-0.5">
<p>
服务器 <span className="font-mono">{session.serverUri}</span>
</p>
<p>
工作区 <span className="font-mono">{session.workspace}</span>
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<div className="app-no-drag flex shrink-0 items-center gap-1">
<PanelToggle
label="源码目录面板"
active={panels.tree}
onClick={() => onTogglePanel("tree")}
>
<PanelLeft />
</PanelToggle>
<PanelToggle
label="挂起更改面板"
active={panels.changes}
onClick={() => onTogglePanel("changes")}
>
<PanelRight />
</PanelToggle>
<PanelToggle
label="操作日志面板"
active={panels.console}
onClick={() => onTogglePanel("console")}
>
<PanelBottom />
</PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" />
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
<FolderCog />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">工作区与映射管理</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onReconnect} aria-label="重新连接">
<Unplug />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">重新连接</TooltipContent>
</Tooltip>
</div>
</header>
)
}
/**
* 面板折叠开关按钮:图标 + tooltip,激活态用底色区分。
*/
function PanelToggle({
label,
active,
onClick,
children,
}: {
label: string
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-pressed={active}
onClick={onClick}
className={cn(!active && "text-muted-foreground")}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{active ? `收起${label}` : `展开${label}`}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,189 @@
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 { ConflictDialog } from "~/components/explorer/conflict-dialog"
import { DiffDialog } from "~/components/explorer/diff-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 { 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 { MappingInfo } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
/**
* 工作台业务弹窗出口:按编排 hook 的弹窗状态渲染对应弹窗,
* 自身不持有业务状态,所有回调回流到动作编排层。
*/
export function WorkspaceDialogs({
dialog,
mappings,
onClose,
onOpen,
onFileAction,
onMappingCreated,
onConflictsResolved,
onForceGetConfirmed,
onGetVersion,
onRenameConfirmed,
onRollback,
onBranchConfirmed,
onMergeConfirmed,
onAddFilesConfirmed,
}: {
dialog: WorkspaceDialogState
mappings: MappingInfo[]
onClose: () => void
onOpen: (dialog: WorkspaceDialogState) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
onMappingCreated: (mappings: MappingInfo[]) => void
onConflictsResolved: () => void
onForceGetConfirmed: (serverPath: string, folder: boolean) => Promise<void>
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) {
return null
}
switch (dialog.kind) {
case "mapping":
return (
<MappingDialog
serverPath={dialog.serverPath}
onClose={onClose}
onCreated={onMappingCreated}
/>
)
case "history":
return (
<HistoryDialog
serverPath={dialog.serverPath}
folder={dialog.folder}
onClose={onClose}
onDiffRevisions={(serverPath, sourceChangeset, targetChangeset) =>
onOpen({
kind: "diff",
request: { mode: "revisions", serverPath, sourceChangeset, targetChangeset },
})
}
onGetVersion={onGetVersion}
onRollback={onRollback}
/>
)
case "compare":
return (
<CompareDialog
serverPath={dialog.serverPath}
mappings={mappings}
onClose={onClose}
onFileAction={onFileAction}
/>
)
case "viewFile":
return (
<FileViewDialog
serverPath={dialog.serverPath}
localPath={dialog.localPath}
changeset={dialog.changeset}
onClose={onClose}
/>
)
case "diff":
return <DiffDialog request={dialog.request} onClose={onClose} />
case "conflicts":
return (
<ConflictDialog
serverPath={dialog.serverPath}
onClose={onClose}
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":
return (
<ConfirmDialog
title="强制获取最新"
description={
<>
<p>
将对 <span className="font-mono text-xs">{dialog.serverPath}</span>{" "}
强制获取服务器最新版本。
</p>
<p className="mt-2 font-medium text-destructive">
本地未签入的修改会被服务器版本直接覆盖,且无法恢复。确定继续吗?
</p>
</>
}
confirmLabel="覆盖本地并获取"
danger
onConfirm={() => onForceGetConfirmed(dialog.serverPath, dialog.folder)}
onClose={onClose}
/>
)
}
}
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from "react"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
import { FolderItemsPanel } from "~/components/explorer/folder-items-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 { ConsolePanel } from "~/components/logs/console-panel"
import { Toaster, showToast } from "~/components/ui/toaster"
import { TooltipProvider } from "~/components/ui/tooltip"
import { useFileActions } from "~/hooks/use-file-actions"
import { usePendingChanges } from "~/hooks/use-pending-changes"
import type { MappingInfo } from "~/lib/api"
import type { WorkspaceSession } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 三栏工作台外壳:组合顶部上下文栏、左树、中间列表、右侧 Changes、底部 Console。
* 挂起更改状态与对象动作编排分别收口在 usePendingChanges / useFileActions。
*/
export function WorkspaceShell({
session,
selectedServerPath,
onNavigate,
onMappingsChanged,
onReconnect,
}: {
session: WorkspaceSession
selectedServerPath: string
onNavigate: (serverPath: string) => void
onMappingsChanged: (mappings: MappingInfo[]) => void
onReconnect: () => void
}) {
const [panels, setPanels] = useState<PanelVisibility>({
tree: true,
changes: true,
console: true,
})
// 中间列表 / 底部日志刷新令牌:文件操作完成后递增触发重新加载。
const [itemsRefreshToken, setItemsRefreshToken] = useState(0)
const [logsRefreshToken, setLogsRefreshToken] = useState(0)
// 工作区集中管理弹窗显隐。
const [manageOpen, setManageOpen] = useState(false)
/**
* 切换指定面板的展开 / 收起状态。
*/
const togglePanel = useCallback((panel: keyof PanelVisibility) => {
setPanels((prev) => ({ ...prev, [panel]: !prev[panel] }))
}, [])
/**
* 触发中间目录列表重新加载。
*/
const refreshItems = useCallback(() => {
setItemsRefreshToken((token) => token + 1)
}, [])
/**
* 触发底部操作日志重新加载。
*/
const refreshLogs = useCallback(() => {
setLogsRefreshToken((token) => token + 1)
}, [])
const pending = usePendingChanges()
const actions = useFileActions({
onMappingsChanged,
refreshPendingChanges: pending.refresh,
refreshItems,
refreshLogs,
})
// 把动作编排的 notice 转成右上角 toast:固定 id 让「正在…」与结果复用同一条目。
useEffect(() => {
if (!actions.notice) {
return
}
const { kind, text } = actions.notice
showToast({
id: "action",
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
text,
})
}, [actions.notice])
return (
<TooltipProvider>
{/* 毛玻璃画布 + 卡片化面板:半透明画布透出 vibrancy,四个面板为圆角卡片留缝隙 */}
<div className="canvas-surface flex h-svh flex-col overflow-hidden">
<TopBar
session={session}
panels={panels}
onTogglePanel={togglePanel}
onReconnect={onReconnect}
onManageWorkspace={() => setManageOpen(true)}
/>
<div className="flex min-h-0 flex-1 px-2 pb-2">
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
<div
className={cn(
"flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
)}
>
<SourceTreePanel
collection={session.collection}
mappings={session.mappings}
selectedServerPath={selectedServerPath}
onNavigate={onNavigate}
onFileAction={actions.handleFileAction}
/>
</div>
<FolderItemsPanel
session={session}
selectedServerPath={selectedServerPath}
pendingByServerPath={pending.pendingByServerPath}
refreshToken={itemsRefreshToken}
onNavigate={onNavigate}
onFileAction={actions.handleFileAction}
onRefresh={refreshItems}
/>
<div
className={cn(
"flex min-h-0 shrink-0 justify-end overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
panels.changes ? "ml-2 w-[340px]" : "ml-0 w-0",
)}
>
<ChangesPanel
mappings={session.mappings}
pendingChanges={pending.pendingChanges}
excludedKeys={pending.excludedKeys}
loading={pending.loading}
error={pending.error}
checkinBusy={actions.checkinBusy}
onToggleExcluded={pending.toggleExcluded}
onCheckin={actions.handleCheckin}
onFileAction={actions.handleFileAction}
onRefresh={() => void pending.refresh()}
/>
</div>
</div>
{/* 底部操作台通过高度过渡实现滑入滑出 */}
<div
className={cn(
"box-border shrink-0 overflow-hidden px-2 transition-[height] duration-250 ease-out-quart",
panels.console ? "h-[188px] pb-2" : "h-0",
)}
>
<ConsolePanel
refreshToken={logsRefreshToken}
busy={actions.actionBusy || actions.checkinBusy}
busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
/>
</div>
<WorkspaceDialogs
dialog={actions.dialog}
mappings={session.mappings}
onClose={() => actions.setDialog(null)}
onOpen={actions.setDialog}
onFileAction={actions.handleFileAction}
onMappingCreated={actions.handleMappingCreated}
onConflictsResolved={actions.handleConflictsResolved}
onForceGetConfirmed={actions.handleForceGetConfirmed}
onGetVersion={actions.runGetVersion}
onRenameConfirmed={actions.handleRenameConfirmed}
onRollback={actions.runRollback}
onBranchConfirmed={actions.handleBranchConfirmed}
onMergeConfirmed={actions.handleMergeConfirmed}
onAddFilesConfirmed={actions.handleAddFilesConfirmed}
/>
{manageOpen && (
<WorkspaceManageDialog
session={session}
onClose={() => setManageOpen(false)}
onMappingsChanged={(mappings) => {
// Mapping 变化影响树映射标识与列表状态列,统一刷新。
onMappingsChanged(mappings)
refreshItems()
}}
/>
)}
<Toaster />
</div>
</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>
)
}
@@ -0,0 +1,310 @@
import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
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, MappingInfo } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
*/
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
export function CompareDialog({
serverPath,
mappings,
onClose,
onFileAction,
}: {
serverPath: string
mappings: MappingInfo[]
onClose: () => 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(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,
includeLocalOnly: !ignoreLocalOnly,
})
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "目录对比失败")
return
}
setDiffs(result.data?.diffs ?? [])
}, [serverPath, ignoreLocalOnly])
/**
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
* 让结果状态(如 签出编辑)立即反映最新事实。
*/
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(() => {
const set = new Set<string>()
for (const diff of diffs) {
if (diff.status !== "upToDate") {
set.add(diff.status)
}
}
return [...set]
}, [diffs])
const visibleDiffs = useMemo(
() =>
diffs.filter((diff) => {
if (diff.status === "upToDate") {
return showUpToDate && statusFilter.size === 0
}
return statusFilter.size === 0 || statusFilter.has(diff.status)
}),
[diffs, showUpToDate, statusFilter],
)
/**
* 切换某个状态筛选 chip。
*/
const toggleStatus = useCallback((status: string) => {
setStatusFilter((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
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>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{presentStatuses.map((status) => (
<button key={status} type="button" onClick={() => toggleStatus(status)}>
<Badge
variant="secondary"
className={cn(
"cursor-pointer rounded-md",
statusBadgeClass(status),
statusFilter.size > 0 && !statusFilter.has(status) && "opacity-40",
)}
>
{statusLabel(status)}
</Badge>
</button>
))}
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
checked={showUpToDate}
onCheckedChange={(value) => setShowUpToDate(value === true)}
/>
显示最新项
</label>
<Button
variant="outline"
size="sm"
onClick={() => setPhase("setup")}
disabled={loading}
>
调整选项
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void runCompare()}
disabled={loading}
>
<RefreshCw data-icon="inline-start" className={cn(loading && "animate-spin")} />
重新对比
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在对比目录…
</div>
) : error ? (
<div className="flex h-48 items-center justify-center text-sm text-destructive">
{error}
</div>
) : visibleDiffs.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
{diffs.length === 0 ? "本地与服务端一致,没有差异" : "没有符合筛选条件的差异项"}
</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 w-28 text-xs text-muted-foreground">状态</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">本地版本</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">最新版本</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleDiffs.map((diff) => {
const target = makeFileTarget({
source: "compare",
folder: diff.folder,
serverPath: diff.serverPath,
mappings,
pendingStatus: diff.status.startsWith("pending") ? diff.status : null,
compareStatus: diff.status,
})
return (
<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">
{diff.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">{diff.name}</span>
</span>
</TableCell>
<TableCell className="py-1.5">
<Badge
variant="secondary"
className={cn("rounded-md", statusBadgeClass(diff.status))}
>
{statusLabel(diff.status)}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.localVersion > 0 ? diff.localVersion : "—"}
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.latestVersion > 0 ? diff.latestVersion : "—"}
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
title={diff.serverPath}
>
{diff.serverPath}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
{visibleDiffs.length} / {diffs.length} 项 · 差异项操作通过右键菜单触发
</p>
</DialogContent>
</Dialog>
)
}
@@ -1,169 +1,303 @@
import { useEffect, useState } from "react"
import { GitCompareArrows, Loader2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { FileDiff, Loader2 } from "lucide-react"
import { DiffDialog, type DiffRequest } from "~/components/explorer/diff-dialog"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { applyConflictChoice } from "~/lib/api/endpoints"
import type { TfsConflictInfo } from "~/lib/api/types"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { ConflictInfo, ConflictResolution } from "~/lib/api"
type ConflictChoice = "useServer" | "keepLocal" | "autoMerge"
interface ConflictDialogProps {
conflicts: TfsConflictInfo[]
open: boolean
onOpenDiff(conflict: TfsConflictInfo): void
onApplied(): void
}
const CHOICE_LABELS: Record<ConflictChoice, string> = {
useServer: "使用服务器版本",
keepLocal: "保留本地版本",
autoMerge: "自动合并",
}
// 每个冲突的取舍选择:未选择 / 采用服务器版本 / 保留本地版本。
type ChoiceValue = "none" | ConflictResolution
/**
* 统一处理 Get Latest / Checkout 返回的冲突明细,允许逐文件或批量选择处理方式。
* 统一冲突弹窗:Get Latest 与 Checkout 共用。
* 支持逐文件选择服务器版本或保留本地版本、批量设置、冲突文件 Diff、批量应用。
*/
export function ConflictDialog({
conflicts,
open,
onOpenDiff,
onApplied,
}: ConflictDialogProps) {
const [choices, setChoices] = useState<Record<string, ConflictChoice>>({})
serverPath,
onClose,
onResolved,
}: {
serverPath: string
onClose: () => void
onResolved: () => void
}) {
const [conflicts, setConflicts] = useState<ConflictInfo[]>([])
const [choices, setChoices] = useState<Record<number, ChoiceValue>>({})
const [loading, setLoading] = useState(true)
const [applying, setApplying] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState<string | null>(null)
// 应用结果摘要:conflictId -> 是否成功。
const [applied, setApplied] = useState<Record<number, boolean>>({})
const [diffRequest, setDiffRequest] = useState<DiffRequest | null>(null)
// 是否发生过成功应用,关闭时据此通知上层刷新。
const [didResolve, setDidResolve] = useState(false)
/**
* 加载当前范围内的未解决冲突。
*/
const loadConflicts = useCallback(async () => {
setLoading(true)
const result = await api.listConflicts({ serverPath, recursive: true })
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "冲突明细加载失败")
return
}
setError(null)
const list = (result.data?.conflicts ?? []).filter((item) => !item.resolved)
setConflicts(list)
setChoices({})
setApplied({})
}, [serverPath])
useEffect(() => {
if (open) {
const nextChoices: Record<string, ConflictChoice> = {}
conflicts.forEach((conflict) => {
nextChoices[conflict.serverPath] = "keepLocal"
void loadConflicts()
}, [loadConflicts])
const chosenCount = useMemo(
() => Object.values(choices).filter((value) => value !== "none").length,
[choices],
)
/**
* 批量把所有冲突设置为同一取舍。
*/
const setAll = useCallback(
(value: ConflictResolution) => {
setChoices(() => {
const next: Record<number, ChoiceValue> = {}
for (const conflict of conflicts) {
next[conflict.conflictId] = value
}
return next
})
setChoices(nextChoices)
setMessage("")
}
}, [open, conflicts])
},
[conflicts],
)
/**
* 批量设置冲突选择,之后仍允许用户逐文件改选。
* 应用所有已选择的取舍,逐个调用后端并记录结果。
*/
function setAll(choice: ConflictChoice) {
const nextChoices: Record<string, ConflictChoice> = {}
conflicts.forEach((conflict) => {
nextChoices[conflict.serverPath] = choice
})
setChoices(nextChoices)
}
/**
* 按当前选择逐个调用后端应用冲突处理结果。
*/
async function applyChoices() {
const applyChoices = useCallback(async () => {
setApplying(true)
setMessage("")
setError(null)
const results: Record<number, boolean> = {}
let resolvedAny = false
for (const conflict of conflicts) {
const choice = choices[conflict.serverPath] || "keepLocal"
const result = await applyConflictChoice({
serverPath: conflict.serverPath,
choice,
const choice = choices[conflict.conflictId]
if (!choice || choice === "none") {
continue
}
const result = await api.applyConflict({
conflictId: conflict.conflictId,
resolution: choice,
})
if (!result.success) {
setApplying(false)
setMessage(result.errorMessage || result.message)
return
const success = result.ok && result.data?.resolution?.resolved === true
results[conflict.conflictId] = success
if (success) {
resolvedAny = true
}
}
setApplying(false)
onApplied()
}
setApplied(results)
if (resolvedAny) {
setDidResolve(true)
}
const failures = Object.values(results).filter((value) => !value).length
if (failures > 0) {
setError(`${failures} 个冲突应用失败,可重试`)
await loadConflicts()
return
}
// 全部成功:重新加载剩余冲突,没有剩余则关闭并通知刷新。
const remain = await api.listConflicts({ serverPath, recursive: true })
const remaining = (remain.data?.conflicts ?? []).filter((item) => !item.resolved)
if (remaining.length === 0) {
onResolved()
return
}
setConflicts(remaining)
setChoices({})
setApplied({})
}, [conflicts, choices, serverPath, loadConflicts, onResolved])
if (!open) {
return null
}
/**
* 关闭弹窗:若期间有成功应用,通知上层刷新状态。
*/
const handleClose = useCallback(() => {
if (didResolve) {
onResolved()
} else {
onClose()
}
}, [didResolve, onResolved, onClose])
return (
<div className="flex h-[min(640px,calc(100svh-96px))] min-h-0 flex-col">
<div className="flex min-h-10 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs">
<div className="text-muted-foreground">{conflicts.length} 个冲突文件</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="xs" variant="outline" onClick={() => setAll("useServer")}>
全部使用服务器版本
</Button>
<Button size="xs" variant="outline" onClick={() => setAll("keepLocal")}>
全部保留本地版本
</Button>
</div>
</div>
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>处理冲突</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
{message && (
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
<div className="grid h-8 shrink-0 grid-cols-[minmax(260px,1fr)_160px_120px_160px_80px] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">文件</div>
<div className="flex items-center">本地路径</div>
<div className="flex items-center">大小</div>
<div className="flex items-center">选择</div>
<div className="flex items-center">Diff</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{conflicts.map((conflict) => (
<div
key={conflict.serverPath}
className="grid min-h-9 grid-cols-[minmax(260px,1fr)_160px_120px_160px_80px] items-center border-b px-3 text-xs"
>
<div className="truncate font-mono">{conflict.serverPath}</div>
<div className="truncate font-mono text-muted-foreground">
{conflict.localPath || "-"}
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
{conflicts.length} 个冲突
</Badge>
<span className="text-xs text-muted-foreground">
逐项选择,或批量设置后统一应用
</span>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("takeServer")}
>
全部采用服务器
</Button>
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("keepLocal")}
>
全部保留本地
</Button>
</div>
<div className="font-mono text-muted-foreground">
{conflict.fileSize}
</div>
<select
className="h-7 rounded-[6px] border bg-background px-2"
value={choices[conflict.serverPath] || "keepLocal"}
onChange={(event) =>
setChoices((currentChoices) => ({
...currentChoices,
[conflict.serverPath]: event.target.value as ConflictChoice,
}))
}
>
{Object.entries(CHOICE_LABELS)
.filter(
([choice]) =>
choice !== "autoMerge" || conflict.autoMergeable
)
.map(([choice, label]) => (
<option key={choice} value={choice}>
{label}
</option>
))}
</select>
<Button
size="xs"
variant="outline"
disabled={!conflict.renderable}
onClick={() => onOpenDiff(conflict)}
>
<GitCompareArrows />
Diff
</Button>
</div>
))}
</div>
<div className="flex h-11 shrink-0 justify-end gap-2 border-t px-3 py-2">
<Button size="sm" disabled={applying} onClick={applyChoices}>
{applying && <Loader2 className="animate-spin" />}
应用选择
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载冲突…
</div>
) : conflicts.length === 0 ? (
<div className="flex h-48 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 text-xs text-muted-foreground">文件</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 w-44 text-xs text-muted-foreground">取舍</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conflicts.map((conflict) => {
const choice = choices[conflict.conflictId] ?? "none"
const appliedState = applied[conflict.conflictId]
const canDiff =
conflict.serverPath != null && conflict.localPath != null
return (
<TableRow key={conflict.conflictId} className="select-none">
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={conflict.serverPath ?? conflict.localPath ?? ""}
>
{conflict.serverPath ?? conflict.localPath ?? `冲突 ${conflict.conflictId}`}
{appliedState === false && (
<span className="ml-2 text-destructive">应用失败</span>
)}
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{conflict.type}
</TableCell>
<TableCell className="py-1.5">
<Select
value={choice}
onValueChange={(value) =>
setChoices((prev) => ({
...prev,
[conflict.conflictId]: value as ChoiceValue,
}))
}
disabled={applying}
>
<SelectTrigger size="sm" className="w-40 text-xs">
<SelectValue placeholder="选择取舍" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">暂不处理</SelectItem>
<SelectItem value="takeServer">采用服务器版本</SelectItem>
<SelectItem value="keepLocal">保留本地版本</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon-sm"
disabled={!canDiff}
title={canDiff ? "查看差异" : "缺少路径信息,无法对比"}
onClick={() =>
setDiffRequest({
mode: "localLatest",
serverPath: conflict.serverPath as string,
localPath: conflict.localPath as string,
})
}
>
<FileDiff />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
<DialogFooter className="shrink-0">
<Button variant="ghost" onClick={handleClose} disabled={applying}>
稍后处理
</Button>
<Button
onClick={() => void applyChoices()}
disabled={applying || chosenCount === 0}
>
{applying ? "正在应用…" : `应用选择(${chosenCount})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{diffRequest && (
<DiffDialog request={diffRequest} onClose={() => setDiffRequest(null)} />
)}
</>
)
}
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { editor as MonacoEditor } from "monaco-editor"
import { ChevronDown, ChevronUp, Loader2, Search } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { api } from "~/lib/api"
import type { TextDiff } from "~/lib/api"
import { cn } from "~/lib/utils"
// Diff 请求:本地 vs latest,或同一服务端文件两个历史版本。
export type DiffRequest =
| { mode: "localLatest"; serverPath: string; localPath: string }
| { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number }
// 视图模式在同一会话内记忆。
const VIEW_MODE_KEY = "mactfs-diff-view-mode"
/**
* 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本:
* 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。
*/
function splitDiffSides(raw: string[]): { original: string; modified: string } {
const original: string[] = []
const modified: string[] = []
for (const line of raw) {
const prefix = line.charAt(0)
const text = line.slice(1)
if (prefix === "-") {
original.push(text)
} else if (prefix === "+") {
modified.push(text)
} else {
original.push(text)
modified.push(text)
}
}
return { original: original.join("\n"), modified: modified.join("\n") }
}
/**
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
*/
export function DiffDialog({
request,
onClose,
}: {
request: DiffRequest
onClose: () => void
}) {
const [diff, setDiff] = useState<TextDiff | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyChanges, setOnlyChanges] = useState(false)
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
return "unified"
}
return "split"
})
// 差异块总数与当前导航到的差异块序号。
const [blockCount, setBlockCount] = useState(0)
const [blockIndex, setBlockIndex] = useState(-1)
const containerRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
useEffect(() => {
let active = true
setLoading(true)
setError(null)
void (async () => {
const result =
request.mode === "localLatest"
? await api.diffLocalLatest({
serverPath: request.serverPath,
localPath: request.localPath,
})
: await api.diffRevisions({
serverPath: request.serverPath,
sourceChangeset: request.sourceChangeset,
targetChangeset: request.targetChangeset,
})
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setError(result.errorMessage ?? "Diff 加载失败")
return
}
setDiff(result.data.diff)
})()
return () => {
active = false
}
}, [request])
const changedCount = useMemo(
() => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length,
[diff],
)
// 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。
useEffect(() => {
if (!diff) {
return
}
let disposed = false
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
let originalModel: MonacoEditor.ITextModel | null = null
let modifiedModel: MonacoEditor.ITextModel | null = null
void (async () => {
const { monaco, detectLanguage } = await import("~/lib/monaco")
if (disposed || !containerRef.current) {
return
}
const { original, modified } = splitDiffSides(diff.lines)
const language = detectLanguage(request.serverPath)
originalModel = monaco.editor.createModel(original, language)
modifiedModel = monaco.editor.createModel(modified, language)
diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
readOnly: true,
originalEditable: false,
automaticLayout: true,
renderSideBySide: viewMode === "split",
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
compactMode: viewMode === "unified",
hideUnchangedRegions: { enabled: onlyChanges },
theme: "mactfs-light",
fontSize: 12,
lineHeight: 20,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderOverviewRuler: true,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
diffWordWrap: "off",
diffAlgorithm: "advanced",
renderLineHighlight: "none",
folding: false,
glyphMargin: false,
guides: { indentation: false },
padding: { top: 8, bottom: 8 },
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false },
renderMarginRevertIcon: false,
})
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
diffEditor.onDidUpdateDiff(() => {
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
})
editorRef.current = diffEditor
})()
return () => {
disposed = true
editorRef.current = null
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
setBlockCount(0)
setBlockIndex(-1)
}
}, [diff, request.serverPath])
/**
* 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。
*/
const switchView = useCallback((mode: "unified" | "split") => {
setViewMode(mode)
editorRef.current?.updateOptions({
renderSideBySide: mode === "split",
compactMode: mode === "unified",
})
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(VIEW_MODE_KEY, mode)
}
}, [])
/**
* 切换「仅看差异」:折叠 / 展开未变动区域。
*/
const switchOnlyChanges = useCallback((value: boolean) => {
setOnlyChanges(value)
editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } })
}, [])
/**
* 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。
*/
const goToBlock = useCallback(
(direction: 1 | -1) => {
const diffEditor = editorRef.current
const changes = diffEditor?.getLineChanges() ?? []
if (!diffEditor || changes.length === 0) {
return
}
const next = (blockIndex + direction + changes.length) % changes.length
setBlockIndex(next)
const change = changes[next]
const line =
change.modifiedStartLineNumber > 0
? change.modifiedStartLineNumber
: Math.max(change.modifiedEndLineNumber, 1)
const modifiedEditor = diffEditor.getModifiedEditor()
modifiedEditor.revealLineInCenter(line)
modifiedEditor.setPosition({ lineNumber: line, column: 1 })
modifiedEditor.focus()
},
[blockIndex],
)
/**
* 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。
*/
const openSearch = useCallback(() => {
const modifiedEditor = editorRef.current?.getModifiedEditor()
modifiedEditor?.focus()
void modifiedEditor?.getAction("actions.find")?.run()
}, [])
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
<DialogHeader>
<DialogTitle>
{request.mode === "localLatest" ? "服务器 latest vs 本地" : "历史版本对比"}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{request.serverPath}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
{diff && (
<>
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
− {diff.sourceLabel}
</Badge>
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700">
+ {diff.targetLabel}
</Badge>
<span className="text-muted-foreground">{changedCount} 行差异</span>
</>
)}
<div className="ml-2 flex items-center rounded-md border p-0.5">
<ViewTab label="统一视图" active={viewMode === "unified"} onClick={() => switchView("unified")} />
<ViewTab label="左右分栏" active={viewMode === "split"} onClick={() => switchView("split")} />
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
disabled={blockCount === 0}
onClick={() => goToBlock(-1)}
aria-label="上一处差异"
>
<ChevronUp />
</Button>
<Button
variant="outline"
size="icon-sm"
disabled={blockCount === 0}
onClick={() => goToBlock(1)}
aria-label="下一处差异"
>
<ChevronDown />
</Button>
<span className="text-muted-foreground">
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
</span>
</div>
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
<Checkbox
checked={onlyChanges}
onCheckedChange={(value) => switchOnlyChanges(value === true)}
/>
仅看差异
</label>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={openSearch}>
<Search className="size-3.5" />
搜索
</Button>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border">
{loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在生成 Diff…
</div>
) : error ? (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-destructive">
{error}
</div>
) : changedCount === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
两个版本内容一致
</div>
) : (
<div ref={containerRef} className="absolute inset-0" />
)}
</div>
</DialogContent>
</Dialog>
)
}
/**
* 视图切换小标签按钮。
*/
function ViewTab({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-[5px] px-2 py-0.5 text-xs",
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
)}
>
{label}
</button>
)
}
@@ -0,0 +1,111 @@
import {
Database,
File,
FileArchive,
FileCode,
FileImage,
FileJson,
FileSpreadsheet,
FileTerminal,
FileText,
FolderClosed,
FolderOpen,
FolderSymlink,
type LucideIcon,
} from "lucide-react"
import { cn } from "~/lib/utils"
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
{
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
icon: FileCode,
className: "text-blue-600",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600",
},
]
/**
* 取文件名扩展名(小写,无点);无扩展名返回空串。
*/
function extOf(name: string): string {
const index = name.lastIndexOf(".")
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
}
/**
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
*/
export function FileIcon({
name,
folder,
mapped = false,
expanded = false,
className,
}: {
name: string
folder: boolean
mapped?: boolean
expanded?: boolean
className?: string
}) {
if (folder) {
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
return (
<Icon
className={cn(
"size-3.5 shrink-0",
mapped ? "text-primary" : "text-sky-600",
className,
)}
/>
)
}
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
const Icon = rule?.icon ?? File
return (
<Icon
className={cn(
"size-3.5 shrink-0",
rule?.className ?? "text-muted-foreground",
className,
)}
/>
)
}
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react"
import { Loader2 } from "lucide-react"
import { Badge } from "~/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { api } from "~/lib/api"
import type { FileContent } from "~/lib/api"
import { cn } from "~/lib/utils"
// 查看来源:本地映射文件或服务器 latest。
type ViewSource = "local" | "server"
/**
* 把字节数格式化为可读大小。
*/
function formatSize(size: number): string {
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
/**
* 文件查看弹窗:只读展示本地、服务器 latest 或指定 changeset 版本内容,
* 支持来源切换、行号与搜索高亮;二进制 / 超大 / 非映射路径给出明确提示。
*/
export function FileViewDialog({
serverPath,
localPath,
changeset,
onClose,
}: {
serverPath: string
localPath: string | null
/** 指定 changeset 时固定展示该版本内容,不提供来源切换。 */
changeset?: number
onClose: () => void
}) {
const [source, setSource] = useState<ViewSource>(localPath && changeset == null ? "local" : "server")
const [content, setContent] = useState<FileContent | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
// 加载所选来源的文件内容;本地内容仅在已映射时可用;指定版本固定按 changeset 读取。
useEffect(() => {
let active = true
setLoading(true)
setError(null)
void (async () => {
const result =
changeset != null
? await api.getFileContent({ serverPath, changeset })
: source === "local" && localPath
? await api.getFileContent({ localPath })
: await api.getFileContent({ serverPath })
if (!active) {
return
}
setLoading(false)
if (!result.ok || !result.data) {
setContent(null)
setError(result.errorMessage ?? "文件内容加载失败")
return
}
setContent(result.data.content)
})()
return () => {
active = false
}
}, [source, serverPath, localPath, changeset])
const lines = useMemo(
() => (content && !content.binary && !content.tooLarge ? content.content.split(/\r?\n/) : []),
[content],
)
const keyword = search.trim().toLowerCase()
const matchCount = useMemo(
() =>
keyword.length === 0
? 0
: lines.filter((line) => line.toLowerCase().includes(keyword)).length,
[lines, keyword],
)
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[90svh] max-h-[90svh] flex-col sm:max-w-[92vw]">
<DialogHeader>
<DialogTitle>查看文件</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{changeset != null ? (
<Badge variant="secondary" className="rounded-md">
版本 C{changeset}
</Badge>
) : (
<div className="flex items-center rounded-md border p-0.5">
<SourceTab
label="本地文件"
active={source === "local"}
disabled={!localPath}
disabledReason="未映射到本地"
onClick={() => setSource("local")}
/>
<SourceTab
label="服务器 latest"
active={source === "server"}
onClick={() => setSource("server")}
/>
</div>
)}
{content && (
<span className="text-xs text-muted-foreground">
{formatSize(content.size)}
{content.changeset > 0 && ` · 版本 ${content.changeset}`}
</span>
)}
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索内容…"
className="ml-auto h-7 w-48 text-xs"
/>
{keyword.length > 0 && (
<Badge variant="secondary" className="rounded-md">
{matchCount} 行命中
</Badge>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
{loading ? (
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载内容…
</div>
) : error ? (
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
{error}
</div>
) : content?.binary ? (
<Notice text={`二进制文件不支持预览(${formatSize(content.size)})`} />
) : content?.tooLarge ? (
<Notice text={`文件过大(${formatSize(content.size)}),不直接渲染内容`} />
) : (
<pre className="min-w-full font-mono text-xs leading-5">
{lines.map((line, index) => {
const hit = keyword.length > 0 && line.toLowerCase().includes(keyword)
return (
<div
key={index}
className={cn("flex", hit && "bg-amber-500/15")}
>
<span className="w-12 shrink-0 border-r pr-2 text-right text-muted-foreground/60 select-none">
{index + 1}
</span>
<span className="whitespace-pre-wrap break-all pl-2">{line}</span>
</div>
)
})}
</pre>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
只读查看 · 来源:
{changeset != null
? `changeset ${changeset} 版本`
: source === "local"
? `本地 ${localPath ?? ""}`
: "服务器 latest"}
</p>
</DialogContent>
</Dialog>
)
}
/**
* 来源切换小标签按钮。
*/
function SourceTab({
label,
active,
disabled,
disabledReason,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
disabledReason?: string
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
title={disabled ? disabledReason : undefined}
onClick={onClick}
className={cn(
"rounded-[5px] px-2 py-0.5 text-xs",
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
>
{label}
</button>
)
}
/**
* 内容区占位提示(二进制 / 超大文件)。
*/
function Notice({ text }: { text: string }) {
return (
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-muted-foreground">
{text}
</div>
)
}
@@ -1,663 +1,299 @@
import { useEffect, useState } from "react"
import { FileText, Folder, Loader2, RefreshCw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
import { ContextMenu } from "~/components/app/context-menu"
import type { ContextMenuItem, ContextMenuState } from "~/components/app/context-menu"
import { SimpleDialog } from "~/components/app/simple-dialog"
import { Button } from "~/components/ui/button"
import { ConflictDialog } from "~/components/explorer/conflict-dialog"
import { DiffPanel } from "~/components/explorer/diff-panel"
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
import { FileViewerDialog } from "~/components/explorer/file-viewer-dialog"
import { FolderCompareDialog } from "~/components/explorer/folder-compare-dialog"
import { HistoryPanel } from "~/components/explorer/history-panel"
import { MappingDialog } from "~/components/explorer/mapping-dialog"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge"
import {
checkoutFiles,
deleteFiles,
getLatest,
listMappings,
listPendingChanges,
listServerFolderItems,
undoFiles,
} from "~/lib/api/endpoints"
import type {
FolderDiffItem,
TfsConflictInfo,
TfsMappingInfo,
TfsPendingChangeInfo,
TfsServerItem,
} from "~/lib/api/types"
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { ServerItem } from "~/lib/api"
import { pathsExist } from "~/lib/electron"
import type { FileActionId, FileTarget, WorkspaceSession } from "~/lib/tfs"
import {
makeFileTarget,
resolveItemLocalState,
resolveLocalPath,
statusBadgeClass,
statusLabel,
} from "~/lib/tfs"
import { cn, formatDateTime } from "~/lib/utils"
interface FolderItemsPanelProps {
connected: boolean
collection: string
serverPath: string
refreshKey: number
onPendingChangesRefresh(): void
onPathEnter(path: string): void
onSelectedMappedItemChange(mapped: boolean): void
}
interface FolderItemView {
item: TfsServerItem
mapping?: TfsMappingInfo
pending?: TfsPendingChangeInfo
localPath: string
mapped: boolean
status: string
}
interface HistoryTarget {
path: string
folder: boolean
label: string
}
interface FileViewTarget {
serverPath: string
localPath?: string
preferLocal: boolean
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
interface FolderItemRow {
item: ServerItem
localPath: string | null
localState: string
}
/**
* 展示当前目录下一级文件和文件夹,复杂对象操作通过右键菜单和弹窗编排。
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
* 单击选中、双击目录进入,行级右键菜单与左侧树共用同一套动作模型。
*/
export function FolderItemsPanel({
connected,
collection,
serverPath,
refreshKey,
onPendingChangesRefresh,
onPathEnter,
onSelectedMappedItemChange,
}: FolderItemsPanelProps) {
const [items, setItems] = useState<TfsServerItem[]>([])
const [mappings, setMappings] = useState<TfsMappingInfo[]>([])
const [pendingChanges, setPendingChanges] = useState<TfsPendingChangeInfo[]>([])
const [selectedPath, setSelectedPath] = useState("")
session,
selectedServerPath,
pendingByServerPath,
refreshToken,
onNavigate,
onFileAction,
onRefresh,
}: {
session: WorkspaceSession
selectedServerPath: string
pendingByServerPath: Record<string, string>
refreshToken: number
onNavigate: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void
}) {
const [items, setItems] = useState<ServerItem[]>([])
const [loading, setLoading] = useState(false)
const [operationPath, setOperationPath] = useState("")
const [message, setMessage] = useState("")
const [menu, setMenu] = useState<ContextMenuState>()
const [mappingPath, setMappingPath] = useState("")
const [historyTarget, setHistoryTarget] = useState<HistoryTarget>()
const [fileViewTarget, setFileViewTarget] = useState<FileViewTarget>()
const [compareTarget, setCompareTarget] = useState<FolderItemView>()
const [diffRequest, setDiffRequest] = useState<DiffPanelRequest>()
const [conflicts, setConflicts] = useState<TfsConflictInfo[]>([])
const [error, setError] = useState<string | null>(null)
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null)
// 本地路径 -> 是否存在;null 表示当前环境无法检测。
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
// 当前路径变化时加载目录内容,并批量检测映射目标的本地存在性。
useEffect(() => {
onSelectedMappedItemChange(false)
if (!connected || !collection || !serverPath) {
setItems([])
setMappings([])
setPendingChanges([])
setSelectedPath("")
return
let active = true
setLoading(true)
setError(null)
setSelectedItemPath(null)
void (async () => {
const result = await api.getFolderItems({
path: selectedServerPath,
collection: session.collection,
})
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setItems([])
setError(result.errorMessage ?? "目录加载失败")
return
}
const list = result.data?.items ?? []
setItems(list)
const localPaths = list
.map((item) => resolveLocalPath(session.mappings, item.serverPath))
.filter((path): path is string => path != null)
const exists = await pathsExist(localPaths)
if (active) {
setExistsMap(exists)
}
})()
return () => {
active = false
}
}, [selectedServerPath, session.collection, session.mappings, refreshToken])
setSelectedPath("")
loadFolderItems()
}, [connected, collection, serverPath, refreshKey])
const viewItems = items.map(toFolderItemView)
const currentMapping = findMapping(serverPath)
const currentDirectoryLocalPath = toMappedLocalPath(serverPath, currentMapping)
const selectedItem = viewItems.find(
(viewItem) => viewItem.item.serverPath === selectedPath
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
const rows = useMemo<FolderItemRow[]>(
() =>
items.map((item) => {
const localPath = resolveLocalPath(session.mappings, item.serverPath)
const localExists =
localPath == null || existsMap == null
? null
: existsMap[localPath] ?? false
return {
item,
localPath,
localState: resolveItemLocalState(localPath != null, localExists),
}
}),
[items, session.mappings, existsMap],
)
useEffect(() => {
onSelectedMappedItemChange(Boolean(selectedItem?.mapped))
}, [selectedItem?.mapped])
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
/**
* 加载当前目录 items、Workspace mappings 和 pending changes,用于表格状态和菜单判断。
*/
async function loadFolderItems() {
const requestServerPath = serverPath
const requestCollection = collection
setLoading(true)
setMessage("")
const itemResult = await listServerFolderItems(
requestServerPath,
requestCollection
)
const mappingResult = await listMappings({ collection: requestCollection })
const pendingResult = await listPendingChanges({
collection: requestCollection,
serverPath: requestServerPath,
})
setLoading(false)
if (requestServerPath !== serverPath || requestCollection !== collection) {
return
}
if (!itemResult.success) {
setItems([])
setMessage(itemResult.errorMessage || itemResult.message)
return
}
setItems(itemResult.data.items)
setMappings(mappingResult.success ? mappingResult.data.mappings : [])
setPendingChanges(
pendingResult.success ? pendingResult.data.pendingChanges : []
)
setMessage(
mappingResult.success
? pendingResult.success
? ""
: pendingResult.errorMessage || pendingResult.message
: mappingResult.errorMessage || mappingResult.message
)
}
/**
* 查找覆盖当前服务端路径的最长 Mapping。
*/
function findMapping(serverItemPath: string) {
return mappings
.filter((mapping) =>
isSameOrChildPath(serverItemPath, mapping.serverPath)
)
.sort((left, right) => right.serverPath.length - left.serverPath.length)[0]
}
/**
* 判断服务端路径是否等于 Mapping 根路径或位于其子目录下。
*/
function isSameOrChildPath(serverItemPath: string, mappingPath: string) {
const itemPath = serverItemPath.toLowerCase()
const rootPath = mappingPath.toLowerCase()
return itemPath === rootPath || itemPath.startsWith(`${rootPath}/`)
}
/**
* 按 Mapping 根路径计算服务端项对应的本地路径。
*/
function toMappedLocalPath(
serverItemPath: string,
mapping?: TfsMappingInfo
) {
if (!mapping) {
return "-"
}
const relativePath = serverItemPath
.slice(mapping.serverPath.length)
.replace(/^\//, "")
return relativePath
? `${mapping.localPath}/${relativePath}`
: mapping.localPath
}
/**
* 查找当前项上的 pending change,目录按包含关系判断。
*/
function findPendingChange(item: TfsServerItem) {
return pendingChanges.find((change) =>
item.folder
? isSameOrChildPath(change.serverPath, item.serverPath)
: change.serverPath === item.serverPath
)
}
/**
* 生成中间列表展示用结构。
*/
function toFolderItemView(item: TfsServerItem): FolderItemView {
const mapping = findMapping(item.serverPath)
const pending = findPendingChange(item)
return {
item,
mapping,
pending,
localPath: toMappedLocalPath(item.serverPath, mapping),
mapped: Boolean(mapping),
status: pending?.status || (mapping ? "mapped" : "unmapped"),
}
}
/**
* 打开文件或文件夹历史弹窗。
*/
function openHistory(path: string, folder: boolean) {
setHistoryTarget({
path,
folder,
label: path,
})
}
/**
* 执行 Get Latest,并在后端返回冲突明细时打开统一冲突弹窗。
*/
async function runGetLatest(viewItem: FolderItemView) {
setOperationPath(viewItem.item.serverPath)
setMessage("")
const result = await getLatest({
serverPath: viewItem.item.serverPath,
recursive: viewItem.item.folder,
})
setOperationPath("")
const conflictDetails = result.data.result?.conflictDetails || []
if (conflictDetails.length > 0) {
setConflicts(conflictDetails)
return
}
if (!result.success) {
setMessage(result.errorMessage || result.message)
return
}
await loadFolderItems()
}
/**
* 执行 checkout,目录递归操作前使用原生确认减少误操作。
*/
async function runCheckout(viewItem: FolderItemView) {
if (
viewItem.item.folder &&
!window.confirm("将递归签出当前目录,是否继续?")
) {
return
}
setOperationPath(viewItem.item.serverPath)
setMessage("")
const result = await checkoutFiles({
paths: [viewItem.item.serverPath],
recursive: viewItem.item.folder,
})
setOperationPath("")
const conflictDetails = result.data.result?.conflictDetails || []
if (conflictDetails.length > 0) {
setConflicts(conflictDetails)
return
}
if (!result.success) {
setMessage(result.errorMessage || result.message)
return
}
onPendingChangesRefresh()
await loadFolderItems()
}
/**
* 对版本控制项执行 pending delete。
*/
async function runDelete(viewItem: FolderItemView) {
setOperationPath(viewItem.item.serverPath)
setMessage("")
const result = await deleteFiles({
paths: [viewItem.item.serverPath],
recursive: viewItem.item.folder,
})
setOperationPath("")
if (!result.success) {
setMessage(result.errorMessage || result.message)
return
}
onPendingChangesRefresh()
await loadFolderItems()
}
/**
* 撤销当前对象上的 pending change。
*/
async function runUndo(viewItem: FolderItemView) {
setOperationPath(viewItem.item.serverPath)
setMessage("")
const result = await undoFiles({
paths: [viewItem.item.serverPath],
recursive: viewItem.item.folder,
})
setOperationPath("")
if (!result.success) {
setMessage(result.errorMessage || result.message)
return
}
onPendingChangesRefresh()
await loadFolderItems()
}
/**
* 根据对象状态生成右键菜单,保持 pending add/edit 等判断集中在列表项模型上。
*/
function buildMenuItems(viewItem: FolderItemView): ContextMenuItem[] {
const pendingAdd = viewItem.pending?.status === "pendingAdd"
const pendingEdit = viewItem.pending?.status === "pendingEdit"
return [
{
key: "open",
label: viewItem.item.folder ? "打开" : "查看",
onSelect: () =>
viewItem.item.folder
? onPathEnter(viewItem.item.serverPath)
: setFileViewTarget({
serverPath: viewItem.item.serverPath,
localPath: viewItem.mapped ? viewItem.localPath : undefined,
preferLocal: viewItem.mapped,
}),
},
{
key: "getLatest",
label: "获取最新版本",
disabled: !viewItem.mapped || pendingEdit,
onSelect: () => runGetLatest(viewItem),
},
{
key: "checkout",
label: "签出",
disabled: !viewItem.mapped || pendingAdd,
onSelect: () => runCheckout(viewItem),
},
{
key: "add",
label: "新增本地文件到服务器",
hidden: !viewItem.item.folder,
disabled: !viewItem.mapped,
onSelect: () => setCompareTarget(viewItem),
},
{
key: "delete",
label: "删除",
disabled: pendingAdd,
danger: true,
onSelect: () => runDelete(viewItem),
},
{
key: "undo",
label: "撤销挂起更改",
disabled: !viewItem.pending,
onSelect: () => runUndo(viewItem),
},
{
key: "history",
label: "查看历史",
onSelect: () => openHistory(viewItem.item.serverPath, viewItem.item.folder),
},
{
key: "compareFolder",
label: "与服务器目录对比",
hidden: !viewItem.item.folder,
disabled: !viewItem.mapped,
onSelect: () => setCompareTarget(viewItem),
},
{
key: "diffLatest",
label: "比较本地与服务器 Latest",
hidden: viewItem.item.folder || pendingAdd,
disabled: !viewItem.mapped,
onSelect: () =>
setDiffRequest({
type: "localLatest",
serverPath: viewItem.item.serverPath,
localPath: viewItem.localPath,
label: `${viewItem.item.serverPath} 本地 ↔ Latest`,
}),
},
{
key: "mapping",
label: "映射到本地",
hidden: viewItem.mapped,
disabled: !viewItem.item.folder,
onSelect: () => setMappingPath(viewItem.item.serverPath),
},
]
}
if (!connected) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
未连接
</div>
)
}
if (!serverPath) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
未选择服务端目录
</div>
)
}
// 中间区域空白处右键的目标:当前目录本身(与目录树右键同一套菜单)。
const currentFolderTarget = makeFileTarget({
source: "list",
folder: true,
serverPath: selectedServerPath,
mappings: session.mappings,
pendingStatus: pendingByServerPath[selectedServerPath] ?? null,
})
return (
<div className="flex h-full min-h-0 flex-col">
<ContextMenu menu={menu} onClose={() => setMenu(undefined)} />
<div className="flex h-9 items-center justify-between gap-3 border-b bg-muted/10 px-3">
<div className="min-w-0 truncate font-mono text-xs text-muted-foreground">
{serverPath}
</div>
<div className="flex shrink-0 items-center gap-2">
{!currentMapping && (
<Button
size="xs"
variant="outline"
onClick={() => setMappingPath(serverPath)}
>
映射到本地
</Button>
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{currentLocalPath ? (
<span className="font-mono">{currentLocalPath}</span>
) : (
"未映射到本地"
)}
<Button
size="icon-xs"
variant="ghost"
disabled={loading}
title="刷新当前目录"
onClick={loadFolderItems}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
</div>
<div className="flex h-8 items-center gap-3 border-b px-3 text-xs text-muted-foreground">
<span className="truncate font-mono">
{currentMapping ? currentDirectoryLocalPath : "当前目录未映射"}
</span>
{message && <span className="truncate text-destructive">{message}</span>}
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={onRefresh}
disabled={loading}
aria-label="刷新当前目录"
title="刷新当前目录"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<div className="grid h-8 grid-cols-[minmax(170px,1fr)_80px_minmax(260px,1.3fr)_minmax(220px,1.1fr)_100px] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">名称</div>
<div className="flex items-center">类型</div>
<div className="flex items-center">服务端路径</div>
<div className="flex items-center">本地路径</div>
<div className="flex items-center">状态</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{viewItems.length === 0 && !loading ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
当前目录无可显示项
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
{/* 由 table-container 自身承担纵横滚动:表头 sticky 不随内容滚走,横向滚动条始终贴可视区底部 */}
<div className="min-h-0 flex-1 overflow-hidden [&_[data-slot=table-container]]:h-full [&_[data-slot=table-container]]:overflow-auto">
{loading ? (
<ItemsSkeleton />
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<AlertCircle className="size-8 text-destructive/60" />
<p className="text-sm text-destructive">{error}</p>
</div>
) : rows.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<FolderOpen className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">当前目录为空</p>
<p className="text-xs text-muted-foreground/70">
右键空白处可对当前目录执行获取、映射或添加本地文件
</p>
</div>
) : (
viewItems.map((viewItem) => (
<div
key={viewItem.item.serverPath}
className={`grid h-8 grid-cols-[minmax(170px,1fr)_80px_minmax(260px,1.3fr)_minmax(220px,1.1fr)_100px] items-center border-b px-3 text-left text-xs ${
selectedPath === viewItem.item.serverPath
? "bg-primary/10"
: "hover:bg-muted/50"
}`}
onClick={() => setSelectedPath(viewItem.item.serverPath)}
onDoubleClick={() =>
viewItem.item.folder && onPathEnter(viewItem.item.serverPath)
}
onContextMenu={(event) => {
event.preventDefault()
setSelectedPath(viewItem.item.serverPath)
setMenu({
x: event.clientX,
y: event.clientY,
items: buildMenuItems(viewItem),
<Table>
<TableHeader className="sticky top-0 z-10 bg-[oklch(0.972_0.003_286)]">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
<TableHead className="h-8 w-20 text-right 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>
{rows.map((row, index) => {
const selected = row.item.serverPath === selectedItemPath
const target = makeFileTarget({
source: "list",
folder: row.item.folder,
serverPath: row.item.serverPath,
mappings: session.mappings,
pendingStatus: pendingByServerPath[row.item.serverPath] ?? null,
})
}}
>
<div className="flex min-w-0 items-center gap-1.5">
{operationPath === viewItem.item.serverPath ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
) : viewItem.item.folder ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate" title={viewItem.item.name}>
{viewItem.item.name}
</span>
</div>
<div className="text-muted-foreground">
{viewItem.item.folder ? "目录" : "文件"}
</div>
<div
className="truncate font-mono text-muted-foreground"
title={viewItem.item.serverPath}
>
{viewItem.item.serverPath}
</div>
<div
className="truncate font-mono text-muted-foreground"
title={viewItem.localPath}
>
{viewItem.localPath}
</div>
<div className="truncate">{viewItem.status}</div>
</div>
))
return (
<FileTargetMenu
key={row.item.serverPath}
target={target}
onAction={onFileAction}
>
<TableRow
data-state={selected ? "selected" : undefined}
className="animate-in fade-in cursor-default select-none fill-mode-backwards duration-200"
style={{ animationDelay: `${Math.min(index, 12) * 18}ms` }}
onClick={() => setSelectedItemPath(row.item.serverPath)}
onContextMenu={(event) => {
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
event.stopPropagation()
setSelectedItemPath(row.item.serverPath)
}}
onDoubleClick={() => {
if (row.item.folder) {
onNavigate(row.item.serverPath)
}
}}
>
<TableCell className="py-1.5">
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
<FileIcon
name={row.item.name}
folder={row.item.folder}
mapped={row.item.folder && row.localPath != null}
/>
<span className="truncate text-sm">{row.item.name}</span>
</span>
</TableCell>
<TableCell className="py-1.5">
<Badge
variant="secondary"
className={cn("gap-1.5 rounded-md", statusBadgeClass(row.localState))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(row.localState)}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{row.item.latestVersion > 0 ? row.item.latestVersion : "—"}
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(row.item.checkinDate)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground">
{row.localPath ?? "—"}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
</Table>
)}
</div>
</FileTargetMenu>
<SimpleDialog
open={Boolean(mappingPath)}
title="Mapping"
description={mappingPath}
className="w-[min(680px,calc(100vw-48px))]"
onClose={() => setMappingPath("")}
>
<MappingDialog
open={Boolean(mappingPath)}
serverPath={mappingPath}
onClose={() => setMappingPath("")}
onSaved={loadFolderItems}
/>
</SimpleDialog>
<SimpleDialog
open={Boolean(historyTarget)}
title="History"
description={historyTarget?.label}
onClose={() => setHistoryTarget(undefined)}
>
{historyTarget && (
<HistoryPanel
path={historyTarget.path}
folder={historyTarget.folder}
label={historyTarget.label}
onOpenDiff={setDiffRequest}
onClose={() => setHistoryTarget(undefined)}
/>
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
{selectedRow ? (
<>
<span className="font-medium text-foreground">{selectedRow.item.name}</span>
<span className="truncate font-mono">{selectedRow.item.serverPath}</span>
<span className="ml-auto shrink-0">
{statusLabel(selectedRow.localState)}
{selectedRow.item.latestVersion > 0 &&
` · 版本 ${selectedRow.item.latestVersion}`}
</span>
</>
) : (
<span>
{rows.length > 0 ? `${rows.length} 个对象 · 未选中` : "未选中对象"}
</span>
)}
</SimpleDialog>
</div>
</section>
)
}
<SimpleDialog
open={Boolean(fileViewTarget)}
title="File Viewer"
description={fileViewTarget?.serverPath}
onClose={() => setFileViewTarget(undefined)}
>
{fileViewTarget && (
<FileViewerDialog
open={Boolean(fileViewTarget)}
serverPath={fileViewTarget.serverPath}
localPath={fileViewTarget.localPath}
preferLocal={fileViewTarget.preferLocal}
onClose={() => setFileViewTarget(undefined)}
/>
)}
</SimpleDialog>
<SimpleDialog
open={Boolean(compareTarget)}
title="Folder Compare"
description={compareTarget?.item.serverPath}
onClose={() => setCompareTarget(undefined)}
>
{compareTarget && (
<FolderCompareDialog
open={Boolean(compareTarget)}
serverPath={compareTarget.item.serverPath}
localPath={compareTarget.localPath}
onPendingChangesRefresh={onPendingChangesRefresh}
onOpenDiff={(diff: FolderDiffItem) =>
setDiffRequest({
type: "localLatest",
serverPath: diff.serverPath,
localPath: diff.localPath,
label: `${diff.serverPath} 本地 ↔ Latest`,
})
}
/>
)}
</SimpleDialog>
<SimpleDialog
open={Boolean(diffRequest)}
title="Diff"
description={diffRequest?.label}
onClose={() => setDiffRequest(undefined)}
>
{diffRequest && (
<DiffPanel
request={diffRequest}
onClose={() => setDiffRequest(undefined)}
/>
)}
</SimpleDialog>
<SimpleDialog
open={conflicts.length > 0}
title="Resolve Conflicts"
onClose={() => setConflicts([])}
>
<ConflictDialog
open={conflicts.length > 0}
conflicts={conflicts}
onOpenDiff={(conflict) =>
conflict.localPath &&
setDiffRequest({
type: "localLatest",
serverPath: conflict.serverPath,
localPath: conflict.localPath,
label: `${conflict.serverPath} 本地 ↔ Latest`,
})
}
onApplied={() => {
setConflicts([])
onPendingChangesRefresh()
loadFolderItems()
}}
/>
</SimpleDialog>
/**
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 loading。
*/
function ItemsSkeleton() {
return (
<div className="space-y-2 p-3">
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
className="flex animate-pulse items-center gap-3"
style={{ animationDelay: `${index * 80}ms` }}
>
<span className="size-4 rounded bg-muted" />
<span className="h-3.5 rounded bg-muted" style={{ width: `${160 + ((index * 53) % 120)}px` }} />
<span className="h-3.5 w-14 rounded-full bg-muted" />
<span className="ml-auto h-3 w-28 rounded bg-muted" />
</div>
))}
</div>
)
}
@@ -0,0 +1,171 @@
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { HistoryEntry } from "~/lib/api"
import { cn, formatDateTime } from "~/lib/utils"
/**
* 获取特定版本弹窗:输入或从该对象历史中点选 changeset,
* 确认后把对象本地内容整体切到该变更集时刻(目录为递归覆盖),
* 对应 VS 的 Get Specific Version + Overwrite。
*/
export function GetVersionDialog({
serverPath,
folder,
onConfirm,
onClose,
}: {
serverPath: string
folder: boolean
/** 确认后执行获取,由编排层调用 get-version 接口并刷新。 */
onConfirm: (changeset: number) => Promise<boolean>
onClose: () => void
}) {
const [input, setInput] = useState("")
const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.getHistory({ path: serverPath, folder })
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "历史加载失败")
return
}
setError(null)
setEntries(result.data?.history ?? [])
})()
return () => {
active = false
}
}, [serverPath, folder])
const changeset = /^\d+$/.test(input.trim()) ? Number(input.trim()) : null
const valid = changeset != null && changeset > 0
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>获取特定版本</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<span className="text-sm text-muted-foreground">Changeset</span>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="输入编号或从下方历史点选"
className="h-8 w-56 font-mono text-sm"
inputMode="numeric"
/>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-32 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载历史…
</div>
) : error ? (
<div className="flex h-32 items-center justify-center text-sm text-destructive">{error}</div>
) : entries.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
没有历史记录,可直接输入 changeset 编号
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
<TableHead className="h-8 w-28 text-xs text-muted-foreground">作者</TableHead>
<TableHead className="h-8 w-36 text-xs text-muted-foreground">时间</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">注释</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow
key={`${entry.changeset}-${entry.serverPath}`}
className={cn(
"cursor-pointer select-none",
changeset === entry.changeset && "bg-primary/10 hover:bg-primary/10",
)}
onClick={() => setInput(String(entry.changeset))}
>
<TableCell className="py-1.5 font-mono text-xs">{entry.changeset}</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs">{entry.author}</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{formatDateTime(entry.date)}
</TableCell>
<TableCell className="max-w-0 truncate py-1.5 text-xs" title={entry.comment}>
{entry.comment || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs font-medium text-destructive">
确认后{folder ? "该目录(递归)" : "该文件"}的本地内容会被所选版本覆盖,
本地未签入的修改无法恢复。
</p>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="destructive"
disabled={!valid || busy}
onClick={() => {
if (changeset == null) {
return
}
void (async () => {
setBusy(true)
const ok = await onConfirm(changeset)
setBusy(false)
if (ok) {
onClose()
}
})()
}}
>
{busy ? "正在获取…" : changeset != null ? `获取 C${changeset}` : "获取此版本"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,531 @@
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, CloudDownload, Loader2 } from "lucide-react"
import { ConfirmDialog } from "~/components/app/confirm-dialog"
import { FileViewDialog } from "~/components/explorer/file-view-dialog"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "~/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { HistoryEntry } from "~/lib/api"
import { formatDateTime } from "~/lib/utils"
// 历史弹窗当前查看的目标(支持从 changeset 文件下钻到单文件历史后返回)。
interface HistoryTarget {
serverPath: string
folder: boolean
}
// 「获取此版本」待确认信息。
interface VersionConfirm {
serverPath: string
changeset: number
folder: boolean
}
// 「回滚」待确认信息:single 仅反做该变更集,toVersion 反做其后全部改动。
interface RollbackConfirm {
serverPath: string
changeset: number
mode: "single" | "toVersion"
}
/**
* History 弹窗:展示文件或目录历史(最近记录)。
* 目录历史可下钻 changeset 影响文件;changeset 文件支持右键查看此版本 /
* 查看该文件历史(可返回)/ 获取此版本;文件历史可勾选两个版本进入版本对比;
* 每条记录支持「获取此版本…」(确认后用该版本覆盖本地)。
*/
export function HistoryDialog({
serverPath,
folder,
onClose,
onDiffRevisions,
onGetVersion,
onRollback,
}: {
serverPath: string
folder: boolean
onClose: () => void
onDiffRevisions?: (serverPath: string, source: number, target: number) => void
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 })
// 下钻历史目标栈:从 changeset 文件进入单文件历史后可逐级返回。
const [targetStack, setTargetStack] = useState<HistoryTarget[]>([])
const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 目录历史下钻:当前查看的 changeset 文件列表,null 表示在历史主列表。
const [changesetFiles, setChangesetFiles] = useState<{
changeset: number
files: HistoryEntry[]
} | null>(null)
const [filesLoading, setFilesLoading] = useState(false)
// 文件历史勾选的 changeset(最多两个),用于版本对比。
const [selected, setSelected] = useState<number[]>([])
const [confirmVersion, setConfirmVersion] = useState<VersionConfirm | null>(null)
// 「回滚」待确认信息。
const [confirmRollback, setConfirmRollback] = useState<RollbackConfirm | null>(null)
// 「查看此版本内容」叠加的文件查看弹窗目标。
const [viewVersion, setViewVersion] = useState<{ serverPath: string; changeset: number } | null>(null)
useEffect(() => {
let active = true
void (async () => {
setLoading(true)
const result = await api.getHistory({ path: target.serverPath, folder: target.folder })
if (!active) {
return
}
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "历史加载失败")
return
}
setError(null)
setEntries(result.data?.history ?? [])
})()
return () => {
active = false
}
}, [target])
/**
* 目录历史下钻:加载指定 changeset 影响的文件列表。
*/
const openChangeset = useCallback(async (changeset: number) => {
setFilesLoading(true)
const result = await api.getChangesetFiles({ changeset })
setFilesLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "Changeset 文件加载失败")
return
}
setError(null)
setChangesetFiles({ changeset, files: result.data?.files ?? [] })
}, [])
/**
* 从 changeset 文件切换到该文件的历史(压栈当前目标,可返回)。
*/
const openFileHistory = useCallback(
(fileServerPath: string) => {
setTargetStack((prev) => [...prev, target])
setTarget({ serverPath: fileServerPath, folder: false })
setChangesetFiles(null)
setSelected([])
},
[target],
)
/**
* 返回上一级历史目标。
*/
const backToPreviousTarget = useCallback(() => {
setTargetStack((prev) => {
const next = [...prev]
const previous = next.pop()
if (previous) {
setTarget(previous)
setChangesetFiles(null)
setSelected([])
}
return next
})
}, [])
/**
* 勾选 / 取消勾选用于对比的 changeset,最多保留两个(先进先出)。
*/
const toggleSelected = useCallback((changeset: number) => {
setSelected((prev) => {
if (prev.includes(changeset)) {
return prev.filter((item) => item !== changeset)
}
const next = [...prev, changeset]
return next.length > 2 ? next.slice(next.length - 2) : next
})
}, [])
const canDiff = onDiffRevisions != null && !target.folder && selected.length === 2
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[88vw]">
<DialogHeader>
<DialogTitle>
{changesetFiles ? `Changeset ${changesetFiles.changeset} 的文件` : "历史记录"}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{target.serverPath}
</DialogDescription>
</DialogHeader>
{(changesetFiles || targetStack.length > 0) && (
<div className="flex items-center gap-2">
{changesetFiles && (
<Button variant="ghost" size="sm" onClick={() => setChangesetFiles(null)}>
<ArrowLeft data-icon="inline-start" />
返回历史列表
</Button>
)}
{!changesetFiles && targetStack.length > 0 && (
<Button variant="ghost" size="sm" onClick={backToPreviousTarget}>
<ArrowLeft data-icon="inline-start" />
返回上级历史
</Button>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading || filesLoading ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载…
</div>
) : error ? (
<div className="flex h-40 items-center justify-center text-sm text-destructive">
{error}
</div>
) : changesetFiles ? (
<ChangesetFilesTable
files={changesetFiles.files}
changeset={changesetFiles.changeset}
onViewVersion={(fileServerPath) =>
setViewVersion({ serverPath: fileServerPath, changeset: changesetFiles.changeset })
}
onOpenFileHistory={openFileHistory}
onGetVersion={
onGetVersion
? (fileServerPath) =>
setConfirmVersion({
serverPath: fileServerPath,
changeset: changesetFiles.changeset,
folder: false,
})
: undefined
}
/>
) : entries.length === 0 ? (
<div className="flex h-40 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">
{!target.folder && <TableHead className="h-8 w-8" />}
<TableHead className="h-8 w-24 text-xs text-muted-foreground">Changeset</TableHead>
<TableHead className="h-8 w-20 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>
{onGetVersion && <TableHead className="h-8 w-12 text-xs text-muted-foreground" />}
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<ContextMenu key={`${entry.changeset}-${entry.serverPath}`}>
<ContextMenuTrigger asChild>
<TableRow
className="cursor-default select-none"
onDoubleClick={() => {
if (target.folder) {
void openChangeset(entry.changeset)
}
}}
>
{!target.folder && (
<TableCell className="py-1.5">
<Checkbox
checked={selected.includes(entry.changeset)}
onCheckedChange={() => toggleSelected(entry.changeset)}
aria-label={`选择 changeset ${entry.changeset}`}
/>
</TableCell>
)}
<TableCell className="py-1.5 font-mono text-xs">
{target.folder ? (
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() => void openChangeset(entry.changeset)}
>
{entry.changeset}
</button>
) : (
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>
{onGetVersion && (
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon-sm"
title={`获取此版本(C${entry.changeset},覆盖本地)`}
onClick={() =>
setConfirmVersion({
serverPath: target.serverPath,
changeset: entry.changeset,
folder: target.folder,
})
}
>
<CloudDownload />
</Button>
</TableCell>
)}
</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>
</Table>
)}
</div>
<div className="flex shrink-0 items-center justify-between">
<p className="text-xs text-muted-foreground">
{changesetFiles
? `${changesetFiles.files.length} 个文件 · 右键文件可查看此版本 / 历史 / 获取`
: target.folder
? "双击记录或点击 changeset 编号查看影响文件"
: "勾选两个版本可进行对比"}
</p>
{!target.folder && onDiffRevisions && (
<Button
size="sm"
disabled={!canDiff}
onClick={() => {
const [a, b] = [...selected].sort((x, y) => x - y)
onDiffRevisions(target.serverPath, a, b)
}}
>
对比所选版本
</Button>
)}
</div>
{confirmVersion && onGetVersion && (
<ConfirmDialog
title={`获取版本 C${confirmVersion.changeset}`}
description={
<>
<p>
将把 <span className="font-mono text-xs">{confirmVersion.serverPath}</span>{" "}
的本地内容替换为 changeset {confirmVersion.changeset} 的版本。
</p>
<p className="mt-2 font-medium text-destructive">
本地未签入的修改会被覆盖,且无法恢复。确定继续吗?
</p>
</>
}
confirmLabel="获取此版本"
danger
onConfirm={async () => {
await onGetVersion(
confirmVersion.serverPath,
confirmVersion.changeset,
confirmVersion.folder,
)
setConfirmVersion(null)
}}
onClose={() => setConfirmVersion(null)}
/>
)}
{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 && (
<FileViewDialog
serverPath={viewVersion.serverPath}
localPath={null}
changeset={viewVersion.changeset}
onClose={() => setViewVersion(null)}
/>
)}
</DialogContent>
</Dialog>
)
}
/**
* Changeset 影响文件列表(目录历史下钻视图),行级右键菜单提供对象操作。
*/
function ChangesetFilesTable({
files,
changeset,
onViewVersion,
onOpenFileHistory,
onGetVersion,
}: {
files: HistoryEntry[]
changeset: number
onViewVersion: (serverPath: string) => void
onOpenFileHistory: (serverPath: string) => void
onGetVersion?: (serverPath: string) => void
}) {
if (files.length === 0) {
return (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
该 changeset 没有文件记录
</div>
)
}
return (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 w-20 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<ContextMenu key={file.serverPath}>
<ContextMenuTrigger asChild>
<TableRow className="cursor-default select-none">
<TableCell className="py-1.5 text-xs">{file.changeType}</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={file.serverPath}
>
{file.serverPath}
</TableCell>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onSelect={() => onViewVersion(file.serverPath)}>
查看此版本内容
</ContextMenuItem>
<ContextMenuItem onSelect={() => onOpenFileHistory(file.serverPath)}>
查看该文件历史
</ContextMenuItem>
{onGetVersion && (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => onGetVersion(file.serverPath)}
>
获取此版本(C{changeset})…
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
))}
</TableBody>
</Table>
)
}
@@ -1,132 +1,201 @@
import { useEffect, useState } from "react"
import { FolderOpen, Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { FolderSearch, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button"
import { addMapping } from "~/lib/api/endpoints"
import { getMactfsBridge } from "~/lib/electron/bridge"
interface MappingDialogProps {
serverPath: string
open: boolean
onClose(): void
onSaved(): void
}
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { api } from "~/lib/api"
import type { CheckMappingTargetResult, MappingInfo } from "~/lib/api"
import { isElectron, selectDirectory } from "~/lib/electron"
/**
* 创建服务端目录到本地目录的 Mapping,供未映射目录右键菜单和弹窗流程复用。
* Mapping 创建弹窗:展示服务端路径,选择本地父目录后由后端预校验最终目标路径,
* 目标已存在时禁止映射;支持创建后立即 Get Latest。
*/
export function MappingDialog({
serverPath,
open,
onClose,
onSaved,
}: MappingDialogProps) {
const [localPath, setLocalPath] = useState("")
const [getLatest, setGetLatest] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState("")
onCreated,
}: {
serverPath: string
onClose: () => void
onCreated: (mappings: MappingInfo[], didGetLatest: boolean) => void
}) {
const [parentPath, setParentPath] = useState("")
const [check, setCheck] = useState<CheckMappingTargetResult | null>(null)
const [checking, setChecking] = useState(false)
const [getLatest, setGetLatest] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* 通过系统目录选择器选择本地父目录(非 Electron 环境保留手动输入)。
*/
const handleBrowse = useCallback(async () => {
const selected = await selectDirectory()
if (selected) {
setParentPath(selected)
}
}, [])
// 父目录变化时调用后端预校验最终目标路径,最终路径以后端结果为准。
useEffect(() => {
if (open) {
setLocalPath("")
setGetLatest(false)
setMessage("")
}
}, [open, serverPath])
/**
* 通过 Electron 主进程选择本地目录,避免渲染进程直接访问本机文件系统。
*/
async function selectLocalDirectory() {
const directory = await getMactfsBridge()?.selectDirectory()
if (directory) {
setLocalPath(directory)
}
}
/**
* 保存 Mapping,成功后通知父组件刷新当前目录状态。
*/
async function saveMapping() {
if (!localPath) {
setMessage("请选择本地目录。")
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,
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, serverPath])
setSaving(true)
setMessage("")
const result = await addMapping({
const exists = check?.exists === true
const canConfirm = check != null && check.allowed && !exists && !busy && !checking
/**
* 创建 Mapping(按需立即 Get Latest),成功后回调上层刷新并关闭弹窗。
*/
const handleConfirm = useCallback(async () => {
if (!check) {
return
}
setBusy(true)
setError(null)
const result = await api.addMapping({
serverPath,
localPath,
localPath: check.targetPath,
getLatest,
})
setSaving(false)
if (!result.success) {
setMessage(result.errorMessage || result.message)
setBusy(false)
if (!result.ok) {
setError(result.errorMessage ?? "创建 Mapping 失败")
return
}
onSaved()
onClose()
}
if (!open) {
return null
}
const mappingsResult = await api.listMappings()
onCreated(mappingsResult.data?.mappings ?? [], getLatest)
}, [check, serverPath, getLatest, onCreated])
return (
<div className="grid gap-3 p-4 text-sm">
<div className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
服务端路径
</span>
<div className="rounded-[6px] border bg-muted/20 px-2 py-1.5 font-mono text-xs">
{serverPath}
</div>
</div>
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>映射到本地</DialogTitle>
<DialogDescription>
为服务端目录创建本地工作区映射
</DialogDescription>
</DialogHeader>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
本地目录
</span>
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
<input
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={localPath}
onChange={(event) => setLocalPath(event.target.value)}
/>
<Button size="sm" variant="outline" onClick={selectLocalDirectory}>
<FolderOpen />
选择目录
<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">
{serverPath}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="parentPath">本地父目录</Label>
<div className="flex gap-2">
<Input
id="parentPath"
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>
<p className="text-xs text-muted-foreground">
选择父目录,最终映射目录由后端按服务端目录名生成
</p>
</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>
)}
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={getLatest}
onCheckedChange={(value) => setGetLatest(value === true)}
disabled={busy}
/>
创建后立即获取最新
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
取消
</Button>
</div>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={getLatest}
onChange={(event) => setGetLatest(event.target.checked)}
/>
创建后立即 Get Latest
</label>
{message && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" disabled={saving} onClick={onClose}>
取消
</Button>
<Button size="sm" disabled={saving} onClick={saveMapping}>
{saving && <Loader2 className="animate-spin" />}
创建 Mapping
</Button>
</div>
</div>
<Button onClick={() => void handleConfirm()} disabled={!canConfirm}>
{busy ? "正在创建…" : "创建映射"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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,279 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronRight, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api"
import type { MappingInfo, ServerItem } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import {
SERVER_ROOT_PATH,
getAncestorPaths,
makeFileTarget,
normalizeServerPath,
resolveLocalPath,
} from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
* 与中间列表共享当前路径;节点支持与中间列表同规则的右键菜单。
*/
export function SourceTreePanel({
collection,
mappings,
selectedServerPath,
onNavigate,
onFileAction,
}: {
collection: string
mappings: MappingInfo[]
selectedServerPath: string
onNavigate: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
// path -> 子目录列表(仅 folder),undefined 表示尚未加载。
const [childrenByPath, setChildrenByPath] = useState<Record<string, ServerItem[]>>({})
const [expanded, setExpanded] = useState<Record<string, boolean>>({
[SERVER_ROOT_PATH]: true,
})
const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(null)
// 手动刷新令牌:递增时清空缓存并重新加载。
const [reloadToken, setReloadToken] = useState(0)
// 已加载与加载中的路径集合,避免异步流程里读到过期 state。
const requestedRef = useRef<Set<string>>(new Set())
/**
* 懒加载指定目录的子目录列表,已加载或加载中的路径直接跳过。
*/
const ensureChildren = useCallback(
async (serverPath: string) => {
const target = normalizeServerPath(serverPath)
if (requestedRef.current.has(target)) {
return
}
requestedRef.current.add(target)
setLoadingPaths((prev) => ({ ...prev, [target]: true }))
const result = await api.getServerTree({ path: target, collection })
setLoadingPaths((prev) => ({ ...prev, [target]: false }))
if (!result.ok) {
requestedRef.current.delete(target)
setError(result.errorMessage ?? "目录加载失败")
return
}
setError(null)
const folders = (result.data?.items ?? []).filter((item) => item.folder)
setChildrenByPath((prev) => ({ ...prev, [target]: folders }))
},
[collection],
)
// 当前路径变化或手动刷新时,逐级加载并展开祖先链与自身。
useEffect(() => {
let active = true
void (async () => {
const chain = [...getAncestorPaths(selectedServerPath), normalizeServerPath(selectedServerPath)]
for (const path of chain) {
if (!active) {
return
}
await ensureChildren(path)
setExpanded((prev) => (prev[path] ? prev : { ...prev, [path]: true }))
}
})()
return () => {
active = false
}
}, [selectedServerPath, ensureChildren, reloadToken])
/**
* 手动刷新目录树:清空懒加载缓存并从根重新加载,保持当前选中路径的展开链。
*/
const handleRefresh = useCallback(() => {
requestedRef.current.clear()
setChildrenByPath({})
setExpanded({ [SERVER_ROOT_PATH]: true })
setError(null)
setReloadToken((token) => token + 1)
}, [])
/**
* 切换节点展开 / 收起,首次展开时触发懒加载。
*/
const toggleExpand = useCallback(
(serverPath: string) => {
setExpanded((prev) => {
const next = !prev[serverPath]
if (next) {
void ensureChildren(serverPath)
}
return { ...prev, [serverPath]: next }
})
},
[ensureChildren],
)
/**
* 点击节点名称:选中并导航到该目录,同时确保其展开。
*/
const handleSelect = useCallback(
(serverPath: string) => {
onNavigate(serverPath)
setExpanded((prev) => (prev[serverPath] ? prev : { ...prev, [serverPath]: true }))
void ensureChildren(serverPath)
},
[onNavigate, ensureChildren],
)
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
源码目录
</span>
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={handleRefresh}
aria-label="刷新目录树"
title="刷新目录树"
>
<RefreshCw />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
{error && (
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
)}
<TreeNode
path={SERVER_ROOT_PATH}
name={SERVER_ROOT_PATH}
depth={0}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={normalizeServerPath(selectedServerPath)}
onToggle={toggleExpand}
onSelect={handleSelect}
onFileAction={onFileAction}
/>
</div>
</ScrollArea>
</aside>
)
}
/**
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
*/
function TreeNode({
path,
name,
depth,
mappings,
childrenByPath,
expanded,
loadingPaths,
selectedServerPath,
onToggle,
onSelect,
onFileAction,
}: {
path: string
name: string
depth: number
mappings: MappingInfo[]
childrenByPath: Record<string, ServerItem[]>
expanded: Record<string, boolean>
loadingPaths: Record<string, boolean>
selectedServerPath: string
onToggle: (serverPath: string) => void
onSelect: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const children = childrenByPath[path]
const isExpanded = expanded[path] === true
const isLoading = loadingPaths[path] === true
const isSelected = selectedServerPath === path
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
const isLeaf = children != null && children.length === 0
const isMapped = resolveLocalPath(mappings, path) != null
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
return (
<div>
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
)}
style={{ paddingLeft: `${depth * 14 + 2}px` }}
title={path}
>
<span className="flex size-5 shrink-0 items-center justify-center">
{isLoading ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isLeaf ? null : (
<button
type="button"
aria-label={isExpanded ? "收起目录" : "展开目录"}
onClick={() => onToggle(path)}
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200 ease-out-quart",
isExpanded && "rotate-90",
)}
/>
</button>
)}
</span>
<button
type="button"
onClick={() => onSelect(path)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
<span
className={cn(
"truncate text-sm",
isSelected && "font-medium text-foreground",
)}
>
{name}
</span>
</button>
</div>
</FileTargetMenu>
{isExpanded && children != null && children.length > 0 && (
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
{children.map((child) => (
<TreeNode
key={child.serverPath}
path={child.serverPath}
name={child.name}
depth={depth + 1}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={selectedServerPath}
onToggle={onToggle}
onSelect={onSelect}
onFileAction={onFileAction}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useState } from "react"
import { FolderSearch, Loader2, Pencil, Plus, Trash2 } from "lucide-react"
import { ConfirmDialog } from "~/components/app/confirm-dialog"
import { MappingDialog } from "~/components/explorer/mapping-dialog"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { CheckMappingTargetResult, MappingInfo, WorkspaceInfo } from "~/lib/api"
import { isElectron, pathsExist, selectDirectory } from "~/lib/electron"
import type { WorkspaceSession } from "~/lib/tfs"
/**
* 工作区集中管理弹窗:展示 Workspace 信息与全部 Mapping,
* 支持新增、删除与修改本地路径(删旧 + 加新),对齐 VS 的 Edit Workspace。
*/
export function WorkspaceManageDialog({
session,
onClose,
onMappingsChanged,
}: {
session: WorkspaceSession
onClose: () => void
/** Mapping 集合变化(新增 / 删除 / 修改)后回调上层刷新。 */
onMappingsChanged: (mappings: MappingInfo[]) => void
}) {
const [info, setInfo] = useState<WorkspaceInfo | null>(null)
// 各 Mapping 本地目录存在性(null 表示无法检测,如非 Electron 环境)。
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
const [error, setError] = useState<string | null>(null)
// 新增映射:输入服务端路径后打开既有 MappingDialog。
const [addServerPath, setAddServerPath] = useState("")
const [addOpen, setAddOpen] = useState(false)
const [editMapping, setEditMapping] = useState<MappingInfo | null>(null)
const [confirmDelete, setConfirmDelete] = useState<MappingInfo | null>(null)
// Workspace 详细信息(Owner / Computer)通过 ensure 接口幂等获取。
useEffect(() => {
let active = true
void (async () => {
const result = await api.ensureWorkspace({})
if (!active) {
return
}
if (result.ok && result.data) {
setInfo(result.data.workspace)
}
})()
return () => {
active = false
}
}, [])
// Mapping 集合变化时重新检测本地目录存在性。
useEffect(() => {
let active = true
void (async () => {
const result = await pathsExist(session.mappings.map((mapping) => mapping.localPath))
if (active) {
setExistsMap(result)
}
})()
return () => {
active = false
}
}, [session.mappings])
/**
* 删除 Mapping(仅解除映射,不删除本地文件),成功后回调上层刷新。
*/
const handleDelete = useCallback(
async (mapping: MappingInfo) => {
const result = await api.deleteMapping({ serverPath: mapping.serverPath })
if (!result.ok) {
setError(result.errorMessage ?? "删除 Mapping 失败")
return
}
setError(null)
onMappingsChanged(result.data?.mappings ?? [])
},
[onMappingsChanged],
)
const trimmedAddPath = addServerPath.trim()
const addPathValid = trimmedAddPath.startsWith("$/") && trimmedAddPath.length > 2
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>工作区管理</DialogTitle>
<DialogDescription>查看 Workspace 信息,集中管理全部本地映射</DialogDescription>
</DialogHeader>
<div className="grid shrink-0 grid-cols-2 gap-x-6 gap-y-1 rounded-md border bg-muted/30 px-3 py-2 text-xs">
<InfoRow label="Workspace" value={session.workspace} mono />
<InfoRow label="服务器" value={session.serverUri} mono />
<InfoRow label="Collection" value={session.collection} />
<InfoRow label="Owner" value={info?.ownerName ?? "…"} />
<InfoRow label="Computer" value={info?.computer ?? "…"} />
<InfoRow label="Mapping 数量" value={String(session.mappings.length)} />
</div>
<div className="flex shrink-0 items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor="addServerPath" className="text-xs">
新增映射的服务端路径
</Label>
<Input
id="addServerPath"
value={addServerPath}
onChange={(event) => setAddServerPath(event.target.value)}
placeholder="$/Project/Folder"
className="h-8 font-mono text-xs"
/>
</div>
<Button
size="sm"
disabled={!addPathValid}
onClick={() => setAddOpen(true)}
title={addPathValid ? undefined : "请输入以 $/ 开头的服务端路径"}
>
<Plus data-icon="inline-start" />
新增映射…
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{session.mappings.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
当前 Workspace 还没有任何映射
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">本地目录</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground" />
</TableRow>
</TableHeader>
<TableBody>
{session.mappings.map((mapping) => {
const exists = existsMap?.[mapping.localPath]
return (
<TableRow key={mapping.serverPath} className="cursor-default select-none">
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={mapping.serverPath}
>
{mapping.serverPath}
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={mapping.localPath}
>
{mapping.localPath}
</TableCell>
<TableCell className="py-1.5">
{existsMap == null ? (
<span className="text-xs text-muted-foreground">无法检测</span>
) : exists ? (
<Badge variant="secondary" className="bg-muted text-foreground/80">
存在
</Badge>
) : (
<Badge
variant="secondary"
className="bg-red-500/10 text-red-700"
>
不存在
</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>
)
}
@@ -0,0 +1,259 @@
import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import { ScrollArea } from "~/components/ui/scroll-area"
import { Textarea } from "~/components/ui/textarea"
import type { MappingInfo, PendingChange } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。
*/
export function ChangesPanel({
mappings,
pendingChanges,
excludedKeys,
loading,
error,
checkinBusy,
onToggleExcluded,
onCheckin,
onFileAction,
onRefresh,
}: {
mappings: MappingInfo[]
pendingChanges: PendingChange[]
excludedKeys: Set<string>
loading: boolean
error: string | null
checkinBusy: boolean
onToggleExcluded: (serverPath: string) => void
onCheckin: (paths: string[], comment: string) => Promise<boolean>
onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void
}) {
const [comment, setComment] = useState("")
const included = useMemo(
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const excluded = useMemo(
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
/**
* 提交签入:只提交 Included 项,成功后清空注释。
*/
const handleCheckin = useCallback(async () => {
const success = await onCheckin(
included.map((change) => change.serverPath),
comment.trim(),
)
if (success) {
setComment("")
}
}, [onCheckin, included, comment])
return (
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
挂起更改
</span>
{pendingChanges.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
)}
<Button
variant="ghost"
size="icon-xs"
className={pendingChanges.length > 0 ? "" : "ml-auto"}
onClick={onRefresh}
disabled={loading}
aria-label="刷新挂起更改"
title="刷新挂起更改"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在加载挂起更改…
</div>
) : error ? (
<div className="flex flex-1 items-center justify-center p-4 text-xs text-destructive">
{error}
</div>
) : pendingChanges.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex flex-col items-center gap-2 text-center">
<ListChecks className="size-6 text-muted-foreground/60" />
<p className="text-xs leading-5 text-muted-foreground">当前没有挂起更改</p>
</div>
</div>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
<GroupHeader label="Included" count={included.length} />
{included.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
没有要签入的更改,勾选下方 Excluded 项可移回
</p>
)}
{included.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
{excluded.length > 0 && (
<>
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
{excluded.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included={false}
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
</>
)}
</div>
</ScrollArea>
)}
<div className="shrink-0 space-y-2 border-t p-3">
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="签入注释(必填)"
rows={3}
disabled={checkinBusy || pendingChanges.length === 0}
className="resize-none text-sm"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
将签入 {included.length} 项
</span>
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
{checkinBusy ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
正在签入…
</>
) : (
"签入"
)}
</Button>
</div>
{comment.trim().length === 0 && pendingChanges.length > 0 && (
<p className="text-xs text-muted-foreground">填写注释后才能签入</p>
)}
</div>
</aside>
)
}
/**
* 分组标题行。
*/
function GroupHeader({
label,
count,
className,
}: {
label: string
count: number
className?: string
}) {
return (
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}>
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-xs text-muted-foreground/70">{count}</span>
</div>
)
}
/**
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。
*/
function PendingRow({
change,
included,
mappings,
onToggleExcluded,
onFileAction,
}: {
change: PendingChange
included: boolean
mappings: MappingInfo[]
onToggleExcluded: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const target = makeFileTarget({
source: "pending",
folder: change.folder,
serverPath: change.serverPath,
mappings,
pendingStatus: change.status,
})
return (
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted",
!included && "opacity-60",
)}
title={change.serverPath}
>
<Checkbox
checked={included}
onCheckedChange={() => onToggleExcluded(change.serverPath)}
aria-label={included ? "移到 Excluded" : "移回 Included"}
/>
{change.folder ? (
<FolderClosed 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="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">
{change.serverPath}
</span>
</span>
<Badge
variant="secondary"
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(change.status)}
</Badge>
</div>
</FileTargetMenu>
)
}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react"
import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api"
import type { OperationLogEntry } from "~/lib/api"
import { cn, formatDateTime } from "~/lib/utils"
/**
* 把毫秒耗时格式化为可读文本。
*/
function formatDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs} ms`
}
return `${(durationMs / 1000).toFixed(1)} s`
}
/**
* 底部 Operation Console:展示最近操作日志(操作、摘要、起止时间、耗时、结果),
* 随主要操作完成自动刷新,长操作执行期间显示执行中提示。
*/
export function ConsolePanel({
refreshToken,
busy,
busyText,
}: {
refreshToken: number
busy: boolean
busyText: string | null
}) {
const [logs, setLogs] = useState<OperationLogEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* 拉取最近操作日志。
*/
const loadLogs = useCallback(async () => {
setLoading(true)
const result = await api.getLogs()
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "操作日志加载失败")
return
}
setError(null)
setLogs(result.data?.logs ?? [])
}, [])
useEffect(() => {
void loadLogs()
}, [loadLogs, refreshToken])
// 最新的操作排在最上面。
const ordered = useMemo(
() => [...logs].sort((a, b) => b.startedAt - a.startedAt),
[logs],
)
return (
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
操作日志
</span>
{busy && (
<span className="flex items-center gap-1.5 text-xs text-primary">
<Loader2 className="size-3 animate-spin" />
{busyText ?? "正在执行…"}
</span>
)}
<span className="ml-auto text-xs text-muted-foreground">
{ordered.length > 0 && `${ordered.length} 条`}
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void loadLogs()}
disabled={loading}
aria-label="刷新日志"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
{error ? (
<p className="px-3 py-2 text-xs text-destructive">{error}</p>
) : ordered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{loading ? "正在加载操作日志…" : "暂无操作日志"}
</p>
) : (
<ul className="px-2 py-1">
{ordered.map((entry, index) => (
<li
key={`${entry.startedAt}-${entry.operation}-${index}`}
className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
title={entry.errorMessage ?? entry.summary}
>
{entry.success ? (
<CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
) : (
<XCircle className="size-3.5 shrink-0 text-destructive" />
)}
<span className="w-36 shrink-0 truncate font-mono text-muted-foreground">
{entry.operation}
</span>
<span className="min-w-0 flex-1 truncate">
{entry.summary}
{!entry.success && entry.errorMessage && (
<span className="text-destructive"> {entry.errorMessage}</span>
)}
</span>
<span className="w-32 shrink-0 text-right text-muted-foreground">
{formatDateTime(entry.startedAt)}
</span>
<span className="w-16 shrink-0 text-right font-mono text-muted-foreground">
{formatDuration(entry.durationMs)}
</span>
</li>
))}
</ul>
)}
</ScrollArea>
</footer>
)
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+3 -3
View File
@@ -5,15 +5,15 @@ import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+261
View File
@@ -0,0 +1,261 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+166
View File
@@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/20 duration-200 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-150",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"ease-spring fixed top-12 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 gap-4 rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-dialog duration-200 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-top-6 data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-top-4 data-closed:duration-150 data-closed:ease-out",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-semibold tracking-tight",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "~/lib/utils"
/**
* 基础文本输入框,统一 macOS 工作台风格的尺寸、边框与聚焦态。
*/
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
className,
)}
{...props}
/>
)
}
export { Input }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
/**
* 表单字段标签,保持轻量并禁止选中,贴合工具型界面。
*/
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-1 text-sm font-medium text-foreground select-none",
className,
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,55 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
// Radix 默认给内容包一层 display:table + min-width:100% 的 div,会让内容按
// 自身宽度撑开导致 truncate 失效、文字被面板边缘硬切;改为 block 占满视口宽度。
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:block! [&>div]:w-full [&>div]:min-w-0!"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+192
View File
@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-primary/8 data-[state=selected]:hover:bg-primary/10",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "~/lib/utils"
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
export type ToastKind = "success" | "error" | "loading"
export interface ToastItem {
id: string
kind: ToastKind
text: string
}
type Listener = (toasts: ToastItem[]) => void
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
let currentToasts: ToastItem[] = []
const listeners = new Set<Listener>()
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
const AUTO_DISMISS_MS = 3200
const MAX_VISIBLE = 4
/**
* 通知所有挂载中的 Toaster 重新渲染。
*/
function emit() {
for (const listener of listeners) {
listener(currentToasts)
}
}
/**
* 关闭指定 toast。
*/
export function dismissToast(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
currentToasts = currentToasts.filter((item) => item.id !== id)
emit()
}
/**
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
*/
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const existing = currentToasts.find((item) => item.id === id)
const next: ToastItem = { id, kind: input.kind, text: input.text }
currentToasts = existing
? currentToasts.map((item) => (item.id === id ? next : item))
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
if (input.kind !== "loading") {
dismissTimers.set(
id,
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
)
}
emit()
return id
}
// toast 类型对应的状态徽标:macOS 通知风的彩色小圆徽(成功绿 / 失败红),loading 用旋转图标。
const KIND_BADGES: Record<"success" | "error", { icon: typeof Check; chipClass: string }> = {
success: { icon: Check, chipClass: "bg-emerald-500" },
error: { icon: X, chipClass: "bg-destructive" },
}
/**
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
* 视觉对齐 macOS 系统通知:白色毛玻璃圆角卡片 + 彩色状态圆徽 + 柔和投影,无彩色边框。
*/
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([])
useEffect(() => {
const listener: Listener = (toasts) => setItems([...toasts])
listeners.add(listener)
setItems([...currentToasts])
return () => {
listeners.delete(listener)
}
}, [])
if (items.length === 0) {
return null
}
return (
<div className="pointer-events-none fixed top-14 right-4 z-[60] flex flex-col items-end gap-2.5">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className="animate-in fade-in slide-in-from-top-2 ease-out-quart pointer-events-auto flex w-fit max-w-[360px] min-w-[200px] items-center gap-2.5 rounded-xl border border-black/8 bg-popover/90 py-2.5 pr-4 pl-3 text-left shadow-[0_8px_24px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.06)] backdrop-blur-xl duration-300"
>
{item.kind === "loading" ? (
<Loader2 className="size-4.5 shrink-0 animate-spin text-primary" />
) : (
<ToastBadge kind={item.kind} />
)}
<span className="min-w-0 text-[13px] leading-snug font-medium break-words text-foreground">
{item.text}
</span>
</button>
))}
</div>
)
}
/**
* 成功 / 失败的彩色圆形徽标:实色圆底 + 白色粗描边图标。
*/
function ToastBadge({ kind }: { kind: "success" | "error" }) {
const badge = KIND_BADGES[kind]
const Icon = badge.icon
return (
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full",
badge.chipClass,
)}
>
<Icon className="size-3 text-white" strokeWidth={3.2} />
</span>
)
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+562
View File
@@ -0,0 +1,562 @@
import { useCallback, useState } from "react"
import type { DiffRequest } from "~/components/explorer/diff-dialog"
import { api } from "~/lib/api"
import type { MappingInfo } from "~/lib/api"
import { isElectron, revealPath } from "~/lib/electron"
import type { FileActionId, FileTarget } from "~/lib/tfs"
// 当前打开的业务弹窗:Mapping / History / 目录对比 / 文件查看 / Diff / 冲突处理 / 覆盖类确认 / 属性 / 获取特定版本。
export type WorkspaceDialogState =
| { kind: "mapping"; serverPath: string }
| { kind: "history"; serverPath: string; folder: boolean }
| { kind: "compare"; serverPath: string }
| { kind: "viewFile"; serverPath: string; localPath: string | null; changeset?: number }
| { kind: "diff"; request: DiffRequest }
| { kind: "conflicts"; serverPath: string }
| { 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
// 顶部细条通知:信息(操作摘要)或错误。
export interface ActionNotice {
kind: "info" | "error"
text: string
}
// 动作编排所需的上层依赖:mappings 更新与各类刷新入口。
interface FileActionsDeps {
onMappingsChanged: (mappings: MappingInfo[]) => void
refreshPendingChanges: () => Promise<void>
refreshItems: () => void
refreshLogs: () => void
}
/**
* 对象动作编排 hook:集中持有弹窗开关、通知与忙碌状态,
* 把右键菜单动作映射为 API 执行或弹窗打开,并按统一规则刷新(含操作日志)。
*/
export function useFileActions({
onMappingsChanged,
refreshPendingChanges,
refreshItems,
refreshLogs,
}: FileActionsDeps) {
const [dialog, setDialog] = useState<WorkspaceDialogState>(null)
const [notice, setNotice] = useState<ActionNotice | null>(null)
const [actionBusy, setActionBusy] = useState(false)
const [checkinBusy, setCheckinBusy] = useState(false)
/**
* 执行 Get Latest(目录递归),返回是否产生冲突;摘要写入顶部通知。
* 默认安全模式:本地改动不会被覆盖而是产生冲突并自动进入冲突弹窗;
* force=true 为强制覆盖本地(仅经确认弹窗触发)。
*/
const runGetLatest = useCallback(
async (target: FileTarget, force = false): Promise<boolean> => {
const result = await api.getLatest({
serverPath: target.serverPath,
recursive: target.folder,
force,
})
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "获取最新失败" })
return false
}
const summary = result.data.result
const parts = [`更新 ${summary.updated} 项`]
if (summary.conflicts > 0) {
parts.push(`冲突 ${summary.conflicts} 项`)
}
if (summary.failures > 0) {
parts.push(`跳过 / 失败 ${summary.failures} 项`)
}
setNotice({
kind: summary.conflicts > 0 ? "error" : "info",
text: `${force ? "强制获取" : "获取最新"}完成:${parts.join(",")}`,
})
refreshItems()
if (summary.conflicts > 0) {
setDialog({ kind: "conflicts", serverPath: target.serverPath })
return true
}
return false
},
[refreshItems, refreshLogs],
)
/**
* 获取指定 changeset 版本(覆盖本地),由历史弹窗中的确认操作触发。
*/
const runGetVersion = useCallback(
async (serverPath: string, changeset: number, folder: boolean): Promise<boolean> => {
setNotice({ kind: "info", text: `正在获取版本 C${changeset}…` })
const result = await api.getVersion({ serverPath, changeset, recursive: folder })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "获取指定版本失败" })
return false
}
const summary = result.data.result
setNotice({
kind: "info",
text: `已获取版本 C${changeset}:更新 ${summary.updated} 项(本地已被该版本覆盖)`,
})
refreshItems()
await refreshPendingChanges()
return true
},
[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,出现冲突时进入统一冲突弹窗;
* 无冲突则签出并提示跳过 / 失败项,完成后刷新挂起更改。
*/
const runCheckout = useCallback(
async (target: FileTarget) => {
const hadConflicts = await runGetLatest(target)
if (hadConflicts) {
setNotice({ kind: "error", text: "获取最新存在冲突,请先处理冲突后再签出" })
return
}
const result = await api.checkout({
paths: [target.serverPath],
recursive: target.folder,
})
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "签出失败" })
return
}
const summary = result.data.result
const failureText =
summary.failures.length > 0
? `,跳过 ${summary.failures.length} 项(${summary.failures.slice(0, 3).join(";")}${summary.failures.length > 3 ? "…" : ""})`
: ""
setNotice({
kind: summary.failures.length > 0 ? "error" : "info",
text: `签出完成:${summary.affected} 项${failureText}`,
})
await refreshPendingChanges()
},
[runGetLatest, refreshPendingChanges, refreshLogs],
)
/**
* 执行 挂起删除 / 撤销更改 两类文件操作并按规则刷新。
*/
const runFileOperation = useCallback(
async (target: FileTarget, action: "delete" | "undo") => {
const labels =
action === "delete"
? { doing: "正在挂起删除…", fail: "挂起删除失败", done: "已挂起删除" }
: { doing: "正在撤销更改…", fail: "撤销更改失败", done: "已撤销" }
setNotice({ kind: "info", text: labels.doing })
const call = action === "delete" ? api.deleteFiles : api.undoFiles
const result = await call({ paths: [target.serverPath], recursive: target.folder })
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? labels.fail })
return
}
setNotice({ kind: "info", text: `${labels.done} ${result.data.result.affected} 项` })
await refreshPendingChanges()
refreshItems()
},
[refreshPendingChanges, refreshItems, refreshLogs],
)
/**
* 对象右键菜单动作统一分发器。
* unmap / getLatest / checkout / delete / undo 直接执行;
* map / history / compare / viewFile / diff 打开对应弹窗。
*/
const handleFileAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
setNotice(null)
switch (action) {
case "unmap": {
const result = await api.deleteMapping({ serverPath: target.serverPath })
refreshLogs()
if (!result.ok) {
setNotice({ kind: "error", text: result.errorMessage ?? "取消映射失败" })
return
}
// 不跳转当前浏览位置,仅更新 mappings 让动作可用性与状态列刷新。
onMappingsChanged(result.data?.mappings ?? [])
break
}
case "getLatest":
setActionBusy(true)
setNotice({ kind: "info", text: "正在获取最新…" })
await runGetLatest(target)
setActionBusy(false)
break
case "forceGetLatest":
// 覆盖类危险操作:先弹确认,确认后才执行。
setDialog({
kind: "confirmForceGet",
serverPath: target.serverPath,
folder: target.folder,
})
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":
setActionBusy(true)
setNotice({ kind: "info", text: "正在签出…" })
await runCheckout(target)
setActionBusy(false)
break
case "delete":
case "undo":
setActionBusy(true)
await runFileOperation(target, action)
setActionBusy(false)
break
case "add": {
// pend add 需要本地路径(localOnly 项服务端尚不存在)。
if (!target.localPath) {
return
}
setActionBusy(true)
setNotice({ kind: "info", text: "正在加入版本控制…" })
const result = await api.addFiles({ paths: [target.localPath], recursive: target.folder })
refreshLogs()
setActionBusy(false)
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "加入版本控制失败" })
return
}
setNotice({ kind: "info", text: `已加入版本控制 ${result.data.result.affected} 项` })
await refreshPendingChanges()
refreshItems()
break
}
case "map":
setDialog({ kind: "mapping", serverPath: target.serverPath })
break
case "history":
setDialog({ kind: "history", serverPath: target.serverPath, folder: target.folder })
break
case "compare":
setDialog({ kind: "compare", serverPath: target.serverPath })
break
case "viewFile":
setDialog({
kind: "viewFile",
serverPath: target.serverPath,
localPath: target.localPath,
})
break
case "diffLocalLatest":
if (target.localPath) {
setDialog({
kind: "diff",
request: {
mode: "localLatest",
serverPath: target.serverPath,
localPath: target.localPath,
},
})
}
break
default:
break
}
},
[onMappingsChanged, runGetLatest, runCheckout, runFileOperation, refreshLogs, refreshItems, refreshPendingChanges],
)
/**
* 提交签入:只提交 Included 项,成功后展示 changeset 并刷新挂起更改与当前目录。
*/
const handleCheckin = useCallback(
async (paths: string[], comment: string): Promise<boolean> => {
setCheckinBusy(true)
setNotice({ kind: "info", text: "正在签入…" })
const result = await api.checkin({ paths, comment })
setCheckinBusy(false)
refreshLogs()
if (!result.ok || !result.data) {
setNotice({ kind: "error", text: result.errorMessage ?? "签入失败" })
return false
}
const summary = result.data.checkin
setNotice({
kind: "info",
text: `签入成功:changeset ${summary.changeset},提交 ${summary.submittedChanges} 项`,
})
await refreshPendingChanges()
refreshItems()
return true
},
[refreshPendingChanges, refreshItems, refreshLogs],
)
/**
* Mapping 创建成功:刷新 mappings 与当前目录列表,保持浏览位置并关闭弹窗。
*/
const handleMappingCreated = useCallback(
(mappings: MappingInfo[]) => {
onMappingsChanged(mappings)
refreshItems()
setDialog(null)
},
[onMappingsChanged, refreshItems],
)
/**
* 冲突取舍完成:关闭弹窗并刷新当前目录与挂起更改。
*/
const handleConflictsResolved = useCallback(() => {
setDialog(null)
refreshItems()
void refreshPendingChanges()
refreshLogs()
setNotice({ kind: "info", text: "冲突处理完成,已刷新状态" })
}, [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],
)
/**
* 强制获取确认后执行:覆盖本地并关闭确认弹窗。
*/
const handleForceGetConfirmed = useCallback(
async (serverPath: string, folder: boolean) => {
setActionBusy(true)
const target: FileTarget = {
source: "list",
folder,
serverPath,
localPath: null,
mapped: true,
mappingRoot: false,
pendingStatus: null,
}
await runGetLatest(target, true)
setActionBusy(false)
setDialog(null)
},
[runGetLatest],
)
return {
dialog,
setDialog,
notice,
actionBusy,
checkinBusy,
handleFileAction,
handleCheckin,
handleMappingCreated,
handleConflictsResolved,
handleForceGetConfirmed,
handleRenameConfirmed,
handleBranchConfirmed,
handleMergeConfirmed,
handleAddFilesConfirmed,
runGetVersion,
runRollback,
}
}
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { api } from "~/lib/api"
import type { PendingChange } from "~/lib/api"
/**
* 挂起更改共享状态:列表加载 / 刷新、Included-Excluded 维护,
* 以及供菜单可用性判断的 serverPath -> 状态 映射。
*/
export function usePendingChanges() {
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Excluded 的挂起更改 serverPath 集合(不持久化),其余默认 Included。
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set())
/**
* 刷新当前 Workspace 的挂起更改列表,并清理悬挂的 Excluded 键。
*/
const refresh = useCallback(async () => {
setLoading(true)
const result = await api.getPendingChanges()
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "挂起更改加载失败")
return
}
setError(null)
const list = result.data?.pendingChanges ?? []
setPendingChanges(list)
setExcludedKeys((prev) => {
const valid = new Set(list.map((change) => change.serverPath))
const next = new Set([...prev].filter((key) => valid.has(key)))
return next.size === prev.size ? prev : next
})
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/**
* 切换某条挂起更改的 Included / Excluded 归属。
*/
const toggleExcluded = useCallback((serverPath: string) => {
setExcludedKeys((prev) => {
const next = new Set(prev)
if (next.has(serverPath)) {
next.delete(serverPath)
} else {
next.add(serverPath)
}
return next
})
}, [])
// serverPath -> 挂起状态,供文件列表生成菜单时控制可用性。
const pendingByServerPath = useMemo(() => {
const map: Record<string, string> = {}
for (const change of pendingChanges) {
map[change.serverPath] = change.status
}
return map
}, [pendingChanges])
return {
pendingChanges,
loading,
error,
excludedKeys,
pendingByServerPath,
refresh,
toggleExcluded,
}
}
+95 -194
View File
@@ -1,216 +1,117 @@
import { getMactfsBridge } from "~/lib/electron/bridge"
import type { ApiResult } from "./types"
const API_BASE_URL = "http://127.0.0.1:38765"
interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
body?: unknown
headers?: HeadersInit
}
export type ApiRequestLogStatus = "running" | "success" | "error"
export interface ApiRequestLogEntry {
id: string
operation: string
summary: string
startedAt: number
endedAt?: number
durationMs?: number
success?: boolean
errorMessage?: string
status: ApiRequestLogStatus
logs?: string[]
}
type ApiRequestLogListener = (entry: ApiRequestLogEntry) => void
const apiRequestLogListeners = new Set<ApiRequestLogListener>()
let apiRequestLogIndex = 0
import { getApiBaseUrl, getToken } from "~/lib/electron"
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
/**
* 订阅统一 API client 的请求日志,供底部 Console 展示每次操作状态。
* 拼接查询参数到请求路径,自动跳过空值。
*/
export function subscribeApiRequestLogs(listener: ApiRequestLogListener) {
apiRequestLogListeners.add(listener)
return () => {
apiRequestLogListeners.delete(listener)
}
}
/**
* 向所有日志订阅方发布一条请求状态更新。
*/
function notifyApiRequestLog(entry: ApiRequestLogEntry) {
apiRequestLogListeners.forEach((listener) => listener(entry))
}
/**
* 生成本次请求的前端日志 id,用于 running 和完成态更新同一行。
*/
function nextApiRequestLogId(apiPath: string) {
apiRequestLogIndex += 1
return `${Date.now()}:${apiRequestLogIndex}:${apiPath}`
}
/**
* 从请求体或 query 中提取路径摘要,便于 Console 快速定位操作对象。
*/
function summarizeApiRequest(apiPath: string, body?: unknown) {
if (body && typeof body === "object" && !Array.isArray(body)) {
const bodySummary = summarizeRequestBody(body as Record<string, unknown>)
if (bodySummary) {
return bodySummary
function buildUrl(
baseUrl: string,
path: string,
query?: ApiRequestOptions["query"],
): string {
const url = new URL(path, baseUrl)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value))
}
}
}
const query = apiPath.split("?")[1]
return query || apiPath
return url.toString()
}
/**
* 按后端常用字段生成请求体摘要。
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
*/
function summarizeRequestBody(body: Record<string, unknown>) {
return [
"serverPath",
"path",
"localPath",
"paths",
"serverPaths",
"changeset",
"sourceChangeset",
"targetChangeset",
]
.filter((key) => body[key] !== undefined && body[key] !== "")
.map((key) => `${key}=${formatSummaryValue(body[key])}`)
.join(" | ")
}
/**
* 将摘要字段值转换成单行文本。
*/
function formatSummaryValue(value: unknown) {
return Array.isArray(value) ? value.join(", ") : String(value)
}
/**
* 创建前端统一失败响应,供网络或 Electron 桥接不可用时展示明确提示。
*/
function createFailureResult<TData>(
message: string,
startedAt = Date.now()
): ApiResult<TData> {
const endedAt = Date.now()
return {
success: false,
message,
errorMessage: message,
operation: "frontendRequest",
startedAt,
endedAt,
durationMs: endedAt - startedAt,
logs: [],
data: {} as TData,
function resolveErrorMessage(
result: ApiResult<unknown> | null,
fallback: string,
): string {
if (result) {
return result.errorMessage || result.message || fallback
}
return fallback
}
/**
* 将统一 API 响应转换为底部 Console 可展示的日志行。
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
*/
function toApiRequestLogEntry(
id: string,
summary: string,
fallbackStartedAt: number,
result: ApiResult<unknown>
): ApiRequestLogEntry {
return {
id,
operation: result.operation,
summary,
startedAt: result.startedAt || fallbackStartedAt,
endedAt: result.endedAt,
durationMs: result.durationMs,
success: result.success,
errorMessage: result.errorMessage || (result.success ? "" : result.message),
status: result.success ? "success" : "error",
logs: result.logs,
async function request<T = Record<string, unknown>>(
method: string,
path: string,
options: ApiRequestOptions = {},
): Promise<ApiResponse<T>> {
const baseUrl = await getApiBaseUrl()
const token = await getToken()
const headers: Record<string, string> = {
Accept: "application/json",
}
}
/**
* 通过统一 API client 调用本地 Java API,并自动附加 Bearer token。
*/
export async function apiRequest<TData>(
apiPath: string,
options: ApiRequestOptions = {}
): Promise<ApiResult<TData>> {
const startedAt = Date.now()
const logId = nextApiRequestLogId(apiPath)
const summary = summarizeApiRequest(apiPath, options.body)
notifyApiRequestLog({
id: logId,
operation: apiPath,
summary,
startedAt,
status: "running",
})
const bridge = getMactfsBridge()
if (!bridge) {
const result = createFailureResult<TData>(
"未检测到 Electron preload,无法读取本地 API token。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
if (token) {
headers.Authorization = `Bearer ${token}`
}
const token = await bridge.getToken()
if (!token) {
const result = createFailureResult<TData>(
"未找到本地 API token,请确认本地服务已启动。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
const init: RequestInit = { method, headers, signal: options.signal }
if (options.body !== undefined) {
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
let response: Response
try {
const headers = new Headers(options.headers)
headers.set("Authorization", `Bearer ${token}`)
if (options.body !== undefined) {
headers.set("Content-Type", "application/json")
}
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
...options,
headers,
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
})
const result = (await response.json()) as ApiResult<TData>
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
response = await fetch(buildUrl(baseUrl, path, options.query), init)
} catch (error) {
const result = createFailureResult<TData>(
error instanceof Error ? error.message : "本地 API 请求失败。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
status: 0,
result: null,
data: null,
errorMessage: `无法连接本地服务:${message}`,
}
}
let result: ApiResult<T> | null = null
try {
result = (await response.json()) as ApiResult<T>
} catch {
result = null
}
const ok = response.ok && result?.success === true
return {
ok,
status: response.status,
result,
data: result?.data ?? null,
errorMessage: ok
? null
: resolveErrorMessage(result, `请求失败(HTTP ${response.status})`),
}
}
// 统一 API 客户端,组件只通过本对象访问后端,不直接 fetch、不直接读 token。
export const apiClient = {
/**
* 发起 GET 请求。
*/
get<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("GET", path, options)
},
/**
* 发起 POST 请求。
*/
post<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("POST", path, options)
},
/**
* 发起 PUT 请求。
*/
put<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("PUT", path, options)
},
/**
* 发起 DELETE 请求。
*/
delete<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("DELETE", path, options)
},
}
+186 -172
View File
@@ -1,272 +1,286 @@
import { apiRequest } from "./client"
import { apiClient } from "./client"
import type {
AppConfig,
AddMappingData,
AddMappingRequest,
ApplyConflictChoiceRequest,
CheckinData,
CheckinRequest,
ChangesetFilesData,
CompareFolderData,
CompareFolderRequest,
CollectionsData,
FileOperationData,
FileOperationRequest,
ConfigData,
ConnectData,
DiffLocalLatestRequest,
DiffRevisionsRequest,
FileContentData,
FileContentRequest,
GetLatestData,
GetLatestRequest,
HistoryData,
MappingsData,
MappingsRequest,
OperationLogsData,
PendingChangesData,
PendingChangesRequest,
ServerTreeData,
TextDiffData,
WorkspaceContextData,
WorkspaceContextRequest,
CheckMappingTargetResult,
CheckinResult,
Collection,
ConflictInfo,
ConflictResolution,
ConflictResolutionResult,
ConnectResult,
FileContent,
FileOperationResult,
FolderDiffItem,
GetLatestResult,
HistoryEntry,
ItemInfo,
MappingInfo,
OperationLogEntry,
PendingChange,
ServerItem,
TextDiff,
WorkspaceContext,
WorkspaceInfo,
} from "./types"
// 文件类操作(checkout / add / delete / undo)的统一请求体。
type FileActionBody = { paths: string[]; recursive?: boolean }
/**
* 读取后端保存的默认连接配置,用于再次打开时回填表单。
* 读取服务端持久化配置。
*/
export function getConfig() {
return apiRequest<ConfigData>("/api/config")
return apiClient.get<{ config: AppConfig }>("/api/config")
}
/**
* 提交连接配置给后端;连接成功时后端会保存为默认配置。
* 保存服务端持久化配置。
*/
export function connectSession(config: AppConfig) {
return apiRequest<ConnectData>("/api/session/connect", {
method: "POST",
body: config,
})
export function saveConfig(config: AppConfig) {
return apiClient.put<{ config: AppConfig }>("/api/config", { body: config })
}
/**
* 加载当前连接可见的 TFS Collection 列表。
* 使用给定连接信息连接 TFS,成功后服务端会保存配置并建立会话。
*/
export function connect(config: Partial<AppConfig> = {}) {
return apiClient.post<ConnectResult>("/api/session/connect", { body: config })
}
/**
* 列出当前账号可见的 Collection。
*/
export function listCollections() {
return apiRequest<CollectionsData>("/api/collections")
return apiClient.get<{ collections: Collection[] }>("/api/collections")
}
/**
* 固定当前 Collection 并自动使用或创建本机默认 Workspace。
* 浏览服务端目录树节点。
*/
export function ensureWorkspaceContext(request: WorkspaceContextRequest) {
return apiRequest<WorkspaceContextData>("/api/workspace/context", {
method: "POST",
body: request,
})
export function getServerTree(params: { path?: string; collection?: string } = {}) {
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-tree", { query: params })
}
/**
* 加载指定 Collection 和服务端路径下的目录节点。
* 读取当前目录下一级文件与文件夹列表。
*/
export function listServerTree(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(`/api/server-tree?${params.toString()}`)
export function getFolderItems(params: { path?: string; collection?: string } = {}) {
return apiClient.get<{ path: string; items: ServerItem[] }>("/api/server-folder/items", { query: params })
}
/**
* 加载当前 Workspace 的 Mapping 列表,用于判断服务端项是否已映射到本地。
* 读取当前 Workspace 名称与 Mapping 列表。
*/
export function listMappings(request: MappingsRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
const query = params.toString()
return apiRequest<MappingsData>(
query ? `/api/mappings?${query}` : "/api/mappings"
)
export function getWorkspace() {
return apiClient.get<{ workspace: string; mappings: MappingInfo[] }>("/api/workspace")
}
/**
* 加载指定目录下一级文件和文件夹,供中间文件列表展示。
* 确保当前 Collection 的默认 Workspace 存在,必要时创建。
*/
export function listServerFolderItems(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(
`/api/server-folder/items?${params.toString()}`
)
export function ensureWorkspace(body: { collection?: string; workspace?: string; comment?: string } = {}) {
return apiClient.post<{ workspace: WorkspaceInfo }>("/api/workspace/ensure", { body })
}
/**
* 创建服务端路径到本地目录的 Mapping,可选择保存后立即 Get Latest。
* 读取工作台固定上下文(serverUri / collection / workspace / mappings)。
*/
export function addMapping(request: AddMappingRequest) {
return apiRequest<AddMappingData>("/api/mappings", {
method: "POST",
body: request,
})
export function getWorkspaceContext() {
return apiClient.get<{ context: WorkspaceContext }>("/api/workspace/context")
}
/**
* 对已映射目录执行元数据级目录对比,返回差异文件状态列表。
* 读取当前 Workspace 的 Mapping 列表。
*/
export function compareFolder(request: CompareFolderRequest) {
return apiRequest<CompareFolderData>("/api/compare/folder", {
method: "POST",
body: request,
})
export function listMappings() {
return apiClient.get<{ mappings: MappingInfo[] }>("/api/mappings")
}
/**
* 查询当前 Workspace 的挂起更改列表,供右侧 Inspector 分组展示。
* 预校验 Mapping 目标路径,返回最终本地目标路径及是否已存在。
*/
export function listPendingChanges(request: PendingChangesRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
if (request.serverPath) {
params.set("serverPath", request.serverPath)
}
const query = params.toString()
return apiRequest<PendingChangesData>(
query ? `/api/pending-changes?${query}` : "/api/pending-changes"
)
export function checkMappingTarget(body: { serverPath: string; localParentPath: string }) {
return apiClient.post<CheckMappingTargetResult>("/api/mappings/check-target", { body })
}
/**
* 获取指定服务端路径的最新版本,目录按 recursive 参数递归。
* 创建 Mapping,可选立即 Get Latest。
*/
export function getLatest(request: GetLatestRequest) {
return apiRequest<GetLatestData>("/api/files/get-latest", {
method: "POST",
body: request,
})
export function addMapping(body: { serverPath: string; localPath: string; getLatest?: boolean }) {
return apiClient.post<{ mapping: MappingInfo; getLatest?: GetLatestResult }>("/api/mappings", { body })
}
/**
* 读取映射目录内本地文件或未映射文件的服务器 latest 内容。
* 删除 Mapping,仅解除映射不删除本地文件。
*/
export function getFileContent(request: FileContentRequest) {
return apiRequest<FileContentData>("/api/files/content", {
method: "POST",
body: request,
})
export function deleteMapping(body: { serverPath?: string; localPath?: string }) {
return apiClient.delete<{ mappings: MappingInfo[] }>("/api/mappings", { body })
}
/**
* 对已映射且本地存在的文件或目录执行 checkout。
* 获取指定服务端路径的最新文件。
* force=false(默认)为安全模式,本地改动会产生冲突而不被覆盖;force=true 强制覆盖本地。
*/
export function checkoutFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/checkout", {
method: "POST",
body: request,
})
export function getLatest(body: { serverPath?: string; recursive?: boolean; force?: boolean } = {}) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-latest", { body })
}
/**
* 获取指定 changeset 版本并覆盖本地(对应 VS 的 Get Specific Version + Overwrite)。
*/
export function getVersion(body: { serverPath: string; changeset: number; recursive?: boolean }) {
return apiClient.post<{ result: GetLatestResult }>("/api/files/get-version", { body })
}
/**
* 对已存在文件或目录执行 checkout。
*/
export function checkout(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/checkout", { body })
}
/**
* 将本地新增文件加入 pending add。
*/
export function addFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/add", {
method: "POST",
body: request,
})
export function addFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/add", { body })
}
/**
* 对已版本控制文件或目录执行 pending delete。
*/
export function deleteFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/delete", {
method: "POST",
body: request,
})
export function deleteFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/delete", { body })
}
/**
* 撤销指定路径上的 pending changes。
* 撤销指定路径上的挂起更改。
*/
export function undoFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/undo", {
method: "POST",
body: request,
})
export function undoFiles(body: FileActionBody) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/undo", { body })
}
/**
* 应用 Get Latest / Checkout 冲突弹窗中的单文件选择。
* 重命名文件或目录(同目录改名),产生 rename 挂起更改。
*/
export function applyConflictChoice(request: ApplyConflictChoiceRequest) {
return apiRequest<FileOperationData>("/api/conflicts/apply", {
method: "POST",
body: request,
})
export function renameFile(body: { serverPath: string; newName: string }) {
return apiClient.post<{ result: FileOperationResult }>("/api/files/rename", { body })
}
/**
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
* 回滚变更集(只产生挂起更改):single 仅反做该 changeset,toVersion 反做其后全部改动。
*/
export function checkin(request: CheckinRequest) {
return apiRequest<CheckinData>("/api/checkin", {
method: "POST",
body: request,
})
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 queryHistory(path: string, folder: boolean) {
const params = new URLSearchParams({ path, folder: String(folder) })
return apiRequest<HistoryData>(`/api/history?${params.toString()}`)
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 的挂起更改。
*/
export function getPendingChanges(params: { serverPath?: string } = {}) {
return apiClient.get<{ pendingChanges: PendingChange[] }>("/api/pending-changes", { query: params })
}
/**
* 提交带注释的签入,paths 为空时签入全部挂起更改。
*/
export function checkin(body: { paths?: string[]; comment: string }) {
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),或读取本地映射文件内容。
*/
export function getFileContent(params: { serverPath?: string; localPath?: string; changeset?: number }) {
return apiClient.get<{ content: FileContent }>("/api/files/content", { query: params })
}
/**
* 对已映射目录执行目录对比。
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
*/
export function compareFolder(body: {
serverPath: string
localPath?: string
recursive?: boolean
includeLocalOnly?: boolean
}) {
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
}
/**
* 查询文件或目录历史。
*/
export function getHistory(params: { path: string; folder?: boolean }) {
return apiClient.get<{ history: HistoryEntry[] }>("/api/history", { query: params })
}
/**
* 查询指定 changeset 影响的文件列表。
*/
export function queryChangesetFiles(changeset: number) {
const params = new URLSearchParams({ changeset: String(changeset) })
return apiRequest<ChangesetFilesData>(
`/api/history/changeset?${params.toString()}`
)
export function getChangesetFiles(params: { changeset: number }) {
return apiClient.get<{ files: HistoryEntry[] }>("/api/history/changeset", { query: params })
}
/**
* 生成本地文件和服务器 latest 之间的文本 diff。
* 生成本地文件与服务器 latest 的文本 Diff。
*/
export function diffLocalLatest(request: DiffLocalLatestRequest) {
return apiRequest<TextDiffData>("/api/diff/local-latest", {
method: "POST",
body: request,
})
export function diffLocalLatest(body: { serverPath: string; localPath: string }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/local-latest", { body })
}
/**
* 生成同一服务端文件两个历史版本之间的文本 diff。
* 生成同一服务端文件两个 changeset 之间的文本 Diff。
*/
export function diffRevisions(request: DiffRevisionsRequest) {
return apiRequest<TextDiffData>("/api/diff/revisions", {
method: "POST",
body: request,
})
export function diffRevisions(body: { serverPath: string; sourceChangeset: number; targetChangeset: number }) {
return apiClient.post<{ diff: TextDiff }>("/api/diff/revisions", { body })
}
/**
* 读取本地 API 服务最近操作日志,供底部 Console 手动刷新。
* 查询当前 Workspace 的冲突明细。
*/
export function listOperationLogs() {
return apiRequest<OperationLogsData>("/api/logs")
export function listConflicts(params: { serverPath?: string; recursive?: boolean } = {}) {
return apiClient.get<{ conflicts: ConflictInfo[] }>("/api/conflicts", { query: params })
}
/**
* 对单个冲突应用取舍(采用服务器版本或保留本地版本)。
*/
export function applyConflict(body: { conflictId: number; resolution: ConflictResolution }) {
return apiClient.post<{ resolution: ConflictResolutionResult }>("/api/conflicts/apply", { body })
}
/**
* 读取最近操作日志。
*/
export function getLogs() {
return apiClient.get<{ logs: OperationLogEntry[] }>("/api/logs")
}
+29
View File
@@ -0,0 +1,29 @@
export type {
ApiRequestOptions,
ApiResponse,
ApiResult,
AppConfig,
CheckMappingTargetResult,
CheckinResult,
Collection,
ConflictInfo,
ConflictResolution,
ConflictResolutionResult,
ConnectResult,
FileContent,
FileOperationResult,
FolderDiffItem,
GetLatestResult,
HistoryEntry,
ItemInfo,
MappingConfig,
MappingInfo,
OperationLogEntry,
PendingChange,
ServerItem,
TextDiff,
WorkspaceContext,
WorkspaceInfo,
} from "./types"
export { apiClient } from "./client"
export * as api from "./endpoints"
+137 -221
View File
@@ -1,43 +1,39 @@
export interface ApiResult<TData = Record<string, unknown>> {
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message: string
message?: string
errorMessage?: string
operation: string
startedAt: number
endedAt: number
durationMs: number
logs: string[]
data: TData
operation?: string
startedAt?: number
endedAt?: number
durationMs?: number
logs?: string[]
data: T
}
export interface HealthData {
status: string
host: string
port: number
tokenFile: string
configFile: string
connected: boolean
// API 客户端对外返回结构,统一封装网络层与业务层成功/失败。
export interface ApiResponse<T = Record<string, unknown>> {
ok: boolean
status: number
result: ApiResult<T> | null
data: T | null
errorMessage: string | null
}
// API 请求选项。
export interface ApiRequestOptions {
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
signal?: AbortSignal
}
// 单条 Mapping 配置,对应服务端 MappingConfig。
export interface MappingConfig {
serverPath?: string
localPath?: string
}
export interface TfsMappingInfo {
serverPath: string
localPath: string
}
export interface TfsWorkspaceInfo {
name: string
ownerName: string
computer: string
comment: string
created: boolean
mappings: TfsMappingInfo[]
}
// 服务端持久化配置,对应 MacTfsServer.AppConfig。
export interface AppConfig {
serverUri?: string
authType?: string
@@ -46,266 +42,186 @@ export interface AppConfig {
password?: string
collection?: string
workspace?: string
mappings?: MappingConfig[]
mappings: MappingConfig[]
}
export interface ConfigData {
config: AppConfig
}
export interface ConnectData {
// 连接结果摘要,对应 /api/session/connect 返回的概要字段。
export interface ConnectResult {
serverUri?: string
collectionCount?: number
}
export interface WorkspaceContextRequest {
collection: string
}
export interface WorkspaceContextData {
collection: string
workspace?: TfsWorkspaceInfo
mappings: TfsMappingInfo[]
}
export interface TfsCollectionInfo {
// Collection 信息,对应 core TfsCollectionInfo。
export interface Collection {
name: string
id: string
}
export interface TfsServerItem {
// 服务端目录项,对应服务端 serverItems 序列化结构。
export interface ServerItem {
name: string
path: string
serverPath: string
type: "folder" | "file"
folder: boolean
latestVersion: number
checkinDate?: number | null
checkinDate: number | null
}
export interface CollectionsData {
collections: TfsCollectionInfo[]
// Workspace Mapping 信息,对应 core TfsMappingInfo。
export interface MappingInfo {
serverPath: string
localPath: string
}
export interface ServerTreeData {
path: string
items: TfsServerItem[]
// Workspace 概要,对应 core TfsWorkspaceInfo。
export interface WorkspaceInfo {
name: string
ownerName: string
computer: string
comment: string
created: boolean
mappings: MappingInfo[]
}
export interface MappingsData {
mappings: TfsMappingInfo[]
}
export interface MappingsRequest {
// 工作台固定上下文,对应 /api/workspace/context 的 context 字段。
export interface WorkspaceContext {
serverUri?: string
authType?: string
collection?: string
workspace?: string
connected: boolean
mappings: MappingInfo[]
}
export interface AddMappingRequest {
// Mapping 目标预校验结果,对应 /api/mappings/check-target。
export interface CheckMappingTargetResult {
serverPath: string
localParentPath: string
name: string
targetPath: string
exists: boolean
allowed: boolean
}
// Get Latest 结果,对应 core TfsGetLatestResult。
export interface GetLatestResult {
updated: number
operations: number
conflicts: number
failures: number
}
// 文件操作结果,对应 core TfsFileOperationResult。
export interface FileOperationResult {
operation: string
affected: number
failures: string[]
}
// 挂起更改项,对应 core TfsPendingChangeInfo。
export interface PendingChange {
serverPath: string
localPath: string
getLatest: boolean
name: string
folder: boolean
status: string
changeType: string
version: number
/** rename 挂起更改的原服务端路径,其余为 null。 */
sourceServerPath: string | null
}
export interface AddMappingData {
mapping?: TfsMappingInfo
getLatest?: unknown
}
export type FolderDiffStatus =
| "localModified"
| "remoteChanged"
| "bothChanged"
| "localOnly"
| "remoteOnly"
| "notDownloaded"
| "localDeleted"
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "upToDate"
export interface CompareFolderRequest {
serverPath: string
localPath: string
recursive: boolean
// 签入结果,对应 core TfsCheckinResult。
export interface CheckinResult {
changeset: number
submittedChanges: number
}
// 目录对比项,对应 core TfsFolderDiffItem。
export interface FolderDiffItem {
serverPath: string
localPath: string
name: string
folder: boolean
status: FolderDiffStatus
status: string
localVersion: number
latestVersion: number
}
export interface CompareFolderData {
diffs: FolderDiffItem[]
}
export type PendingChangeStatus =
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "pendingRename"
| "pending"
export interface TfsPendingChangeInfo {
serverPath: string
localPath: string
name: string
folder: boolean
status: PendingChangeStatus
changeType: string
version: number
}
export interface PendingChangesData {
pendingChanges: TfsPendingChangeInfo[]
}
export interface PendingChangesRequest {
collection?: string
serverPath?: string
}
export interface GetLatestRequest {
serverPath: string
recursive: boolean
}
export interface TfsGetLatestResult {
updated: number
operations: number
conflicts: number
failures: number
skipped?: string[]
conflictDetails?: TfsConflictInfo[]
}
export interface GetLatestData {
result?: TfsGetLatestResult
}
export interface FileOperationRequest {
paths: string[]
recursive?: boolean
}
export interface TfsFileOperationResult {
operation: string
affected: number
failures: string[]
skipped?: string[]
conflictDetails?: TfsConflictInfo[]
}
export interface FileOperationData {
result?: TfsFileOperationResult
}
export interface CheckinRequest {
serverPaths: string[]
comment: string
}
export interface TfsCheckinResult {
changeset: number
submittedChanges: number
}
export interface CheckinData {
checkin?: TfsCheckinResult
}
export interface TfsHistoryEntry {
// 历史记录项,对应 core TfsHistoryEntry。
export interface HistoryEntry {
serverPath: string
name: string
changeType: string
itemType: string
changeset: number
author: string
date?: number | null
date: number | null
comment: string
}
export interface HistoryData {
history: TfsHistoryEntry[]
}
export interface ChangesetFilesData {
files: TfsHistoryEntry[]
}
export interface DiffLocalLatestRequest {
// 服务端对象属性信息,对应 core TfsItemInfo(属性弹窗用)。
export interface ItemInfo {
serverPath: string
localPath: string
folder: boolean
changeset: number
checkinDate: number | null
author: string
comment: string
/** 文件大小(字节),目录为 null。 */
size: number | null
/** 文件编码名称,目录为 null。 */
encoding: string | null
}
export interface DiffRevisionsRequest {
// 文件内容,对应 core TfsFileContent,binary / tooLarge 时 content 为空串。
export interface FileContent {
serverPath: string
sourceChangeset: number
targetChangeset: number
changeset: number
content: string
binary: boolean
size: number
tooLarge: boolean
}
export interface TfsTextDiff {
// 文本 Diff,对应 core TfsTextDiff。
export interface TextDiff {
sourceLabel: string
targetLabel: string
lines: string[]
}
export interface TextDiffData {
diff?: TfsTextDiff
// 冲突明细,对应 core TfsConflictInfo。
export interface ConflictInfo {
conflictId: number
type: string
serverPath: string | null
localPath: string | null
yourServerItem: string | null
theirServerItem: string | null
baseServerItem: string | null
resolved: boolean
}
export interface TfsFileContent {
serverPath: string
localPath?: string
changeset: number
source: "local" | "server"
size: number
binary: boolean
renderable: boolean
encoding: string
content: string
// 第一版支持的冲突取舍:采用服务器版本或保留本地版本。
export type ConflictResolution = "takeServer" | "keepLocal"
// 冲突应用结果,对应 core TfsConflictResolution。
export interface ConflictResolutionResult {
conflictId: number
resolution: string
resolved: boolean
remainingConflicts: number
}
export interface FileContentRequest {
serverPath: string
localPath?: string
preferLocal?: boolean
}
export interface FileContentData {
file?: TfsFileContent
}
export interface TfsConflictInfo {
serverPath: string
localPath?: string
fileSize: number
binary: boolean
renderable: boolean
autoMergeable: boolean
reason: string
}
export interface ApplyConflictChoiceRequest {
serverPath: string
choice: "useServer" | "keepLocal" | "autoMerge"
}
export interface TfsOperationLogEntry {
// 操作日志项,对应服务端 OperationLogEntry。
export interface OperationLogEntry {
operation: string
summary: string
startedAt: number
endedAt: number
durationMs: number
success: boolean
errorMessage?: string
}
export interface OperationLogsData {
logs: TfsOperationLogEntry[]
errorMessage: string | null
}
+102 -23
View File
@@ -1,31 +1,110 @@
export interface ServiceStatus {
baseUrl: string
running: boolean
connected: boolean
message: string
tokenFile: string
configFile?: string
javaArch?: string
javaHome?: string
nativeCompatible?: boolean
started?: boolean
}
import type { MactfsBridge, ServiceStatus } from "./types"
export interface MacTfsBridge {
getToken(): Promise<string>
getServiceStatus(): Promise<ServiceStatus>
selectDirectory(): Promise<string | null>
}
const DEFAULT_BASE_URL = "http://127.0.0.1:38765"
declare global {
interface Window {
mactfs?: MacTfsBridge
/**
* 获取 Electron 暴露的 window.mactfs 桥接对象,非 Electron 环境(含 SSR)返回 null。
*/
export function getBridge(): MactfsBridge | null {
if (typeof window === "undefined") {
return null
}
return window.mactfs ?? null
}
/**
* 返回 preload 暴露的 macTFS 桥接对象,避免组件直接依赖 Electron 细节。
* 判断当前是否运行在 Electron 桥接环境中。
*/
export function getMactfsBridge() {
return window.mactfs
export function isElectron(): boolean {
return getBridge() != null
}
/**
* 获取本地 API 基础地址,非 Electron 环境回退到默认本地地址。
*/
export async function getApiBaseUrl(): Promise<string> {
const bridge = getBridge()
if (!bridge) {
return DEFAULT_BASE_URL
}
return bridge.getApiBaseUrl()
}
/**
* 获取本地服务 Bearer Token,非 Electron 环境返回 null。
*/
export async function getToken(): Promise<string | null> {
const bridge = getBridge()
if (!bridge) {
return null
}
return bridge.getToken()
}
/**
* 查询本地服务状态,非 Electron 环境返回未就绪状态并说明原因。
*/
export async function getServiceStatus(): Promise<ServiceStatus> {
const bridge = getBridge()
if (!bridge) {
return unavailableStatus()
}
return bridge.getServiceStatus()
}
/**
* 拉起并等待本地服务就绪,非 Electron 环境返回未就绪状态并说明原因。
*/
export async function startService(): Promise<ServiceStatus> {
const bridge = getBridge()
if (!bridge) {
return unavailableStatus()
}
return bridge.startService()
}
/**
* 打开系统目录选择器,非 Electron 环境返回 null。
*/
export async function selectDirectory(): Promise<string | null> {
const bridge = getBridge()
if (!bridge) {
return null
}
return bridge.selectDirectory()
}
/**
* 批量检测本地绝对路径是否存在,非 Electron 环境返回 null 表示无法检测。
*/
export async function pathsExist(paths: string[]): Promise<Record<string, boolean> | null> {
const bridge = getBridge()
if (!bridge || paths.length === 0) {
return bridge ? {} : null
}
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 环境下的占位服务状态。
*/
function unavailableStatus(): ServiceStatus {
return {
running: false,
baseUrl: DEFAULT_BASE_URL,
tokenAvailable: false,
health: null,
error: "当前不在 Electron 桌面环境,无法访问本地服务",
}
}
+12
View File
@@ -0,0 +1,12 @@
export type { MactfsBridge, ServiceHealth, ServiceStatus } from "./types"
export {
getApiBaseUrl,
getBridge,
getServiceStatus,
getToken,
isElectron,
pathsExist,
revealPath,
selectDirectory,
startService,
} from "./bridge"
+36
View File
@@ -0,0 +1,36 @@
// 本地 API 服务健康信息,对应服务端 /api/health 的 data 字段。
export interface ServiceHealth {
status: string
host: string
port: number
tokenFile: string
configFile: string
connected: boolean
}
// 渲染进程感知的本地服务状态,由 Electron 主进程计算返回。
export interface ServiceStatus {
running: boolean
baseUrl: string
tokenAvailable: boolean
health: ServiceHealth | null
error: string | null
}
// preload 暴露到 window.mactfs 的窄接口契约。
export interface MactfsBridge {
isElectron: true
getApiBaseUrl(): Promise<string>
getToken(): Promise<string | null>
getServiceStatus(): Promise<ServiceStatus>
startService(): Promise<ServiceStatus>
selectDirectory(): Promise<string | null>
pathsExist(paths: string[]): Promise<Record<string, boolean>>
revealPath(targetPath: string, isFolder: boolean): Promise<boolean>
}
declare global {
interface Window {
mactfs?: MactfsBridge
}
}
+75
View File
@@ -0,0 +1,75 @@
// Monaco Editor 客户端引导模块:注册各语言 worker 后导出 monaco 实例。
// 该模块体积大,只允许在浏览器端通过动态 import 加载(如 Diff 弹窗内)。
import * as monaco from "monaco-editor"
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
if (label === "json") {
return new JsonWorker()
}
if (label === "css" || label === "scss" || label === "less") {
return new CssWorker()
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new HtmlWorker()
}
if (label === "typescript" || label === "javascript") {
return new TsWorker()
}
return new EditorWorker()
},
}
// 自定义主题:贴合应用本身的中性灰底色与红绿差异配色,弱化 Monaco 默认的高饱和蓝。
monaco.editor.defineTheme("mactfs-light", {
base: "vs",
inherit: true,
rules: [],
colors: {
"editor.background": "#ffffff",
"editor.foreground": "#1f1f1f",
"editor.lineHighlightBackground": "#00000005",
"editorLineNumber.foreground": "#a3a3a3",
"editorLineNumber.activeForeground": "#525252",
"editorCursor.foreground": "#4f46e5",
"focusBorder": "#00000000",
"diffEditor.insertedTextBackground": "#22c55e2e",
"diffEditor.insertedLineBackground": "#22c55e14",
"diffEditor.removedTextBackground": "#ef44442e",
"diffEditor.removedLineBackground": "#ef444414",
"diffEditorGutter.insertedLineBackground": "#22c55e21",
"diffEditorGutter.removedLineBackground": "#ef444421",
"diffEditorOverview.insertedForeground": "#22c55e99",
"diffEditorOverview.removedForeground": "#ef444499",
"diffEditor.unchangedRegionBackground": "#fafafa",
"diffEditor.unchangedRegionForeground": "#737373",
"scrollbarSlider.background": "#0000001f",
"scrollbarSlider.hoverBackground": "#00000033",
"scrollbarSlider.activeBackground": "#00000040",
},
})
/**
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
*/
export function detectLanguage(path: string): string {
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase()
const dot = name.lastIndexOf(".")
const ext = dot >= 0 ? name.slice(dot) : ""
if (ext.length === 0) {
return "plaintext"
}
for (const language of monaco.languages.getLanguages()) {
if (language.extensions?.some((item) => item.toLowerCase() === ext)) {
return language.id
}
}
return "plaintext"
}
export { monaco }
+11
View File
@@ -0,0 +1,11 @@
/**
* 是否运行在 macOS 的 Electron 窗口内。
* 用于判断隐藏式标题栏与 vibrancy 场景:需要为红绿灯预留空间并启用顶栏拖拽。
*/
export function isMacElectron(): boolean {
if (typeof navigator === "undefined") {
return false
}
const ua = navigator.userAgent
return ua.includes("Electron") && ua.includes("Macintosh")
}
+47
View File
@@ -0,0 +1,47 @@
// 登录页「最近连接」本地记录,仅存非敏感字段(不存密码)。
export interface RecentServer {
serverUri: string
domain: string
username: string
lastUsedAt: number
}
const STORAGE_KEY = "mactfs.recent-servers"
const MAX_ENTRIES = 5
/**
* 读取最近连接记录,本地存储不可用或数据损坏时返回空列表。
*/
export function loadRecentServers(): RecentServer[] {
if (typeof window === "undefined") {
return []
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw) as RecentServer[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/**
* 写入一条最近连接记录:按 serverUri + username 去重置顶,最多保留 5 条。
*/
export function saveRecentServer(entry: Omit<RecentServer, "lastUsedAt">): RecentServer[] {
const next: RecentServer[] = [
{ ...entry, lastUsedAt: Date.now() },
...loadRecentServers().filter(
(item) => item.serverUri !== entry.serverUri || item.username !== entry.username,
),
].slice(0, MAX_ENTRIES)
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// 本地存储写入失败不影响连接流程
}
return next
}
+341
View File
@@ -0,0 +1,341 @@
// 对象动作统一模型:左侧树、中间列表、Pending Changes、目录对比结果
// 都转换成 FileTarget,再由 buildFileMenu 按状态生成同一套菜单与置灰规则。
import type { MappingInfo } from "~/lib/api"
import { resolveLocalPath } from "./mapping"
import { normalizeServerPath } from "./path"
// 对象来源:决定菜单里展示哪一组动作。
export type FileTargetSource = "tree" | "list" | "pending" | "compare"
// 统一的对象目标描述。
export interface FileTarget {
source: FileTargetSource
folder: boolean
serverPath: string
/** 依据 Mapping 推导出的本地路径,未映射为 null。 */
localPath: string | null
/** 是否处于某条 Mapping 子树内。 */
mapped: boolean
/** serverPath 本身是否就是一条 Mapping 的根。 */
mappingRoot: boolean
/** 挂起更改状态(pendingEdit / pendingAdd / ...),无挂起为 null。 */
pendingStatus: string | null
/** 目录对比结果状态(localOnly / remoteChanged / ...),非对比来源为 null。 */
compareStatus?: string | null
}
// 全部对象动作。
export type FileActionId =
| "map"
| "unmap"
| "getLatest"
| "forceGetLatest"
| "getSpecificVersion"
| "checkout"
| "rename"
| "delete"
| "undo"
| "add"
| "compare"
| "history"
| "viewFile"
| "diffLocalLatest"
| "branch"
| "merge"
| "revealInFinder"
| "addLocalFiles"
| "properties"
// 单个菜单项:是否可用与置灰原因。
export interface FileMenuItem {
id: FileActionId
label: string
enabled: boolean
danger?: boolean
/** 置灰原因,展示为菜单项后缀说明。 */
reason?: string
}
/**
* 由来源数据构造统一 FileTarget,本地路径与映射状态从 mappings 推导。
*/
export function makeFileTarget(input: {
source: FileTargetSource
folder: boolean
serverPath: string
mappings: MappingInfo[]
pendingStatus?: string | null
compareStatus?: string | null
}): FileTarget {
const serverPath = normalizeServerPath(input.serverPath)
const localPath = resolveLocalPath(input.mappings, serverPath)
const mappingRoot = input.mappings.some(
(mapping) => normalizeServerPath(mapping.serverPath) === serverPath,
)
return {
source: input.source,
folder: input.folder,
serverPath,
localPath,
mapped: localPath != null,
mappingRoot,
pendingStatus: input.pendingStatus ?? null,
compareStatus: input.compareStatus ?? null,
}
}
const NOT_MAPPED_REASON = "未映射到本地"
/**
* 按对象状态生成右键菜单分组:同一路径在树和列表中产出完全一致的动作与置灰规则。
* Checkin、全局设置等不进入对象菜单。
*/
export function buildFileMenu(target: FileTarget): FileMenuItem[][] {
// 目录对比中的 localOnly 项:服务端不存在,只保留本地查看与加入版本控制。
if (target.source === "compare" && target.compareStatus === "localOnly") {
return buildLocalOnlyMenu(target)
}
return target.folder ? buildFolderMenu(target) : buildFileItemMenu(target)
}
/**
* 仅本地存在(localOnly)的对比结果项菜单:加入版本控制 + 本地查看。
*/
function buildLocalOnlyMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
if (!target.folder) {
sections.push([{ id: "viewFile", label: "查看文件", enabled: true }])
}
sections.push([
{
id: "add",
label: "加入版本控制",
enabled: target.localPath != null,
reason: target.localPath != null ? undefined : "缺少本地路径",
},
])
return sections
}
/**
* 目录菜单:本地工作区动作(未映射置灰)+ Mapping 动作 + 服务端动作。
*/
function buildFolderMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
const workspaceActions: FileMenuItem[] = [
{
id: "getLatest",
label: "获取最新",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "forceGetLatest",
label: "强制获取最新…",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "checkout",
label: "签出编辑",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "rename",
label: "重命名…",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "delete",
label: "挂起删除",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "undo",
label: "撤销更改",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
]
sections.push(workspaceActions)
const mappingActions: FileMenuItem[] = []
if (!target.mapped) {
mappingActions.push({ id: "map", label: "映射到本地", enabled: true })
}
if (target.mappingRoot) {
mappingActions.push({ id: "unmap", label: "取消映射", enabled: true, danger: true })
}
if (mappingActions.length > 0) {
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([
{
id: "compare",
label: "目录对比",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{ id: "history", label: "查看历史", enabled: true },
{ id: "branch", label: "分支…", enabled: true },
{ id: "merge", label: "合并…", enabled: true },
])
sections.push(buildAdvancedSection(target))
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 覆盖。
*/
function buildFileItemMenu(target: FileTarget): FileMenuItem[][] {
const sections: FileMenuItem[][] = []
const pending = target.pendingStatus
const isPendingAdd = pending === "pendingAdd"
const viewActions: FileMenuItem[] = [
{ id: "viewFile", label: "查看文件", enabled: true },
]
if (!isPendingAdd) {
viewActions.push({
id: "diffLocalLatest",
label: "与最新版本比较",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
})
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)
// Pending Changes 列表项以撤销为主,不再重复签出 / 删除入口。
if (target.source === "pending") {
sections.push([
{ id: "undo", label: "撤销更改", enabled: true, danger: true },
])
// pendingAdd 项服务端尚不存在,属性查询无意义。
if (!isPendingAdd) {
sections.push([{ id: "properties", label: "属性…", enabled: true }])
}
return sections
}
const workspaceActions: FileMenuItem[] = [
{
// 安全模式获取:本地改动会产生冲突而不会被覆盖,挂起状态下也允许执行。
id: "getLatest",
label: "获取最新",
enabled: target.mapped,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "forceGetLatest",
label: "强制获取最新…",
enabled: target.mapped,
danger: true,
reason: target.mapped ? undefined : NOT_MAPPED_REASON,
},
{
id: "checkout",
label: "签出编辑",
enabled: target.mapped && pending == null,
reason: !target.mapped
? NOT_MAPPED_REASON
: pending === "pendingEdit"
? "已签出"
: pending != null
? "存在挂起更改"
: 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",
label: "挂起删除",
enabled: target.mapped && pending !== "pendingDelete",
danger: true,
reason: !target.mapped
? NOT_MAPPED_REASON
: pending === "pendingDelete"
? "已挂起删除"
: undefined,
},
{
id: "undo",
label: "撤销更改",
enabled: pending != null,
reason: pending == null ? "没有挂起更改" : undefined,
},
]
sections.push(workspaceActions)
// pendingAdd 文件服务端尚不存在,不提供分支、合并、属性与获取特定版本。
if (!isPendingAdd) {
sections.push([
{ id: "branch", label: "分支…", enabled: true },
{ id: "merge", label: "合并…", enabled: true },
])
sections.push(buildAdvancedSection(target))
}
return sections
}
+24
View File
@@ -0,0 +1,24 @@
export { SERVER_ROOT_PATH } from "./session"
export type { WorkspaceSession } from "./session"
export {
getAncestorPaths,
getParentPath,
isSameOrUnderPath,
normalizeServerPath,
} from "./path"
export { findMapping, resolveLocalPath } from "./mapping"
export {
STATUS_BADGE_CLASSES,
STATUS_LABELS,
resolveItemLocalState,
statusBadgeClass,
statusLabel,
} from "./status"
export type { ItemLocalState } from "./status"
export { buildFileMenu, makeFileTarget } from "./actions"
export type {
FileActionId,
FileMenuItem,
FileTarget,
FileTargetSource,
} from "./actions"
+45
View File
@@ -0,0 +1,45 @@
import type { MappingInfo } from "~/lib/api"
import { isSameOrUnderPath, normalizeServerPath } from "./path"
/**
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
*/
export function findMapping(
mappings: MappingInfo[],
serverPath: string,
): MappingInfo | null {
const target = normalizeServerPath(serverPath)
let matched: MappingInfo | null = null
for (const mapping of mappings) {
if (isSameOrUnderPath(mapping.serverPath, target)) {
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
matched = mapping
}
}
}
return matched
}
/**
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
*/
export function resolveLocalPath(
mappings: MappingInfo[],
serverPath: string,
): string | null {
const target = normalizeServerPath(serverPath)
const mapping = findMapping(mappings, target)
if (!mapping) {
return null
}
const base = normalizeServerPath(mapping.serverPath)
if (target === base) {
return mapping.localPath
}
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
const localBase = mapping.localPath.endsWith("/")
? mapping.localPath.slice(0, -1)
: mapping.localPath
return `${localBase}/${suffix}`
}
+52
View File
@@ -0,0 +1,52 @@
import { SERVER_ROOT_PATH } from "./session"
/**
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
*/
export function normalizeServerPath(serverPath: string): string {
let result = serverPath.trim()
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
result = result.slice(0, -1)
}
return result.length === 0 ? SERVER_ROOT_PATH : result
}
/**
* 取服务端路径的父路径,根路径返回自身。
*/
export function getParentPath(serverPath: string): string {
const normalized = normalizeServerPath(serverPath)
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
return SERVER_ROOT_PATH
}
const index = normalized.lastIndexOf("/")
const parent = normalized.slice(0, index)
return parent === "$" ? SERVER_ROOT_PATH : parent
}
/**
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
*/
export function getAncestorPaths(serverPath: string): string[] {
const normalized = normalizeServerPath(serverPath)
const ancestors: string[] = []
let current = normalized
while (current !== SERVER_ROOT_PATH) {
current = getParentPath(current)
ancestors.unshift(current)
}
return ancestors
}
/**
* 判断 childPath 是否等于 basePath 或位于其子树内。
*/
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
const base = normalizeServerPath(basePath)
const child = normalizeServerPath(childPath)
if (base === child) {
return true
}
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
return child.startsWith(prefix)
}
+12
View File
@@ -0,0 +1,12 @@
import type { MappingInfo } from "~/lib/api"
// 服务端目录树根路径,工作台导航的默认起点。
export const SERVER_ROOT_PATH = "$/"
// 工作台进入后固定的共享上下文,serverUri / collection / workspace 不再在台内切换。
export interface WorkspaceSession {
serverUri: string
collection: string
workspace: string
mappings: MappingInfo[]
}
+73
View File
@@ -0,0 +1,73 @@
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
// 业务状态对应的中文文案。
export const STATUS_LABELS: Record<string, string> = {
localModified: "本地已修改",
remoteChanged: "服务器已更新",
bothChanged: "两端都有修改",
localOnly: "仅本地存在",
remoteOnly: "仅服务器存在",
notDownloaded: "未下载",
localDeleted: "本地已删除",
pendingEdit: "签出编辑",
pendingAdd: "挂起新增",
pendingDelete: "挂起删除",
pendingRename: "挂起重命名",
pending: "挂起更改",
upToDate: "最新",
notMapped: "未映射",
mapped: "已映射",
}
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
export const STATUS_BADGE_CLASSES: Record<string, string> = {
localModified: "bg-blue-500/10 text-blue-700",
remoteChanged: "bg-violet-500/10 text-violet-700",
bothChanged: "bg-red-500/15 text-red-700",
localOnly: "bg-green-500/10 text-green-700",
remoteOnly: "bg-violet-500/10 text-violet-700",
notDownloaded: "bg-amber-500/10 text-amber-700",
localDeleted: "bg-red-500/10 text-red-700",
pendingEdit: "bg-blue-500/10 text-blue-700",
pendingAdd: "bg-green-500/10 text-green-700",
pendingDelete: "bg-red-500/10 text-red-700",
pendingRename: "bg-blue-500/10 text-blue-700",
pending: "bg-blue-500/10 text-blue-700",
upToDate: "bg-muted text-muted-foreground",
notMapped: "bg-muted text-muted-foreground",
mapped: "bg-muted text-foreground/80",
}
/**
* 取状态的中文文案,未知状态原样返回。
*/
export function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status
}
/**
* 取状态 badge 的配色 class,未知状态用中性灰。
*/
export function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
}
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
/**
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
*/
export function resolveItemLocalState(
mapped: boolean,
localExists: boolean | null,
): ItemLocalState {
if (!mapped) {
return "notMapped"
}
if (localExists === false) {
return "notDownloaded"
}
return "mapped"
}
+12
View File
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 把毫秒时间戳格式化为 `YYYY-MM-DD HH:mm`,空值返回占位符。
*/
export function formatDateTime(value: number | null | undefined): string {
if (value == null || value <= 0) {
return "—"
}
const date = new Date(value)
const pad = (input: number) => String(input).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+6
View File
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏前标记 vibrancy 环境(macOS Electron 下透出窗口毛玻璃) */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy")}catch(e){}})()`,
}}
/>
<Meta />
<Links />
</head>
+82 -581
View File
@@ -1,618 +1,119 @@
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { AppShell } from "~/components/app/app-shell"
import { SimpleDialog } from "~/components/app/simple-dialog"
import { ConnectView } from "~/components/app/connect-view"
import { WorkspaceShell } from "~/components/app/workspace-shell"
import { Button } from "~/components/ui/button"
import { DiffPanel } from "~/components/explorer/diff-panel"
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
import { FileViewerDialog } from "~/components/explorer/file-viewer-dialog"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
import { HistoryPanel } from "~/components/explorer/history-panel"
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
import type { ApiRequestLogEntry } from "~/lib/api/client"
import {
connectSession,
ensureWorkspaceContext,
getConfig,
listCollections,
listOperationLogs,
} from "~/lib/api/endpoints"
import type {
ApiResult,
AppConfig,
ConnectData,
HealthData,
TfsCollectionInfo,
TfsOperationLogEntry,
TfsPendingChangeInfo,
TfsWorkspaceInfo,
} from "~/lib/api/types"
import type { ServiceStatus } from "~/lib/electron/bridge"
import { getMactfsBridge } from "~/lib/electron/bridge"
import { getServiceStatus, isElectron, startService } from "~/lib/electron"
import type { ServiceStatus } from "~/lib/electron"
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
import type { WorkspaceSession } from "~/lib/tfs/session"
interface ServiceViewState {
loading: boolean
serviceStatus?: ServiceStatus
healthResult?: ApiResult<HealthData>
errorMessage?: string
}
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
type AppPhase = "checking" | "blocked" | "connect" | "workspace"
interface ConnectionForm {
serverUri: string
authType: string
domain: string
username: string
password: string
}
const EMPTY_CONNECTION_FORM: ConnectionForm = {
serverUri: "",
authType: "ntlm-explicit",
domain: "",
username: "",
password: "",
/**
* 页面标题(同时作为 Electron 窗口标题)。
*/
export function meta() {
return [{ title: "MacTFS" }]
}
/**
* 将后端配置转换为表单可直接使用的字符串结构,避免受缺省字段影响。
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
*/
function toConnectionForm(config?: AppConfig): ConnectionForm {
return {
serverUri: config?.serverUri || "",
authType: config?.authType || "ntlm-explicit",
domain: config?.domain || "",
username: config?.username || "",
password: config?.password || "",
}
}
export default function Home() {
const [state, setState] = useState<ServiceViewState>({
loading: true,
})
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
const [connecting, setConnecting] = useState(false)
const [collectionsLoading, setCollectionsLoading] = useState(false)
const [workspaceLoading, setWorkspaceLoading] = useState(false)
const [connected, setConnected] = useState(false)
const [connectResult, setConnectResult] = useState<ConnectData>()
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
const [preferredCollection, setPreferredCollection] = useState("")
const [selectedCollection, setSelectedCollection] = useState("")
const [workspace, setWorkspace] = useState<TfsWorkspaceInfo>()
const [selectedServerPath, setSelectedServerPath] = useState("")
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
const [folderRefreshKey, setFolderRefreshKey] = useState(0)
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
const [fileViewTarget, setFileViewTarget] = useState<TfsPendingChangeInfo>()
const [historyTarget, setHistoryTarget] = useState<TfsPendingChangeInfo>()
const [diffRequest, setDiffRequest] = useState<DiffPanelRequest>()
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
const [phase, setPhase] = useState<AppPhase>("checking")
const [status, setStatus] = useState<ServiceStatus | null>(null)
const [session, setSession] = useState<WorkspaceSession | null>(null)
const [selectedServerPath, setSelectedServerPath] = useState(SERVER_ROOT_PATH)
/**
* 先确认服务状态,再读取默认配置并验证自动 token 请求。
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
*/
async function loadServiceStatus() {
setState((currentState) => ({
...currentState,
loading: true,
errorMessage: undefined,
}))
const bridge = getMactfsBridge()
if (!bridge) {
setState({
loading: false,
errorMessage:
"未检测到 Electron preload,请通过 Electron 启动 macTFS。",
})
return
const bootstrap = useCallback(async () => {
setPhase("checking")
let next = await getServiceStatus()
if (!next.running && isElectron()) {
next = await startService()
}
const serviceStatus = await bridge.getServiceStatus()
const healthResult = serviceStatus.running
? await apiRequest<HealthData>("/api/health")
: undefined
const configResult = serviceStatus.running ? await getConfig() : undefined
if (configResult?.success) {
setForm(toConnectionForm(configResult.data.config))
setPreferredCollection(configResult.data.config.collection || "")
setSelectedCollection(configResult.data.config.collection || "")
}
setState({
loading: false,
serviceStatus,
healthResult,
errorMessage: configResult?.success
? undefined
: configResult?.errorMessage || configResult?.message,
})
if (healthResult?.data.connected && configResult?.data.config.collection) {
setConnected(true)
setWorkspace(
configResult.data.config.workspace
? ({
name: configResult.data.config.workspace,
ownerName: "",
computer: "",
comment: "",
created: false,
mappings: [],
} satisfies TfsWorkspaceInfo)
: undefined
)
setSelectedServerPath("$/")
} else {
setConnected(false)
}
}
useEffect(() => {
return subscribeApiRequestLogs(upsertOperationLog)
setStatus(next)
setPhase(next.running ? "connect" : "blocked")
}, [])
useEffect(() => {
loadServiceStatus()
void bootstrap()
}, [bootstrap])
/**
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
*/
const handleReady = useCallback((next: WorkspaceSession) => {
setSession(next)
setSelectedServerPath(SERVER_ROOT_PATH)
setPhase("workspace")
}, [])
useEffect(() => {
if (connected) {
loadOperationLogs()
}
}, [connected])
const serviceReady =
state.serviceStatus?.running && state.healthResult?.success
const canConnect = serviceReady && !state.loading && !connecting
const serviceMessage = serviceReady
? "本地 API 已连接。"
: state.serviceStatus?.message ||
state.errorMessage ||
"正在检查本地 API 服务。"
const requestStatus = state.healthResult?.success
? state.healthResult.message
: state.healthResult?.errorMessage || state.errorMessage || "等待检查"
/**
* 从工作台返回连接入口,重新走连接流程。
*/
const handleReconnect = useCallback(() => {
setSession(null)
setPhase("connect")
}, [])
/**
* 更新连接表单字段,保持输入状态与受控表单一致。
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
*/
function updateFormField(field: keyof ConnectionForm, value: string) {
setForm((currentForm) => ({
...currentForm,
[field]: value,
}))
}
const handleNavigate = useCallback((serverPath: string) => {
setSelectedServerPath(serverPath)
}, [])
/**
* 调用连接 API,成功后进入已连接工作台状态。
* Mapping 创建 / 取消后更新固定上下文中的映射列表,浏览位置保持不变。
*/
async function handleConnect(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setConnecting(true)
setConnectResult(undefined)
setState((currentState) => ({
...currentState,
errorMessage: undefined,
}))
const handleMappingsChanged = useCallback((mappings: WorkspaceSession["mappings"]) => {
setSession((prev) => (prev ? { ...prev, mappings } : prev))
}, [])
const result = await connectSession(form)
setConnecting(false)
if (!result.success) {
setState((currentState) => ({
...currentState,
errorMessage: result.errorMessage || result.message,
}))
return
}
setConnected(true)
setConnectResult(result.data)
await loadCollections()
}
/**
* 加载登录后的 Collection 列表,并按上次使用记录默认选中。
*/
async function loadCollections() {
setCollectionsLoading(true)
const result = await listCollections()
setCollectionsLoading(false)
if (!result.success) {
setState((currentState) => ({
...currentState,
errorMessage: result.errorMessage || result.message,
}))
return
}
setCollections(result.data.collections)
const preferred =
result.data.collections.find(
(collection) => collection.name === preferredCollection
) || result.data.collections[0]
setSelectedCollection(preferred?.name || "")
}
/**
* 固定当前 Collection,并让后端自动使用或创建 Workspace 后进入工作台。
*/
async function enterWorkspace() {
if (!selectedCollection) {
setState((currentState) => ({
...currentState,
errorMessage: "请选择 Collection。",
}))
return
}
setWorkspaceLoading(true)
setState((currentState) => ({
...currentState,
errorMessage: undefined,
}))
const result = await ensureWorkspaceContext({
collection: selectedCollection,
})
setWorkspaceLoading(false)
if (!result.success) {
setState((currentState) => ({
...currentState,
errorMessage: result.errorMessage || result.message,
}))
return
}
setWorkspace(result.data.workspace)
setPreferredCollection(result.data.collection)
setSelectedServerPath("$/")
}
/**
* 触发右侧 Pending Changes 面板重新查询当前 Workspace 状态。
*/
function refreshPendingChanges() {
setPendingRefreshKey((currentKey) => currentKey + 1)
}
/**
* 触发中间文件列表刷新,供 Checkin 或右侧撤销后更新当前目录状态。
*/
function refreshFolderItems() {
setFolderRefreshKey((currentKey) => currentKey + 1)
}
/**
* 切换主工作区当前路径,并清理上一目录的选中项状态。
*/
function selectServerPath(path: string) {
setSelectedMappedItem(false)
setSelectedServerPath(path)
}
/**
* 合并统一 API client 推送的请求日志,保留最近 80 条。
*/
function upsertOperationLog(entry: ApiRequestLogEntry) {
setOperationLogs((currentLogs) =>
[entry, ...currentLogs.filter((log) => log.id !== entry.id)].slice(0, 80)
if (phase === "workspace" && session) {
return (
<WorkspaceShell
session={session}
selectedServerPath={selectedServerPath}
onNavigate={handleNavigate}
onMappingsChanged={handleMappingsChanged}
onReconnect={handleReconnect}
/>
)
}
/**
* 从服务端读取最近操作日志,补齐当前 UI 会话之前的 API 操作记录。
*/
async function loadOperationLogs() {
setOperationLogsLoading(true)
const result = await listOperationLogs()
setOperationLogsLoading(false)
if (!result.success) {
return
}
const serverLogs = result.data.logs.map(toOperationLog).reverse()
setOperationLogs((currentLogs) => [
...currentLogs.filter((log) => log.status === "running"),
...serverLogs,
].slice(0, 80))
}
/**
* 将后端操作日志转换成底部 Console 的展示结构。
*/
function toOperationLog(
entry: TfsOperationLogEntry,
index: number
): ApiRequestLogEntry {
return {
id: `server:${entry.startedAt}:${entry.operation}:${index}`,
operation: entry.operation,
summary: entry.summary || "-",
startedAt: entry.startedAt,
endedAt: entry.endedAt,
durationMs: entry.durationMs,
success: entry.success,
errorMessage: entry.errorMessage,
status: entry.success ? "success" : "error",
}
if (phase === "connect") {
return <ConnectView onReady={handleReady} />
}
return (
<div className="min-h-svh bg-muted/40 p-4 text-sm">
<AppShell
connected={connected}
serviceReady={Boolean(serviceReady)}
serviceLoading={state.loading}
serverUri={connectResult?.serverUri || form.serverUri}
username={form.username}
collection={selectedCollection}
workspace={workspace?.name}
collectionCount={connectResult?.collectionCount}
serviceBaseUrl={state.serviceStatus?.baseUrl}
tokenFile={state.serviceStatus?.tokenFile}
serviceMessage={serviceMessage}
requestStatus={requestStatus}
operationLogs={operationLogs}
operationLogsLoading={operationLogsLoading}
sourceList={
<ServerTreePanel
connected={connected && Boolean(workspace)}
collection={selectedCollection}
selectedPath={selectedServerPath}
onPathSelect={selectServerPath}
/>
}
inspector={
<PendingChangesPanel
connected={connected && Boolean(workspace)}
collection={selectedCollection}
refreshKey={pendingRefreshKey}
onOpenFile={setFileViewTarget}
onOpenDiff={(change) =>
setDiffRequest({
type: "localLatest",
serverPath: change.serverPath,
localPath: change.localPath,
label: `${change.serverPath} 本地 ↔ Latest`,
})
}
onOpenHistory={setHistoryTarget}
onCheckinSuccess={refreshFolderItems}
/>
}
onRefreshService={loadServiceStatus}
onRefreshOperationLogs={loadOperationLogs}
>
{connected && workspace ? (
<FolderItemsPanel
connected={connected}
collection={selectedCollection}
serverPath={selectedServerPath}
refreshKey={folderRefreshKey}
onPendingChangesRefresh={refreshPendingChanges}
onPathEnter={selectServerPath}
onSelectedMappedItemChange={setSelectedMappedItem}
/>
) : connected ? (
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
<div className="min-w-0">
<h1 className="text-base font-medium">选择 Collection</h1>
<p className="mt-1 text-muted-foreground">
Collection 确认后,后端会自动使用或创建默认 Workspace。
</p>
</div>
<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>
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
<div className="grid gap-3">
{collectionsLoading ? (
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在加载 Collection
</div>
) : (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Collection
</span>
<select
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={selectedCollection}
required
onChange={(event) =>
setSelectedCollection(event.target.value)
}
>
{collections.map((collection) => (
<option
key={collection.id || collection.name}
value={collection.name}
>
{collection.name}
</option>
))}
</select>
</label>
)}
{state.errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{state.errorMessage}
</div>
)}
<div className="flex justify-end">
<Button
disabled={!selectedCollection || workspaceLoading}
onClick={enterWorkspace}
>
{workspaceLoading && <Loader2 className="animate-spin" />}
进入工作台
</Button>
</div>
</div>
</div>
) : (
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
<div className="min-w-0">
<h1 className="text-base font-medium">连接 TFS</h1>
<p className="mt-1 text-muted-foreground">
{serviceReady ? "本地 API 已就绪。" : serviceMessage}
</p>
</div>
<form className="grid gap-4" onSubmit={handleConnect}>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
TFS 地址
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.serverUri}
required
placeholder="http://tfs.example.com:8080/tfs/"
onChange={(event) =>
updateFormField("serverUri", event.target.value)
}
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
认证方式
</span>
<select
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.authType}
onChange={(event) =>
updateFormField("authType", event.target.value)
}
>
<option value="ntlm-explicit">NTLM 显式账号密码</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
域
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.domain}
placeholder="domain"
onChange={(event) =>
updateFormField("domain", event.target.value)
}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
用户名
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.username}
required
autoComplete="username"
onChange={(event) =>
updateFormField("username", event.target.value)
}
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
密码
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
type="password"
value={form.password}
required
autoComplete="current-password"
onChange={(event) =>
updateFormField("password", event.target.value)
}
/>
</label>
</div>
{state.errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{state.errorMessage}
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={!canConnect}>
{connecting && <Loader2 className="animate-spin" />}
连接
</Button>
</div>
</form>
</div>
{phase === "checking" && (
<p className="mt-4 text-sm text-muted-foreground">正在检查本地服务…</p>
)}
<SimpleDialog
open={Boolean(fileViewTarget)}
title="File Viewer"
description={fileViewTarget?.serverPath}
onClose={() => setFileViewTarget(undefined)}
>
{fileViewTarget && (
<FileViewerDialog
open={Boolean(fileViewTarget)}
serverPath={fileViewTarget.serverPath}
localPath={fileViewTarget.localPath}
preferLocal
onClose={() => setFileViewTarget(undefined)}
/>
)}
</SimpleDialog>
<SimpleDialog
open={Boolean(historyTarget)}
title="History"
description={historyTarget?.serverPath}
onClose={() => setHistoryTarget(undefined)}
>
{historyTarget && (
<HistoryPanel
path={historyTarget.serverPath}
folder={historyTarget.folder}
label={historyTarget.serverPath}
onOpenDiff={setDiffRequest}
onClose={() => setHistoryTarget(undefined)}
/>
)}
</SimpleDialog>
<SimpleDialog
open={Boolean(diffRequest)}
title="Diff"
description={diffRequest?.label}
onClose={() => setDiffRequest(undefined)}
>
{diffRequest && (
<DiffPanel
request={diffRequest}
onClose={() => setDiffRequest(undefined)}
/>
)}
</SimpleDialog>
</AppShell>
{phase === "blocked" && (
<div className="mt-4 space-y-3 text-sm">
<p className="font-medium text-destructive">本地服务未就绪</p>
<p className="text-muted-foreground">
{status?.error ?? "无法连接本地 API 服务"}
</p>
<Button size="sm" onClick={() => void bootstrap()}>
重试连接
</Button>
</div>
)}
</div>
</div>
)
}