frontend: FE-030 目录树与文件列表精致化,图标体系+骨架屏+空状态+行级联动效+圆点徽标
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user