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 (