feat: Add directory tree tab to session files

This commit is contained in:
weishu
2026-02-06 20:18:46 +08:00
parent dc7472f1d0
commit fb9abc18d4
13 changed files with 573 additions and 32 deletions
@@ -0,0 +1,206 @@
import { useCallback, useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import { FileIcon } from '@/components/FileIcon'
import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory'
function ChevronIcon(props: { className?: string; collapsed: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`${props.className ?? ''} transition-transform duration-200 ${props.collapsed ? '' : 'rotate-90'}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function FolderIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
)
}
function DirectorySkeleton(props: { depth: number; rows?: number }) {
const rows = props.rows ?? 4
const indent = 12 + props.depth * 14
return (
<div className="animate-pulse">
{Array.from({ length: rows }).map((_, index) => (
<div
key={`dir-skel-${props.depth}-${index}`}
className="flex items-center gap-3 px-3 py-2"
style={{ paddingLeft: indent }}
>
<div className="h-5 w-5 rounded bg-[var(--app-subtle-bg)]" />
<div className="h-3 w-40 rounded bg-[var(--app-subtle-bg)]" />
</div>
))}
</div>
)
}
function DirectoryErrorRow(props: { depth: number; message: string }) {
const indent = 12 + props.depth * 14
return (
<div
className="px-3 py-2 text-xs text-[var(--app-hint)] bg-amber-500/10"
style={{ paddingLeft: indent }}
>
{props.message}
</div>
)
}
function DirectoryNode(props: {
api: ApiClient | null
sessionId: string
path: string
label: string
depth: number
onOpenFile: (path: string) => void
expanded: Set<string>
onToggle: (path: string) => void
}) {
const isExpanded = props.expanded.has(props.path)
const { entries, error, isLoading } = useSessionDirectory(props.api, props.sessionId, props.path, {
enabled: isExpanded
})
const directories = useMemo(() => entries.filter((entry) => entry.type === 'directory'), [entries])
const files = useMemo(() => entries.filter((entry) => entry.type === 'file'), [entries])
const childDepth = props.depth + 1
const indent = 12 + props.depth * 14
const childIndent = 12 + childDepth * 14
return (
<div>
<button
type="button"
onClick={() => props.onToggle(props.path)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-[var(--app-subtle-bg)] transition-colors"
style={{ paddingLeft: indent }}
>
<ChevronIcon collapsed={!isExpanded} className="text-[var(--app-hint)]" />
<FolderIcon className="text-[var(--app-link)]" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{props.label}</div>
</div>
</button>
{isExpanded ? (
isLoading ? (
<DirectorySkeleton depth={childDepth} />
) : error ? (
<DirectoryErrorRow depth={childDepth} message={error} />
) : (
<div>
{directories.map((entry) => {
const childPath = props.path ? `${props.path}/${entry.name}` : entry.name
return (
<DirectoryNode
key={childPath}
api={props.api}
sessionId={props.sessionId}
path={childPath}
label={entry.name}
depth={childDepth}
onOpenFile={props.onOpenFile}
expanded={props.expanded}
onToggle={props.onToggle}
/>
)
})}
{files.map((entry) => {
const filePath = props.path ? `${props.path}/${entry.name}` : entry.name
return (
<button
key={filePath}
type="button"
onClick={() => props.onOpenFile(filePath)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-[var(--app-subtle-bg)] transition-colors"
style={{ paddingLeft: childIndent }}
>
<span className="h-4 w-4" />
<FileIcon fileName={entry.name} size={22} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{entry.name}</div>
</div>
</button>
)
})}
{directories.length === 0 && files.length === 0 ? (
<div
className="px-3 py-2 text-sm text-[var(--app-hint)]"
style={{ paddingLeft: childIndent }}
>
Empty directory.
</div>
) : null}
</div>
)
) : null}
</div>
)
}
export function DirectoryTree(props: {
api: ApiClient | null
sessionId: string
rootLabel: string
onOpenFile: (path: string) => void
}) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['']))
const handleToggle = useCallback((path: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
return (
<div className="border-t border-[var(--app-divider)]">
<DirectoryNode
api={props.api}
sessionId={props.sessionId}
path=""
label={props.rootLabel}
depth={0}
onOpenFile={props.onOpenFile}
expanded={expanded}
onToggle={handleToggle}
/>
</div>
)
}