优化web/server模式下的目录选择,现在支持目录树选择,而不是硬文本写入

This commit is contained in:
xintaofei
2026-03-30 14:59:23 +08:00
parent 9b9169f61d
commit 8d393b3b4f
23 changed files with 1077 additions and 344 deletions

View File

@@ -0,0 +1,349 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { useTranslations } from "next-intl"
import {
ChevronRight,
ChevronUp,
FolderIcon,
FolderOpenIcon,
Home,
Loader2,
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { getHomeDirectory, listDirectoryEntries } from "@/lib/api"
import type { DirectoryEntry } from "@/lib/types"
interface DirectoryBrowserDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (path: string) => void
title?: string
initialPath?: string
}
export function DirectoryBrowserDialog({
open,
onOpenChange,
onSelect,
title,
initialPath,
}: DirectoryBrowserDialogProps) {
const t = useTranslations("DirectoryBrowser")
const [rootPath, setRootPath] = useState("")
const [pathInput, setPathInput] = useState("")
const [entries, setEntries] = useState<Map<string, DirectoryEntry[]>>(
new Map()
)
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [loading, setLoading] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const initialized = useRef(false)
const loadEntries = useCallback(
async (path: string): Promise<DirectoryEntry[] | null> => {
// Already cached
if (entries.has(path)) return entries.get(path)!
setLoading((prev) => new Set(prev).add(path))
setError(null)
try {
const result = await listDirectoryEntries(path)
setEntries((prev) => new Map(prev).set(path, result))
return result
} catch {
setError(t("errorLoadingDir"))
return null
} finally {
setLoading((prev) => {
const next = new Set(prev)
next.delete(path)
return next
})
}
},
[entries, t]
)
const navigateTo = useCallback(
async (path: string) => {
const result = await loadEntries(path)
if (result !== null) {
setRootPath(path)
setPathInput(path)
setExpandedPaths(new Set())
setSelectedPath(null)
}
},
[loadEntries]
)
// Initialize on open
useEffect(() => {
if (!open) {
initialized.current = false
return
}
if (initialized.current) return
initialized.current = true
const init = async () => {
try {
const startPath = initialPath || (await getHomeDirectory())
setRootPath(startPath)
setPathInput(startPath)
setSelectedPath(null)
setExpandedPaths(new Set())
setEntries(new Map())
setError(null)
setLoading(new Set([startPath]))
const result = await listDirectoryEntries(startPath)
setEntries(new Map([[startPath, result]]))
setLoading(new Set())
} catch {
setError(t("errorLoadingDir"))
setLoading(new Set())
}
}
init()
}, [open, initialPath, t])
const handleToggleExpand = useCallback(
async (path: string) => {
const newExpanded = new Set(expandedPaths)
if (newExpanded.has(path)) {
newExpanded.delete(path)
setExpandedPaths(newExpanded)
} else {
await loadEntries(path)
newExpanded.add(path)
setExpandedPaths(newExpanded)
}
},
[expandedPaths, loadEntries]
)
const handleSelect = useCallback(
(path: string) => {
setSelectedPath(path === selectedPath ? null : path)
},
[selectedPath]
)
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(selectedPath)
onOpenChange(false)
}
}, [selectedPath, onSelect, onOpenChange])
const handleNavigateUp = useCallback(() => {
if (!rootPath) return
const parts = rootPath.replace(/\/$/, "").split("/")
if (parts.length <= 1) return
parts.pop()
const parent = parts.join("/") || "/"
navigateTo(parent)
}, [rootPath, navigateTo])
const handleGoHome = useCallback(async () => {
try {
const home = await getHomeDirectory()
navigateTo(home)
} catch {
setError(t("errorLoadingDir"))
}
}, [navigateTo, t])
const handlePathInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && pathInput.trim()) {
navigateTo(pathInput.trim())
}
},
[pathInput, navigateTo]
)
const handleDoubleClick = useCallback(
(path: string) => {
onSelect(path)
onOpenChange(false)
},
[onSelect, onOpenChange]
)
const renderEntries = (parentPath: string, depth: number) => {
const children = entries.get(parentPath)
const isLoading = loading.has(parentPath)
if (isLoading) {
return (
<div
className="flex items-center gap-2 py-2 text-sm text-muted-foreground"
style={{ paddingLeft: `${depth * 20 + 8}px` }}
>
<Loader2 className="size-3.5 animate-spin" />
<span>{t("loading")}</span>
</div>
)
}
if (!children) return null
if (children.length === 0) {
return (
<div
className="py-2 text-sm text-muted-foreground"
style={{ paddingLeft: `${depth * 20 + 28}px` }}
>
{t("emptyDirectory")}
</div>
)
}
return children.map((entry) => {
const isExpanded = expandedPaths.has(entry.path)
const isSelected = selectedPath === entry.path
return (
<div key={entry.path}>
<button
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-muted/50",
isSelected && "bg-accent text-accent-foreground"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => handleSelect(entry.path)}
onDoubleClick={() => handleDoubleClick(entry.path)}
type="button"
>
<span
className="shrink-0 p-0.5"
onClick={(e) => {
e.stopPropagation()
if (entry.hasChildren) {
handleToggleExpand(entry.path)
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation()
if (entry.hasChildren) {
handleToggleExpand(entry.path)
}
}
}}
role="button"
tabIndex={0}
>
<ChevronRight
className={cn(
"size-3.5 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
!entry.hasChildren && "invisible"
)}
/>
</span>
{isExpanded ? (
<FolderOpenIcon className="size-4 shrink-0 text-blue-500" />
) : (
<FolderIcon className="size-4 shrink-0 text-blue-500" />
)}
<span className="truncate">{entry.name}</span>
</button>
{isExpanded && renderEntries(entry.path, depth + 1)}
</div>
)
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title ?? t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={handleGoHome}
title={t("goHome")}
type="button"
>
<Home className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={handleNavigateUp}
title={t("navigateUp")}
type="button"
>
<ChevronUp className="size-4" />
</Button>
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
placeholder={t("pathPlaceholder")}
className="flex-1 h-8 text-sm font-mono"
/>
</div>
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-1">
{renderEntries(rootPath, 0)}
{error && !loading.size && (
<div className="p-4 text-center text-sm text-destructive">
{error}
</div>
)}
</div>
</ScrollArea>
{selectedPath && (
<p className="truncate text-xs text-muted-foreground">
{selectedPath}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
type="button"
>
{t("cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedPath}
type="button"
>
{t("select")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}