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:
luccast
2026-02-01 20:16:00 -05:00
parent 532338cfa0
commit 77f7d2988b
2 changed files with 151 additions and 5 deletions
+31 -4
View File
@@ -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}
/>
))}
+120 -1
View File
@@ -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">