mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat: enhance FileTree component with starring functionality
- Added support for starring and unstarring files within the FileTree component. - Integrated starred files state management in the WorkspacePage, persisting changes to local storage. - Updated UI to display starred files in a dedicated section with both collapsed and expanded views. - Refactored FileTree and FileTreeItem components to accommodate new props for starring functionality.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Star } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
@@ -18,6 +18,8 @@ interface FileTreeProps {
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
onStar?: (path: string) => void
|
||||
starredPaths?: Set<string>
|
||||
level?: number
|
||||
}
|
||||
|
||||
@@ -26,10 +28,13 @@ interface FileTreeItemProps {
|
||||
selectedPath: string | null
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory?: (path: string) => Promise<DirectoryEntry[]>
|
||||
onStar?: (path: string) => void
|
||||
starredPaths?: Set<string>
|
||||
level: number
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: FileTreeItemProps) {
|
||||
function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, onStar, starredPaths, level }: FileTreeItemProps) {
|
||||
const isStarred = starredPaths?.has(entry.path) ?? false
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [children, setChildren] = useState<DirectoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -90,7 +95,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
style={{ paddingLeft }}
|
||||
className={`w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-1 cursor-pointer ${
|
||||
className={`group w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
|
||||
@@ -152,6 +157,24 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Star button for files */}
|
||||
{!isDirectory && onStar && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStar(entry.path)
|
||||
}}
|
||||
className={`p-0.5 rounded transition-colors flex-shrink-0 ${
|
||||
isStarred
|
||||
? 'text-yellow-400 hover:text-yellow-300'
|
||||
: 'text-shell-600 hover:text-yellow-400 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar file' : 'Star file'}
|
||||
>
|
||||
<Star size={12} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Children */}
|
||||
@@ -172,6 +195,8 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
onStar={onStar}
|
||||
starredPaths={starredPaths}
|
||||
level={level + 1}
|
||||
/>
|
||||
))
|
||||
@@ -187,7 +212,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, onStar, starredPaths, level = 0 }: FileTreeProps) {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry) => (
|
||||
@@ -197,6 +222,8 @@ export function FileTree({ entries, selectedPath, onSelect, onLoadDirectory, lev
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
onStar={onStar}
|
||||
starredPaths={starredPaths}
|
||||
level={level}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Star,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { FileTree, MarkdownViewer } from '~/components/workspace'
|
||||
@@ -82,10 +84,13 @@ function WorkspacePage() {
|
||||
// Sidebar collapse state
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
// Starred files state
|
||||
const [starredPaths, setStarredPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
// Root entries for FileTree
|
||||
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
|
||||
|
||||
// Load saved path or default on mount
|
||||
// Load saved path and starred files on mount
|
||||
useEffect(() => {
|
||||
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
|
||||
if (savedPath) {
|
||||
@@ -95,6 +100,17 @@ function WorkspacePage() {
|
||||
} else {
|
||||
loadDefaultPath()
|
||||
}
|
||||
|
||||
// Load starred files
|
||||
const savedStarred = localStorage.getItem('crabcrawl:starredFiles')
|
||||
if (savedStarred) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedStarred)
|
||||
setStarredPaths(new Set(parsed))
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load entries when workspace path changes and is valid
|
||||
@@ -312,6 +328,21 @@ function WorkspacePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle starring/unstarring files
|
||||
const handleStar = useCallback((filePath: string) => {
|
||||
setStarredPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath)
|
||||
} else {
|
||||
next.add(filePath)
|
||||
}
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('crabcrawl:starredFiles', JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
@@ -436,6 +467,92 @@ function WorkspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starred files section */}
|
||||
{starredPaths.size > 0 && (
|
||||
<>
|
||||
{sidebarCollapsed ? (
|
||||
// Collapsed: stacked file icons
|
||||
<div className="flex flex-col items-center gap-1 py-2 border-b border-shell-800">
|
||||
{[...starredPaths].slice(0, 5).map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
className={`relative p-1.5 rounded transition-colors ${
|
||||
isSelected ? 'bg-crab-500/20' : 'hover:bg-shell-800'
|
||||
}`}
|
||||
title={fileName}
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className={isSelected ? 'text-crab-400' : 'text-shell-500'}
|
||||
/>
|
||||
<Star
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="absolute -top-0.5 -right-0.5 text-yellow-400"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{starredPaths.size > 5 && (
|
||||
<span className="text-[10px] text-shell-500">+{starredPaths.size - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Expanded: starred files list
|
||||
<div className="border-b border-shell-800">
|
||||
<div className="px-4 py-2">
|
||||
<span className="font-display text-[10px] text-yellow-500/80 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Star size={10} fill="currentColor" />
|
||||
Starred
|
||||
</span>
|
||||
</div>
|
||||
<div className="pb-2">
|
||||
{[...starredPaths].map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className={`group flex items-center gap-2 px-4 py-1.5 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 hover:text-yellow-300 flex-shrink-0"
|
||||
title="Unstar file"
|
||||
>
|
||||
<Star size={12} fill="currentColor" />
|
||||
</button>
|
||||
<FileText
|
||||
size={14}
|
||||
className={`flex-shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-console text-sm truncate flex-1">
|
||||
{fileName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
@@ -445,6 +562,8 @@ function WorkspacePage() {
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
onStar={handleStar}
|
||||
starredPaths={starredPaths}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
|
||||
Reference in New Issue
Block a user