mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat(workspace): add workspace file browser and markdown viewer
Add comprehensive workspace exploration functionality allowing users to browse and view files from the host filesystem. This feature enables inspection of AI agent workspace contents directly through the web interface. - Add workspace-fs library for safe file system operations with path validation - Create tRPC workspace router with directory listing and file reading endpoints - Implement workspace UI components with directory tree navigation - Add markdown file viewer with syntax highlighting - Configure Docker volume mounting for ~/.openclaw/workspace access - Update navigation with tab-based switching between Monitor and Workspace views
This commit is contained in:
@@ -37,3 +37,6 @@ documents/*
|
||||
|
||||
# Persistence data
|
||||
data/
|
||||
|
||||
# coding agent plans
|
||||
plans/
|
||||
@@ -13,6 +13,10 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
ENV HOME=/root
|
||||
|
||||
# Create workspace directory for volume mounting
|
||||
RUN mkdir -p /root/.openclaw/workspace
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-e CLAWDBOT_URL=ws://host.docker.internal:18789 \
|
||||
-v ~/.openclaw/workspace:/root/.openclaw/workspace \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
```
|
||||
|
||||
@@ -33,11 +34,37 @@ docker run -d \
|
||||
> If you're running Moltbot with `bind: loopback` and `tailscale serve` for secure tailnet-only access, you'll need to run the crabwalk container with host networking - replace `p:3000:3000` with `--network host`
|
||||
> This allows the container to reach 127.0.0.1:18789 while maintaining the security benefits of loopback-only binding.
|
||||
|
||||
#### Workspace Access
|
||||
|
||||
The workspace explorer needs access to your local files. By default, it looks for files at `~/.openclaw/workspace`. In Docker, mount your host workspace to the same path in the container:
|
||||
|
||||
```bash
|
||||
# Default workspace path (recommended)
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-v ~/.openclaw/workspace:/root/.openclaw/workspace \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
|
||||
# Custom workspace path on host
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-v /path/to/your/workspace:/root/.openclaw/workspace \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
```
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose.yml
|
||||
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
|
||||
CLAWDBOT_API_TOKEN=your-token docker-compose up -d
|
||||
```
|
||||
|
||||
To use a custom workspace path with docker-compose, set the `WORKSPACE_HOST_PATH` environment variable:
|
||||
|
||||
```bash
|
||||
WORKSPACE_HOST_PATH=/path/to/your/workspace CLAWDBOT_API_TOKEN=your-token docker-compose up -d
|
||||
```
|
||||
|
||||
> If gateway is `bind: loopback` only, you will need to edit the `docker-compose.yml` to add `network_mode: host`
|
||||
|
||||
@@ -5,4 +5,9 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- CLAWDBOT_API_TOKEN=${CLAWDBOT_API_TOKEN}
|
||||
volumes:
|
||||
# Mount host workspace directory to container
|
||||
# The container expects the workspace at ~/.openclaw/workspace
|
||||
# Change the host path if your workspace is in a different location
|
||||
- ${WORKSPACE_HOST_PATH:-~/.openclaw/workspace}:/root/.openclaw/workspace
|
||||
restart: unless-stopped
|
||||
|
||||
Generated
+4245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- bufferutil
|
||||
- esbuild
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
// Format file size to human-readable format
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
interface FileTreeProps {
|
||||
entries: DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
level?: number
|
||||
}
|
||||
|
||||
interface FileTreeItemProps {
|
||||
entry: DirectoryEntry
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
level: number
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [children, setChildren] = useState<DirectoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const isSelected = selectedPath === entry.path
|
||||
const isDirectory = entry.type === 'directory'
|
||||
const paddingLeft = level * 16 + 8
|
||||
|
||||
const loadChildren = useCallback(async () => {
|
||||
if (!isDirectory || children.length > 0 || !onLoadDirectory) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const entries = await onLoadDirectory(entry.path)
|
||||
setChildren(entries)
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [entry.path, isDirectory, children.length, onLoadDirectory])
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isDirectory) {
|
||||
if (!expanded) {
|
||||
await loadChildren()
|
||||
setExpanded(true)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[expanded, loadChildren, isDirectory]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isDirectory) {
|
||||
if (!expanded) {
|
||||
await loadChildren()
|
||||
setExpanded(true)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
onSelect(entry.path, 'directory')
|
||||
} else {
|
||||
onSelect(entry.path, 'file')
|
||||
}
|
||||
}, [entry.path, entry.type, expanded, loadChildren, onSelect, isDirectory])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
style={{ paddingLeft }}
|
||||
className={`w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
|
||||
}`}
|
||||
whileHover={{ x: 2 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{/* Expand/collapse chevron for directories */}
|
||||
{isDirectory ? (
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
className="p-0.5 hover:bg-shell-700 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<ChevronRight size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
) : expanded ? (
|
||||
<ChevronDown size={14} className="text-shell-500" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-shell-500" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-5" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{isDirectory ? (
|
||||
expanded ? (
|
||||
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-neon-mint flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText
|
||||
size={16}
|
||||
className={`flex-shrink-0 ${
|
||||
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`font-console text-sm truncate flex-1 ${
|
||||
isSelected ? 'text-crab-400' : ''
|
||||
}`}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
{/* Metadata for files */}
|
||||
{!isDirectory && (
|
||||
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Children */}
|
||||
<AnimatePresence>
|
||||
{expanded && isDirectory && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
children.map((childEntry) => (
|
||||
<FileTreeItem
|
||||
key={childEntry.path}
|
||||
entry={childEntry}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="py-1 px-4">
|
||||
<span className="font-console text-xs text-shell-500 italic">Empty folder</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, level = 0 }: FileTreeProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
level={level}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTree
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { FileText, AlertCircle } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Format file size to human-readable format
|
||||
function formatFileSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return ''
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format date to relative time
|
||||
function formatModifiedDate(date: Date | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 30) {
|
||||
return d.toLocaleDateString()
|
||||
} else if (days > 0) {
|
||||
return `${days}d ago`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ago`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ago`
|
||||
} else {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, fileName, fileSize, fileModified, error }: MarkdownViewerProps) {
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
}, [fileName])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-crab-900/30 flex items-center justify-center border border-crab-700/50">
|
||||
<AlertCircle size={32} className="text-crab-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-crab-400 mb-2">Error Loading File</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content && !fileName) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-shell-800/50 flex items-center justify-center border border-shell-700">
|
||||
<FileText size={32} className="text-shell-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-gray-400 mb-2">No File Selected</h3>
|
||||
<p className="font-console text-sm text-shell-500 max-w-md">
|
||||
Select a file from the sidebar to view its contents
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
|
||||
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
|
||||
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
|
||||
{isMarkdown && (
|
||||
<span className="px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30">
|
||||
Markdown
|
||||
</span>
|
||||
)}
|
||||
{/* File metadata */}
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{fileSize !== undefined && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
{fileModified && (
|
||||
<span className="font-console text-[10px] text-shell-500">
|
||||
{formatModifiedDate(fileModified)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-display text-neon-mint mt-6 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-display text-gray-200 mt-4 mb-2">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code className="bg-shell-800 text-neon-peach px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<pre className="bg-shell-900 border border-shell-800 rounded-lg p-4 overflow-x-auto mb-4">
|
||||
<code className="text-sm font-mono text-gray-300">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 mb-4 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 mb-4 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-neon-cyan hover:text-neon-mint transition-colors underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-crab-500 pl-4 italic text-shell-400 mb-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-shell-700 my-6" />,
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-4">{children}</table>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-shell-800">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-shell-700 px-4 py-2 text-left font-display text-sm text-gray-200">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-shell-700 px-4 py-2 text-sm text-gray-300">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownViewer
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileTree } from './FileTree'
|
||||
export { MarkdownViewer } from './MarkdownViewer'
|
||||
@@ -11,6 +11,14 @@ import {
|
||||
type MonitorAction,
|
||||
type MonitorExecEvent,
|
||||
} from '~/integrations/clawdbot'
|
||||
import {
|
||||
listDirectory,
|
||||
readFile,
|
||||
pathExists,
|
||||
getDefaultWorkspacePath,
|
||||
type DirectoryEntry,
|
||||
type FileContent,
|
||||
} from '~/lib/workspace-fs'
|
||||
|
||||
// Server-side debug mode state
|
||||
let debugMode = false
|
||||
@@ -216,6 +224,64 @@ const clawdbotRouter = router({
|
||||
}),
|
||||
})
|
||||
|
||||
// Workspace router for file system operations
|
||||
const workspaceRouter = router({
|
||||
// Validate workspace path exists
|
||||
validatePath: publicProcedure
|
||||
.input(z.object({ path: z.string() }))
|
||||
.query(async ({ input }): Promise<{ valid: boolean; error?: string }> => {
|
||||
try {
|
||||
const exists = await pathExists(input.path)
|
||||
if (!exists) {
|
||||
return { valid: false, error: 'Path does not exist' }
|
||||
}
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Get default workspace path
|
||||
getDefaultPath: publicProcedure.query((): { path: string } => {
|
||||
return { path: getDefaultWorkspacePath() }
|
||||
}),
|
||||
|
||||
// List directory contents
|
||||
listDirectory: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.query(async ({ input }): Promise<{ entries: DirectoryEntry[]; error?: string }> => {
|
||||
try {
|
||||
const entries = await listDirectory(input.workspaceRoot, input.path)
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return {
|
||||
entries: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to list directory',
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Read file contents
|
||||
readFile: publicProcedure
|
||||
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
|
||||
.query(async ({ input }): Promise<FileContent & { error?: string }> => {
|
||||
try {
|
||||
const result = await readFile(input.workspaceRoot, input.path)
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
content: '',
|
||||
path: input.path,
|
||||
name: '',
|
||||
error: error instanceof Error ? error.message : 'Failed to read file',
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ name: z.string().optional() }))
|
||||
@@ -232,6 +298,7 @@ export const appRouter = router({
|
||||
}),
|
||||
|
||||
clawdbot: clawdbotRouter,
|
||||
workspace: workspaceRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* File system utilities for workspace explorer
|
||||
* Provides safe directory traversal and file reading operations
|
||||
*/
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
path: string
|
||||
extension?: string
|
||||
size?: number
|
||||
modifiedAt?: Date
|
||||
}
|
||||
|
||||
export interface FileContent {
|
||||
content: string
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path is within the allowed workspace root
|
||||
* Prevents directory traversal attacks
|
||||
*/
|
||||
export function validatePath(workspaceRoot: string, targetPath: string): string {
|
||||
// Resolve to absolute paths
|
||||
const resolvedRoot = path.resolve(workspaceRoot)
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
|
||||
// Ensure target path is within root path by checking with trailing separator
|
||||
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
|
||||
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep
|
||||
if (!resolvedTarget.startsWith(normalizedRoot) && resolvedTarget !== resolvedRoot) {
|
||||
throw new Error('Path traversal detected: target path is outside workspace root')
|
||||
}
|
||||
|
||||
return resolvedTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists directory contents
|
||||
* Returns files and directories with their types
|
||||
*/
|
||||
export async function listDirectory(
|
||||
workspaceRoot: string,
|
||||
targetPath: string
|
||||
): Promise<DirectoryEntry[]> {
|
||||
const safePath = validatePath(workspaceRoot, targetPath)
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
|
||||
const result: DirectoryEntry[] = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(targetPath, entry.name)
|
||||
const ext = entry.isFile() ? path.extname(entry.name).toLowerCase() : undefined
|
||||
const isFile = entry.isFile()
|
||||
|
||||
// Get file stats for metadata
|
||||
let size: number | undefined
|
||||
let modifiedAt: Date | undefined
|
||||
try {
|
||||
const stats = await fs.stat(path.join(safePath, entry.name))
|
||||
size = isFile ? stats.size : undefined
|
||||
modifiedAt = stats.mtime
|
||||
} catch {
|
||||
// Stats unavailable, continue without metadata
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
path: entryPath,
|
||||
extension: ext,
|
||||
size,
|
||||
modifiedAt,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
result.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
return a.type === 'directory' ? -1 : 1
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to list directory: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file contents
|
||||
* Only reads text files (markdown, json, txt, etc.)
|
||||
*/
|
||||
export async function readFile(
|
||||
workspaceRoot: string,
|
||||
filePath: string
|
||||
): Promise<FileContent> {
|
||||
const safePath = validatePath(workspaceRoot, filePath)
|
||||
|
||||
try {
|
||||
// Check if file exists and is a file
|
||||
const stats = await fs.stat(safePath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Path is not a file')
|
||||
}
|
||||
|
||||
// Check file size (limit to 10MB)
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
||||
if (stats.size > maxSize) {
|
||||
throw new Error('File too large (max 10MB)')
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(safePath, 'utf-8')
|
||||
const name = path.basename(safePath)
|
||||
|
||||
return {
|
||||
content,
|
||||
path: filePath,
|
||||
name,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path exists and is accessible
|
||||
*/
|
||||
export async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default workspace path
|
||||
* Returns the user's home directory + .openclaw/workspace
|
||||
*/
|
||||
export function getDefaultWorkspacePath(): string {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'
|
||||
return path.join(homeDir, '.openclaw', 'workspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a markdown file based on extension
|
||||
*/
|
||||
export function isMarkdownFile(filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
return ext === '.md' || ext === '.markdown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is viewable as text
|
||||
*/
|
||||
export function isTextFile(filename: string): boolean {
|
||||
const textExtensions = [
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.txt',
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.css',
|
||||
'.html',
|
||||
'.xml',
|
||||
'.sh',
|
||||
'.bash',
|
||||
'.zsh',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.go',
|
||||
'.rs',
|
||||
'.java',
|
||||
'.c',
|
||||
'.cpp',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.cs',
|
||||
'.php',
|
||||
'.swift',
|
||||
'.kt',
|
||||
'.scala',
|
||||
'.r',
|
||||
'.pl',
|
||||
'.lua',
|
||||
'.vim',
|
||||
'.conf',
|
||||
'.cfg',
|
||||
'.ini',
|
||||
'.toml',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.dockerignore',
|
||||
]
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
return textExtensions.includes(ext) || !ext.includes('.')
|
||||
}
|
||||
+11
-3
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, FolderOpen } from 'lucide-react'
|
||||
import { version } from '../../package.json'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
@@ -135,18 +135,26 @@ function Home() {
|
||||
>
|
||||
<span className="text-crab-600">></span> Real-time AI agent activity monitoring<br />
|
||||
<span className="text-crab-600">></span> Session tracking & action visualization<br />
|
||||
<span className="text-crab-600">></span> Multi-platform gateway interface
|
||||
<span className="text-crab-600">></span> Workspace file browser & markdown viewer
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Button */}
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
>
|
||||
<Link to="/monitor" className="btn-retro inline-block rounded-lg font-black!">
|
||||
Launch Monitor
|
||||
</Link>
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="btn-retro btn-retro-secondary inline-flex items-center gap-2 rounded-lg font-black!"
|
||||
>
|
||||
<FolderOpen size={18} />
|
||||
Explore Workspace
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Decorative line */}
|
||||
|
||||
@@ -378,16 +378,29 @@ function MonitorPage() {
|
||||
<ArrowLeft size={18} className="text-gray-400 group-hover:text-crab-400" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-7 h-7" />
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
<h1 className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
{/* Workspace tab - inactive */}
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { FileTree, MarkdownViewer } from '~/components/workspace'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
export const Route = createFileRoute('/workspace/')({
|
||||
component: WorkspacePageWrapper,
|
||||
})
|
||||
|
||||
// Wrapper to ensure client-only rendering
|
||||
function WorkspacePageWrapper() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-shell-950 text-white">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-16 h-16" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-display text-sm text-gray-400 tracking-wide uppercase">
|
||||
Loading Workspace...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <WorkspacePage />
|
||||
}
|
||||
|
||||
function WorkspacePage() {
|
||||
// Workspace path state
|
||||
const [workspacePath, setWorkspacePath] = useState('')
|
||||
const [workspacePathInput, setWorkspacePathInput] = useState('')
|
||||
const [pathError, setPathError] = useState<string | null>(null)
|
||||
const [pathValid, setPathValid] = useState(false)
|
||||
|
||||
// File tree state
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pathCache, setPathCache] = useState<Map<string, DirectoryEntry[]>>(new Map())
|
||||
|
||||
// Selected file state
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [selectedFileContent, setSelectedFileContent] = useState('')
|
||||
const [selectedFileName, setSelectedFileName] = useState('')
|
||||
const [selectedFileSize, setSelectedFileSize] = useState<number | undefined>()
|
||||
const [selectedFileModified, setSelectedFileModified] = useState<Date | undefined>()
|
||||
const [fileError, setFileError] = useState<string | undefined>()
|
||||
|
||||
// Sidebar collapse state
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
// Root entries for FileTree
|
||||
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
|
||||
|
||||
// Load saved path or default on mount
|
||||
useEffect(() => {
|
||||
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
|
||||
if (savedPath) {
|
||||
setWorkspacePathInput(savedPath)
|
||||
// Auto-validate saved path
|
||||
validatePathAndSet(savedPath)
|
||||
} else {
|
||||
loadDefaultPath()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load entries when workspace path changes and is valid
|
||||
useEffect(() => {
|
||||
if (workspacePath && pathValid) {
|
||||
loadDirectory(workspacePath)
|
||||
}
|
||||
}, [workspacePath, pathValid])
|
||||
|
||||
const loadDefaultPath = async () => {
|
||||
try {
|
||||
const result = await trpc.workspace.getDefaultPath.query()
|
||||
setWorkspacePathInput(result.path)
|
||||
// Don't auto-set workspace path - let user confirm
|
||||
} catch (error) {
|
||||
console.error('Failed to get default path:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const validatePathAndSet = async (pathToValidate: string) => {
|
||||
setPathError(null)
|
||||
setPathValid(false)
|
||||
|
||||
if (!pathToValidate.trim()) {
|
||||
setPathError('Please enter a path')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await trpc.workspace.validatePath.query({
|
||||
path: pathToValidate,
|
||||
})
|
||||
|
||||
if (result.valid) {
|
||||
setWorkspacePath(pathToValidate)
|
||||
setPathValid(true)
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('crabcrawl:workspacePath', pathToValidate)
|
||||
// Clear cache when path changes
|
||||
setPathCache(new Map())
|
||||
setSelectedPath(null)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
} else {
|
||||
setPathError(result.error || 'Invalid path')
|
||||
}
|
||||
} catch (error) {
|
||||
setPathError(error instanceof Error ? error.message : 'Failed to validate path')
|
||||
}
|
||||
}
|
||||
|
||||
const validateAndSetPath = async () => {
|
||||
await validatePathAndSet(workspacePathInput)
|
||||
}
|
||||
|
||||
const loadDirectory = async (dirPath: string): Promise<DirectoryEntry[]> => {
|
||||
// Check cache first
|
||||
if (pathCache.has(dirPath)) {
|
||||
return pathCache.get(dirPath)!
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await trpc.workspace.listDirectory.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: dirPath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
setPathCache((prev) => new Map(prev).set(dirPath, result.entries))
|
||||
return result.entries
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error)
|
||||
return []
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
setFileError(undefined)
|
||||
try {
|
||||
const result = await trpc.workspace.readFile.query({
|
||||
workspaceRoot: workspacePath,
|
||||
path: filePath,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setFileError(result.error)
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
} else {
|
||||
setSelectedFileContent(result.content)
|
||||
setSelectedFileName(result.name)
|
||||
// Get file metadata from the parent directory entry if available
|
||||
const parentDir = pathCache.get(selectedPath?.substring(0, selectedPath.lastIndexOf('/')) || workspacePath)
|
||||
const fileEntry = parentDir?.find(e => e.path === filePath)
|
||||
setSelectedFileSize(fileEntry?.size)
|
||||
setSelectedFileModified(fileEntry?.modifiedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
setFileError(error instanceof Error ? error.message : 'Failed to read file')
|
||||
setSelectedFileContent('')
|
||||
setSelectedFileName('')
|
||||
setSelectedFileSize(undefined)
|
||||
setSelectedFileModified(undefined)
|
||||
}
|
||||
},
|
||||
[workspacePath, pathCache, selectedPath]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (path: string, type: 'file' | 'directory') => {
|
||||
if (type === 'file') {
|
||||
setSelectedPath(path)
|
||||
await loadFile(path)
|
||||
}
|
||||
// Note: directory expansion is handled by FileTree component internally
|
||||
},
|
||||
[loadFile]
|
||||
)
|
||||
|
||||
// Handle directory loading for FileTree
|
||||
const handleLoadDirectory = useCallback(
|
||||
async (dirPath: string): Promise<DirectoryEntry[]> => {
|
||||
return loadDirectory(dirPath)
|
||||
},
|
||||
[workspacePath]
|
||||
)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!workspacePath || !pathValid) return
|
||||
|
||||
// Clear cache and reload
|
||||
setPathCache(new Map())
|
||||
await loadDirectory(workspacePath)
|
||||
|
||||
// Reload selected file if any
|
||||
if (selectedPath) {
|
||||
await loadFile(selectedPath)
|
||||
}
|
||||
}, [workspacePath, pathValid, selectedPath])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
validateAndSetPath()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-3 bg-shell-900 relative">
|
||||
{/* Gradient accent */}
|
||||
<div className="absolute inset-0 bg-linear-to-r from-crab-950/20 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all border border-transparent hover:border-shell-600 group"
|
||||
>
|
||||
<ArrowLeft size={18} className="text-gray-400 group-hover:text-crab-400" />
|
||||
</Link>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - inactive */}
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Workspace tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3 flex-1 max-w-2xl mx-4">
|
||||
{/* Path input */}
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<FolderOpen size={16} className="text-shell-500 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={workspacePathInput}
|
||||
onChange={(e) => setWorkspacePathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
|
||||
/>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
className="px-3 py-1.5 bg-crab-600 hover:bg-crab-500 text-white text-sm font-display rounded-lg transition-colors"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pathError && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/90 border border-crab-700 rounded-lg flex items-center gap-2 z-50">
|
||||
<AlertCircle size={14} className="text-crab-400" />
|
||||
<span className="text-xs text-crab-200 font-console">{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all border border-transparent hover:border-shell-600 disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={`text-gray-400 group-hover:text-crab-400 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!sidebarCollapsed && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 320, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Sidebar header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-shell-800">
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="p-1 hover:bg-shell-800 rounded transition-colors"
|
||||
title="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose size={14} className="text-shell-500 hover:text-crab-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Enter a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 relative bg-shell-950">
|
||||
{/* Floating sidebar toggle when collapsed */}
|
||||
{sidebarCollapsed && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="absolute left-4 top-4 z-10 p-2 bg-shell-800/80 hover:bg-shell-700 rounded-lg border border-shell-700 transition-all"
|
||||
title="Show sidebar"
|
||||
>
|
||||
<PanelLeft size={18} className="text-gray-400 hover:text-crab-400" />
|
||||
</motion.button>
|
||||
)}
|
||||
<MarkdownViewer
|
||||
content={selectedFileContent}
|
||||
fileName={selectedFileName}
|
||||
fileSize={selectedFileSize}
|
||||
fileModified={selectedFileModified}
|
||||
error={fileError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user