import { useCallback, useEffect, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { Machine, MachineDirectoryEntry } from '@/types/api' import { queryKeys } from '@/lib/query-keys' import { useTranslation } from '@/lib/use-translation' function FolderIcon(props: { className?: string }) { return ( ) } function GitIcon(props: { className?: string }) { return ( ) } function ChevronLeftIcon(props: { className?: string }) { return ( ) } function MachineIcon(props: { className?: string }) { return ( ) } function RefreshIcon(props: { className?: string }) { return ( ) } function getMachineTitle(machine: Machine): string { if (machine.metadata?.displayName) return machine.metadata.displayName if (machine.metadata?.host) return machine.metadata.host return machine.id.slice(0, 8) } function getMachineRootsSummary(machine: Machine): string { const roots = machine.metadata?.workspaceRoots ?? [] if (roots.length === 0) return '' if (roots.length === 1) return roots[0] return `${roots[0]} (+${roots.length - 1})` } function isWindowsStylePath(path: string): boolean { return /^[A-Za-z]:[\\/]/.test(path) || path.includes('\\') } function getPathSeparator(path: string): '/' | '\\' { return isWindowsStylePath(path) ? '\\' : '/' } function normalizePathForComparison(path: string): string { const normalized = path.replace(/[\\/]+/g, '/') if (/^[A-Za-z]:\/$/.test(normalized) || normalized === '/') { return normalized } if (/^[A-Za-z]:$/.test(normalized)) { return `${normalized}/` } return normalized.replace(/\/+$/, '') } function denormalizePath(path: string, sample: string): string { return getPathSeparator(sample) === '\\' ? path.replace(/\//g, '\\') : path } function joinPath(base: string, name: string): string { const normalizedBase = normalizePathForComparison(base) const joined = normalizedBase === '/' || /^[A-Za-z]:\/$/.test(normalizedBase) ? `${normalizedBase}${name}` : `${normalizedBase}/${name}` return denormalizePath(joined, base) } function parentPath(path: string): string { const normalizedPath = normalizePathForComparison(path) if (normalizedPath === '/' || /^[A-Za-z]:\/$/.test(normalizedPath)) { return denormalizePath(normalizedPath, path) } const idx = normalizedPath.lastIndexOf('/') const parent = idx <= 0 ? '/' : normalizedPath.slice(0, idx) const resolvedParent = /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent return denormalizePath(resolvedParent, path) } function isPathWithin(candidate: string, root: string): boolean { const c = normalizePathForComparison(candidate) const r = normalizePathForComparison(root) return c === r || c.startsWith(r.endsWith('/') ? r : `${r}/`) } function buildBreadcrumbs(currentPath: string, root: string): { label: string; path: string }[] { const normalizedRoot = normalizePathForComparison(root) const normalizedCurrent = normalizePathForComparison(currentPath) const rootLabel = (() => { if (normalizedRoot === '/') return '/' if (/^[A-Za-z]:\/$/.test(normalizedRoot)) return normalizedRoot.slice(0, 2) return normalizedRoot.split('/').pop() || normalizedRoot })() const relative = normalizedCurrent.slice(normalizedRoot.length).replace(/^\/+/, '') const crumbs: { label: string; path: string }[] = [{ label: rootLabel, path: denormalizePath(normalizedRoot, root) }] if (!relative) return crumbs const parts = relative.split('/').filter(Boolean) let acc = normalizedRoot for (const part of parts) { acc = acc === '/' || /^[A-Za-z]:\/$/.test(acc) ? `${acc}${part}` : `${acc}/${part}` crumbs.push({ label: part, path: denormalizePath(acc, root) }) } return crumbs } export function WorkspaceBrowser(props: { api: ApiClient machines: Machine[] machinesLoading: boolean onStartSession: (machineId: string, directory: string) => void initialMachineId?: string }) { const { t } = useTranslation() const { api, machines, machinesLoading, initialMachineId } = props const queryClient = useQueryClient() const [machineId, setMachineId] = useState(initialMachineId ?? null) const [selectedRoot, setSelectedRoot] = useState(null) const [currentPath, setCurrentPath] = useState(null) const [entries, setEntries] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (machines.length === 0) { if (machineId !== null) setMachineId(null) return } if (machineId && machines.find(m => m.id === machineId)) return // Honor an explicit initial machine before falling back to the // last-used or first-available machine. if (initialMachineId && machines.find(m => m.id === initialMachineId)) { setMachineId(initialMachineId) return } try { const lastUsed = localStorage.getItem('hapi:lastMachineId') const found = lastUsed ? machines.find(m => m.id === lastUsed) : null setMachineId(found ? found.id : machines[0].id) } catch { setMachineId(machines[0].id) } }, [machines, machineId, initialMachineId]) const selectedMachine = useMemo( () => machineId ? machines.find(m => m.id === machineId) ?? null : null, [machineId, machines] ) const workspaceRoots = useMemo( () => selectedMachine?.metadata?.workspaceRoots ?? [], [selectedMachine?.metadata?.workspaceRoots] ) const loadDirectory = useCallback(async (path: string) => { if (!machineId) return setIsLoading(true) setError(null) try { const result = await api.listMachineDirectory(machineId, path) if (result.success && result.entries) { setEntries(result.entries) setCurrentPath(path) } else { setError(result.error ?? 'Failed to list directory') // CLI may have just pushed new metadata (e.g. workspaceRoots) // that we haven't picked up yet — refetch so the UI can // transition out of the no-root state if applicable. void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to list directory') void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) } finally { setIsLoading(false) } }, [api, machineId, queryClient]) useEffect(() => { if (workspaceRoots.length === 0) { if (selectedRoot !== null) setSelectedRoot(null) return } if (selectedRoot && workspaceRoots.includes(selectedRoot)) return setSelectedRoot(workspaceRoots[0] ?? null) }, [workspaceRoots, selectedRoot]) // Auto-load selected root when a machine/root is selected useEffect(() => { if (!machineId || !selectedRoot) return if (currentPath && isPathWithin(currentPath, selectedRoot)) return void loadDirectory(selectedRoot) }, [machineId, selectedRoot, currentPath, loadDirectory]) // 切换机器时才重置浏览状态;workspaceRoots 的刷新由上面的 effect // 只做 root 有效性校正,避免 metadata 更新时清空用户当前浏览位置。 useEffect(() => { setSelectedRoot(workspaceRoots[0] ?? null) setCurrentPath(null) setEntries([]) setError(null) }, [machineId]) const handleEntryClick = useCallback((entry: MachineDirectoryEntry) => { if (entry.type !== 'directory' || !currentPath) return void loadDirectory(joinPath(currentPath, entry.name)) }, [currentPath, loadDirectory]) const handleGoUp = useCallback(() => { if (!currentPath || !selectedRoot) return if (normalizePathForComparison(currentPath) === normalizePathForComparison(selectedRoot)) return const parent = parentPath(currentPath) if (!isPathWithin(parent, selectedRoot)) return void loadDirectory(parent) }, [currentPath, selectedRoot, loadDirectory]) const handleRefresh = useCallback(() => { if (currentPath) void loadDirectory(currentPath) }, [currentPath, loadDirectory]) const handleStartSession = useCallback(() => { if (!machineId || !currentPath) return props.onStartSession(machineId, currentPath) }, [machineId, currentPath, props]) const breadcrumbs = useMemo(() => { if (!currentPath || !selectedRoot) return [] return buildBreadcrumbs(currentPath, selectedRoot) }, [currentPath, selectedRoot]) const directories = useMemo(() => entries.filter(e => e.type === 'directory'), [entries]) const atRoot = !!(currentPath && selectedRoot && normalizePathForComparison(currentPath) === normalizePathForComparison(selectedRoot)) const machineSelector = (
) // No machines connected if (machines.length === 0 && !machinesLoading) { return (
{machineSelector}
{t('browse.noMachinesConnected')}
) } // Selected machine hasn't reported workspace roots — show an info state. // Browsing is opt-in, triggered by `--workspace-root`. if (selectedMachine && workspaceRoots.length === 0) { return (
{machineSelector}
{t('browse.noRootTitle')}
{t('browse.noRootHint')}
hapi runner start --workspace-root /path/a --workspace-root /path/b
{t('browse.noRootFooter')}
) } return (
{machineSelector} {workspaceRoots.length > 1 && (
)} {currentPath && (
{breadcrumbs.map((crumb, i) => ( {i > 0 && /} ))}
)}
{error && (
{error}
)}
{isLoading && entries.length === 0 ? (
{t('loading')}
) : directories.length === 0 ? (
{t('browse.empty')}
) : (
{directories.map(entry => ( ))}
)}
{currentPath && (
{currentPath}
)}
) }