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
@@ -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>
)
}