frontend: FE-030 目录树与文件列表精致化,图标体系+骨架屏+空状态+行级联动效+圆点徽标

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-11 17:27:50 +08:00
co-authored by Cursor
parent b6447443c5
commit be055673f1
5 changed files with 203 additions and 51 deletions
@@ -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 dark:text-blue-400",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600 dark:text-amber-400",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500 dark:text-zinc-400",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600 dark:text-violet-400",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600 dark:text-emerald-400",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600 dark:text-orange-400",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600 dark:text-green-400",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600 dark:text-zinc-300",
},
]
/**
* 取文件名扩展名(小写,无点);无扩展名返回空串。
*/
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 dark:text-sky-400",
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,
)}
/>
)
}
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
import { Button } from "~/components/ui/button"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge"
import {
Table,
@@ -154,17 +155,19 @@ export function FolderItemsPanel({
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
<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>
<ItemsSkeleton />
) : error ? (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
{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 items-center justify-center text-sm text-muted-foreground">
当前目录为空
<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>
) : (
<Table>
@@ -182,7 +185,7 @@ export function FolderItemsPanel({
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
{rows.map((row, index) => {
const selected = row.item.serverPath === selectedItemPath
const target = makeFileTarget({
source: "list",
@@ -199,7 +202,8 @@ export function FolderItemsPanel({
>
<TableRow
data-state={selected ? "selected" : undefined}
className="cursor-default select-none"
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) => {
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。
@@ -214,19 +218,20 @@ export function FolderItemsPanel({
>
<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" />
)}
<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("rounded-md", statusBadgeClass(row.localState))}
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>
@@ -269,3 +274,25 @@ export function FolderItemsPanel({
</section>
)
}
/**
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 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>
)
}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronRight, FolderClosed, FolderOpen, Loader2, RefreshCw } from "lucide-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"
@@ -12,6 +13,7 @@ import {
getAncestorPaths,
makeFileTarget,
normalizeServerPath,
resolveLocalPath,
} from "~/lib/tfs"
import { cn } from "~/lib/utils"
@@ -202,6 +204,7 @@ function TreeNode({
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 (
@@ -209,8 +212,8 @@ function TreeNode({
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"group flex h-7 items-center rounded-md pr-1",
isSelected ? "bg-primary/10" : "hover:bg-muted",
"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` }}
>
@@ -222,10 +225,13 @@ function TreeNode({
type="button"
aria-label={isExpanded ? "收起目录" : "展开目录"}
onClick={() => onToggle(path)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
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", isExpanded && "rotate-90")}
className={cn(
"size-3.5 transition-transform duration-200 ease-out-quart",
isExpanded && "rotate-90",
)}
/>
</button>
)}
@@ -235,11 +241,7 @@ function TreeNode({
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" />
)}
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
<span
className={cn(
"truncate text-sm",
@@ -251,23 +253,26 @@ function TreeNode({
</button>
</div>
</FileTargetMenu>
{isExpanded &&
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}
/>
))}
{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>
)
}