Skip to content

Commit b60223b

Browse files
committed
feat: file type filter chips, improved drag-drop overlay, and toast types
- File extension filter bar with clickable chip tags above file tree - Recursive tree filtering by selected extension - Improved drag-drop overlay with animated icon and dashed border - Toast component now supports success/error/info types
1 parent 9c353fa commit b60223b

2 files changed

Lines changed: 121 additions & 20 deletions

File tree

structure-insight/components/MainContent.tsx

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,53 @@ import { useAppLogic } from '../hooks/useAppLogic';
88
import ScrollSlider from './ScrollSlider';
99
import StructureView from './StructureView';
1010
import ScrollToTopButton from './ScrollToTopButton';
11+
import { FileNode } from '../types';
1112

1213
interface MainContentProps {
1314
logic: ReturnType<typeof useAppLogic>;
1415
codeViewRef: React.RefObject<HTMLDivElement>;
1516
leftPanelRef: React.RefObject<HTMLDivElement>;
1617
}
1718

19+
/** Recursively collect unique file extensions from a tree */
20+
function collectExtensions(nodes: FileNode[]): string[] {
21+
const exts = new Set<string>();
22+
const walk = (items: FileNode[]) => {
23+
for (const node of items) {
24+
if (node.isDirectory) {
25+
walk(node.children);
26+
} else {
27+
const dot = node.name.lastIndexOf('.');
28+
if (dot > 0 && dot < node.name.length - 1) {
29+
exts.add(node.name.slice(dot).toLowerCase());
30+
}
31+
}
32+
}
33+
};
34+
walk(nodes);
35+
return Array.from(exts).sort();
36+
}
37+
38+
/** Recursively filter tree to files matching `ext`, keeping directories that have matching children */
39+
function filterTreeByExtension(nodes: FileNode[], ext: string | null): FileNode[] {
40+
if (!ext) return nodes;
41+
return nodes.reduce<FileNode[]>((acc, node) => {
42+
if (node.isDirectory) {
43+
const filteredChildren = filterTreeByExtension(node.children, ext);
44+
if (filteredChildren.length > 0) {
45+
acc.push({ ...node, children: filteredChildren });
46+
}
47+
} else {
48+
const dot = node.name.lastIndexOf('.');
49+
const fileExt = dot > 0 && dot < node.name.length - 1 ? node.name.slice(dot).toLowerCase() : '';
50+
if (fileExt === ext) {
51+
acc.push(node);
52+
}
53+
}
54+
return acc;
55+
}, []);
56+
}
57+
1858
const LoadingIndicator: React.FC<{message: string}> = ({message}) => (
1959
<div className="flex flex-col items-center justify-center h-full text-center p-4">
2060
<i className="fa-solid fa-spinner fa-spin text-4xl text-primary mb-4"></i>
@@ -27,6 +67,11 @@ const MainContent: React.FC<MainContentProps> = ({ logic, codeViewRef, leftPanel
2767
const { state, handlers } = logic;
2868
const { isMobile } = state;
2969
const fileTreeScrollRef = React.useRef<HTMLDivElement>(null);
70+
const [filterExt, setFilterExt] = React.useState<string | null>(null);
71+
72+
const treeData = state.processedData?.treeData || [];
73+
const extensions = React.useMemo(() => collectExtensions(treeData), [treeData]);
74+
const filteredNodes = React.useMemo(() => filterTreeByExtension(treeData, filterExt), [treeData, filterExt]);
3075

3176
const mobileFabIcon = () => {
3277
if (!state.processedData) return 'fa-list-ul';
@@ -41,8 +86,21 @@ const MainContent: React.FC<MainContentProps> = ({ logic, codeViewRef, leftPanel
4186
<main className="flex-1 flex overflow-hidden relative">
4287
<AnimatePresence>
4388
{state.isDragging && (
44-
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-primary/50 backdrop-blur-sm flex items-center justify-center z-30 pointer-events-none">
45-
<div className="text-center text-white bg-primary/80 p-8 rounded-lg"><i className="fa-solid fa-upload fa-3x mb-4"></i><p className="text-xl font-bold">拖放文件夹以进行分析</p></div>
89+
<motion.div
90+
initial={{ opacity: 0 }}
91+
animate={{ opacity: 1 }}
92+
exit={{ opacity: 0 }}
93+
className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-white/70 dark:bg-dark-bg/70 backdrop-blur-sm"
94+
>
95+
<div className="border-4 border-dashed border-primary/60 rounded-2xl p-12 flex flex-col items-center gap-4 max-w-md mx-4">
96+
<motion.i
97+
className="fa-solid fa-cloud-arrow-up text-5xl text-primary"
98+
animate={{ scale: [1, 1.15, 1] }}
99+
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
100+
/>
101+
<p className="text-xl font-bold text-light-text dark:text-dark-text">拖放文件夹或 .zip 文件</p>
102+
<p className="text-sm text-light-subtle-text dark:text-dark-subtle-text">支持任意代码项目</p>
103+
</div>
46104
</motion.div>
47105
)}
48106
</AnimatePresence>
@@ -52,14 +110,31 @@ const MainContent: React.FC<MainContentProps> = ({ logic, codeViewRef, leftPanel
52110
<AnimatePresence initial={false}>
53111
{state.mobileView === 'tree' && state.processedData && (
54112
<motion.div key="tree" initial={{x: '-100%'}} animate={{x: '0%'}} exit={{x: '-100%'}} transition={{duration: 0.3, ease: 'easeInOut'}} className="absolute inset-0 h-full overflow-y-auto bg-light-panel dark:bg-dark-panel">
55-
<FileTree
56-
nodes={state.processedData.treeData || []}
57-
onFileSelect={handlers.handleFileTreeSelect}
58-
onDeleteFile={handlers.handleDeleteFile}
59-
onCopyPath={handlers.handleCopyPath}
113+
{extensions.length > 0 && (
114+
<div className="flex flex-wrap gap-1.5 px-3 pt-2 pb-1 border-b border-light-border dark:border-dark-border">
115+
{extensions.map(ext => (
116+
<button
117+
key={ext}
118+
onClick={() => setFilterExt(filterExt === ext ? null : ext)}
119+
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
120+
filterExt === ext
121+
? 'bg-primary text-white'
122+
: 'bg-light-hover dark:bg-dark-hover text-light-subtle-text dark:text-dark-subtle-text hover:bg-primary/20 dark:hover:bg-primary/20'
123+
}`}
124+
>
125+
{ext}
126+
</button>
127+
))}
128+
</div>
129+
)}
130+
<FileTree
131+
nodes={filteredNodes}
132+
onFileSelect={handlers.handleFileTreeSelect}
133+
onDeleteFile={handlers.handleDeleteFile}
134+
onCopyPath={handlers.handleCopyPath}
60135
onToggleExclude={handlers.handleToggleExclude}
61-
selectedFilePath={state.selectedFilePath}
62-
showCharCount={state.showCharCount}
136+
selectedFilePath={state.selectedFilePath}
137+
showCharCount={state.showCharCount}
63138
/>
64139
</motion.div>
65140
)}
@@ -105,15 +180,34 @@ const MainContent: React.FC<MainContentProps> = ({ logic, codeViewRef, leftPanel
105180
<div ref={leftPanelRef} className="relative h-full bg-light-panel dark:bg-dark-panel" style={{ width: `${state.panelWidth}%` }}>
106181
<div ref={fileTreeScrollRef} className="h-full overflow-y-auto no-scrollbar">
107182
{state.processedData && (
108-
<FileTree
109-
nodes={state.processedData.treeData || []}
110-
onFileSelect={handlers.handleFileTreeSelect}
111-
onDeleteFile={handlers.handleDeleteFile}
112-
onCopyPath={handlers.handleCopyPath}
113-
onToggleExclude={handlers.handleToggleExclude}
114-
selectedFilePath={state.selectedFilePath}
115-
showCharCount={state.showCharCount}
116-
/>
183+
<>
184+
{extensions.length > 0 && (
185+
<div className="flex flex-wrap gap-1.5 px-3 pt-2 pb-1 border-b border-light-border dark:border-dark-border">
186+
{extensions.map(ext => (
187+
<button
188+
key={ext}
189+
onClick={() => setFilterExt(filterExt === ext ? null : ext)}
190+
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
191+
filterExt === ext
192+
? 'bg-primary text-white'
193+
: 'bg-light-hover dark:bg-dark-hover text-light-subtle-text dark:text-dark-subtle-text hover:bg-primary/20 dark:hover:bg-primary/20'
194+
}`}
195+
>
196+
{ext}
197+
</button>
198+
))}
199+
</div>
200+
)}
201+
<FileTree
202+
nodes={filteredNodes}
203+
onFileSelect={handlers.handleFileTreeSelect}
204+
onDeleteFile={handlers.handleDeleteFile}
205+
onCopyPath={handlers.handleCopyPath}
206+
onToggleExclude={handlers.handleToggleExclude}
207+
selectedFilePath={state.selectedFilePath}
208+
showCharCount={state.showCharCount}
209+
/>
210+
</>
117211
)}
118212
</div>
119213
{state.processedData && <ScrollSlider scrollRef={fileTreeScrollRef} />}

structure-insight/components/Toast.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ import { motion } from 'framer-motion';
44
interface ToastProps {
55
message: string;
66
onDone: () => void;
7+
type?: 'success' | 'error' | 'info';
78
}
89

9-
const Toast: React.FC<ToastProps> = ({ message, onDone }) => {
10+
const Toast: React.FC<ToastProps> = ({ message, onDone, type = 'success' }) => {
11+
const iconMap = {
12+
success: 'fa-circle-check text-green-500',
13+
error: 'fa-circle-xmark text-red-500',
14+
info: 'fa-circle-info text-blue-500',
15+
};
16+
1017
React.useEffect(() => {
1118
const timer = setTimeout(onDone, 2500);
1219
return () => clearTimeout(timer);
@@ -19,7 +26,7 @@ const Toast: React.FC<ToastProps> = ({ message, onDone }) => {
1926
exit={{ opacity: 0, y: -20, scale: 0.95 }}
2027
className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-light-panel dark:bg-dark-panel text-light-text dark:text-dark-text px-4 py-2 rounded-lg shadow-lg z-50 flex items-center space-x-2 border border-light-border dark:border-dark-border"
2128
>
22-
<i className="fa-solid fa-check-circle text-green-500"></i>
29+
<i className={`fa-solid ${iconMap[type]}`}></i>
2330
<span className="text-sm font-medium">{message}</span>
2431
</motion.div>
2532
);

0 commit comments

Comments
 (0)