优化web/server模式下的目录选择,现在支持目录树选择,而不是硬文本写入
This commit is contained in:
349
src/components/shared/directory-browser-dialog.tsx
Normal file
349
src/components/shared/directory-browser-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user