mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Add GitGraph icon button to FileExplorerPanel, positioned left of the eye icon - Add "Document Graph" tooltip on hover - Add isGraphViewOpen state in App.tsx for controlling graph modal visibility - Wire onOpenGraphView callback through RightPanel → FileExplorerPanel
598 lines
23 KiB
TypeScript
598 lines
23 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff, GitGraph } from 'lucide-react';
|
|
import type { Session, Theme, FocusArea } from '../types';
|
|
import type { FileNode } from '../types/fileTree';
|
|
import type { FileTreeChanges } from '../utils/fileExplorer';
|
|
import { getFileIcon } from '../utils/theme';
|
|
import { useLayerStack } from '../contexts/LayerStackContext';
|
|
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
|
|
|
// Auto-refresh interval options in seconds
|
|
const AUTO_REFRESH_OPTIONS = [
|
|
{ label: 'Every 5 seconds', value: 5 },
|
|
{ label: 'Every 20 seconds', value: 20 },
|
|
{ label: 'Every 60 seconds', value: 60 },
|
|
{ label: 'Every 3 minutes', value: 180 },
|
|
];
|
|
|
|
// Helper to format bytes into human-readable format
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
}
|
|
|
|
// Flattened node for virtualization
|
|
interface FlattenedNode {
|
|
node: FileNode;
|
|
path: string;
|
|
depth: number;
|
|
globalIndex: number;
|
|
}
|
|
|
|
interface FileExplorerPanelProps {
|
|
session: Session;
|
|
theme: Theme;
|
|
fileTreeFilter: string;
|
|
setFileTreeFilter: (filter: string) => void;
|
|
fileTreeFilterOpen: boolean;
|
|
setFileTreeFilterOpen: (open: boolean) => void;
|
|
filteredFileTree: FileNode[];
|
|
selectedFileIndex: number;
|
|
setSelectedFileIndex: (index: number) => void;
|
|
activeFocus: FocusArea;
|
|
activeRightTab: string;
|
|
previewFile: {name: string; content: string; path: string} | null;
|
|
setActiveFocus: (focus: FocusArea) => void;
|
|
fileTreeContainerRef?: React.RefObject<HTMLDivElement>;
|
|
fileTreeFilterInputRef?: React.RefObject<HTMLInputElement>;
|
|
toggleFolder: (path: string, activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
|
handleFileClick: (node: any, path: string, activeSession: Session) => Promise<void>;
|
|
expandAllFolders: (activeSessionId: string, activeSession: Session, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
|
collapseAllFolders: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
|
updateSessionWorkingDirectory: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => Promise<void>;
|
|
refreshFileTree: (sessionId: string) => Promise<FileTreeChanges | undefined>;
|
|
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
|
onAutoRefreshChange?: (interval: number) => void;
|
|
onShowFlash?: (message: string) => void;
|
|
showHiddenFiles: boolean;
|
|
setShowHiddenFiles: (value: boolean) => void;
|
|
onOpenGraphView?: () => void;
|
|
}
|
|
|
|
function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
|
const {
|
|
session, theme, fileTreeFilter, setFileTreeFilter, fileTreeFilterOpen, setFileTreeFilterOpen,
|
|
filteredFileTree, selectedFileIndex, setSelectedFileIndex, activeFocus, activeRightTab,
|
|
previewFile, setActiveFocus, fileTreeFilterInputRef, toggleFolder, handleFileClick, expandAllFolders,
|
|
collapseAllFolders, updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange, onShowFlash,
|
|
showHiddenFiles, setShowHiddenFiles, onOpenGraphView
|
|
} = props;
|
|
|
|
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
|
const layerIdRef = useRef<string>();
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
// Refresh overlay state
|
|
const [overlayOpen, setOverlayOpen] = useState(false);
|
|
const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number } | null>(null);
|
|
const refreshButtonRef = useRef<HTMLButtonElement>(null);
|
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const isOverOverlayRef = useRef(false);
|
|
const autoRefreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Use refs to avoid recreating the timer when callbacks change
|
|
const refreshFileTreeRef = useRef(refreshFileTree);
|
|
const sessionIdRef = useRef(session.id);
|
|
|
|
// Keep refs up to date
|
|
useEffect(() => {
|
|
refreshFileTreeRef.current = refreshFileTree;
|
|
}, [refreshFileTree]);
|
|
|
|
useEffect(() => {
|
|
sessionIdRef.current = session.id;
|
|
}, [session.id]);
|
|
|
|
// Get current auto-refresh interval from session
|
|
const autoRefreshInterval = session.fileTreeAutoRefreshInterval || 0;
|
|
|
|
// Handle refresh with animation and flash notification
|
|
const handleRefresh = useCallback(async () => {
|
|
setIsRefreshing(true);
|
|
|
|
try {
|
|
const changes = await refreshFileTree(session.id);
|
|
|
|
// Show center screen flash notification with change count
|
|
if (changes && onShowFlash) {
|
|
const message = changes.totalChanges === 0
|
|
? 'No changes detected'
|
|
: `Detected ${changes.totalChanges} change${changes.totalChanges === 1 ? '' : 's'}`;
|
|
onShowFlash(message);
|
|
}
|
|
} finally {
|
|
// Keep spinner visible for at least 500ms for visual feedback
|
|
setTimeout(() => setIsRefreshing(false), 500);
|
|
}
|
|
}, [refreshFileTree, session.id, onShowFlash]);
|
|
|
|
// Auto-refresh timer - uses refs to avoid resetting timer when callbacks change
|
|
useEffect(() => {
|
|
// Clear existing timer
|
|
if (autoRefreshTimerRef.current) {
|
|
clearInterval(autoRefreshTimerRef.current);
|
|
autoRefreshTimerRef.current = null;
|
|
}
|
|
|
|
// Start new timer if interval is set
|
|
if (autoRefreshInterval > 0) {
|
|
autoRefreshTimerRef.current = setInterval(() => {
|
|
// Use refs to get latest values without causing effect re-runs
|
|
refreshFileTreeRef.current(sessionIdRef.current);
|
|
}, autoRefreshInterval * 1000);
|
|
}
|
|
|
|
// Cleanup on unmount or interval change
|
|
return () => {
|
|
if (autoRefreshTimerRef.current) {
|
|
clearInterval(autoRefreshTimerRef.current);
|
|
autoRefreshTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [autoRefreshInterval]); // Only depends on the interval now
|
|
|
|
// Hover handlers for refresh button overlay
|
|
const handleRefreshMouseEnter = useCallback(() => {
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
if (refreshButtonRef.current) {
|
|
const rect = refreshButtonRef.current.getBoundingClientRect();
|
|
setOverlayPosition({ top: rect.bottom + 4, left: rect.right });
|
|
}
|
|
setOverlayOpen(true);
|
|
}, 400);
|
|
}, []);
|
|
|
|
const handleRefreshMouseLeave = useCallback(() => {
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
hoverTimeoutRef.current = null;
|
|
}
|
|
// Delay closing to allow mouse to reach overlay
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
if (!isOverOverlayRef.current) {
|
|
setOverlayOpen(false);
|
|
}
|
|
}, 100);
|
|
}, []);
|
|
|
|
const handleOverlayMouseEnter = useCallback(() => {
|
|
isOverOverlayRef.current = true;
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
hoverTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handleOverlayMouseLeave = useCallback(() => {
|
|
isOverOverlayRef.current = false;
|
|
setOverlayOpen(false);
|
|
}, []);
|
|
|
|
const handleIntervalSelect = useCallback((interval: number) => {
|
|
onAutoRefreshChange?.(interval);
|
|
setOverlayOpen(false);
|
|
}, [onAutoRefreshChange]);
|
|
|
|
// Register layer when filter is open
|
|
useEffect(() => {
|
|
if (fileTreeFilterOpen) {
|
|
const id = registerLayer({
|
|
type: 'overlay',
|
|
priority: MODAL_PRIORITIES.FILE_TREE_FILTER,
|
|
blocksLowerLayers: false,
|
|
capturesFocus: true,
|
|
focusTrap: 'none',
|
|
onEscape: () => {
|
|
setFileTreeFilterOpen(false);
|
|
setFileTreeFilter('');
|
|
},
|
|
allowClickOutside: true,
|
|
ariaLabel: 'File Tree Filter'
|
|
});
|
|
layerIdRef.current = id;
|
|
return () => unregisterLayer(id);
|
|
}
|
|
|
|
}, [fileTreeFilterOpen, registerLayer, unregisterLayer]);
|
|
|
|
// Update handler when dependencies change
|
|
useEffect(() => {
|
|
if (fileTreeFilterOpen && layerIdRef.current) {
|
|
updateLayerHandler(layerIdRef.current, () => {
|
|
setFileTreeFilterOpen(false);
|
|
setFileTreeFilter('');
|
|
});
|
|
}
|
|
}, [fileTreeFilterOpen, setFileTreeFilterOpen, setFileTreeFilter, updateLayerHandler]);
|
|
|
|
// Filter hidden files from the tree based on showHiddenFiles setting
|
|
const filterHiddenFiles = useCallback((nodes: FileNode[]): FileNode[] => {
|
|
if (!nodes) return [];
|
|
if (showHiddenFiles) return nodes;
|
|
return nodes
|
|
.filter(node => !node.name.startsWith('.'))
|
|
.map(node => ({
|
|
...node,
|
|
children: node.children ? filterHiddenFiles(node.children) : undefined
|
|
}));
|
|
}, [showHiddenFiles]);
|
|
|
|
// Apply hidden file filtering to the already-filtered tree
|
|
const displayTree = useMemo(() => {
|
|
return filterHiddenFiles(filteredFileTree || []);
|
|
}, [filteredFileTree, filterHiddenFiles]);
|
|
|
|
// Flatten tree for virtualization - only includes visible nodes (respects expanded state)
|
|
// When filtering, auto-expand all folders to show matches
|
|
const flattenedTree = useMemo(() => {
|
|
const expandedSet = new Set(session.fileExplorerExpanded || []);
|
|
const isFiltering = fileTreeFilter.length > 0;
|
|
const result: FlattenedNode[] = [];
|
|
let globalIndex = 0;
|
|
|
|
const flatten = (nodes: FileNode[], currentPath = '', depth = 0) => {
|
|
for (const node of nodes) {
|
|
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
|
|
result.push({ node, path: fullPath, depth, globalIndex });
|
|
globalIndex++;
|
|
|
|
// When filtering, auto-expand all folders to reveal matches
|
|
// Otherwise, only include children if folder is manually expanded
|
|
const shouldShowChildren = node.type === 'folder' && node.children &&
|
|
(isFiltering || expandedSet.has(fullPath));
|
|
|
|
if (shouldShowChildren) {
|
|
flatten(node.children!, fullPath, depth + 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
flatten(displayTree);
|
|
return result;
|
|
}, [displayTree, session.fileExplorerExpanded, fileTreeFilter]);
|
|
|
|
// Virtualization setup
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
const ROW_HEIGHT = 28; // Height of each tree row in pixels
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: flattenedTree.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: 10, // Render 10 extra items above/below viewport for smooth scrolling
|
|
});
|
|
|
|
// Memoized row renderer
|
|
const TreeRow = useCallback(({ item, virtualRow }: { item: FlattenedNode; virtualRow: { index: number; start: number; size: number } }) => {
|
|
const { node, path: fullPath, depth, globalIndex } = item;
|
|
const absolutePath = `${session.fullPath}/${fullPath}`;
|
|
const change = session.changedFiles?.find(f => f.path.includes(node.name));
|
|
const isFolder = node.type === 'folder';
|
|
const expandedSet = new Set(session.fileExplorerExpanded || []);
|
|
const isExpanded = expandedSet.has(fullPath);
|
|
const isSelected = previewFile?.path === absolutePath;
|
|
const isKeyboardSelected = activeFocus === 'right' && activeRightTab === 'files' && globalIndex === selectedFileIndex;
|
|
|
|
// Generate indent guides for each depth level
|
|
const indentGuides = [];
|
|
for (let i = 0; i < depth; i++) {
|
|
indentGuides.push(
|
|
<div
|
|
key={i}
|
|
className="absolute top-0 bottom-0 w-px"
|
|
style={{
|
|
left: `${12 + i * 16}px`,
|
|
backgroundColor: theme.colors.border,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-file-index={globalIndex}
|
|
className={`absolute top-0 left-0 w-full flex items-center gap-2 py-1 text-xs cursor-pointer hover:bg-white/5 px-2 rounded transition-colors border-l-2 select-none min-w-0 ${isSelected ? 'bg-white/10' : ''}`}
|
|
style={{
|
|
height: `${virtualRow.size}px`,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
paddingLeft: `${8 + depth * 16}px`,
|
|
color: change ? theme.colors.textMain : theme.colors.textDim,
|
|
borderLeftColor: isKeyboardSelected ? theme.colors.accent : 'transparent',
|
|
backgroundColor: isKeyboardSelected ? theme.colors.bgActivity : (isSelected ? 'rgba(255,255,255,0.1)' : 'transparent')
|
|
}}
|
|
onMouseDown={(e) => {
|
|
// Prevent focus from leaving the filter input when filtering
|
|
if (fileTreeFilter.length > 0) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
onClick={() => {
|
|
if (isFolder) {
|
|
toggleFolder(fullPath, session.id, setSessions);
|
|
} else {
|
|
setSelectedFileIndex(globalIndex);
|
|
// Only change focus if not filtering
|
|
if (fileTreeFilter.length === 0) {
|
|
setActiveFocus('right');
|
|
}
|
|
}
|
|
}}
|
|
onDoubleClick={() => {
|
|
if (!isFolder) {
|
|
handleFileClick(node, fullPath, session);
|
|
}
|
|
}}
|
|
>
|
|
{indentGuides}
|
|
{isFolder && (
|
|
isExpanded ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
|
)}
|
|
<span className="flex-shrink-0">{isFolder ? <Folder className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} /> : getFileIcon(change?.type, theme)}</span>
|
|
<span className={`truncate min-w-0 flex-1 ${change ? 'font-medium' : ''}`} title={node.name}>{node.name}</span>
|
|
{change && (
|
|
<span
|
|
className="flex-shrink-0 text-[9px] px-1 rounded uppercase"
|
|
style={{
|
|
backgroundColor: change.type === 'added' ? theme.colors.success + '20' : change.type === 'deleted' ? theme.colors.error + '20' : theme.colors.warning + '20',
|
|
color: change.type === 'added' ? theme.colors.success : change.type === 'deleted' ? theme.colors.error : theme.colors.warning
|
|
}}
|
|
>
|
|
{change.type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
}, [session.fullPath, session.changedFiles, session.fileExplorerExpanded, session.id, previewFile?.path, activeFocus, activeRightTab, selectedFileIndex, theme, toggleFolder, setSessions, setSelectedFileIndex, setActiveFocus, handleFileClick, fileTreeFilter]);
|
|
|
|
return (
|
|
<div className="space-y-2 relative">
|
|
{/* File Tree Filter */}
|
|
{fileTreeFilterOpen && (
|
|
<div className="mb-3 pt-4">
|
|
<input
|
|
ref={fileTreeFilterInputRef}
|
|
autoFocus
|
|
type="text"
|
|
placeholder="Filter files..."
|
|
value={fileTreeFilter}
|
|
onChange={(e) => setFileTreeFilter(e.target.value)}
|
|
className="w-full px-3 py-2 rounded border bg-transparent outline-none text-sm"
|
|
style={{ borderColor: theme.colors.accent, color: theme.colors.textMain }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header with CWD and controls */}
|
|
<div
|
|
className="sticky top-0 z-10 flex items-center justify-between gap-2 text-xs font-bold pt-4 pb-2 mb-2 min-w-0"
|
|
style={{
|
|
backgroundColor: theme.colors.bgSidebar
|
|
}}
|
|
>
|
|
<span className="opacity-50 truncate min-w-0 flex-1" title={session.cwd}>{session.cwd}</span>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{onOpenGraphView && (
|
|
<button
|
|
onClick={onOpenGraphView}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title="Document Graph"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<GitGraph className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title={showHiddenFiles ? "Hide dotfiles" : "Show dotfiles"}
|
|
style={{
|
|
color: showHiddenFiles ? theme.colors.accent : theme.colors.textDim,
|
|
backgroundColor: showHiddenFiles ? `${theme.colors.accent}20` : 'transparent'
|
|
}}
|
|
>
|
|
{showHiddenFiles ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
|
</button>
|
|
<button
|
|
ref={refreshButtonRef}
|
|
onClick={handleRefresh}
|
|
onMouseEnter={handleRefreshMouseEnter}
|
|
onMouseLeave={handleRefreshMouseLeave}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title={autoRefreshInterval > 0 ? `Auto-refresh every ${autoRefreshInterval}s` : "Refresh file tree"}
|
|
style={{
|
|
color: autoRefreshInterval > 0 ? theme.colors.accent : theme.colors.textDim,
|
|
backgroundColor: autoRefreshInterval > 0 ? `${theme.colors.accent}20` : 'transparent'
|
|
}}
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => expandAllFolders(session.id, session, setSessions)}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title="Expand all folders"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<div className="flex flex-col items-center -space-y-1.5">
|
|
<ChevronUp className="w-3.5 h-3.5" />
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => collapseAllFolders(session.id, setSessions)}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title="Collapse all folders"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<div className="flex flex-col items-center -space-y-1.5">
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
<ChevronUp className="w-3.5 h-3.5" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File tree content - virtualized */}
|
|
{session.fileTreeError ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
|
<div className="text-xs text-center" style={{ color: theme.colors.error }}>
|
|
{session.fileTreeError}
|
|
</div>
|
|
<button
|
|
onClick={() => updateSessionWorkingDirectory(session.id, setSessions)}
|
|
className="flex items-center gap-2 px-3 py-2 rounded border hover:bg-white/5 transition-colors text-xs"
|
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
|
>
|
|
<Folder className="w-4 h-4" />
|
|
Select New Directory
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{(!session.fileTree || session.fileTree.length === 0) && (
|
|
<div className="text-xs opacity-50 italic">Loading files...</div>
|
|
)}
|
|
{flattenedTree.length > 0 && (
|
|
<div
|
|
ref={parentRef}
|
|
className="flex-1 overflow-auto"
|
|
style={{ height: 'calc(100vh - 200px)' }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
const item = flattenedTree[virtualRow.index];
|
|
return (
|
|
<TreeRow
|
|
key={item.path}
|
|
item={item}
|
|
virtualRow={virtualRow}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{fileTreeFilter && flattenedTree.length === 0 && (
|
|
<div className="text-xs opacity-50 italic text-center py-4">No files match your search</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Auto-refresh overlay - rendered via portal */}
|
|
{overlayOpen && overlayPosition && createPortal(
|
|
<div
|
|
className="fixed z-[100] rounded-lg shadow-xl border overflow-hidden"
|
|
style={{
|
|
backgroundColor: theme.colors.bgSidebar,
|
|
borderColor: theme.colors.border,
|
|
minWidth: '180px',
|
|
top: overlayPosition.top,
|
|
left: overlayPosition.left,
|
|
transform: 'translateX(-100%)'
|
|
}}
|
|
onMouseEnter={handleOverlayMouseEnter}
|
|
onMouseLeave={handleOverlayMouseLeave}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="px-3 py-2 text-xs font-medium border-b"
|
|
style={{
|
|
backgroundColor: theme.colors.bgActivity,
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.textMain
|
|
}}
|
|
>
|
|
Auto-refresh
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div className="p-1">
|
|
{AUTO_REFRESH_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => handleIntervalSelect(option.value)}
|
|
className="w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
|
|
style={{
|
|
color: autoRefreshInterval === option.value ? theme.colors.accent : theme.colors.textMain,
|
|
backgroundColor: autoRefreshInterval === option.value ? `${theme.colors.accent}15` : 'transparent'
|
|
}}
|
|
>
|
|
<span>{option.label}</span>
|
|
{autoRefreshInterval === option.value && (
|
|
<Check className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} />
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
{/* Disable option - only shown when auto-refresh is active */}
|
|
{autoRefreshInterval > 0 && (
|
|
<>
|
|
<div
|
|
className="my-1 border-t"
|
|
style={{ borderColor: theme.colors.border }}
|
|
/>
|
|
<button
|
|
onClick={() => handleIntervalSelect(0)}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
Disable auto-refresh
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Status bar at bottom */}
|
|
{session.fileTreeStats && (
|
|
<div
|
|
className="flex-shrink-0 flex items-center justify-center gap-3 px-3 py-1.5 text-xs border-t mt-2"
|
|
style={{
|
|
backgroundColor: theme.colors.bgActivity,
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.textDim
|
|
}}
|
|
>
|
|
<span>
|
|
<span style={{ color: theme.colors.accent }}>{session.fileTreeStats.fileCount.toLocaleString()}</span>
|
|
<span className="opacity-60"> file{session.fileTreeStats.fileCount !== 1 ? 's' : ''}, </span>
|
|
<span style={{ color: theme.colors.accent }}>{session.fileTreeStats.folderCount.toLocaleString()}</span>
|
|
<span className="opacity-60"> folder{session.fileTreeStats.folderCount !== 1 ? 's' : ''}</span>
|
|
</span>
|
|
<span>
|
|
<span className="opacity-60">Size:</span>{' '}
|
|
<span style={{ color: theme.colors.accent }}>
|
|
{formatBytes(session.fileTreeStats.totalSize)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const FileExplorerPanel = memo(FileExplorerPanelInner);
|