Merge pull request #28 from Popidge/workspace-view

Feat - Workspace view (#24)
This commit is contained in:
Jamie Taylor
2026-02-02 00:12:21 +00:00
committed by GitHub
14 changed files with 5550 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
@@ -54,6 +54,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
```
@@ -62,11 +63,37 @@ docker run -d \
> If you're running OpenClaw 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
+207
View File
@@ -0,0 +1,207 @@
import { useState, useCallback, useEffect } 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)
// Reset children when entry path changes (e.g., on refresh)
useEffect(() => {
setChildren([])
setExpanded(false)
}, [entry.path])
const isSelected = selectedPath === entry.path
const isDirectory = entry.type === 'directory'
const paddingLeft = level * 16 + 8
const loadChildren = useCallback(async () => {
if (!isDirectory || !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, 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'
+73
View File
@@ -11,6 +11,15 @@ import {
type MonitorAction,
type MonitorExecEvent,
} from '~/integrations/clawdbot'
import {
listDirectory,
readFile,
pathExists,
getDefaultWorkspacePath,
expandTilde,
type DirectoryEntry,
type FileContent,
} from '~/lib/workspace-fs'
// Server-side debug mode state
let debugMode = false
@@ -216,6 +225,69 @@ 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; expandedPath?: string }> => {
try {
const expandedPath = expandTilde(input.path)
const exists = await pathExists(expandedPath)
if (!exists) {
return { valid: false, error: 'Path does not exist' }
}
return { valid: true, expandedPath }
} 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 expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
const entries = await listDirectory(expandedRoot, expandedPath)
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 expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
const result = await readFile(expandedRoot, expandedPath)
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 +304,7 @@ export const appRouter = router({
}),
clawdbot: clawdbotRouter,
workspace: workspaceRouter,
})
export type AppRouter = typeof appRouter
+252
View File
@@ -0,0 +1,252 @@
import { promises as fs } from 'fs'
import os from 'os'
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)
// Normalize paths for cross-platform comparison
// Convert backslashes to forward slashes and ensure consistent formatting
const normalizeForComparison = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '')
const normalizedRoot = normalizeForComparison(resolvedRoot) + '/'
const normalizedTarget = normalizeForComparison(resolvedTarget)
// Ensure target path is within root path by checking with trailing separator
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(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 = os.homedir()
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'
}
/**
* Expands tilde (~) to the user's home directory on Unix-based systems
* Handles both "~/" prefix and standalone "~" path
*/
export function expandTilde(inputPath: string): string {
// Only expand if path starts with ~
if (!inputPath.startsWith('~')) {
return inputPath
}
// Get home directory using Node.js built-in (handles cross-platform)
// Returns /root in containerized environments if HOME is not set
const homeDir = os.homedir()
// Handle "~/" prefix or standalone "~"
if (inputPath === '~' || inputPath.startsWith('~/')) {
return path.join(homeDir, inputPath.slice(1))
}
// Path starts with ~ but not followed by / (e.g., ~username)
// This is a valid Unix path referring to another user's home
// Return as-is and let the system handle it
return inputPath
}
/**
* 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',
]
// Get extension - handle files starting with dot (like .gitignore)
// path.extname returns '' for files like 'Makefile' and '.gitignore'
// We need to distinguish between extensionless files and dotfiles
const lastDotIndex = filename.lastIndexOf('.')
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
return textExtensions.includes(ext) || ext === ''
}
+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">
+491
View File
@@ -0,0 +1,491 @@
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'
// Get parent directory path using path separator logic
// Works cross-platform for both / and \ separators
function getParentDirPath(filePath: string): string {
// Normalize to forward slashes for consistent processing
const normalized = filePath.replace(/\\/g, '/')
const lastSlashIndex = normalized.lastIndexOf('/')
if (lastSlashIndex <= 0) {
return filePath
}
// Return the original path up to the last separator
return filePath.substring(0, lastSlashIndex)
}
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 && result.expandedPath) {
// Use the expanded path (e.g., ~/Documents -> /home/user/Documents)
setWorkspacePath(result.expandedPath)
setWorkspacePathInput(result.expandedPath)
setPathValid(true)
// Persist to localStorage
localStorage.setItem('crabcrawl:workspacePath', result.expandedPath)
// 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(getParentDirPath(filePath) || 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
// Store current selection before clearing cache
const currentSelectedPath = selectedPath
// Clear cache first, then reload
// Use a callback to ensure cache is cleared before loading
setPathCache(new Map())
// Small delay to ensure React has processed the state update
// before we try to load the directory
await new Promise(resolve => setTimeout(resolve, 0))
// Reload root directory - this will repopulate the file tree
// Force reload by bypassing cache check
setLoading(true)
try {
const result = await trpc.workspace.listDirectory.query({
workspaceRoot: workspacePath,
path: workspacePath,
})
if (result.error) {
throw new Error(result.error)
}
// Update cache with fresh data
setPathCache(new Map([[workspacePath, result.entries]]))
} catch (error) {
console.error('Failed to load directory:', error)
} finally {
setLoading(false)
}
// Reload selected file if any (with error handling for deleted files)
if (currentSelectedPath) {
try {
const result = await trpc.workspace.readFile.query({
workspaceRoot: workspacePath,
path: currentSelectedPath,
})
if (result.error) {
// File no longer exists - clear selection gracefully
setSelectedPath(null)
setSelectedFileContent('')
setSelectedFileName('')
setSelectedFileSize(undefined)
setSelectedFileModified(undefined)
setFileError(result.error)
} else {
setSelectedFileContent(result.content)
setSelectedFileName(result.name)
// Get file metadata from the parent directory entry if available
const parentDir = pathCache.get(getParentDirPath(currentSelectedPath) || workspacePath)
const fileEntry = parentDir?.find(e => e.path === currentSelectedPath)
setSelectedFileSize(fileEntry?.size)
setSelectedFileModified(fileEntry?.modifiedAt)
}
} catch (error) {
// File no longer exists - clear selection gracefully
setSelectedPath(null)
setSelectedFileContent('')
setSelectedFileName('')
setSelectedFileSize(undefined)
setSelectedFileModified(undefined)
setFileError(error instanceof Error ? error.message : 'Failed to read file')
}
}
}, [workspacePath, pathValid, selectedPath, loadFile])
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>
)
}