feat: 工作目录支持页面内目录浏览器
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { listDirectories } from '../api/client'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
open: boolean
|
||||
initialPath: string
|
||||
onSelect: (path: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
|
||||
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
|
||||
*/
|
||||
export function DirectoryPickerDialog({
|
||||
open,
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: DirectoryPickerDialogProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||
const [draftPath, setDraftPath] = useState('')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef, '[data-picker-path]')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const start = initialPath.trim()
|
||||
setCurrentPath(start || null)
|
||||
setDraftPath(start)
|
||||
}, [initialPath, open])
|
||||
|
||||
const listing = useQuery({
|
||||
queryKey: ['directories', currentPath],
|
||||
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (listing.data?.path) {
|
||||
setDraftPath(listing.data.path)
|
||||
}
|
||||
}, [listing.data?.path])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = listing.data?.entries ?? []
|
||||
const currentDirectory = listing.data?.path ?? null
|
||||
const parent = listing.data?.parent ?? null
|
||||
|
||||
const navigate = (path: string | null) => {
|
||||
setCurrentPath(path)
|
||||
setDraftPath(path ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="modal-card directory-picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="directory-picker-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">WORKING DIRECTORY</p>
|
||||
<h2 id="directory-picker-title">选择项目工作目录</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭目录选择"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="directory-picker-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(parent)}
|
||||
disabled={!parent}
|
||||
>
|
||||
上一级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(null)}
|
||||
>
|
||||
盘符
|
||||
</button>
|
||||
<form
|
||||
className="directory-path-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigate(draftPath.trim() || null)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="field-input mono"
|
||||
value={draftPath}
|
||||
onChange={(event) => setDraftPath(event.target.value)}
|
||||
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||
aria-label="目录路径"
|
||||
data-picker-path
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="directory-picker-body">
|
||||
{listing.isPending ? (
|
||||
<p className="muted-copy">正在读取目录…</p>
|
||||
) : null}
|
||||
{listing.isError ? (
|
||||
<div className="inline-alert inline-alert-error" role="alert">
|
||||
{listing.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||
<p className="empty-inline">
|
||||
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
|
||||
</p>
|
||||
) : null}
|
||||
{entries.length > 0 ? (
|
||||
<ul className="directory-list">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="directory-row"
|
||||
onClick={() => navigate(entry.path)}
|
||||
title={entry.path}
|
||||
>
|
||||
<span className="directory-icon" aria-hidden="true">
|
||||
📁
|
||||
</span>
|
||||
<span className="directory-name">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{listing.data?.truncated ? (
|
||||
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="directory-picker-footer">
|
||||
<div className="directory-picker-summary">
|
||||
<span className="section-label">将使用</span>
|
||||
<code>{currentDirectory ?? '尚未选择目录'}</code>
|
||||
</div>
|
||||
<div className="modal-actions directory-picker-actions">
|
||||
<button type="button" className="button button-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
disabled={!currentDirectory}
|
||||
onClick={() => {
|
||||
if (currentDirectory) {
|
||||
onSelect(currentDirectory)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
使用此目录
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user