mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat: Add directory tree tab to session files
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user