feat(browse):基本完成文件夹浏览器的功能。
This commit is contained in:
		| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|   "name": "comic_viewer", |   "name": "comic_viewer", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "version": "0.0.0", |   "version": "0.2.0", | ||||||
|   "type": "module", |   "type": "module", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev": "vite", |     "dev": "vite", | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								src-tauri/.cargo/config
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src-tauri/.cargo/config
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | [target.'cfg(target_os = "linux")'] | ||||||
|  | rustflags = ["-C", "link-arg=-nostartfiles"] | ||||||
|  |  | ||||||
|  | [target.'cfg(target_os = "windows")'] | ||||||
|  | rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"] | ||||||
|  |  | ||||||
|  | [target.'cfg(target_os = "macos")'] | ||||||
|  | rustflags = ["-C", "link-args=-e __start -static -nostartfiles"] | ||||||
							
								
								
									
										23
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -326,11 +326,13 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "comic_viewer" | name = "comic_viewer" | ||||||
| version = "0.0.0" | version = "0.2.0" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "anyhow", |  "anyhow", | ||||||
|  "chrono", |  "chrono", | ||||||
|  "image", |  "image", | ||||||
|  |  "md-5", | ||||||
|  |  "mountpoints", | ||||||
|  "once_cell", |  "once_cell", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
| @@ -1590,6 +1592,15 @@ version = "0.1.10" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" | checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "md-5" | ||||||
|  | version = "0.10.5" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" | ||||||
|  | dependencies = [ | ||||||
|  |  "digest", | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "memchr" | name = "memchr" | ||||||
| version = "2.5.0" | version = "2.5.0" | ||||||
| @@ -1626,6 +1637,16 @@ dependencies = [ | |||||||
|  "windows-sys 0.45.0", |  "windows-sys 0.45.0", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "mountpoints" | ||||||
|  | version = "0.2.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "3090e3ea5226b07cedd89a57c735d3ed0374a84236a85529a1d8bfec98eb8ec5" | ||||||
|  | dependencies = [ | ||||||
|  |  "libc", | ||||||
|  |  "winapi", | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "nanorand" | name = "nanorand" | ||||||
| version = "0.7.0" | version = "0.7.0" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [package] | [package] | ||||||
| name = "comic_viewer" | name = "comic_viewer" | ||||||
| version = "0.0.0" | version = "0.2.0" | ||||||
| description = "A Tauri App" | description = "A Tauri App" | ||||||
| authors = ["you"] | authors = ["you"] | ||||||
| license = "" | license = "" | ||||||
| @@ -14,7 +14,7 @@ tauri-build = { version = "1.2", features = [] } | |||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| once_cell = "1.17.0" | once_cell = "1.17.0" | ||||||
| tauri = { version = "1.2", features = ["dialog-open", "fs-read-file", "protocol-all", "shell-open"] } | tauri = { version = "1.2", features = ["dialog-open", "fs-exists", "fs-read-dir", "fs-read-file", "protocol-asset", "shell-open"] } | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
| chrono = { version = "0.4.23", features = ["serde"] } | chrono = { version = "0.4.23", features = ["serde"] } | ||||||
| @@ -25,6 +25,8 @@ serde_repr = "0.1.10" | |||||||
| tokio = { version = "1.23.1", features = ["full"] } | tokio = { version = "1.23.1", features = ["full"] } | ||||||
| image = "0.24.5" | image = "0.24.5" | ||||||
| uuid = "1.3.0" | uuid = "1.3.0" | ||||||
|  | mountpoints = "0.2.1" | ||||||
|  | md-5 = "0.10.5" | ||||||
|  |  | ||||||
| [features] | [features] | ||||||
| # this feature is used for production builds or when `devPath` points to the filesystem | # this feature is used for production builds or when `devPath` points to the filesystem | ||||||
|   | |||||||
| @@ -1,6 +1,12 @@ | |||||||
|  | use std::path::Path; | ||||||
|  |  | ||||||
| use anyhow::anyhow; | use anyhow::anyhow; | ||||||
|  | use mountpoints::mountinfos; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use walkdir::WalkDir; | use tauri::Runtime; | ||||||
|  | use walkdir::{DirEntry, WalkDir}; | ||||||
|  |  | ||||||
|  | use crate::utils; | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize)] | #[derive(Debug, Clone, Serialize)] | ||||||
| pub struct FileItem { | pub struct FileItem { | ||||||
| @@ -11,16 +17,54 @@ pub struct FileItem { | |||||||
|     pub width: u32, |     pub width: u32, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize)] | ||||||
|  | pub struct DirItem { | ||||||
|  |     pub id: String, | ||||||
|  |     pub dirname: String, | ||||||
|  |     pub path: String, | ||||||
|  |     pub root: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn is_hidden(entry: &DirEntry) -> bool { | ||||||
|  |     entry | ||||||
|  |         .file_name() | ||||||
|  |         .to_str() | ||||||
|  |         .map(|s| s.starts_with(".") || s.starts_with("$")) | ||||||
|  |         .unwrap_or(false) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn is_self<P: AsRef<Path>>(entry: &DirEntry, target: P) -> bool { | ||||||
|  |     entry.path().eq(target.as_ref()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn is_root(entry: &DirEntry) -> bool { | ||||||
|  |     entry | ||||||
|  |         .path() | ||||||
|  |         .to_str() | ||||||
|  |         .map(|s| s.eq_ignore_ascii_case("/")) | ||||||
|  |         .unwrap_or(false) | ||||||
|  | } | ||||||
|  |  | ||||||
| #[tauri::command] | #[tauri::command] | ||||||
| pub fn scan_directory(target: String) -> Result<Vec<FileItem>, String> { | pub async fn scan_directory(target: String) -> Result<Vec<FileItem>, String> { | ||||||
|     let mut file_items = WalkDir::new(target) |     let mut file_items = WalkDir::new(target) | ||||||
|  |         .max_depth(1) | ||||||
|         .into_iter() |         .into_iter() | ||||||
|  |         .filter_entry(|entry| !is_hidden(entry)) | ||||||
|         .filter_map(|f| f.ok()) |         .filter_map(|f| f.ok()) | ||||||
|         .filter(|f| f.path().is_file()) |         .filter(|f| f.path().is_file()) | ||||||
|         .map(|f| { |         .map(|f| { | ||||||
|             let (width, height) = image::image_dimensions(f.path())?; |             let (width, height) = image::image_dimensions(f.path())?; | ||||||
|  |             let file_hash_id = f | ||||||
|  |                 .path() | ||||||
|  |                 .to_str() | ||||||
|  |                 .map(crate::utils::md5_hash) | ||||||
|  |                 .map(|hash| utils::uuid_from(hash.as_slice())) | ||||||
|  |                 .transpose() | ||||||
|  |                 .map_err(|e| anyhow!(e))? | ||||||
|  |                 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); | ||||||
|             Ok(FileItem { |             Ok(FileItem { | ||||||
|                 id: uuid::Uuid::new_v4().to_string(), |                 id: file_hash_id, | ||||||
|                 filename: f |                 filename: f | ||||||
|                     .path() |                     .path() | ||||||
|                     .file_name() |                     .file_name() | ||||||
| @@ -44,3 +88,101 @@ pub fn scan_directory(target: String) -> Result<Vec<FileItem>, String> { | |||||||
|  |  | ||||||
|     Ok(file_items) |     Ok(file_items) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[tauri::command] | ||||||
|  | pub async fn show_drives<R: Runtime>( | ||||||
|  |     _app: tauri::AppHandle<R>, | ||||||
|  |     _window: tauri::Window<R>, | ||||||
|  | ) -> Result<Vec<DirItem>, String> { | ||||||
|  |     let mut drives = vec![]; | ||||||
|  |     match mountinfos() { | ||||||
|  |         Ok(mounts) => { | ||||||
|  |             #[cfg(any(target_os = "macos", target_os = "linux"))] | ||||||
|  |             let mounts = mounts | ||||||
|  |                 .iter() | ||||||
|  |                 .filter(|m| !m.path.starts_with("/System") && !m.path.starts_with("/dev")); | ||||||
|  |             for mount in mounts { | ||||||
|  |                 let dirname = mount | ||||||
|  |                     .path | ||||||
|  |                     .as_path() | ||||||
|  |                     .file_name() | ||||||
|  |                     .unwrap_or_default() | ||||||
|  |                     .to_os_string() | ||||||
|  |                     .into_string() | ||||||
|  |                     .unwrap(); | ||||||
|  |                 let dirname = if dirname.len() == 0 { | ||||||
|  |                     String::from("/") | ||||||
|  |                 } else { | ||||||
|  |                     dirname | ||||||
|  |                 }; | ||||||
|  |                 let dir_hash_id = mount | ||||||
|  |                     .path | ||||||
|  |                     .to_str() | ||||||
|  |                     .map(crate::utils::md5_hash) | ||||||
|  |                     .map(|hash| utils::uuid_from(hash.as_slice())) | ||||||
|  |                     .transpose()? | ||||||
|  |                     .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); | ||||||
|  |                 drives.push(DirItem { | ||||||
|  |                     id: dir_hash_id, | ||||||
|  |                     dirname, | ||||||
|  |                     path: String::from(mount.path.to_str().unwrap_or_default()), | ||||||
|  |                     root: true, | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         Err(err) => return Err(format!("不能列出系统中已挂载的磁盘:{}", err)), | ||||||
|  |     } | ||||||
|  |     Ok(drives) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[tauri::command] | ||||||
|  | pub async fn scan_for_child_dirs<R: Runtime>( | ||||||
|  |     _app: tauri::AppHandle<R>, | ||||||
|  |     _window: tauri::Window<R>, | ||||||
|  |     target: String, | ||||||
|  | ) -> Result<Vec<DirItem>, String> { | ||||||
|  |     println!("请求扫描文件夹:{}", target); | ||||||
|  |     let target = if target.eq_ignore_ascii_case("/") { | ||||||
|  |         Path::new(r"/") | ||||||
|  |     } else { | ||||||
|  |         Path::new(&target) | ||||||
|  |     }; | ||||||
|  |     let mut child_dirs = WalkDir::new(target) | ||||||
|  |         .max_depth(1) | ||||||
|  |         .into_iter() | ||||||
|  |         .filter_entry(|entry| !is_hidden(entry) && !is_root(entry)) | ||||||
|  |         .filter_map(|d| d.ok()) | ||||||
|  |         .filter(|d| d.path().is_dir() && !is_self(d, target)) | ||||||
|  |         .map(|d| { | ||||||
|  |             println!("扫描到的文件夹:{}", d.path().display()); | ||||||
|  |             let dir_hash_id = d | ||||||
|  |                 .path() | ||||||
|  |                 .to_str() | ||||||
|  |                 .map(crate::utils::md5_hash) | ||||||
|  |                 .map(|hash| utils::uuid_from(hash.as_slice())) | ||||||
|  |                 .transpose() | ||||||
|  |                 .map_err(|e| anyhow!(e))? | ||||||
|  |                 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); | ||||||
|  |             Ok(DirItem { | ||||||
|  |                 id: dir_hash_id, | ||||||
|  |                 dirname: d | ||||||
|  |                     .path() | ||||||
|  |                     .file_name() | ||||||
|  |                     .ok_or(anyhow!("不能获取到文件夹的名称。"))? | ||||||
|  |                     .to_owned() | ||||||
|  |                     .into_string() | ||||||
|  |                     .unwrap(), | ||||||
|  |                 path: d | ||||||
|  |                     .path() | ||||||
|  |                     .clone() | ||||||
|  |                     .to_str() | ||||||
|  |                     .ok_or(anyhow!("不能获取到文件夹路径。"))? | ||||||
|  |                     .to_string(), | ||||||
|  |                 root: false, | ||||||
|  |             }) | ||||||
|  |         }) | ||||||
|  |         .collect::<Result<Vec<DirItem>, anyhow::Error>>() | ||||||
|  |         .map_err(|e| e.to_string())?; | ||||||
|  |     child_dirs.sort_by(|a, b| a.dirname.partial_cmp(&b.dirname).unwrap()); | ||||||
|  |     Ok(child_dirs) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -2,13 +2,18 @@ | |||||||
| #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | ||||||
|  |  | ||||||
| mod commands; | mod commands; | ||||||
|  | mod utils; | ||||||
|  |  | ||||||
| use commands::AppHold; | use commands::AppHold; | ||||||
| use tauri::Manager; | use tauri::Manager; | ||||||
|  |  | ||||||
| fn main() { | fn main() { | ||||||
|     tauri::Builder::default() |     tauri::Builder::default() | ||||||
|         .invoke_handler(tauri::generate_handler![commands::prelude::scan_directory]) |         .invoke_handler(tauri::generate_handler![ | ||||||
|  |             commands::prelude::scan_directory, | ||||||
|  |             commands::prelude::show_drives, | ||||||
|  |             commands::prelude::scan_for_child_dirs | ||||||
|  |         ]) | ||||||
|         .setup(|app| { |         .setup(|app| { | ||||||
|             let main_window = app.get_window("main").unwrap(); |             let main_window = app.get_window("main").unwrap(); | ||||||
|             #[cfg(debug_assertions)] |             #[cfg(debug_assertions)] | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								src-tauri/src/utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src-tauri/src/utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | use md5::{Digest, Md5}; | ||||||
|  | use uuid::Builder; | ||||||
|  |  | ||||||
|  | pub fn md5_hash(source: &str) -> Vec<u8> { | ||||||
|  |     let mut hasher = Md5::new(); | ||||||
|  |     hasher.update(source); | ||||||
|  |     let hash = hasher.finalize(); | ||||||
|  |     hash.to_vec() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn uuid_from(source: &[u8]) -> Result<String, String> { | ||||||
|  |     let builder = Builder::from_md5_bytes( | ||||||
|  |         source | ||||||
|  |             .try_into() | ||||||
|  |             .map_err(|_| String::from("源内容长度不足。"))?, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     Ok(builder.into_uuid().to_string()) | ||||||
|  | } | ||||||
| @@ -8,7 +8,7 @@ | |||||||
|   }, |   }, | ||||||
|   "package": { |   "package": { | ||||||
|     "productName": "漫画阅读器", |     "productName": "漫画阅读器", | ||||||
|     "version": "0.1.0" |     "version": "0.2.0" | ||||||
|   }, |   }, | ||||||
|   "tauri": { |   "tauri": { | ||||||
|     "allowlist": { |     "allowlist": { | ||||||
| @@ -17,17 +17,23 @@ | |||||||
|         "all": false, |         "all": false, | ||||||
|         "copyFile": false, |         "copyFile": false, | ||||||
|         "createDir": false, |         "createDir": false, | ||||||
|         "exists": false, |         "exists": true, | ||||||
|         "readDir": false, |         "readDir": true, | ||||||
|         "readFile": true, |         "readFile": true, | ||||||
|         "removeDir": false, |         "removeDir": false, | ||||||
|         "removeFile": false, |         "removeFile": false, | ||||||
|         "renameFile": false, |         "renameFile": false, | ||||||
|         "scope": [], |         "scope": [ | ||||||
|  |           "*" | ||||||
|  |         ], | ||||||
|         "writeFile": false |         "writeFile": false | ||||||
|       }, |       }, | ||||||
|       "protocol": { |       "protocol": { | ||||||
|         "all": true |         "all": false, | ||||||
|  |         "asset": true, | ||||||
|  |         "assetScope": [ | ||||||
|  |           "*" | ||||||
|  |         ] | ||||||
|       }, |       }, | ||||||
|       "shell": { |       "shell": { | ||||||
|         "all": false, |         "all": false, | ||||||
| @@ -60,8 +66,7 @@ | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "security": { |     "security": { | ||||||
|       "devCsp": "default-src 'self'; img-src 'self' asset: https://asset.localhost", |       "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost" | ||||||
|       "csp": null |  | ||||||
|     }, |     }, | ||||||
|     "updater": { |     "updater": { | ||||||
|       "active": false |       "active": false | ||||||
|   | |||||||
| @@ -1,9 +1,14 @@ | |||||||
| //@ts-nocheck | //@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 { ifElse, path, propEq } from 'ramda'; | ||||||
| import { FC, useMemo } from 'react'; | import { FC, useMemo } from 'react'; | ||||||
|  | import { useMount } from 'react-use'; | ||||||
|  | import { DirTree } from './components/DirTree'; | ||||||
| import { FileList } from './components/FileList'; | import { FileList } from './components/FileList'; | ||||||
| import { FileToolbar } from './components/FileTools'; | import { FileToolbar } from './components/FileTools'; | ||||||
|  | import { loadDrives } from './queries/directories'; | ||||||
|  |  | ||||||
| const bgSelectFn = ifElse( | const bgSelectFn = ifElse( | ||||||
|   propEq('colorScheme', 'dark'), |   propEq('colorScheme', 'dark'), | ||||||
| @@ -18,10 +23,18 @@ export const NavMenu: FC = () => { | |||||||
|   const disabledColor = useMemo(() => path<string>(['gray', 7])(theme.colors), [theme.colors]); |   const disabledColor = useMemo(() => path<string>(['gray', 7])(theme.colors), [theme.colors]); | ||||||
|   const navMenuBg = useMemo(() => bgSelectFn(theme), [theme, theme]); |   const navMenuBg = useMemo(() => bgSelectFn(theme), [theme, theme]); | ||||||
|  |  | ||||||
|  |   useMount(() => { | ||||||
|  |     try { | ||||||
|  |       loadDrives(); | ||||||
|  |     } catch (e) { | ||||||
|  |       notifications.show({ message: `未能成功加载全部磁盘列表,${e.message}`, color: 'red' }); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Stack |     <Stack | ||||||
|       spacing={8} |       spacing={8} | ||||||
|       miw={200} |       miw={220} | ||||||
|       h="inherit" |       h="inherit" | ||||||
|       sx={theme => ({ |       sx={theme => ({ | ||||||
|         flexGrow: 1, |         flexGrow: 1, | ||||||
| @@ -32,8 +45,25 @@ export const NavMenu: FC = () => { | |||||||
|       py={4} |       py={4} | ||||||
|       align="center" |       align="center" | ||||||
|     > |     > | ||||||
|       <FileToolbar /> |       <Tabs defaultValue="folder" w="100%" h="100%"> | ||||||
|       <FileList /> |         <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> |     </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; |   width: number; | ||||||
|   height: 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