import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
ChevronDown,
ChevronRight,
Eye,
File,
Folder,
FolderOpen,
Pencil,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EmptyState } from '@/components/shared/empty-state'
import { ErrorState } from '@/components/shared/error-state'
import { LoadingCards } from '@/components/shared/loading'
import { Button } from '@/components/ui/button'
import { CreateFileDialog } from '@/components/workspace/create-file-dialog'
import { FileBreadcrumb } from '@/components/workspace/file-breadcrumb'
import { FileEditor } from '@/components/workspace/file-editor'
import { FileViewer } from '@/components/workspace/file-viewer'
import { UploadDropZone } from '@/components/workspace/upload-drop-zone'
import { useCreateFile, useDeleteFile, useSaveFile } from '@/hooks/use-workspace'
import { api } from '@/lib/api-client'
import type { TreeEntry } from '@/types'
import { isEditable, isImage } from '@/types/workspace'
export const Route = createFileRoute('/workspace/')({ component: WorkspacePage })
type SelectedFile = {
path: string
mode: 'view' | 'edit'
}
function WorkspacePage() {
const { t } = useTranslation()
// Tree state: path-based expansion tracking
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [selectedFile, setSelectedFile] = useState<SelectedFile | null>(null)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showUpload, setShowUpload] = useState(false)
// Current directory context for breadcrumb + create
const currentDir = useMemo(() => {
if (!selectedFile) return ''
const parts = selectedFile.path.split('/')
parts.pop()
return parts.join('/')
}, [selectedFile])
// --- Data fetching ---
// Root tree
const {
data: rootEntries,
isLoading: rootLoading,
isError: rootError,
refetch: refetchRoot,
isFetching: rootFetching,
} = useQuery({
queryKey: ['workspace-tree'],
queryFn: async () => {
const res = await api.get<TreeEntry[]>('/api/workspace/tree')
return Array.isArray(res) ? res : []
},
refetchInterval: 15000,
})
// Children for each expanded directory
const expandedArr = useMemo(() => [...expandedPaths], [expandedPaths])
const { data: childrenMap } = useQuery({
queryKey: ['workspace-children', expandedArr],
queryFn: async () => {
const result: Record<string, TreeEntry[]> = {}
for (const dir of expandedArr) {
try {
const res = await api.get<TreeEntry[]>(
`/api/workspace/tree?dir=${encodeURIComponent(dir)}`,
)
result[dir] = Array.isArray(res) ? res : []
} catch {
result[dir] = []
}
}
return result
},
enabled: expandedArr.length > 0,
})
// Selected file content
const { data: fileData, isLoading: fileLoading } = useQuery({
queryKey: ['workspace-file', selectedFile?.path],
queryFn: async () => {
if (!selectedFile) return null
const res = await api.get<string>(
`/api/workspace/file/${encodeURIComponent(selectedFile.path)}`,
)
return res
},
enabled: !!selectedFile,
})
// --- Mutations ---
const saveFile = useSaveFile()
const createFile = useCreateFile()
const deleteFile = useDeleteFile()
// --- Handlers ---
const toggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
return next
})
}, [])
const handleFileClick = useCallback(
(entry: TreeEntry, parentPath: string) => {
if (entry.is_dir) {
const fullPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
toggleExpand(fullPath)
} else {
const fullPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
setSelectedFile({ path: fullPath, mode: 'view' })
}
},
[toggleExpand],
)
const handleDoubleClick = useCallback((entry: TreeEntry, parentPath: string) => {
if (entry.is_dir) return
const fullPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
if (isEditable(fullPath)) {
setSelectedFile({ path: fullPath, mode: 'edit' })
}
}, [])
const handleBreadcrumbNavigate = useCallback(
(dir: string) => {
setSelectedFile(null)
if (dir) {
toggleExpand(dir)
}
},
[toggleExpand],
)
const handleSave = useCallback(
(content: string) => {
if (!selectedFile) return
saveFile.mutate({ path: selectedFile.path, content })
},
[selectedFile, saveFile],
)
const handleCreate = useCallback(
(fullPath: string, isDir: boolean) => {
createFile.mutate({ path: fullPath, isDir }, { onSuccess: () => refetchRoot() })
},
[createFile, refetchRoot],
)
const handleDelete = useCallback(() => {
if (!selectedFile) return
if (!confirm(`Delete ${selectedFile.path}?`)) return
deleteFile.mutate(selectedFile.path, {
onSuccess: () => {
setSelectedFile(null)
refetchRoot()
},
})
}, [selectedFile, deleteFile, refetchRoot])
// --- Render helpers ---
const renderEntry = (entry: TreeEntry, parentPath: string, depth: number = 0) => {
const fullPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
const isExpanded = expandedPaths.has(fullPath)
const isSelected = selectedFile?.path === fullPath
return (
<div key={fullPath}>
<div
role="treeitem"
tabIndex={0}
className={`flex items-center gap-2 py-1.5 px-2 hover:bg-muted/50 rounded cursor-pointer text-sm ${
isSelected ? 'bg-primary/10 text-primary' : ''
}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleFileClick(entry, parentPath)}
onDoubleClick={() => handleDoubleClick(entry, parentPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleFileClick(entry, parentPath)
}
}}
>
{entry.is_dir ? (
<>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Folder className="h-4 w-4 text-warning shrink-0" />
</>
) : (
<>
<span className="w-4" />
<File className="h-4 w-4 text-muted-foreground shrink-0" />
</>
)}
<span className="truncate">{entry.name}</span>
{!entry.is_dir && entry.size > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{entry.size > 1024 ? `${(entry.size / 1024).toFixed(1)}KB` : `${entry.size}B`}
</span>
)}
</div>
{isExpanded &&
entry.is_dir &&
(Array.isArray(childrenMap?.[fullPath]) ? childrenMap[fullPath] : []).map((child) =>
renderEntry(child, fullPath, depth + 1),
)}
</div>
)
}
// --- Main render ---
if (rootLoading) return <LoadingCards count={4} />
if (rootError) return <ErrorState onRetry={() => refetchRoot()} />
return (
<div className="flex flex-col h-full gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold">{t('workspace.title')}</h1>
<p className="text-muted-foreground">{t('workspace.description')}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-1" /> {t('common.create')}
</Button>
<Button variant="outline" size="sm" onClick={() => refetchRoot()} disabled={rootFetching}>
<RefreshCw className={`h-4 w-4 ${rootFetching ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Split layout */}
<div className="flex flex-1 gap-4 min-h-0">
{/* Left panel: File tree */}
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
<div className="p-2">
<div className="flex items-center justify-between px-2 py-1.5 mb-1">
<span className="text-sm font-medium flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> {t('workspace.files')}
</span>
</div>
{!rootEntries || rootEntries.length === 0 ? (
<EmptyState
icon={<FolderOpen className="h-8 w-8" />}
title={t('workspace.noWorkspace')}
description={t('workspace.description')}
className="py-6"
/>
) : (
<div className="space-y-0">{rootEntries.map((entry) => renderEntry(entry, ''))}</div>
)}
</div>
{/* Upload zone */}
{showUpload && (
<div className="px-2 pb-2">
<UploadDropZone currentDir={currentDir} onUploaded={() => refetchRoot()} />
</div>
)}
<div className="px-2 pb-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => setShowUpload(!showUpload)}
>
{showUpload ? 'Hide upload' : 'Upload file'}
</Button>
</div>
</div>
{/* Right panel: File viewer/editor */}
<div className="flex-1 flex flex-col min-w-0 border rounded-lg overflow-hidden">
{selectedFile ? (
<>
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
<FileBreadcrumb path={selectedFile.path} onNavigate={handleBreadcrumbNavigate} />
<div className="flex items-center gap-1">
{isEditable(selectedFile.path) && (
<>
<Button
variant={selectedFile.mode === 'view' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => setSelectedFile((s) => s && { ...s, mode: 'view' })}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant={selectedFile.mode === 'edit' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => setSelectedFile((s) => s && { ...s, mode: 'edit' })}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{selectedFile.mode === 'edit' && (
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
if (fileData != null) handleSave(fileData)
}}
disabled={saveFile.isPending}
>
Save
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0">
{fileLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
) : selectedFile.mode === 'edit' && isEditable(selectedFile.path) ? (
<FileEditor
path={selectedFile.path}
content={fileData ?? ''}
onSave={handleSave}
/>
) : isImage(selectedFile.path) ? (
<div className="flex items-center justify-center h-full p-4">
<img
src={`/api/workspace/file/${encodeURIComponent(selectedFile.path)}`}
alt={selectedFile.path}
className="max-w-full max-h-full object-contain"
/>
</div>
) : (
<FileViewer path={selectedFile.path} content={fileData ?? ''} />
)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center space-y-2">
<File className="h-10 w-10 mx-auto opacity-50" />
<p className="text-sm">{t('workspace.selectFile')}</p>
<p className="text-xs">{t('workspace.doubleClickEdit')}</p>
</div>
</div>
)}
</div>
</div>
{/* Create File Dialog */}
<CreateFileDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
currentDir={currentDir}
onSubmit={handleCreate}
/>
</div>
)
}