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
+13
View File
@@ -2,6 +2,7 @@ import type {
AttachmentMetadata,
AuthResponse,
DeleteUploadResponse,
ListDirectoryResponse,
FileReadResponse,
FileSearchResponse,
GitCommandResponse,
@@ -239,6 +240,18 @@ export class ApiClient {
return await this.request<FileReadResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`)
}
async listSessionDirectory(sessionId: string, path?: string): Promise<ListDirectoryResponse> {
const params = new URLSearchParams()
if (path) {
params.set('path', path)
}
const qs = params.toString()
return await this.request<ListDirectoryResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/directory${qs ? `?${qs}` : ''}`
)
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<UploadFileResponse> {
return await this.request<UploadFileResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/upload`, {
method: 'POST',
@@ -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>
)
}
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import type { ApiClient } from '@/api/client'
import type { DirectoryEntry } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
export function useSessionDirectory(
api: ApiClient | null,
sessionId: string | null,
path: string,
options?: { enabled?: boolean }
): {
entries: DirectoryEntry[]
error: string | null
isLoading: boolean
refetch: () => Promise<unknown>
} {
const resolvedSessionId = sessionId ?? 'unknown'
const enabled = Boolean(api && sessionId) && (options?.enabled ?? true)
const query = useQuery({
queryKey: queryKeys.sessionDirectory(resolvedSessionId, path),
queryFn: async () => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
const response = await api.listSessionDirectory(sessionId, path)
if (!response.success) {
return { entries: [], error: response.error ?? 'Failed to list directory' }
}
return { entries: response.entries ?? [], error: null }
},
enabled,
})
const queryError = query.error instanceof Error
? query.error.message
: query.error
? 'Failed to list directory'
: null
return {
entries: query.data?.entries ?? [],
error: queryError ?? query.data?.error ?? null,
isLoading: query.isLoading,
refetch: query.refetch
}
}
+9 -2
View File
@@ -5,6 +5,7 @@ export function useAppGoBack(): () => void {
const navigate = useNavigate()
const router = useRouter()
const pathname = useLocation({ select: (location) => location.pathname })
const search = useLocation({ select: (location) => location.search })
return useCallback(() => {
// Use explicit path navigation for consistent behavior across all environments
@@ -22,7 +23,13 @@ export function useAppGoBack(): () => void {
// For single file view, go back to files list
if (pathname.match(/^\/sessions\/[^/]+\/file$/)) {
const filesPath = pathname.replace(/\/file$/, '/files')
navigate({ to: filesPath })
const tab = (search && typeof search === 'object' && 'tab' in search)
? (search as { tab?: unknown }).tab
: undefined
const nextSearch = tab === 'directories' ? { tab: 'directories' as const } : {}
navigate({ to: filesPath, search: nextSearch })
return
}
@@ -35,5 +42,5 @@ export function useAppGoBack(): () => void {
// Fallback to history.back() for other cases
router.history.back()
}, [navigate, pathname, router])
}, [navigate, pathname, router, search])
}
+1
View File
@@ -5,6 +5,7 @@ export const queryKeys = {
machines: ['machines'] as const,
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const,
sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const,
sessionFile: (sessionId: string, path: string) => ['session-file', sessionId, path] as const,
gitFileDiff: (sessionId: string, path: string, staged?: boolean) => [
'git-file-diff',
+26 -1
View File
@@ -413,6 +413,16 @@ const sessionDetailRoute = createRoute({
const sessionFilesRoute = createRoute({
getParentRoute: () => sessionDetailRoute,
path: 'files',
validateSearch: (search: Record<string, unknown>): { tab?: 'changes' | 'directories' } => {
const tabValue = typeof search.tab === 'string' ? search.tab : undefined
const tab = tabValue === 'directories'
? 'directories'
: tabValue === 'changes'
? 'changes'
: undefined
return tab ? { tab } : {}
},
component: FilesPage,
})
@@ -425,6 +435,7 @@ const sessionTerminalRoute = createRoute({
type SessionFileSearch = {
path: string
staged?: boolean
tab?: 'changes' | 'directories'
}
const sessionFileRoute = createRoute({
@@ -438,7 +449,21 @@ const sessionFileRoute = createRoute({
? false
: undefined
return staged === undefined ? { path } : { path, staged }
const tabValue = typeof search.tab === 'string' ? search.tab : undefined
const tab = tabValue === 'directories'
? 'directories'
: tabValue === 'changes'
? 'changes'
: undefined
const result: SessionFileSearch = { path }
if (staged !== undefined) {
result.staged = staged
}
if (tab !== undefined) {
result.tab = tab
}
return result
},
component: FilePage,
})
+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 ? (
+13
View File
@@ -99,6 +99,19 @@ export type FileSearchResponse = {
error?: string
}
export type DirectoryEntry = {
name: string
type: 'file' | 'directory' | 'other'
size?: number
modified?: number
}
export type ListDirectoryResponse = {
success: boolean
entries?: DirectoryEntry[]
error?: string
}
export type FileReadResponse = {
success: boolean
content?: string