feat(browse):基本完成文件夹浏览器的功能。
This commit is contained in:
		| @@ -1,9 +1,14 @@ | ||||
| //@ts-nocheck | ||||
| import { Stack, useMantineTheme } from '@mantine/core'; | ||||
| import { Stack, Tabs, useMantineTheme } from '@mantine/core'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { IconFiles, IconFolders } from '@tabler/icons-react'; | ||||
| import { ifElse, path, propEq } from 'ramda'; | ||||
| import { FC, useMemo } from 'react'; | ||||
| import { useMount } from 'react-use'; | ||||
| import { DirTree } from './components/DirTree'; | ||||
| import { FileList } from './components/FileList'; | ||||
| import { FileToolbar } from './components/FileTools'; | ||||
| import { loadDrives } from './queries/directories'; | ||||
|  | ||||
| const bgSelectFn = ifElse( | ||||
|   propEq('colorScheme', 'dark'), | ||||
| @@ -18,10 +23,18 @@ export const NavMenu: FC = () => { | ||||
|   const disabledColor = useMemo(() => path<string>(['gray', 7])(theme.colors), [theme.colors]); | ||||
|   const navMenuBg = useMemo(() => bgSelectFn(theme), [theme, theme]); | ||||
|  | ||||
|   useMount(() => { | ||||
|     try { | ||||
|       loadDrives(); | ||||
|     } catch (e) { | ||||
|       notifications.show({ message: `未能成功加载全部磁盘列表,${e.message}`, color: 'red' }); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Stack | ||||
|       spacing={8} | ||||
|       miw={200} | ||||
|       miw={220} | ||||
|       h="inherit" | ||||
|       sx={theme => ({ | ||||
|         flexGrow: 1, | ||||
| @@ -32,8 +45,25 @@ export const NavMenu: FC = () => { | ||||
|       py={4} | ||||
|       align="center" | ||||
|     > | ||||
|       <FileToolbar /> | ||||
|       <FileList /> | ||||
|       <Tabs defaultValue="folder" w="100%" h="100%"> | ||||
|         <Tabs.List> | ||||
|           <Tabs.Tab value="folder" icon={<IconFolders stroke={1.5} size={16} />}> | ||||
|             文件夹 | ||||
|           </Tabs.Tab> | ||||
|           <Tabs.Tab value="files" icon={<IconFiles stroke={1.5} size={16} />}> | ||||
|             文件列表 | ||||
|           </Tabs.Tab> | ||||
|         </Tabs.List> | ||||
|         <Tabs.Panel value="folder" h="100%"> | ||||
|           <DirTree /> | ||||
|         </Tabs.Panel> | ||||
|         <Tabs.Panel value="files" h="100%"> | ||||
|           <Stack spacing={8} py={4} w="100%" h="100%" align="center"> | ||||
|             <FileToolbar /> | ||||
|             <FileList /> | ||||
|           </Stack> | ||||
|         </Tabs.Panel> | ||||
|       </Tabs> | ||||
|     </Stack> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										130
									
								
								src/components/DirTree.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/components/DirTree.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | ||||
| import styled from '@emotion/styled'; | ||||
| import { ActionIcon, Flex, Stack, Text, Tooltip } from '@mantine/core'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { IconSquareMinus, IconSquarePlus } from '@tabler/icons-react'; | ||||
| import { invoke } from '@tauri-apps/api'; | ||||
| import EventEmitter from 'events'; | ||||
| import { equals, isEmpty, length, map, not } from 'ramda'; | ||||
| import { FC, PropsWithChildren, useCallback, useContext, useState } from 'react'; | ||||
| import { EventBusContext } from '../EventBus'; | ||||
| import { DirItem } from '../models'; | ||||
| import { loadSubDirectories } from '../queries/directories'; | ||||
| import { | ||||
|   currentRootsSelector, | ||||
|   isExpandedSelector, | ||||
|   selectDirectories, | ||||
|   useDirTreeStore | ||||
| } from '../states/dirs'; | ||||
| import { useFileListStore } from '../states/files'; | ||||
|  | ||||
| const Tree = styled.ul` | ||||
|   --spacing: 0.5rem; | ||||
|   list-style-type: none; | ||||
|   list-style-position: outside; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   width: max-content; | ||||
|   li ul { | ||||
|     padding-left: calc(2 * var(--spacing)); | ||||
|   } | ||||
| `; | ||||
|  | ||||
| const Branch: FC<PropsWithChildren<{ current: DirItem; expanded: boolean }>> = ({ | ||||
|   children, | ||||
|   current | ||||
| }) => { | ||||
|   const { directories: allSubDirs } = useDirTreeStore(); | ||||
|   const [subDirs, setSubDirs] = useState<DirItem[]>([]); | ||||
|   const isCurrentExpanded = useDirTreeStore(isExpandedSelector(current.id)); | ||||
|   const expend = useDirTreeStore.use.expandDir(); | ||||
|   const fold = useDirTreeStore.use.foldDir(); | ||||
|   const selectDir = useDirTreeStore.use.selectDirectory(); | ||||
|   const selectedDirectory = useDirTreeStore.use.selected(); | ||||
|   const storeFiles = useFileListStore.use.updateFiles(); | ||||
|   const ebus = useContext<EventEmitter>(EventBusContext); | ||||
|  | ||||
|   const handleExpandAction = useCallback(async () => { | ||||
|     try { | ||||
|       if (isCurrentExpanded) { | ||||
|         fold(current.id); | ||||
|       } else { | ||||
|         await loadSubDirectories(current); | ||||
|         setSubDirs(selectDirectories(current.id)(useDirTreeStore.getState().directories)); | ||||
|         expend(current.id); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       notifications.show({ | ||||
|         message: `未能成功加载指定文件夹下的子文件夹,${e}`, | ||||
|         color: 'red' | ||||
|       }); | ||||
|     } | ||||
|   }, [current, allSubDirs, isCurrentExpanded]); | ||||
|   const handleSelectAction = useCallback(async () => { | ||||
|     try { | ||||
|       selectDir(current.id); | ||||
|       const files = await invoke('scan_directory', { target: current.path }); | ||||
|       console.log('[debug]获取到文件个数:', length(files)); | ||||
|       storeFiles(files); | ||||
|       ebus.emit('reset_views'); | ||||
|     } catch (e) { | ||||
|       console.error('[error]打开文件夹', e); | ||||
|       notifications.show({ message: `未能成功打开指定文件夹,请重试。${e}`, color: 'red' }); | ||||
|     } | ||||
|   }, [current]); | ||||
|  | ||||
|   return ( | ||||
|     <li> | ||||
|       <Flex direction="row" justify="flex-start" align="center" spacing={8} maw={250}> | ||||
|         <ActionIcon onClick={handleExpandAction}> | ||||
|           {isCurrentExpanded ? ( | ||||
|             <IconSquareMinus stroke={1.5} size={16} /> | ||||
|           ) : ( | ||||
|             <IconSquarePlus stroke={1.5} size={16} /> | ||||
|           )} | ||||
|         </ActionIcon> | ||||
|         <Tooltip label={children}> | ||||
|           <Text | ||||
|             size="sm" | ||||
|             truncate | ||||
|             onClick={handleSelectAction} | ||||
|             sx={{ cursor: 'pointer' }} | ||||
|             bg={equals(current.id, selectedDirectory) && 'blue'} | ||||
|           > | ||||
|             {children} | ||||
|           </Text> | ||||
|         </Tooltip> | ||||
|       </Flex> | ||||
|       {not(isEmpty(subDirs)) && isCurrentExpanded && ( | ||||
|         <Tree> | ||||
|           {map( | ||||
|             item => ( | ||||
|               <Branch key={item.id} current={item}> | ||||
|                 {item.dirname} | ||||
|               </Branch> | ||||
|             ), | ||||
|             subDirs | ||||
|           )} | ||||
|         </Tree> | ||||
|       )} | ||||
|     </li> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const DirTree: FC = () => { | ||||
|   const roots = useDirTreeStore(currentRootsSelector()); | ||||
|  | ||||
|   return ( | ||||
|     <Stack w="auto" h="100%" spacing={8} px={4} py={4} sx={{ overflow: 'auto' }}> | ||||
|       <Tree> | ||||
|         {map( | ||||
|           item => ( | ||||
|             <Branch key={item.id} current={item}> | ||||
|               {item.dirname} | ||||
|             </Branch> | ||||
|           ), | ||||
|           roots | ||||
|         )} | ||||
|       </Tree> | ||||
|     </Stack> | ||||
|   ); | ||||
| }; | ||||
| @@ -6,3 +6,13 @@ export type FileItem = { | ||||
|   width: number; | ||||
|   height: number; | ||||
| }; | ||||
|  | ||||
| export type DirItem = { | ||||
|   sort: number; | ||||
|   parent?: string; | ||||
|   id: string; | ||||
|   dirname: string; | ||||
|   path: string; | ||||
|   root: boolean; | ||||
|   expanded: boolean; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										24
									
								
								src/queries/directories.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/queries/directories.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { invoke } from '@tauri-apps/api'; | ||||
| import { useDirTreeStore } from '../states/dirs'; | ||||
|  | ||||
| export async function loadDrives() { | ||||
|   try { | ||||
|     const drives = await invoke('show_drives'); | ||||
|     const { getState } = useDirTreeStore; | ||||
|     getState().updateDrives(drives); | ||||
|   } catch (e) { | ||||
|     console.error('[error]fetch drives', e); | ||||
|     throw e; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function loadSubDirectories(target: DirItem) { | ||||
|   try { | ||||
|     const directories = await invoke('scan_for_child_dirs', { target: target.path }); | ||||
|     const { getState } = useDirTreeStore; | ||||
|     getState().saveDirectories(directories, target.id); | ||||
|   } catch (e) { | ||||
|     console.error('[error]fetch subdirs', e); | ||||
|     throw e; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/states/dirs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/states/dirs.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| import { | ||||
|   addIndex, | ||||
|   append, | ||||
|   compose, | ||||
|   equals, | ||||
|   filter, | ||||
|   find, | ||||
|   includes, | ||||
|   isNil, | ||||
|   map, | ||||
|   mergeLeft, | ||||
|   not, | ||||
|   propEq, | ||||
|   reduce, | ||||
|   uniq | ||||
| } from 'ramda'; | ||||
| import { DirItem } from '../models'; | ||||
| import { SyncAction, SyncObjectCallback, SyncParamAction } from '../types'; | ||||
| import { createStoreHook } from '../utils/store_creator'; | ||||
|  | ||||
| interface DirsStates { | ||||
|   drives: DirItem[]; | ||||
|   directories: DirItem[]; | ||||
|   focused?: DirItem; | ||||
|   selected?: DirItem; | ||||
|   expanded: string[]; | ||||
| } | ||||
|  | ||||
| type DirsActions = { | ||||
|   updateDrives: (dirs: Omit<DirItem, 'sort' | 'parent'>[]) => void; | ||||
|   saveDirectories: (dirs: Omit<DirItem, 'sort' | 'parent'>[], parent: string) => void; | ||||
|   focus: SyncParamAction<string>; | ||||
|   unfocus: SyncAction; | ||||
|   selectDirectory: SyncParamAction<string>; | ||||
|   unselectDirectory: SyncAction; | ||||
|   expandDir: SyncParamAction<string>; | ||||
|   foldDir: SyncParamAction<string>; | ||||
| }; | ||||
|  | ||||
| const initialState: DirsStates = { | ||||
|   drives: [], | ||||
|   directories: [], | ||||
|   focused: undefined, | ||||
|   selected: undefined, | ||||
|   expanded: [] | ||||
| }; | ||||
|  | ||||
| export const useDirTreeStore = createStoreHook<DirsStates & DirsActions>((set, get) => ({ | ||||
|   ...initialState, | ||||
|   updateDrives(dirs) { | ||||
|     set(df => { | ||||
|       df.drives = addIndex<Omit<DirItem, 'sort' | 'paraent'>, DirItem>(map)( | ||||
|         (item, index) => mergeLeft({ sort: index * 10, path: item.path, parent: undefined }, item), | ||||
|         dirs | ||||
|       ); | ||||
|     }); | ||||
|   }, | ||||
|   saveDirectories(dirs, parent) { | ||||
|     const convertedDirs = addIndex<Omit<DirItem, 'sort' | 'parent'>, DirItem>(map)( | ||||
|       (item, index) => mergeLeft({ sort: index * 10, path: item.path, parent }, item), | ||||
|       dirs | ||||
|     ); | ||||
|     const premerged = reduce( | ||||
|       (acc, elem) => { | ||||
|         const dir = find(propEq('id', elem.id), convertedDirs); | ||||
|         if (not(isNil(dir))) { | ||||
|           acc = append(mergeLeft(dir, elem), acc); | ||||
|         } else { | ||||
|           acc = append(elem, acc); | ||||
|         } | ||||
|         return acc; | ||||
|       }, | ||||
|       [], | ||||
|       get().directories | ||||
|     ); | ||||
|     const afterMerged = reduce( | ||||
|       (acc, elem) => { | ||||
|         const dir = find(propEq('id', elem.id), acc); | ||||
|         if (isNil(dir)) { | ||||
|           return append(elem, acc); | ||||
|         } else { | ||||
|           return acc; | ||||
|         } | ||||
|       }, | ||||
|       premerged, | ||||
|       convertedDirs | ||||
|     ); | ||||
|     set(df => { | ||||
|       df.directories = afterMerged; | ||||
|     }); | ||||
|   }, | ||||
|   focus(specifiedDirId) { | ||||
|     const requestedDir = find(propEq('id', specifiedDirId), get().directories); | ||||
|     if (not(isNil(requestedDir))) { | ||||
|       set(df => { | ||||
|         df.focused = requestedDir; | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|   unfocus() { | ||||
|     set(df => { | ||||
|       df.focus = undefined; | ||||
|     }); | ||||
|   }, | ||||
|   selectDirectory(dirId) { | ||||
|     set(df => { | ||||
|       df.selected = dirId; | ||||
|     }); | ||||
|   }, | ||||
|   unselectDirectory() { | ||||
|     set(df => { | ||||
|       df.selected = undefined; | ||||
|     }); | ||||
|   }, | ||||
|   expandDir(dirId) { | ||||
|     set(df => { | ||||
|       df.expanded = uniq(append(dirId, df.expanded)); | ||||
|     }); | ||||
|   }, | ||||
|   foldDir(dirId) { | ||||
|     set(df => { | ||||
|       df.expanded = filter(compose(not, equals(dirId)), df.expanded); | ||||
|     }); | ||||
|   } | ||||
| })); | ||||
|  | ||||
| export function currentRootsSelector(): SyncObjectCallback<DirsStates, DirItem[]> { | ||||
|   return state => (isNil(state.focused) ? state.drives : [state.focused]); | ||||
| } | ||||
|  | ||||
| export function selectDirectories(parent: string): SyncObjectCallback<DirItem[], DirItem[]> { | ||||
|   return dirs => filter(propEq('parent', parent), dirs) ?? []; | ||||
| } | ||||
|  | ||||
| export function subDirectoriesSelector(parent: string): SyncObjectCallback<DirsStates, DirItem[]> { | ||||
|   return state => filter(propEq('parent', parent), state.directories) ?? []; | ||||
| } | ||||
|  | ||||
| export function isExpandedSelector(dirId: string): SyncObjectCallback<DirsStates, bool> { | ||||
|   return state => includes(dirId, state.expanded); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user