feat: 工作目录支持页面内目录浏览器

This commit is contained in:
2026-10-24 23:20:20 +08:00
parent dd7612bc74
commit e555610037
15 changed files with 1154 additions and 16 deletions
@@ -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>
)
}