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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user