mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc7ed9c09 | ||
|
|
00d88aa1a8 | ||
|
|
8dbb6802e6 | ||
|
|
c76d7c3354 |
@@ -29,11 +29,11 @@ Collect and submit feedback about Crabwalk to help improve the product.
|
||||
|
||||
## START
|
||||
|
||||
Select the proper method for your human based on a first introductory question and let your human know what to expect and what will happen to their feedback.
|
||||
Select the proper method for your human based on a first introductory question. **ALWAYS be brief**, don't overwhelm your human.
|
||||
|
||||
Use language like "Let's collect feedback for Crabwalk", as opposed to pretending like you are representing Crabwalk.
|
||||
|
||||
- Ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
|
||||
Now, ask your human a broad question about their experience with Crabwalk so far. Proceed from there.
|
||||
|
||||
## Two Feedback Modes
|
||||
|
||||
|
||||
+19
-14
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SettingsPanelProps {
|
||||
persistenceStartedAt: number | null
|
||||
persistenceSessionCount: number
|
||||
persistenceActionCount: number
|
||||
gatewayEndpoint: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onHistoricalModeChange: (enabled: boolean) => void
|
||||
@@ -37,6 +38,7 @@ export function SettingsPanel({
|
||||
persistenceStartedAt,
|
||||
persistenceSessionCount,
|
||||
persistenceActionCount,
|
||||
gatewayEndpoint,
|
||||
open,
|
||||
onOpenChange,
|
||||
onHistoricalModeChange,
|
||||
@@ -305,7 +307,7 @@ export function SettingsPanel({
|
||||
|
||||
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> endpoint: ws://127.0.0.1:18789
|
||||
<span className="text-crab-600">></span> endpoint: {gatewayEndpoint}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> protocol: v3
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
|
||||
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
|
||||
interface ChallengePayload {
|
||||
nonce: string
|
||||
ts: number
|
||||
@@ -32,7 +34,7 @@ export class ClawdbotClient {
|
||||
private _connecting = false
|
||||
|
||||
constructor(
|
||||
private url: string = 'ws://127.0.0.1:18789',
|
||||
private url: string = DEFAULT_GATEWAY_URL,
|
||||
private token?: string
|
||||
) {}
|
||||
|
||||
@@ -239,11 +241,14 @@ export class ClawdbotClient {
|
||||
// Singleton instance for server use
|
||||
let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotEndpoint(): string {
|
||||
return DEFAULT_GATEWAY_URL
|
||||
}
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient(url, token)
|
||||
clientInstance = new ClawdbotClient(DEFAULT_GATEWAY_URL, token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { initTRPC } from '@trpc/server'
|
||||
import { observable } from '@trpc/server/observable'
|
||||
import superjson from 'superjson'
|
||||
import { z } from 'zod'
|
||||
import { getClawdbotClient } from '~/integrations/openclaw/client'
|
||||
import { getClawdbotClient, getClawdbotEndpoint } from '~/integrations/openclaw/client'
|
||||
import { getPersistenceService } from '~/integrations/openclaw/persistence'
|
||||
import {
|
||||
parseEventFrame,
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import {
|
||||
listDirectory,
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
createFile,
|
||||
pathExists,
|
||||
getDefaultWorkspacePath,
|
||||
expandTilde,
|
||||
@@ -69,6 +72,10 @@ const openclawRouter = router({
|
||||
return { connected: client.connected }
|
||||
}),
|
||||
|
||||
gatewayEndpoint: publicProcedure.query(() => {
|
||||
return { url: getClawdbotEndpoint() }
|
||||
}),
|
||||
|
||||
setDebugMode: publicProcedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(({ input }) => {
|
||||
@@ -286,6 +293,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
@@ -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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ function MonitorPageWrapper() {
|
||||
|
||||
const RETRY_DELAY = 3000
|
||||
const MAX_RETRIES = 10
|
||||
const DEFAULT_GATEWAY_ENDPOINT = 'ws://127.0.0.1:18789'
|
||||
|
||||
function MonitorPage() {
|
||||
const [connected, setConnected] = useState(false)
|
||||
@@ -73,6 +74,7 @@ function MonitorPage() {
|
||||
const [debugMode, setDebugMode] = useState(false)
|
||||
const [logCollection, setLogCollection] = useState(false)
|
||||
const [logCount, setLogCount] = useState(0)
|
||||
const [gatewayEndpoint, setGatewayEndpoint] = useState(DEFAULT_GATEWAY_ENDPOINT)
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
|
||||
// Persistence service state
|
||||
@@ -116,8 +118,18 @@ function MonitorPage() {
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
checkPersistenceStatus()
|
||||
loadGatewayEndpoint()
|
||||
}, [])
|
||||
|
||||
const loadGatewayEndpoint = async () => {
|
||||
try {
|
||||
const data = await trpc.openclaw.gatewayEndpoint.query()
|
||||
setGatewayEndpoint(data.url)
|
||||
} catch {
|
||||
// keep default
|
||||
}
|
||||
}
|
||||
|
||||
const checkPersistenceStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.openclaw.persistenceStatus.query()
|
||||
@@ -468,6 +480,7 @@ function MonitorPage() {
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
gatewayEndpoint={gatewayEndpoint}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
|
||||
+212
-18
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user