frontend: add lazy server tree and folder items list with synced navigation and local state badges
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -559,7 +559,7 @@ chmod 644 <file>
|
||||
|
||||
## 十三、当前分支现状
|
||||
|
||||
`FE-001`、`FE-002`、`FE-003`、`FE-004` 已完成,前端基础设施、接口契约、连接入口与工作台骨架已就绪:
|
||||
`FE-001` 至 `FE-005` 已完成,前端基础设施、接口契约、连接入口、工作台骨架与目录浏览已就绪:
|
||||
|
||||
- `electron/main.cjs` 已补齐服务检测、按本地开发约定拉起服务、token 读取与目录选择
|
||||
- `electron/preload.cjs` 已落地,通过 `contextBridge` 暴露 `window.mactfs` 窄接口
|
||||
@@ -571,13 +571,15 @@ chmod 644 <file>
|
||||
- `app/components/app/connect-view.tsx` 已实现连接表单、Collection 选择与确保默认 Workspace
|
||||
- `app/components/app/workspace-shell.tsx` 已实现三栏工作台外壳,左 / 右 / 底面板支持折叠
|
||||
- `app/components/app/top-bar.tsx` 已实现顶部上下文栏(Server / Collection / Workspace + 面板开关 + 重新连接)
|
||||
- `app/components/explorer/source-tree-panel.tsx`、`folder-items-panel.tsx`、`app/components/inspector/changes-panel.tsx`、`app/components/logs/console-panel.tsx` 已建立各面板骨架
|
||||
- `app/lib/tfs/session.ts` 已定义工作台固定上下文 `WorkspaceSession` 与根路径常量 `SERVER_ROOT_PATH`
|
||||
- `app/components/explorer/source-tree-panel.tsx` 已实现目录树懒加载与同步展开;`folder-items-panel.tsx` 已实现当前目录文件列表(状态 / 版本 / 时间 / 本地路径,单击选中、双击进入)
|
||||
- `app/components/inspector/changes-panel.tsx`、`app/components/logs/console-panel.tsx` 为面板骨架
|
||||
- `app/lib/tfs` 已沉淀 `session.ts`(固定上下文、`SERVER_ROOT_PATH`)、`path.ts`(路径工具)、`mapping.ts`(本地路径推导)、`status.ts`(状态文案与配色收口)
|
||||
- Electron 桥已含 `pathsExist` 本地存在性批量检测,用于「已映射未下载」状态
|
||||
- `home.tsx` 已编排 检测服务 → 连接 → 工作台 的视图状态机,并统一维护 `selectedServerPath` 同步导航状态
|
||||
|
||||
仍未建立:
|
||||
|
||||
- 目录树数据加载与中间文件列表(FE-005)
|
||||
- 右键菜单与各业务弹窗(FE-006 起)
|
||||
- 对象右键菜单与通用动作模型(FE-006)
|
||||
- Mapping / History / 目录对比 / 文件查看 / Diff / 冲突等业务弹窗(FE-007 起)
|
||||
|
||||
因此当前前端开发应从 `FE-005` 继续推进,不再参考旧分支“已经做完”的状态来判断进度。
|
||||
因此当前前端开发应从 `FE-006` 继续推进,不再参考旧分支“已经做完”的状态来判断进度。
|
||||
|
||||
@@ -54,7 +54,11 @@ export function WorkspaceShell({
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
<FolderItemsPanel session={session} selectedServerPath={selectedServerPath} />
|
||||
<FolderItemsPanel
|
||||
session={session}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
{panels.changes && <ChangesPanel />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,65 +1,219 @@
|
||||
import { FolderOpen } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FileText, FolderClosed, Loader2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import {
|
||||
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 { resolveLocalPath } from "~/lib/tfs/mapping"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import {
|
||||
resolveItemLocalState,
|
||||
statusBadgeClass,
|
||||
statusLabel,
|
||||
} from "~/lib/tfs/status"
|
||||
import { cn, formatDateTime } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
|
||||
*/
|
||||
function findMapping(session: WorkspaceSession, serverPath: string) {
|
||||
let matched: WorkspaceSession["mappings"][number] | null = null
|
||||
for (const mapping of session.mappings) {
|
||||
if (
|
||||
serverPath === mapping.serverPath ||
|
||||
serverPath.startsWith(`${mapping.serverPath}/`)
|
||||
) {
|
||||
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
|
||||
matched = mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
|
||||
interface FolderItemRow {
|
||||
item: ServerItem
|
||||
localPath: string | null
|
||||
localState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 中间主工作区骨架:顶部展示当前路径与 Mapping 摘要,主体为文件列表占位,
|
||||
* 真实目录数据加载与表格在 FE-005 实现。
|
||||
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
|
||||
* 单击选中、双击目录进入,与左侧树共享当前路径状态。
|
||||
*/
|
||||
export function FolderItemsPanel({
|
||||
session,
|
||||
selectedServerPath,
|
||||
onNavigate,
|
||||
}: {
|
||||
session: WorkspaceSession
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
}) {
|
||||
const mapping = findMapping(session, selectedServerPath)
|
||||
const [items, setItems] = useState<ServerItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
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(() => {
|
||||
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])
|
||||
|
||||
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
|
||||
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],
|
||||
)
|
||||
|
||||
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
|
||||
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b 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">
|
||||
{mapping ? (
|
||||
<span className="font-mono">{mapping.localPath}</span>
|
||||
{currentLocalPath ? (
|
||||
<span className="font-mono">{currentLocalPath}</span>
|
||||
) : (
|
||||
"未映射到本地"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<FolderOpen className="size-8 text-muted-foreground/60" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前目录文件列表将在后续任务加载
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground/80">
|
||||
{selectedServerPath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载目录…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-full 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-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) => {
|
||||
const selected = row.item.serverPath === selectedItemPath
|
||||
return (
|
||||
<TableRow
|
||||
key={row.item.serverPath}
|
||||
data-state={selected ? "selected" : undefined}
|
||||
className="cursor-default select-none"
|
||||
onClick={() => 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}>
|
||||
{row.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">{row.item.name}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("rounded-md", statusBadgeClass(row.localState))}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center border-t px-3 text-xs text-muted-foreground">
|
||||
未选中对象
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { FolderClosed } from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ChevronRight, FolderClosed, FolderOpen, Loader2 } from "lucide-react"
|
||||
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { api } from "~/lib/api"
|
||||
import type { ServerItem } from "~/lib/api"
|
||||
import { getAncestorPaths, normalizeServerPath } from "~/lib/tfs/path"
|
||||
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 左侧 Source Tree 面板骨架:展示固定 Collection 的根节点并与中间列表共享路径状态,
|
||||
* 目录树懒加载与子节点展开在 FE-005 实现。
|
||||
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
|
||||
* 与中间列表共享当前路径;中间双击进入目录时自动展开并选中对应节点。
|
||||
*/
|
||||
export function SourceTreePanel({
|
||||
collection,
|
||||
@@ -17,33 +21,206 @@ export function SourceTreePanel({
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
}) {
|
||||
const rootActive = selectedServerPath === SERVER_ROOT_PATH
|
||||
// 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)
|
||||
// 已加载与加载中的路径集合,避免异步流程里读到过期 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])
|
||||
|
||||
/**
|
||||
* 切换节点展开 / 收起,首次展开时触发懒加载。
|
||||
*/
|
||||
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 border-r bg-sidebar">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between border-b px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
|
||||
<span className="max-w-32 truncate text-xs text-muted-foreground">{collection}</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(SERVER_ROOT_PATH)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm",
|
||||
rootActive
|
||||
? "bg-primary/10 font-medium text-foreground"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs">{SERVER_ROOT_PATH}</span>
|
||||
</button>
|
||||
<p className="px-2 py-3 text-xs leading-5 text-muted-foreground">
|
||||
服务端目录树将在后续任务加载,当前仅提供根节点导航。
|
||||
</p>
|
||||
{error && (
|
||||
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
<TreeNode
|
||||
path={SERVER_ROOT_PATH}
|
||||
name={SERVER_ROOT_PATH}
|
||||
depth={0}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={normalizeServerPath(selectedServerPath)}
|
||||
onToggle={toggleExpand}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,行高 28px。
|
||||
*/
|
||||
function TreeNode({
|
||||
path,
|
||||
name,
|
||||
depth,
|
||||
childrenByPath,
|
||||
expanded,
|
||||
loadingPaths,
|
||||
selectedServerPath,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
path: string
|
||||
name: string
|
||||
depth: number
|
||||
childrenByPath: Record<string, ServerItem[]>
|
||||
expanded: Record<string, boolean>
|
||||
loadingPaths: Record<string, boolean>
|
||||
selectedServerPath: string
|
||||
onToggle: (serverPath: string) => void
|
||||
onSelect: (serverPath: string) => 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
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-7 items-center rounded-md pr-1",
|
||||
isSelected ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||
>
|
||||
<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 hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-transform", 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"
|
||||
>
|
||||
{isExpanded && !isLeaf ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isSelected && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded &&
|
||||
children?.map((child) => (
|
||||
<TreeNode
|
||||
key={child.serverPath}
|
||||
path={child.serverPath}
|
||||
name={child.name}
|
||||
depth={depth + 1}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -74,6 +74,17 @@ export async function selectDirectory(): Promise<string | null> {
|
||||
return bridge.selectDirectory()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在,非 Electron 环境返回 null 表示无法检测。
|
||||
*/
|
||||
export async function pathsExist(paths: string[]): Promise<Record<string, boolean> | null> {
|
||||
const bridge = getBridge()
|
||||
if (!bridge || paths.length === 0) {
|
||||
return bridge ? {} : null
|
||||
}
|
||||
return bridge.pathsExist(paths)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造非 Electron 环境下的占位服务状态。
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
getServiceStatus,
|
||||
getToken,
|
||||
isElectron,
|
||||
pathsExist,
|
||||
selectDirectory,
|
||||
startService,
|
||||
} from "./bridge"
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface MactfsBridge {
|
||||
getServiceStatus(): Promise<ServiceStatus>
|
||||
startService(): Promise<ServiceStatus>
|
||||
selectDirectory(): Promise<string | null>
|
||||
pathsExist(paths: string[]): Promise<Record<string, boolean>>
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MappingInfo } from "~/lib/api"
|
||||
|
||||
import { isSameOrUnderPath, normalizeServerPath } from "./path"
|
||||
|
||||
/**
|
||||
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
|
||||
*/
|
||||
export function findMapping(
|
||||
mappings: MappingInfo[],
|
||||
serverPath: string,
|
||||
): MappingInfo | null {
|
||||
const target = normalizeServerPath(serverPath)
|
||||
let matched: MappingInfo | null = null
|
||||
for (const mapping of mappings) {
|
||||
if (isSameOrUnderPath(mapping.serverPath, target)) {
|
||||
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
|
||||
matched = mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
|
||||
*/
|
||||
export function resolveLocalPath(
|
||||
mappings: MappingInfo[],
|
||||
serverPath: string,
|
||||
): string | null {
|
||||
const target = normalizeServerPath(serverPath)
|
||||
const mapping = findMapping(mappings, target)
|
||||
if (!mapping) {
|
||||
return null
|
||||
}
|
||||
const base = normalizeServerPath(mapping.serverPath)
|
||||
if (target === base) {
|
||||
return mapping.localPath
|
||||
}
|
||||
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
|
||||
const localBase = mapping.localPath.endsWith("/")
|
||||
? mapping.localPath.slice(0, -1)
|
||||
: mapping.localPath
|
||||
return `${localBase}/${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SERVER_ROOT_PATH } from "./session"
|
||||
|
||||
/**
|
||||
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
|
||||
*/
|
||||
export function normalizeServerPath(serverPath: string): string {
|
||||
let result = serverPath.trim()
|
||||
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
return result.length === 0 ? SERVER_ROOT_PATH : result
|
||||
}
|
||||
|
||||
/**
|
||||
* 取服务端路径的父路径,根路径返回自身。
|
||||
*/
|
||||
export function getParentPath(serverPath: string): string {
|
||||
const normalized = normalizeServerPath(serverPath)
|
||||
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
|
||||
return SERVER_ROOT_PATH
|
||||
}
|
||||
const index = normalized.lastIndexOf("/")
|
||||
const parent = normalized.slice(0, index)
|
||||
return parent === "$" ? SERVER_ROOT_PATH : parent
|
||||
}
|
||||
|
||||
/**
|
||||
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
|
||||
*/
|
||||
export function getAncestorPaths(serverPath: string): string[] {
|
||||
const normalized = normalizeServerPath(serverPath)
|
||||
const ancestors: string[] = []
|
||||
let current = normalized
|
||||
while (current !== SERVER_ROOT_PATH) {
|
||||
current = getParentPath(current)
|
||||
ancestors.unshift(current)
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 childPath 是否等于 basePath 或位于其子树内。
|
||||
*/
|
||||
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
|
||||
const base = normalizeServerPath(basePath)
|
||||
const child = normalizeServerPath(childPath)
|
||||
if (base === child) {
|
||||
return true
|
||||
}
|
||||
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
|
||||
return child.startsWith(prefix)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
|
||||
|
||||
// 业务状态对应的中文文案。
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
localModified: "本地已修改",
|
||||
remoteChanged: "服务器已更新",
|
||||
bothChanged: "两端都有修改",
|
||||
localOnly: "仅本地存在",
|
||||
remoteOnly: "仅服务器存在",
|
||||
notDownloaded: "未下载",
|
||||
localDeleted: "本地已删除",
|
||||
pendingEdit: "签出编辑",
|
||||
pendingAdd: "挂起新增",
|
||||
pendingDelete: "挂起删除",
|
||||
pendingRename: "挂起重命名",
|
||||
pending: "挂起更改",
|
||||
upToDate: "最新",
|
||||
notMapped: "未映射",
|
||||
mapped: "已映射",
|
||||
}
|
||||
|
||||
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
||||
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
upToDate: "bg-muted text-muted-foreground",
|
||||
notMapped: "bg-muted text-muted-foreground",
|
||||
mapped: "bg-muted text-foreground/80",
|
||||
}
|
||||
|
||||
/**
|
||||
* 取状态的中文文案,未知状态原样返回。
|
||||
*/
|
||||
export function statusLabel(status: string): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
/**
|
||||
* 取状态 badge 的配色 class,未知状态用中性灰。
|
||||
*/
|
||||
export function statusBadgeClass(status: string): string {
|
||||
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
|
||||
}
|
||||
|
||||
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
|
||||
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
|
||||
|
||||
/**
|
||||
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
|
||||
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
|
||||
*/
|
||||
export function resolveItemLocalState(
|
||||
mapped: boolean,
|
||||
localExists: boolean | null,
|
||||
): ItemLocalState {
|
||||
if (!mapped) {
|
||||
return "notMapped"
|
||||
}
|
||||
if (localExists === false) {
|
||||
return "notDownloaded"
|
||||
}
|
||||
return "mapped"
|
||||
}
|
||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把毫秒时间戳格式化为 `YYYY-MM-DD HH:mm`,空值返回占位符。
|
||||
*/
|
||||
export function formatDateTime(value: number | null | undefined): string {
|
||||
if (value == null || value <= 0) {
|
||||
return "—"
|
||||
}
|
||||
const date = new Date(value)
|
||||
const pad = (input: number) => String(input).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
@@ -212,6 +212,22 @@ async function startService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在,供渲染层展示“已映射未下载”状态。
|
||||
*/
|
||||
function pathsExist(paths) {
|
||||
const result = {}
|
||||
if (!Array.isArray(paths)) {
|
||||
return result
|
||||
}
|
||||
for (const target of paths) {
|
||||
if (typeof target === "string" && target.length > 0) {
|
||||
result[target] = fs.existsSync(target)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册渲染进程通过 preload 调用的窄接口,集中暴露 token、服务状态与目录选择能力。
|
||||
*/
|
||||
@@ -229,6 +245,7 @@ function registerIpcHandlers() {
|
||||
}
|
||||
return result.filePaths[0]
|
||||
})
|
||||
ipcMain.handle("mactfs:paths-exist", (event, paths) => pathsExist(paths))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,4 +33,10 @@ contextBridge.exposeInMainWorld("mactfs", {
|
||||
selectDirectory() {
|
||||
return ipcRenderer.invoke("mactfs:select-directory")
|
||||
},
|
||||
/**
|
||||
* 批量检测本地绝对路径是否存在。
|
||||
*/
|
||||
pathsExist(paths) {
|
||||
return ipcRenderer.invoke("mactfs:paths-exist", paths)
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user