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,66 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm, symlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { RpcHandlerManager } from '../../../api/rpc/RpcHandlerManager'
import { registerDirectoryHandlers } from './directories'
async function createTempDir(prefix: string): Promise<string> {
const base = tmpdir()
const path = join(base, `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(path, { recursive: true })
return path
}
describe('directory RPC handlers', () => {
let rootDir: string
let rpc: RpcHandlerManager
beforeEach(async () => {
if (rootDir) {
await rm(rootDir, { recursive: true, force: true })
}
rootDir = await createTempDir('hapi-dir-handler')
await mkdir(join(rootDir, 'src'), { recursive: true })
await writeFile(join(rootDir, 'src', 'index.ts'), 'console.log("ok")')
await writeFile(join(rootDir, 'README.md'), '# test')
rpc = new RpcHandlerManager({ scopePrefix: 'session-test' })
registerDirectoryHandlers(rpc, rootDir)
})
it('lists root directory via empty path', async () => {
const response = await rpc.handleRequest({
method: 'session-test:listDirectory',
params: JSON.stringify({ path: '' })
})
const parsed = JSON.parse(response) as { success: boolean; entries?: Array<{ name: string; type: string }> }
expect(parsed.success).toBe(true)
const names = (parsed.entries ?? []).map((entry) => entry.name)
expect(names).toContain('src')
expect(names).toContain('README.md')
})
it('skips symlink stat in listDirectory', async () => {
try {
await symlink('/definitely-not-a-real-path', join(rootDir, 'bad-link'))
} catch {
// symlink may be disallowed on some systems; skip the test
return
}
const response = await rpc.handleRequest({
method: 'session-test:listDirectory',
params: JSON.stringify({ path: '' })
})
const parsed = JSON.parse(response) as { success: boolean; entries?: Array<{ name: string; type: string; size?: number }> }
expect(parsed.success).toBe(true)
const link = (parsed.entries ?? []).find((entry) => entry.name === 'bad-link')
expect(link).toBeTruthy()
expect(link?.type).toBe('other')
expect(link?.size).toBeUndefined()
})
})
+24 -13
View File
@@ -1,6 +1,6 @@
import { logger } from '@/ui/logger'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { basename, join, resolve } from 'path'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
@@ -46,17 +46,20 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => {
logger.debug('List directory request:', data.path)
const validation = validatePath(data.path, workingDirectory)
const targetPath = data.path || '.'
const validation = validatePath(targetPath, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
try {
const entries = await readdir(data.path, { withFileTypes: true })
const resolvedPath = resolve(workingDirectory, targetPath)
const entries = await readdir(resolvedPath, { withFileTypes: true })
const directoryEntries: DirectoryEntry[] = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(data.path, entry.name)
const fullPath = join(resolvedPath, entry.name)
let type: 'file' | 'directory' | 'other' = 'other'
let size: number | undefined
let modified: number | undefined
@@ -65,14 +68,18 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
type = 'directory'
} else if (entry.isFile()) {
type = 'file'
} else if (entry.isSymbolicLink()) {
type = 'other'
}
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch (error) {
logger.debug(`Failed to stat ${fullPath}:`, error)
if (!entry.isSymbolicLink()) {
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch (error) {
logger.debug(`Failed to stat ${fullPath}:`, error)
}
}
return {
@@ -100,11 +107,15 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth)
const validation = validatePath(data.path, workingDirectory)
const targetPath = data.path || '.'
const validation = validatePath(targetPath, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
const resolvedRoot = resolve(workingDirectory, targetPath)
async function buildTree(path: string, name: string, currentDepth: number): Promise<TreeNode | null> {
try {
const stats = await stat(path)
@@ -157,8 +168,8 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
return rpcError('maxDepth must be non-negative')
}
const baseName = data.path === '/' ? '/' : basename(data.path) || data.path
const tree = await buildTree(data.path, baseName, 0)
const baseName = resolvedRoot === '/' ? '/' : basename(resolvedRoot) || resolvedRoot
const tree = await buildTree(resolvedRoot, baseName, 0)
if (!tree) {
return rpcError('Failed to access the specified path')
+17
View File
@@ -27,6 +27,19 @@ export type RpcDeleteUploadResponse = {
error?: string
}
export type RpcDirectoryEntry = {
name: string
type: 'file' | 'directory' | 'other'
size?: number
modified?: number
}
export type RpcListDirectoryResponse = {
success: boolean
entries?: RpcDirectoryEntry[]
error?: string
}
export type RpcPathExistsResponse = {
exists: Record<string, boolean>
}
@@ -155,6 +168,10 @@ export class RpcGateway {
return await this.sessionRpc(sessionId, 'readFile', { path }) as RpcReadFileResponse
}
async listDirectory(sessionId: string, path: string): Promise<RpcListDirectoryResponse> {
return await this.sessionRpc(sessionId, 'listDirectory', { path }) as RpcListDirectoryResponse
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<RpcUploadFileResponse> {
return await this.sessionRpc(sessionId, 'uploadFile', { sessionId, filename, content, mimeType }) as RpcUploadFileResponse
}
+21 -2
View File
@@ -15,13 +15,28 @@ import type { SSEManager } from '../sse/sseManager'
import { EventPublisher, type SyncEventListener } from './eventPublisher'
import { MachineCache, type Machine } from './machineCache'
import { MessageService } from './messageService'
import { RpcGateway, type RpcCommandResponse, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse, type RpcDeleteUploadResponse } from './rpcGateway'
import {
RpcGateway,
type RpcCommandResponse,
type RpcDeleteUploadResponse,
type RpcListDirectoryResponse,
type RpcPathExistsResponse,
type RpcReadFileResponse,
type RpcUploadFileResponse
} from './rpcGateway'
import { SessionCache } from './sessionCache'
export type { Session, SyncEvent } from '@hapi/protocol/types'
export type { Machine } from './machineCache'
export type { SyncEventListener } from './eventPublisher'
export type { RpcCommandResponse, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse, RpcDeleteUploadResponse } from './rpcGateway'
export type {
RpcCommandResponse,
RpcDeleteUploadResponse,
RpcListDirectoryResponse,
RpcPathExistsResponse,
RpcReadFileResponse,
RpcUploadFileResponse
} from './rpcGateway'
export type ResumeSessionResult =
| { type: 'success'; sessionId: string }
@@ -415,6 +430,10 @@ export class SyncEngine {
return await this.rpcGateway.readSessionFile(sessionId, path)
}
async listDirectory(sessionId: string, path: string): Promise<RpcListDirectoryResponse> {
return await this.rpcGateway.listDirectory(sessionId, path)
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<RpcUploadFileResponse> {
return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType)
}
+30
View File
@@ -9,6 +9,10 @@ const fileSearchSchema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional()
})
const directorySchema = z.object({
path: z.string().optional()
})
const filePathSchema = z.object({
path: z.string().min(1)
})
@@ -180,5 +184,31 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<We
return c.json({ success: true, files })
})
app.get('/sessions/:id/directory', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
const sessionPath = sessionResult.session.metadata?.path
if (!sessionPath) {
return c.json({ success: false, error: 'Session path not available' })
}
const parsed = directorySchema.safeParse(c.req.query())
if (!parsed.success) {
return c.json({ error: 'Invalid query' }, 400)
}
const path = parsed.data.path ?? ''
const result = await runRpc(() => engine.listDirectory(sessionResult.sessionId, path))
return c.json(result)
})
return app
}
+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