From 733dd48663f658da422f02cd6177c3136b72a5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Mon, 20 Mar 2023 16:42:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(browse):=E5=9F=BA=E6=9C=AC=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E6=96=87=E4=BB=B6=E5=A4=B9=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- src-tauri/.cargo/config | 8 ++ src-tauri/Cargo.lock | 23 ++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/commands/files.rs | 148 +++++++++++++++++++++++++++++++- src-tauri/src/main.rs | 7 +- src-tauri/src/utils.rs | 19 ++++ src-tauri/tauri.conf.json | 19 ++-- src/NavMenu.tsx | 38 +++++++- src/components/DirTree.tsx | 130 ++++++++++++++++++++++++++++ src/models.ts | 10 +++ src/queries/directories.ts | 24 ++++++ src/states/dirs.ts | 141 ++++++++++++++++++++++++++++++ 13 files changed, 557 insertions(+), 20 deletions(-) create mode 100644 src-tauri/.cargo/config create mode 100644 src-tauri/src/utils.rs create mode 100644 src/components/DirTree.tsx create mode 100644 src/queries/directories.ts create mode 100644 src/states/dirs.ts diff --git a/package.json b/package.json index 51b8f92..575fad6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "comic_viewer", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -46,4 +46,4 @@ "typescript": "^4.6.4", "vite": "^4.0.0" } -} +} \ No newline at end of file diff --git a/src-tauri/.cargo/config b/src-tauri/.cargo/config new file mode 100644 index 0000000..6287935 --- /dev/null +++ b/src-tauri/.cargo/config @@ -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"] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1601ae8..cd723bb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -326,11 +326,13 @@ dependencies = [ [[package]] name = "comic_viewer" -version = "0.0.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", "image", + "md-5", + "mountpoints", "once_cell", "serde", "serde_json", @@ -1590,6 +1592,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "memchr" version = "2.5.0" @@ -1626,6 +1637,16 @@ dependencies = [ "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]] name = "nanorand" version = "0.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9497a07..159ce39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comic_viewer" -version = "0.0.0" +version = "0.2.0" description = "A Tauri App" authors = ["you"] license = "" @@ -14,7 +14,7 @@ tauri-build = { version = "1.2", features = [] } [dependencies] 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_json = "1.0" chrono = { version = "0.4.23", features = ["serde"] } @@ -25,6 +25,8 @@ serde_repr = "0.1.10" tokio = { version = "1.23.1", features = ["full"] } image = "0.24.5" uuid = "1.3.0" +mountpoints = "0.2.1" +md-5 = "0.10.5" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 449e729..3570087 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,6 +1,12 @@ +use std::path::Path; + use anyhow::anyhow; +use mountpoints::mountinfos; use serde::Serialize; -use walkdir::WalkDir; +use tauri::Runtime; +use walkdir::{DirEntry, WalkDir}; + +use crate::utils; #[derive(Debug, Clone, Serialize)] pub struct FileItem { @@ -11,16 +17,54 @@ pub struct FileItem { 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>(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] -pub fn scan_directory(target: String) -> Result, String> { +pub async fn scan_directory(target: String) -> Result, String> { let mut file_items = WalkDir::new(target) + .max_depth(1) .into_iter() + .filter_entry(|entry| !is_hidden(entry)) .filter_map(|f| f.ok()) .filter(|f| f.path().is_file()) .map(|f| { 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 { - id: uuid::Uuid::new_v4().to_string(), + id: file_hash_id, filename: f .path() .file_name() @@ -44,3 +88,101 @@ pub fn scan_directory(target: String) -> Result, String> { Ok(file_items) } + +#[tauri::command] +pub async fn show_drives( + _app: tauri::AppHandle, + _window: tauri::Window, +) -> Result, 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( + _app: tauri::AppHandle, + _window: tauri::Window, + target: String, +) -> Result, 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::, anyhow::Error>>() + .map_err(|e| e.to_string())?; + child_dirs.sort_by(|a, b| a.dirname.partial_cmp(&b.dirname).unwrap()); + Ok(child_dirs) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0769172..e3e284a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,13 +2,18 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; +mod utils; use commands::AppHold; use tauri::Manager; fn main() { 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| { let main_window = app.get_window("main").unwrap(); #[cfg(debug_assertions)] diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 0000000..75b2856 --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,19 @@ +use md5::{Digest, Md5}; +use uuid::Builder; + +pub fn md5_hash(source: &str) -> Vec { + let mut hasher = Md5::new(); + hasher.update(source); + let hash = hasher.finalize(); + hash.to_vec() +} + +pub fn uuid_from(source: &[u8]) -> Result { + let builder = Builder::from_md5_bytes( + source + .try_into() + .map_err(|_| String::from("源内容长度不足。"))?, + ); + + Ok(builder.into_uuid().to_string()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 271a18b..6f9ccae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "漫画阅读器", - "version": "0.1.0" + "version": "0.2.0" }, "tauri": { "allowlist": { @@ -17,17 +17,23 @@ "all": false, "copyFile": false, "createDir": false, - "exists": false, - "readDir": false, + "exists": true, + "readDir": true, "readFile": true, "removeDir": false, "removeFile": false, "renameFile": false, - "scope": [], + "scope": [ + "*" + ], "writeFile": false }, "protocol": { - "all": true + "all": false, + "asset": true, + "assetScope": [ + "*" + ] }, "shell": { "all": false, @@ -60,8 +66,7 @@ } }, "security": { - "devCsp": "default-src 'self'; img-src 'self' asset: https://asset.localhost", - "csp": null + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost" }, "updater": { "active": false diff --git a/src/NavMenu.tsx b/src/NavMenu.tsx index 4828aba..3e08345 100644 --- a/src/NavMenu.tsx +++ b/src/NavMenu.tsx @@ -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(['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 ( ({ flexGrow: 1, @@ -32,8 +45,25 @@ export const NavMenu: FC = () => { py={4} align="center" > - - + + + }> + 文件夹 + + }> + 文件列表 + + + + + + + + + + + + ); }; diff --git a/src/components/DirTree.tsx b/src/components/DirTree.tsx new file mode 100644 index 0000000..f32e2b8 --- /dev/null +++ b/src/components/DirTree.tsx @@ -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> = ({ + children, + current +}) => { + const { directories: allSubDirs } = useDirTreeStore(); + const [subDirs, setSubDirs] = useState([]); + 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(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 ( +
  • + + + {isCurrentExpanded ? ( + + ) : ( + + )} + + + + {children} + + + + {not(isEmpty(subDirs)) && isCurrentExpanded && ( + + {map( + item => ( + + {item.dirname} + + ), + subDirs + )} + + )} +
  • + ); +}; + +export const DirTree: FC = () => { + const roots = useDirTreeStore(currentRootsSelector()); + + return ( + + + {map( + item => ( + + {item.dirname} + + ), + roots + )} + + + ); +}; diff --git a/src/models.ts b/src/models.ts index 850443e..b8fb066 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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; +}; diff --git a/src/queries/directories.ts b/src/queries/directories.ts new file mode 100644 index 0000000..ea7bf54 --- /dev/null +++ b/src/queries/directories.ts @@ -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; + } +} diff --git a/src/states/dirs.ts b/src/states/dirs.ts new file mode 100644 index 0000000..f49c775 --- /dev/null +++ b/src/states/dirs.ts @@ -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[]) => void; + saveDirectories: (dirs: Omit[], parent: string) => void; + focus: SyncParamAction; + unfocus: SyncAction; + selectDirectory: SyncParamAction; + unselectDirectory: SyncAction; + expandDir: SyncParamAction; + foldDir: SyncParamAction; +}; + +const initialState: DirsStates = { + drives: [], + directories: [], + focused: undefined, + selected: undefined, + expanded: [] +}; + +export const useDirTreeStore = createStoreHook((set, get) => ({ + ...initialState, + updateDrives(dirs) { + set(df => { + df.drives = addIndex, DirItem>(map)( + (item, index) => mergeLeft({ sort: index * 10, path: item.path, parent: undefined }, item), + dirs + ); + }); + }, + saveDirectories(dirs, parent) { + const convertedDirs = addIndex, 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 { + return state => (isNil(state.focused) ? state.drives : [state.focused]); +} + +export function selectDirectories(parent: string): SyncObjectCallback { + return dirs => filter(propEq('parent', parent), dirs) ?? []; +} + +export function subDirectoriesSelector(parent: string): SyncObjectCallback { + return state => filter(propEq('parent', parent), state.directories) ?? []; +} + +export function isExpandedSelector(dirId: string): SyncObjectCallback { + return state => includes(dirId, state.expanded); +}