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:
Jamie Taylor
2026-01-31 17:27:22 +00:00
parent e0b5ddfec5
commit 7e75a12614
14 changed files with 5429 additions and 12 deletions
+3
View File
@@ -37,3 +37,6 @@ documents/*
# Persistence data
data/
# coding agent plans
plans/
+4
View File
@@ -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
+28 -1
View File
@@ -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
View File
@@ -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
+4245
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- bufferutil
- esbuild
+201
View File
@@ -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
+205
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export { FileTree } from './FileTree'
export { MarkdownViewer } from './MarkdownViewer'
+67
View File
@@ -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
+217
View File
@@ -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
View File
@@ -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">&gt;</span> Real-time AI agent activity monitoring<br />
<span className="text-crab-600">&gt;</span> Session tracking & action visualization<br />
<span className="text-crab-600">&gt;</span> Multi-platform gateway interface
<span className="text-crab-600">&gt;</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 */}
+21 -8
View File
@@ -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">
+417
View File
@@ -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>
)
}