Compare commits

...
Author SHA1 Message Date
Jamie Taylor 2a6c4b8bda fix(workspace): improve file editor header responsiveness and prevent overflow
Restructure header into three distinct sections (controls, filename/indicators,
metadata) with proper flex constraints and truncation behavior. Add responsive
visibility controls to hide secondary elements on small screens. Fix content
overflow issues in markdown and code block rendering with max-width constraints
and improved text wrapping.
2026-02-04 16:47:59 +00:00
Jamie Taylor 20f5d980a6 fix(workspace): correct file editor header layout and stretch edit to fill height of container 2026-02-04 15:08:27 +00:00
Jamie Taylor a5c9306cff feat(workspace): add file editing capabilities with save, delete, and create operations
- Add FileEditor component replacing MarkdownViewer with save functionality
- Add ConfirmationDialog for destructive operations like file deletion
- Add FileContextMenu for right-click file operations (edit, copy path, delete)
- Add NewFileDialog for creating new files in the workspace
- Add tRPC procedures for writeFile, deleteFile, and createFile operations
- Enhance validatePath with symlink resolution to prevent path traversal attacks
- Integrate new file management features into workspace page with context menu support
2026-02-03 18:05:51 +00:00
luccast c76d7c3354 docs: update skill.md to enhance installation instructions and add optional QR code display
- Removed the installation check for `qrencode` from the main installation steps to streamline the process.
- Added a new section detailing optional installation of `qrencode` for QR code display, with platform-specific commands for macOS, Debian/Ubuntu, Fedora, and Arch.
- Improved clarity and organization of installation instructions to enhance user experience.
2026-02-03 09:09:55 -05:00
11 changed files with 1323 additions and 47 deletions
+19 -14
View File
@@ -16,7 +16,7 @@ Real-time companion monitor for OpenClaw agents.
## Step 1: Install
```bash
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && (command -v qrencode &>/dev/null || (command -v pacman &>/dev/null && sudo pacman -S --noconfirm qrencode || command -v apt &>/dev/null && sudo apt install -y qrencode || command -v brew &>/dev/null && brew install qrencode || command -v dnf &>/dev/null && sudo dnf install -y qrencode) 2>/dev/null || true) && echo "🦀 Crabwalk ${VERSION} installed"
VERSION=$(curl -s https://api.github.com/repos/luccast/crabwalk/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && mkdir -p ~/.crabwalk ~/.local/bin && curl -sL "https://github.com/luccast/crabwalk/releases/download/${VERSION}/crabwalk-${VERSION}.tar.gz" | tar -xz -C ~/.crabwalk && cp ~/.crabwalk/bin/crabwalk ~/.local/bin/ && chmod +x ~/.local/bin/crabwalk && (echo $PATH | grep -q "$HOME/.local/bin" || ([ -f ~/.bashrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; [ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc; export PATH="$HOME/.local/bin:$PATH")) && echo "🦀 Crabwalk ${VERSION} installed"
```
## Step 2: Verify
@@ -135,19 +135,6 @@ if ! echo $PATH | grep -q "$HOME/.local/bin"; then
[ -f ~/.zshrc ] && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
export PATH="$HOME/.local/bin:$PATH"
fi
# Install qrencode for QR code display (optional)
if ! command -v qrencode &>/dev/null; then
if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm qrencode 2>/dev/null || true
elif command -v apt &>/dev/null; then
sudo apt install -y qrencode 2>/dev/null || true
elif command -v brew &>/dev/null; then
brew install qrencode 2>/dev/null || true
elif command -v dnf &>/dev/null; then
sudo dnf install -y qrencode 2>/dev/null || true
fi
fi
```
## Manual Update
@@ -169,6 +156,24 @@ echo "🦀 Updated to ${VERSION}"
- **Submit Feedback**: `https://crabwalk.app/feedback-skill` — Help improve Crabwalk by sharing your experience
- **Browse Feedback**: `https://crabwalk.app/#/feedback` — See what others are saying
## Optional: QR Code Display
Install `qrencode` to display QR codes for easy mobile access:
```bash
# macOS
brew install qrencode
# Debian/Ubuntu
sudo apt install qrencode
# Fedora
sudo dnf install qrencode
# Arch
sudo pacman -S qrencode
```
---
Repository: https://github.com/luccast/crabwalk
@@ -0,0 +1,104 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, X, Check } from 'lucide-react'
interface ConfirmationDialogProps {
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
export function ConfirmationDialog({
open,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'danger',
}: ConfirmationDialogProps) {
const iconColor = variant === 'danger' ? 'text-crab-400' : variant === 'warning' ? 'text-neon-peach' : 'text-neon-cyan'
const iconBg = variant === 'danger' ? 'bg-crab-900/30' : variant === 'warning' ? 'bg-neon-peach/10' : 'bg-neon-cyan/10'
const iconBorder = variant === 'danger' ? 'border-crab-700/50' : variant === 'warning' ? 'border-neon-peach/30' : 'border-neon-cyan/30'
const confirmButtonClass = variant === 'danger'
? 'bg-crab-600 hover:bg-crab-500 text-white'
: 'bg-neon-mint hover:bg-neon-mint/90 text-shell-950'
// Handle Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
if (open) {
document.addEventListener('keydown', handleEscape)
}
return () => document.removeEventListener('keydown', handleEscape)
}, [open, onCancel])
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.3 }}
className="relative w-full max-w-md bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
>
{/* Header */}
<div className={`flex items-center gap-3 px-5 py-4 border-b border-shell-800 ${iconBg}`}>
<div className={`p-2 rounded-lg ${iconBg} border ${iconBorder}`}>
<AlertTriangle size={20} className={iconColor} />
</div>
<h3 className="font-display text-lg text-gray-200">{title}</h3>
</div>
{/* Content */}
<div className="px-5 py-4">
<p className="font-console text-sm text-shell-500 leading-relaxed">
{message}
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
<button
onClick={onCancel}
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
>
<X size={14} />
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-console transition-colors border ${confirmButtonClass}`}
>
<Check size={14} />
{confirmText}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
export default ConfirmationDialog
@@ -0,0 +1,151 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface ContextMenuItem {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
}
interface FileContextMenuProps {
open: boolean
position: { x: number; y: number } | null
items: ContextMenuItem[]
onClose: () => void
}
export function FileContextMenu({ open, position, items, onClose }: FileContextMenuProps) {
// Calculate position that stays within viewport
const adjustedPosition = React.useMemo(() => {
if (!position) return null
const menuWidth = 192 // w-48 = 12rem = 192px
const menuHeight = items.length * 42 // approximate height based on item count
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position.x
let y = position.y
// Adjust if menu would go off right edge
if (x + menuWidth + padding > viewportWidth) {
x = Math.max(padding, viewportWidth - menuWidth - padding)
}
// Adjust if menu would go off bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = Math.max(padding, viewportHeight - menuHeight - padding)
}
return { x, y }
}, [position, items.length])
// Close menu when clicking outside or pressing Escape
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleClick = () => onClose()
if (open) {
document.addEventListener('click', handleClick)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('click', handleClick)
document.removeEventListener('keydown', handleEscape)
}
}, [open, onClose])
// Prevent clicks inside menu from closing it
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<AnimatePresence>
{open && adjustedPosition && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', duration: 0.15 }}
className="fixed z-50 bg-shell-900 rounded-lg border border-shell-700 shadow-xl overflow-hidden"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
onClick={handleMenuClick}
>
{/* Menu items */}
<div className="py-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.onClick()
onClose()
}}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left font-console text-sm transition-colors ${
item.danger
? 'text-crab-400 hover:bg-crab-900/30'
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
}`}
>
<span className={item.danger ? 'text-crab-500' : 'text-shell-500'}>
{item.icon}
</span>
{item.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// Icon components
export const FileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
export const TrashIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
)
export const CopyIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
export const EditIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
export const NewFileIcon = ({ size = 14 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
export default FileContextMenu
+451
View File
@@ -0,0 +1,451 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
FileText,
Edit2,
Save,
X,
AlertCircle,
Check,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
interface FileEditorProps {
content: string
fileName: string
filePath?: string
fileSize?: number
fileModified?: Date
error?: string
isStarred?: boolean
onStar?: (path: string) => void
onSave?: (content: string, callback: (success: boolean) => void) => void
}
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]
}
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'
}
}
function Star({ size = 16, fill = 'none', className = '' }: { size?: number; fill?: string; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill}
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
)
}
export function FileEditor({
content,
fileName,
filePath,
fileSize,
fileModified,
error,
isStarred,
onStar,
onSave,
}: FileEditorProps) {
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState(content)
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSavedContent, setLastSavedContent] = useState(content)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isMarkdown = fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
// Track previous file path to detect file changes
const prevFilePathRef = useRef<string | undefined>(filePath)
// Reset edit state when file changes to prevent saving stale content
useEffect(() => {
if (filePath !== prevFilePathRef.current) {
// File changed - exit edit mode and reset buffer
setIsEditing(false)
setEditContent(content)
setLastSavedContent(content)
setSaveStatus('idle')
prevFilePathRef.current = filePath
} else if (!isEditing && content !== lastSavedContent) {
// Sync content when not editing and it changed externally
setEditContent(content)
setLastSavedContent(content)
}
}, [content, isEditing, lastSavedContent, filePath])
// Track if content has unsaved changes
const hasUnsavedChanges = editContent !== lastSavedContent
const handleSave = useCallback(() => {
if (!onSave || !hasUnsavedChanges) return
setIsSaving(true)
setSaveStatus('saving')
onSave(editContent, (success) => {
setIsSaving(false)
if (success) {
setLastSavedContent(editContent)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
} else {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 2000)
}
})
}, [editContent, hasUnsavedChanges, onSave])
const handleCancel = useCallback(() => {
setEditContent(lastSavedContent)
setIsEditing(false)
setSaveStatus('idle')
}, [lastSavedContent])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Ctrl/Cmd + S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
// Escape to cancel
if (e.key === 'Escape') {
handleCancel()
}
}
// Focus textarea when entering edit mode
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus()
}
}, [isEditing])
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-2 px-4 py-3 border-b border-shell-800 bg-shell-900/50 min-w-0">
{/* Left: Controls - always visible, fixed width */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Star button */}
{filePath && onStar && (
<button
onClick={() => onStar(filePath)}
className={`p-1.5 rounded transition-colors ${
isStarred
? 'text-yellow-400 hover:text-yellow-300'
: 'text-shell-600 hover:text-yellow-400'
}`}
title={isStarred ? 'Unstar file' : 'Star file'}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
{/* Action buttons */}
{onSave && (
<div className="flex items-center gap-1">
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
title="Edit file (or press E)"
>
<Edit2 size={14} />
<span className="hidden sm:inline">Edit</span>
</button>
) : (
<>
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-neon-mint/10 hover:bg-neon-mint/20 rounded-lg text-sm font-console text-neon-mint transition-colors border border-neon-mint/30 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save (Ctrl+S)"
>
<Save size={14} />
<span className="hidden sm:inline">Save</span>
</button>
<button
onClick={handleCancel}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
title="Cancel (Esc)"
>
<X size={14} />
<span className="hidden sm:inline">Cancel</span>
</button>
</>
)}
</div>
)}
</div>
{/* Middle: Filename and indicators - flexes and truncates */}
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<FileText size={18} className={`flex-shrink-0 ${isMarkdown ? 'text-crab-400' : 'text-shell-500'}`} />
<h2 className="font-display text-sm text-gray-200 truncate min-w-0">{fileName}</h2>
{isMarkdown && (
<span className="hidden sm:inline px-2 py-0.5 bg-crab-900/30 text-crab-400 text-[10px] font-console uppercase rounded border border-crab-700/30 flex-shrink-0">
Markdown
</span>
)}
{/* Edit mode indicator */}
{isEditing && (
<span className="hidden sm:inline px-2 py-0.5 bg-neon-mint/10 text-neon-mint text-[10px] font-console uppercase rounded border border-neon-mint/30 flex-shrink-0">
Editing
</span>
)}
{/* Unsaved changes indicator */}
{isEditing && hasUnsavedChanges && (
<span className="hidden sm:inline px-2 py-0.5 bg-neon-peach/10 text-neon-peach text-[10px] font-console uppercase rounded border border-neon-peach/30 animate-pulse flex-shrink-0">
Unsaved
</span>
)}
{/* Save status */}
<AnimatePresence>
{isEditing && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="hidden sm:flex items-center gap-1.5 flex-shrink-0"
>
{saveStatus === 'saving' && (
<>
<div className="w-2.5 h-2.5 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin" />
<span className="font-console text-[10px] text-neon-cyan">Saving...</span>
</>
)}
{saveStatus === 'saved' && (
<>
<Check size={12} className="text-neon-mint" />
<span className="font-console text-[10px] text-neon-mint">Saved</span>
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle size={12} className="text-neon-peach" />
<span className="font-console text-[10px] text-neon-peach">Save failed</span>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Right: Metadata - shows when space allows, hides on very small screens */}
<div className="flex items-center gap-3 flex-shrink-0 overflow-hidden">
<div className="hidden min-[480px]:flex items-center gap-3">
{fileSize !== undefined && (
<span className="font-console text-[10px] text-shell-500 whitespace-nowrap">
{formatFileSize(fileSize)}
</span>
)}
{fileModified && (
<span className="font-console text-[10px] text-shell-500 whitespace-nowrap">
{formatModifiedDate(fileModified)}
</span>
)}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-[1200px] mx-auto h-full">
<AnimatePresence mode="wait">
{isEditing ? (
<motion.div
key="editor"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full"
>
<textarea
ref={textareaRef}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full h-full min-h-[400px] bg-shell-900 border border-shell-700 rounded-lg p-4 font-mono text-sm text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 break-words"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
/>
</motion.div>
) : (
<motion.div
key="viewer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="min-w-0"
>
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-full break-words">
<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 mb-4 max-w-full">
<code className="text-xs sm:text-sm font-mono text-gray-300 whitespace-pre-wrap break-all">{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-xs sm:text-sm text-gray-300 whitespace-pre-wrap break-all overflow-x-auto max-w-full">{content}</pre>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)
}
export default FileEditor
+16 -2
View File
@@ -18,6 +18,7 @@ interface FileTreeProps {
selectedPath: string | null
onSelect: (path: string, type: 'file' | 'directory') => void
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
onContextMenu?: (e: React.MouseEvent, path: string) => void
level?: number
}
@@ -26,10 +27,11 @@ interface FileTreeItemProps {
selectedPath: string | null
onSelect: (path: string, type: 'file' | 'directory') => void
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
onContextMenu?: (e: React.MouseEvent, path: string) => void
level: number
}
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onContextMenu, level }: FileTreeItemProps) {
const [expanded, setExpanded] = useState(false)
const [children, setChildren] = useState<DirectoryEntry[]>([])
const [loading, setLoading] = useState(false)
@@ -85,10 +87,20 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
}
}, [entry.path, entry.type, expanded, loadChildren, onSelect, isDirectory])
const handleContextMenuFn = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu && entry.type === 'file') {
onContextMenu(e, entry.path)
}
},
[entry.path, entry.type, onContextMenu]
)
return (
<div>
<motion.div
onClick={handleClick}
onContextMenu={handleContextMenuFn}
style={{ paddingLeft }}
className={`flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
isSelected
@@ -172,6 +184,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
selectedPath={selectedPath}
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
level={level + 1}
/>
))
@@ -187,7 +200,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
)
}
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, level = 0 }: FileTreeProps) {
export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, onContextMenu, level = 0 }: FileTreeProps) {
return (
<div className="py-1">
{entries.map((entry) => (
@@ -197,6 +210,7 @@ export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, lev
selectedPath={selectedPath}
onSelect={onSelect}
onLoadDirectory={onLoadDirectory}
onContextMenu={onContextMenu}
level={level}
/>
))}
+7 -5
View File
@@ -135,11 +135,12 @@ export function MarkdownViewer({ content, fileName, filePath, fileSize, fileModi
</div>
</div>
{/* Content */}
{/* Content - Fixed max-width with word wrap */}
<div className="flex-1 overflow-auto p-6">
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
<div className="max-w-[1200px] mx-auto">
{isMarkdown ? (
<div className="prose prose-invert prose-sm max-w-none break-words">
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-display text-crab-400 mb-4 pb-2 border-b border-shell-800">
@@ -212,8 +213,9 @@ export function MarkdownViewer({ content, fileName, filePath, fileSize, fileModi
</ReactMarkdown>
</div>
) : (
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">{content}</pre>
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap break-words">{content}</pre>
)}
</div>
</div>
</div>
)
+192
View File
@@ -0,0 +1,192 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { FileText, X, Plus, AlertCircle } from 'lucide-react'
interface NewFileDialogProps {
open: boolean
onClose: () => void
onCreate: (fileName: string, content: string) => void
}
export function NewFileDialog({ open, onClose, onCreate }: NewFileDialogProps) {
const [fileName, setFileName] = useState('')
const [content, setContent] = useState('')
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Focus input when dialog opens
useEffect(() => {
if (open) {
setFileName('')
setContent('')
setError(null)
const timeoutId = setTimeout(() => inputRef.current?.focus(), 50)
return () => clearTimeout(timeoutId)
}
}, [open])
const handleSubmit = useCallback(async () => {
setError(null)
if (!fileName.trim()) {
setError('Please enter a file name')
return
}
// Validate file name - allow forward slash for subdirectory creation
const invalidChars = /[<>:"\\|?*\x00-\x1f]/g
if (invalidChars.test(fileName)) {
setError('File name contains invalid characters')
return
}
// Check for path traversal - only block parent directory traversal, not relative paths
if (fileName.includes('..') || fileName.startsWith('/')) {
setError('File name cannot contain path traversal sequences')
return
}
// Create the file - wait for completion before closing
try {
await onCreate(fileName.trim(), content)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create file')
}
}, [fileName, content, onCreate, onClose])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Only submit on Enter if focus is on the filename input (not content textarea)
if (e.key === 'Enter' && e.target === inputRef.current) {
handleSubmit()
}
if (e.key === 'Escape') {
onClose()
}
}
const addExtension = (ext: string) => {
if (!fileName.includes('.')) {
setFileName(fileName + ext)
}
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.3 }}
className="relative w-full max-w-lg bg-shell-900 rounded-xl border border-shell-700 shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-shell-800 bg-neon-mint/5">
<div className="p-2 rounded-lg bg-neon-mint/10 border border-neon-mint/30">
<Plus size={20} className="text-neon-mint" />
</div>
<h3 className="font-display text-lg text-gray-200">Create New File</h3>
</div>
{/* Content */}
<div className="px-5 py-4 space-y-4">
{/* File name input */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
File Name
</label>
<div className="relative">
<FileText size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
<input
ref={inputRef}
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="example.md"
className={`w-full bg-shell-800 border rounded-lg pl-10 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:ring-1 ${
error
? 'border-crab-500 focus:border-crab-500 focus:ring-crab-500/20'
: 'border-shell-700 focus:border-neon-mint focus:ring-neon-mint/20'
}`}
/>
</div>
{error && (
<div className="flex items-center gap-1.5 mt-2 text-crab-400">
<AlertCircle size={12} />
<span className="font-console text-xs">{error}</span>
</div>
)}
</div>
{/* Quick extension buttons */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
Quick Extensions
</label>
<div className="flex gap-2">
{['.md', '.txt', '.json', '.html'].map((ext) => (
<button
key={ext}
onClick={() => addExtension(ext)}
className="px-3 py-1.5 bg-shell-800 hover:bg-shell-700 rounded-lg text-xs font-console text-gray-400 transition-colors border border-shell-700"
>
{ext}
</button>
))}
</div>
</div>
{/* Optional content */}
<div>
<label className="block font-console text-xs text-shell-500 uppercase tracking-wider mb-2">
Initial Content (optional)
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter file content..."
className="w-full h-32 bg-shell-800 border border-shell-700 rounded-lg p-3 text-sm font-mono text-gray-300 placeholder-shell-600 resize-none focus:outline-none focus:border-neon-mint focus:ring-1 focus:ring-neon-mint/20"
spellCheck={false}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-shell-800 bg-shell-900/50">
<button
onClick={onClose}
className="flex items-center gap-2 px-4 py-2 bg-shell-800 hover:bg-shell-700 rounded-lg text-sm font-console text-gray-300 transition-colors border border-shell-700"
>
<X size={14} />
Cancel
</button>
<button
onClick={handleSubmit}
className="flex items-center gap-2 px-4 py-2 bg-neon-mint hover:bg-neon-mint/90 rounded-lg text-sm font-console text-shell-950 transition-colors"
>
<Plus size={14} />
Create File
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
export default NewFileDialog
+4
View File
@@ -1,5 +1,9 @@
export { FileTree } from './FileTree'
export { FileEditor } from './FileEditor'
export { MarkdownViewer } from './MarkdownViewer'
export { MobileBottomToolbar } from './MobileBottomToolbar'
export { MobileFileDrawer } from './MobileFileDrawer'
export { MobilePathSheet } from './MobilePathSheet'
export { ConfirmationDialog } from './ConfirmationDialog'
export { FileContextMenu, FileIcon, TrashIcon, CopyIcon, EditIcon, NewFileIcon } from './FileContextMenu'
export { NewFileDialog } from './NewFileDialog'
+56
View File
@@ -14,6 +14,9 @@ import {
import {
listDirectory,
readFile,
writeFile,
deleteFile,
createFile,
pathExists,
getDefaultWorkspacePath,
expandTilde,
@@ -286,6 +289,59 @@ const workspaceRouter = router({
}
}
}),
// Write file contents
writeFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), path: z.string(), content: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
await writeFile(expandedRoot, expandedPath, input.content)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write file',
}
}
}),
// Delete file
deleteFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), path: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
const expandedPath = expandTilde(input.path)
await deleteFile(expandedRoot, expandedPath)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete file',
}
}
}),
// Create file
createFile: publicProcedure
.input(z.object({ workspaceRoot: z.string(), fileName: z.string(), content: z.string().optional() }))
.mutation(async ({ input }): Promise<{ success: boolean; error?: string; filePath?: string }> => {
try {
const expandedRoot = expandTilde(input.workspaceRoot)
// Construct path server-side using Node.js path.join
const path = await import('path')
const fullPath = path.join(expandedRoot, input.fileName)
await createFile(expandedRoot, fullPath, input.content || '')
return { success: true, filePath: fullPath }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create file',
}
}
}),
})
export const appRouter = router({
+111 -8
View File
@@ -24,26 +24,31 @@ export interface FileContent {
/**
* Validates that a path is within the allowed workspace root
* Prevents directory traversal attacks
* Prevents directory traversal attacks and symlink escapes
*/
export function validatePath(workspaceRoot: string, targetPath: string): string {
export async function validatePath(workspaceRoot: string, targetPath: string): Promise<string> {
// Resolve to absolute paths
const resolvedRoot = path.resolve(workspaceRoot)
const resolvedTarget = path.resolve(targetPath)
// Resolve symlinks to prevent escaping workspace via symlinked paths
// This ensures we validate the actual filesystem location, not the symlink itself
const realRoot = await fs.realpath(resolvedRoot)
const realTarget = await fs.realpath(resolvedTarget)
// 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)
const normalizedRoot = normalizeForComparison(realRoot) + '/'
const normalizedTarget = normalizeForComparison(realTarget)
// 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)) {
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(realRoot)) {
throw new Error('Path traversal detected: target path is outside workspace root')
}
return resolvedTarget
return realTarget
}
/**
@@ -54,7 +59,7 @@ export async function listDirectory(
workspaceRoot: string,
targetPath: string
): Promise<DirectoryEntry[]> {
const safePath = validatePath(workspaceRoot, targetPath)
const safePath = await validatePath(workspaceRoot, targetPath)
try {
const entries = await fs.readdir(safePath, { withFileTypes: true })
@@ -111,7 +116,7 @@ export async function readFile(
workspaceRoot: string,
filePath: string
): Promise<FileContent> {
const safePath = validatePath(workspaceRoot, filePath)
const safePath = await validatePath(workspaceRoot, filePath)
try {
// Check if file exists and is a file
@@ -250,3 +255,101 @@ export function isTextFile(filename: string): boolean {
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
return textExtensions.includes(ext) || ext === ''
}
/**
* Writes content to a file
* Creates the file if it doesn't exist, overwrites if it does
*/
export async function writeFile(
workspaceRoot: string,
filePath: string,
content: string
): Promise<void> {
const safePath = await validatePath(workspaceRoot, filePath)
try {
// Check if parent directory exists
const parentDir = path.dirname(safePath)
const parentStats = await fs.stat(parentDir)
if (!parentStats.isDirectory()) {
throw new Error('Parent path is not a directory')
}
// Write file content
await fs.writeFile(safePath, content, 'utf-8')
} catch (error) {
throw new Error(
`Failed to write file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Deletes a file
*/
export async function deleteFile(
workspaceRoot: string,
filePath: string
): Promise<void> {
const safePath = await 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')
}
// Delete the file
await fs.unlink(safePath)
} catch (error) {
throw new Error(
`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Creates a new file with optional content
*/
export async function createFile(
workspaceRoot: string,
filePath: string,
content: string = ''
): Promise<void> {
// Expand user home directory and resolve path
const expandedRoot = expandTilde(workspaceRoot)
const expandedFilePath = expandTilde(filePath)
// Get the parent directory path
const parentDir = path.dirname(expandedFilePath)
const fileName = path.basename(expandedFilePath)
// Validate that parent directory is within workspace root
const safeParentPath = await validatePath(expandedRoot, parentDir)
// Check if file already exists
const exists = await pathExists(expandedFilePath)
if (exists) {
throw new Error('File already exists')
}
// Check if parent directory exists
try {
const parentStats = await fs.stat(safeParentPath)
if (!parentStats.isDirectory()) {
throw new Error('Parent path is not a directory')
}
} catch {
throw new Error('Parent directory does not exist')
}
// Create the file
try {
await fs.writeFile(expandedFilePath, content, 'utf-8')
} catch (error) {
throw new Error(
`Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
+212 -18
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import {
@@ -10,14 +10,21 @@ import {
PanelLeftClose,
Star,
FileText,
Plus,
} from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import {
FileTree,
MarkdownViewer,
FileEditor,
MobileBottomToolbar,
MobileFileDrawer,
MobilePathSheet,
ConfirmationDialog,
FileContextMenu,
TrashIcon,
CopyIcon,
EditIcon,
NewFileDialog,
} from '~/components/workspace'
import { NavTabs } from '~/components/navigation'
import { CrabIdleAnimation } from '~/components/ani'
@@ -356,6 +363,152 @@ function WorkspacePage() {
})
}, [])
// Delete confirmation dialog state
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
// New file dialog state
const [newFileDialogOpen, setNewFileDialogOpen] = useState(false)
// Context menu state
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [contextMenuFilePath, setContextMenuFilePath] = useState<string | null>(null)
// Handle save file with confirmation callback
const handleSave = useCallback(
async (content: string, callback: (success: boolean) => void) => {
if (!selectedPath) {
callback(false)
return
}
try {
await trpc.workspace.writeFile.mutate({
workspaceRoot: workspacePath,
path: selectedPath,
content,
})
// Reload the file to get updated metadata
await loadFile(selectedPath)
// Refresh the directory to update metadata
await handleRefresh()
callback(true)
} catch (error) {
console.error('Failed to save file:', error)
callback(false)
}
},
[selectedPath, workspacePath, loadFile, handleRefresh]
)
// Handle delete file
const handleDeleteFile = useCallback(async () => {
if (!fileToDelete) return
try {
await trpc.workspace.deleteFile.mutate({
workspaceRoot: workspacePath,
path: fileToDelete,
})
// If deleted file was selected, clear selection
if (selectedPath === fileToDelete) {
setSelectedPath(null)
setSelectedFileContent('')
setSelectedFileName('')
setSelectedFileSize(undefined)
setSelectedFileModified(undefined)
}
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
} catch (error) {
console.error('Failed to delete file:', error)
} finally {
setFileToDelete(null)
setDeleteConfirmOpen(false)
}
}, [fileToDelete, workspacePath, selectedPath, handleRefresh])
// Handle create file
const handleCreateFile = useCallback(
async (fileName: string, content: string) => {
try {
const result = await trpc.workspace.createFile.mutate({
workspaceRoot: workspacePath,
fileName,
content,
})
if (result.success && result.filePath) {
// Clear cache and refresh
setPathCache(new Map())
await handleRefresh()
// Select the new file
setSelectedPath(result.filePath)
await loadFile(result.filePath)
} else if (result.error) {
throw new Error(result.error)
}
} catch (error) {
console.error('Failed to create file:', error)
throw error
}
},
[workspacePath, loadFile, handleRefresh]
)
// Handle context menu
const handleContextMenu = useCallback(
(e: React.MouseEvent, filePath: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuFilePath(filePath)
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuOpen(true)
},
[]
)
// Context menu items
const contextMenuItems = useMemo(() => [
{
icon: <EditIcon size={14} />,
label: 'Edit',
onClick: () => {
if (contextMenuFilePath) {
handleSelect(contextMenuFilePath, 'file')
}
},
},
{
icon: <CopyIcon size={14} />,
label: 'Copy Path',
onClick: async () => {
if (contextMenuFilePath) {
try {
await navigator.clipboard.writeText(contextMenuFilePath)
} catch {
console.warn('Failed to copy to clipboard')
}
}
},
},
{
icon: <TrashIcon size={14} />,
label: 'Delete',
danger: true,
onClick: () => {
if (contextMenuFilePath) {
setFileToDelete(contextMenuFilePath)
setDeleteConfirmOpen(true)
}
},
},
], [contextMenuFilePath, handleSelect])
return (
@@ -436,14 +589,14 @@ function WorkspacePage() {
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
>
{/* Sidebar header */}
{/* Sidebar header */}
<div className={`flex items-center justify-between px-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
{!sidebarCollapsed && (
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
Files
</span>
)}
<div className={`flex items-center gap-2 ${sidebarCollapsed ? 'mx-auto' : ''}`}>
<div className={`flex items-center gap-1 ${sidebarCollapsed ? 'mx-auto flex-col' : ''}`}>
{loading && !sidebarCollapsed && (
<motion.div
animate={{ rotate: 360 }}
@@ -452,6 +605,15 @@ function WorkspacePage() {
<RefreshCw size={14} className="text-shell-500" />
</motion.div>
)}
{!sidebarCollapsed && (
<button
onClick={() => setNewFileDialogOpen(true)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
title="New File"
>
<Plus size={16} className="text-shell-500 hover:text-neon-mint" />
</button>
)}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
@@ -544,17 +706,18 @@ function WorkspacePage() {
</>
)}
{/* File tree */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
/>
) : (
{/* File tree */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
{pathValid ? (
<FileTree
entries={rootEntries}
selectedPath={selectedPath}
onSelect={handleSelect}
onLoadDirectory={handleLoadDirectory}
onContextMenu={handleContextMenu}
/>
) : (
<div className="p-4 text-center">
<p className="font-console text-xs text-shell-500">
Enter a workspace path to browse files
@@ -564,7 +727,7 @@ function WorkspacePage() {
</div>
)}
{/* Sidebar footer */}
{/* Sidebar footer */}
{pathValid && !sidebarCollapsed && (
<div className="px-4 py-2 border-t border-shell-800">
<p className="font-console text-[10px] text-shell-600 truncate">
@@ -575,9 +738,9 @@ function WorkspacePage() {
</motion.div>
)}
{/* Main content area */}
{/* Main content area */}
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
<MarkdownViewer
<FileEditor
content={selectedFileContent}
fileName={selectedFileName}
filePath={selectedPath ?? undefined}
@@ -586,6 +749,7 @@ function WorkspacePage() {
error={fileError}
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
onStar={handleStar}
onSave={handleSave}
/>
</div>
</div>
@@ -627,6 +791,36 @@ function WorkspacePage() {
/>
</>
)}
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={deleteConfirmOpen}
title="Delete File"
message={`Are you sure you want to delete "${fileToDelete?.split('/').pop()}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteFile}
onCancel={() => {
setDeleteConfirmOpen(false)
setFileToDelete(null)
}}
variant="danger"
/>
{/* New file dialog */}
<NewFileDialog
open={newFileDialogOpen}
onClose={() => setNewFileDialogOpen(false)}
onCreate={handleCreateFile}
/>
{/* Context menu */}
<FileContextMenu
open={contextMenuOpen}
position={contextMenuPosition}
items={contextMenuItems}
onClose={() => setContextMenuOpen(false)}
/>
</div>
)
}