From fb9abc18d4fb0844df52191c031205948ab95cae Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 6 Feb 2026 20:18:46 +0800 Subject: [PATCH] feat: Add directory tree tab to session files --- .../common/handlers/directories.test.ts | 66 ++++++ .../modules/common/handlers/directories.ts | 37 ++-- hub/src/sync/rpcGateway.ts | 17 ++ hub/src/sync/syncEngine.ts | 23 +- hub/src/web/routes/git.ts | 30 +++ web/src/api/client.ts | 13 ++ .../components/SessionFiles/DirectoryTree.tsx | 206 ++++++++++++++++++ web/src/hooks/queries/useSessionDirectory.ts | 49 +++++ web/src/hooks/useAppGoBack.ts | 11 +- web/src/lib/query-keys.ts | 1 + web/src/router.tsx | 27 ++- web/src/routes/sessions/files.tsx | 112 ++++++++-- web/src/types/api.ts | 13 ++ 13 files changed, 573 insertions(+), 32 deletions(-) create mode 100644 cli/src/modules/common/handlers/directories.test.ts create mode 100644 web/src/components/SessionFiles/DirectoryTree.tsx create mode 100644 web/src/hooks/queries/useSessionDirectory.ts diff --git a/cli/src/modules/common/handlers/directories.test.ts b/cli/src/modules/common/handlers/directories.test.ts new file mode 100644 index 00000000..f4fc7afa --- /dev/null +++ b/cli/src/modules/common/handlers/directories.test.ts @@ -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 { + 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() + }) +}) diff --git a/cli/src/modules/common/handlers/directories.ts b/cli/src/modules/common/handlers/directories.ts index cc9def2b..e8fd176b 100644 --- a/cli/src/modules/common/handlers/directories.ts +++ b/cli/src/modules/common/handlers/directories.ts @@ -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('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('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 { 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') diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 094baeaf..84a3b05e 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -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 } @@ -155,6 +168,10 @@ export class RpcGateway { return await this.sessionRpc(sessionId, 'readFile', { path }) as RpcReadFileResponse } + async listDirectory(sessionId: string, path: string): Promise { + return await this.sessionRpc(sessionId, 'listDirectory', { path }) as RpcListDirectoryResponse + } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.sessionRpc(sessionId, 'uploadFile', { sessionId, filename, content, mimeType }) as RpcUploadFileResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ea2e15b9..1ab46e65 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -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 { + return await this.rpcGateway.listDirectory(sessionId, path) + } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType) } diff --git a/hub/src/web/routes/git.ts b/hub/src/web/routes/git.ts index dea7d69b..972f6e36 100644 --- a/hub/src/web/routes/git.ts +++ b/hub/src/web/routes/git.ts @@ -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 { + 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 } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f93b847e..347e78f1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,6 +2,7 @@ import type { AttachmentMetadata, AuthResponse, DeleteUploadResponse, + ListDirectoryResponse, FileReadResponse, FileSearchResponse, GitCommandResponse, @@ -239,6 +240,18 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`) } + async listSessionDirectory(sessionId: string, path?: string): Promise { + const params = new URLSearchParams() + if (path) { + params.set('path', path) + } + + const qs = params.toString() + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/directory${qs ? `?${qs}` : ''}` + ) + } + async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/upload`, { method: 'POST', diff --git a/web/src/components/SessionFiles/DirectoryTree.tsx b/web/src/components/SessionFiles/DirectoryTree.tsx new file mode 100644 index 00000000..1fe7581d --- /dev/null +++ b/web/src/components/SessionFiles/DirectoryTree.tsx @@ -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 ( + + + + ) +} + +function FolderIcon(props: { className?: string }) { + return ( + + + + ) +} + +function DirectorySkeleton(props: { depth: number; rows?: number }) { + const rows = props.rows ?? 4 + const indent = 12 + props.depth * 14 + + return ( +
+ {Array.from({ length: rows }).map((_, index) => ( +
+
+
+
+ ))} +
+ ) +} + +function DirectoryErrorRow(props: { depth: number; message: string }) { + const indent = 12 + props.depth * 14 + return ( +
+ {props.message} +
+ ) +} + +function DirectoryNode(props: { + api: ApiClient | null + sessionId: string + path: string + label: string + depth: number + onOpenFile: (path: string) => void + expanded: Set + 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 ( +
+ + + {isExpanded ? ( + isLoading ? ( + + ) : error ? ( + + ) : ( +
+ {directories.map((entry) => { + const childPath = props.path ? `${props.path}/${entry.name}` : entry.name + return ( + + ) + })} + + {files.map((entry) => { + const filePath = props.path ? `${props.path}/${entry.name}` : entry.name + return ( + + ) + })} + + {directories.length === 0 && files.length === 0 ? ( +
+ Empty directory. +
+ ) : null} +
+ ) + ) : null} +
+ ) +} + +export function DirectoryTree(props: { + api: ApiClient | null + sessionId: string + rootLabel: string + onOpenFile: (path: string) => void +}) { + const [expanded, setExpanded] = useState>(() => 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 ( +
+ +
+ ) +} + diff --git a/web/src/hooks/queries/useSessionDirectory.ts b/web/src/hooks/queries/useSessionDirectory.ts new file mode 100644 index 00000000..8862e75d --- /dev/null +++ b/web/src/hooks/queries/useSessionDirectory.ts @@ -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 +} { + 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 + } +} diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts index 623ba530..98361669 100644 --- a/web/src/hooks/useAppGoBack.ts +++ b/web/src/hooks/useAppGoBack.ts @@ -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]) } diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 4b12ef84..a00b5512 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -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', diff --git a/web/src/router.tsx b/web/src/router.tsx index e2e75014..018f7f83 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -413,6 +413,16 @@ const sessionDetailRoute = createRoute({ const sessionFilesRoute = createRoute({ getParentRoute: () => sessionDetailRoute, path: 'files', + validateSearch: (search: Record): { 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, }) diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index a54e3ff4..870d66f6 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -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 (
@@ -278,7 +326,7 @@ export default function FilesPage() {
- {!gitLoading && gitStatus ? ( +
+
+ + +
+
+ + {!gitLoading && gitStatus && !searchQuery && activeTab === 'changes' ? (
@@ -319,14 +396,12 @@ export default function FilesPage() {
- {showGitErrorBanner ? ( + {showGitErrorBanner && activeTab === 'changes' ? (
{gitError}
) : null} - {gitLoading ? ( - - ) : shouldSearch ? ( + {shouldSearch ? ( searchResults.isLoading ? ( ) : searchResults.error ? ( @@ -347,6 +422,15 @@ export default function FilesPage() { ))}
) + ) : activeTab === 'directories' ? ( + handleOpenFile(path)} + /> + ) : gitLoading ? ( + ) : (
{gitStatus?.stagedFiles.length ? ( diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 3c50f426..22a9d731 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -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