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
+98 -14
View File
@@ -1,13 +1,16 @@
import { useCallback, useMemo, useState } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import type { FileSearchItem, GitFileStatus } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { DirectoryTree } from '@/components/SessionFiles/DirectoryTree'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles'
import { useSession } from '@/hooks/queries/useSession'
import { useSessionFileSearch } from '@/hooks/queries/useSessionFileSearch'
import { encodeBase64 } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import { useQueryClient } from '@tanstack/react-query'
function BackIcon(props: { className?: string }) {
return (
@@ -227,11 +230,16 @@ function FileListSkeleton(props: { label: string; rows?: number }) {
export default function FilesPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const queryClient = useQueryClient()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId/files' })
const search = useSearch({ from: '/sessions/$sessionId/files' })
const { session } = useSession(api, sessionId)
const [searchQuery, setSearchQuery] = useState('')
const initialTab = search.tab === 'directories' ? 'directories' : 'changes'
const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab)
const {
status: gitStatus,
error: gitError,
@@ -240,26 +248,66 @@ export default function FilesPage() {
} = useGitStatusFiles(api, sessionId)
const shouldSearch = Boolean(searchQuery)
|| (gitStatus ? (gitStatus.totalStaged === 0 && gitStatus.totalUnstaged === 0) : Boolean(gitError))
|| (activeTab === 'changes'
&& (gitStatus
? (gitStatus.totalStaged === 0 && gitStatus.totalUnstaged === 0)
: Boolean(gitError)))
const searchResults = useSessionFileSearch(api, sessionId, searchQuery, {
enabled: shouldSearch && !gitLoading
enabled: shouldSearch
})
const handleOpenFile = useCallback((path: string, staged?: boolean) => {
const search = staged === undefined
? { path: encodeBase64(path) }
: { path: encodeBase64(path), staged }
const fileSearch = staged === undefined
? (activeTab === 'directories'
? { path: encodeBase64(path), tab: 'directories' as const }
: { path: encodeBase64(path) })
: (activeTab === 'directories'
? { path: encodeBase64(path), staged, tab: 'directories' as const }
: { path: encodeBase64(path), staged })
navigate({
to: '/sessions/$sessionId/file',
params: { sessionId },
search
search: fileSearch
})
}, [navigate, sessionId])
}, [activeTab, navigate, sessionId])
const branchLabel = gitStatus?.branch ?? 'detached'
const subtitle = session?.metadata?.path ?? sessionId
const showGitErrorBanner = Boolean(gitError)
const rootLabel = useMemo(() => {
const base = session?.metadata?.path ?? sessionId
const parts = base.split(/[/\\]/).filter(Boolean)
return parts.length ? parts[parts.length - 1] : base
}, [session?.metadata?.path, sessionId])
const handleRefresh = useCallback(() => {
if (searchQuery) {
void queryClient.invalidateQueries({
queryKey: queryKeys.sessionFiles(sessionId, searchQuery)
})
return
}
if (activeTab === 'directories') {
void queryClient.invalidateQueries({
queryKey: ['session-directory', sessionId]
})
return
}
void refetchGit()
}, [activeTab, queryClient, refetchGit, searchQuery, sessionId])
const handleTabChange = useCallback((nextTab: 'changes' | 'directories') => {
setActiveTab(nextTab)
navigate({
to: '/sessions/$sessionId/files',
params: { sessionId },
search: nextTab === 'changes' ? {} : { tab: nextTab },
replace: true,
})
}, [navigate, sessionId])
return (
<div className="flex h-full flex-col">
@@ -278,7 +326,7 @@ export default function FilesPage() {
</div>
<button
type="button"
onClick={() => { void refetchGit() }}
onClick={handleRefresh}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
title="Refresh"
>
@@ -303,7 +351,36 @@ export default function FilesPage() {
</div>
</div>
{!gitLoading && gitStatus ? (
<div className="bg-[var(--app-bg)] border-b border-[var(--app-divider)]" role="tablist">
<div className="mx-auto w-full max-w-content grid grid-cols-2">
<button
type="button"
role="tab"
aria-selected={activeTab === 'changes'}
onClick={() => handleTabChange('changes')}
className={`relative py-3 text-center text-sm font-semibold transition-colors hover:bg-[var(--app-subtle-bg)] ${activeTab === 'changes' ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}
>
Changes
<span
className={`absolute bottom-0 left-1/2 h-0.5 w-10 -translate-x-1/2 rounded-full ${activeTab === 'changes' ? 'bg-[var(--app-link)]' : 'bg-transparent'}`}
/>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'directories'}
onClick={() => handleTabChange('directories')}
className={`relative py-3 text-center text-sm font-semibold transition-colors hover:bg-[var(--app-subtle-bg)] ${activeTab === 'directories' ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}
>
Directories
<span
className={`absolute bottom-0 left-1/2 h-0.5 w-10 -translate-x-1/2 rounded-full ${activeTab === 'directories' ? 'bg-[var(--app-link)]' : 'bg-transparent'}`}
/>
</button>
</div>
</div>
{!gitLoading && gitStatus && !searchQuery && activeTab === 'changes' ? (
<div className="bg-[var(--app-bg)]">
<div className="mx-auto w-full max-w-content px-3 py-2 border-b border-[var(--app-divider)]">
<div className="flex items-center gap-2 text-sm">
@@ -319,14 +396,12 @@ export default function FilesPage() {
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-content">
{showGitErrorBanner ? (
{showGitErrorBanner && activeTab === 'changes' ? (
<div className="border-b border-[var(--app-divider)] bg-amber-500/10 px-3 py-2 text-xs text-[var(--app-hint)]">
{gitError}
</div>
) : null}
{gitLoading ? (
<FileListSkeleton label="Loading Git status…" />
) : shouldSearch ? (
{shouldSearch ? (
searchResults.isLoading ? (
<FileListSkeleton label="Loading files…" />
) : searchResults.error ? (
@@ -347,6 +422,15 @@ export default function FilesPage() {
))}
</div>
)
) : activeTab === 'directories' ? (
<DirectoryTree
api={api}
sessionId={sessionId}
rootLabel={rootLabel}
onOpenFile={(path) => handleOpenFile(path)}
/>
) : gitLoading ? (
<FileListSkeleton label="Loading Git status…" />
) : (
<div>
{gitStatus?.stagedFiles.length ? (