From 4839be21508f31d4c4f6b39f1912c318e0fa5fff Mon Sep 17 00:00:00 2001 From: rthw <35174166+rthw@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:43:49 +0200 Subject: [PATCH 1/2] feat: open folder passed as launch argument --- src-tauri/src/app_state.rs | 1 + src-tauri/src/launch_request.rs | 14 ++++++--- src-tauri/src/lib.rs | 23 ++++++++++++-- src/App.tsx | 8 +++++ src/hooks/useAppInitialization.ts | 2 ++ src/hooks/useAppNavigation.ts | 51 ++++++++++++++++++------------- src/hooks/useTauriListeners.ts | 3 ++ src/store/useProcessStore.ts | 2 ++ 8 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index a680f3a4fc..735bc99a7a 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -229,6 +229,7 @@ pub struct AppState { pub indexing_task_handle: Mutex>>, pub lut_cache: Mutex>>, pub initial_file_path: Mutex>, + pub initial_folder_path: Mutex>, pub pending_edit_session: Mutex>, pub thumbnail_cancellation_token: Arc, pub thumbnail_progress: Mutex, diff --git a/src-tauri/src/launch_request.rs b/src-tauri/src/launch_request.rs index 9f70fcbfa9..f6e705eb60 100644 --- a/src-tauri/src/launch_request.rs +++ b/src-tauri/src/launch_request.rs @@ -28,6 +28,7 @@ pub struct HeadlessExportSession { pub enum LaunchRequest { None, OpenFile(String), + OpenFolder(String), EditSession(ExternalEditSession), HeadlessExport(HeadlessExportSession), InvalidHeadless(String), @@ -37,6 +38,7 @@ pub enum LaunchRequest { #[serde(rename_all = "camelCase")] pub struct LaunchPayload { pub open_with_file: Option, + pub open_with_folder: Option, pub edit_session: Option, } @@ -156,17 +158,18 @@ pub fn parse_launch_args(args: &[String]) -> LaunchRequest { } (Some(source), None) => LaunchRequest::OpenFile(source), _ => match plain { + Some(path) if PathBuf::from(&path).is_dir() => LaunchRequest::OpenFolder(path), Some(path) => LaunchRequest::OpenFile(path), None => LaunchRequest::None, }, } } -fn handle_file_open(app_handle: &tauri::AppHandle, path: PathBuf) { +fn emit_path_open(app_handle: &tauri::AppHandle, event: &str, path: PathBuf) { if let Some(path_str) = path.to_str() - && let Err(e) = app_handle.emit("open-with-file", path_str) + && let Err(e) = app_handle.emit(event, path_str) { - log::error!("Failed to emit open-with-file event: {}", e); + log::error!("Failed to emit {} event: {}", event, e); } } @@ -178,7 +181,10 @@ pub fn emit_launch_request(app_handle: &tauri::AppHandle, request: LaunchRequest } } LaunchRequest::OpenFile(path) => { - handle_file_open(app_handle, PathBuf::from(path)); + emit_path_open(app_handle, "open-with-file", PathBuf::from(path)); + } + LaunchRequest::OpenFolder(path) => { + emit_path_open(app_handle, "open-with-folder", PathBuf::from(path)); } LaunchRequest::HeadlessExport(_) => { cli_println!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4054c330e2..f64a90d85d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1741,10 +1741,14 @@ fn frontend_ready( } let open_with_file = state.initial_file_path.lock().unwrap().take(); + let open_with_folder = state.initial_folder_path.lock().unwrap().take(); let edit_session = state.pending_edit_session.lock().unwrap().take(); if let Some(path) = &open_with_file { log::info!("Frontend is ready, returning initial path: {}", path); } + if let Some(path) = &open_with_folder { + log::info!("Frontend is ready, returning initial folder: {}", path); + } if let Some(session) = &edit_session { log::info!( "Frontend is ready, returning external edit session for: {}", @@ -1753,6 +1757,7 @@ fn frontend_ready( } Ok(LaunchPayload { open_with_file, + open_with_folder, edit_session, }) } @@ -1853,6 +1858,10 @@ pub fn run() { log::info!("Initial open: Storing path {} for later.", path); *state.initial_file_path.lock().unwrap() = Some(path); } + LaunchRequest::OpenFolder(path) => { + log::info!("Initial open: Storing folder {} for later.", path); + *state.initial_folder_path.lock().unwrap() = Some(path); + } _ => {} } } @@ -2163,6 +2172,7 @@ pub fn run() { indexing_task_handle: Mutex::new(None), lut_cache: Mutex::new(HashMap::new()), initial_file_path: Mutex::new(None), + initial_folder_path: Mutex::new(None), pending_edit_session: Mutex::new(None), thumbnail_cancellation_token: Arc::new(AtomicBool::new(false)), thumbnail_progress: Mutex::new(ThumbnailProgressTracker { total: 0, completed: 0 }), @@ -2331,11 +2341,18 @@ pub fn run() { emit_launch_request( app_handle, - LaunchRequest::OpenFile(path_str.to_string()), + if path.is_dir() { + LaunchRequest::OpenFolder(path_str.to_string()) + } else { + LaunchRequest::OpenFile(path_str.to_string()) + }, ); } else { - *state.initial_file_path.lock().unwrap() = - Some(path_str.to_string()); + if path.is_dir() { + *state.initial_folder_path.lock().unwrap() = Some(path_str.to_string()); + } else { + *state.initial_file_path.lock().unwrap() = Some(path_str.to_string()); + } log::info!( "macOS initial open: Stored path {} for later.", diff --git a/src/App.tsx b/src/App.tsx index bb0464beec..4eb7d124c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -347,6 +347,7 @@ function App() { handleSelectSubfolder, handleSelectAlbum, handleOpenFolder, + handleOpenFolderPath, handleNavBack, handleNavForward, handleContinueSession, @@ -361,6 +362,13 @@ function App() { finishExternalEdit, } = useExternalEditSession(handleImageSelect); + const initialFolderToOpen = useProcessStore((state) => state.initialFolderToOpen); + useEffect(() => { + if (!initialFolderToOpen) return; + useProcessStore.getState().setProcess({ initialFolderToOpen: null }); + handleOpenFolderPath(initialFolderToOpen); + }, [initialFolderToOpen, handleOpenFolderPath]); + const { handleRate, handleClearSelection, diff --git a/src/hooks/useAppInitialization.ts b/src/hooks/useAppInitialization.ts index 45d3623bc0..fa86021746 100644 --- a/src/hooks/useAppInitialization.ts +++ b/src/hooks/useAppInitialization.ts @@ -269,6 +269,8 @@ export const useAppInitialization = ({ .then((launch: any) => { if (launch?.editSession) { useProcessStore.getState().setProcess({ externalEditSession: launch.editSession }); + } else if (launch?.openWithFolder) { + useProcessStore.getState().setProcess({ initialFolderToOpen: launch.openWithFolder }); } else if (launch?.openWithFile) { useProcessStore.getState().setProcess({ initialFileToOpen: launch.openWithFile }); } diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 6637d75766..43ed67342c 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -508,24 +508,16 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro } }, [handleSelectSubfolder, handleSelectAlbum]); - const handleOpenFolder = useCallback(async () => { - const { osPlatform, appSettings, handleSettingsChange } = useSettingsStore.getState(); - const { rootPaths, folderTrees, setLibrary } = useLibraryStore.getState(); - const isAndroid = osPlatform === 'android'; - - try { - let selectedPath = ''; - if (isAndroid) { - selectedPath = await invoke(Invokes.GetOrCreateInternalLibraryRoot); - } else { - const selected = await open({ directory: true, multiple: false, defaultPath: await homeDir() }); - if (typeof selected === 'string') { - selectedPath = selected; - } - } + const handleOpenFolderPath = useCallback( + async (selectedPath: string) => { + const { appSettings, handleSettingsChange } = useSettingsStore.getState(); + const { rootPaths, folderTrees, setLibrary } = useLibraryStore.getState(); - if (selectedPath) { - if (!rootPaths.includes(selectedPath)) { + try { + const existingRoot = rootPaths.find( + (root) => selectedPath === root || selectedPath.startsWith(`${root}${selectedPath.includes('/') ? '/' : '\\'}`), + ); + if (!existingRoot) { const newRootPaths = [...rootPaths, selectedPath]; setLibrary({ rootPaths: newRootPaths }); @@ -542,19 +534,35 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro appSettings?.enableFolderImageCounts || appSettings?.folderTreeSort?.key === 'imageCount', }); setLibrary({ folderTrees: [...folderTrees, newTree] }); - } catch (e) { - toast.error(`Failed to load folder tree: ${e}`); } finally { setLibrary({ isTreeLoading: false }); } + await handleSelectSubfolder(selectedPath, true); + } else { + await handleSelectSubfolder(selectedPath, false); } - await handleSelectSubfolder(selectedPath, true); + } catch (err) { + console.error('Failed to open folder:', err); + toast.error('Failed to open the requested folder.'); } + }, + [handleSelectSubfolder], + ); + + const handleOpenFolder = useCallback(async () => { + const { osPlatform } = useSettingsStore.getState(); + const isAndroid = osPlatform === 'android'; + + try { + const selectedPath = isAndroid + ? await invoke(Invokes.GetOrCreateInternalLibraryRoot) + : await open({ directory: true, multiple: false, defaultPath: await homeDir() }); + if (typeof selectedPath === 'string') await handleOpenFolderPath(selectedPath); } catch (err) { console.error(isAndroid ? 'Failed to open Android library root:' : 'Failed to open directory dialog:', err); toast.error(isAndroid ? 'Failed to open library.' : 'Failed to open folder selection dialog.'); } - }, [handleSelectSubfolder]); + }, [handleOpenFolderPath]); const handleContinueSession = () => { const restore = async () => { @@ -665,6 +673,7 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro handleSelectSubfolder, handleSelectAlbum, handleOpenFolder, + handleOpenFolderPath, handleNavBack, handleNavForward, handleContinueSession, diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index 6b8b5bc1aa..0c94da0b96 100644 --- a/src/hooks/useTauriListeners.ts +++ b/src/hooks/useTauriListeners.ts @@ -89,6 +89,9 @@ export function useTauriListeners({ listen('open-with-file', (event: any) => { if (isEffectActive) useProcessStore.getState().setProcess({ initialFileToOpen: event.payload as string }); }), + listen('open-with-folder', (event: any) => { + if (isEffectActive) useProcessStore.getState().setProcess({ initialFolderToOpen: event.payload as string }); + }), listen('external-edit-session', (event: any) => { if (isEffectActive) useProcessStore.getState().setProcess({ externalEditSession: event.payload }); }), diff --git a/src/store/useProcessStore.ts b/src/store/useProcessStore.ts index f58a3dcf83..18f6e3ba82 100644 --- a/src/store/useProcessStore.ts +++ b/src/store/useProcessStore.ts @@ -28,6 +28,7 @@ interface ProcessState { isCopied: boolean; isPasted: boolean; initialFileToOpen: string | null; + initialFolderToOpen: string | null; externalEditSession: ExternalEditSession | null; activeAiTasks: Record; @@ -62,6 +63,7 @@ export const useProcessStore = create((set, get) => ({ isCopied: false, isPasted: false, initialFileToOpen: null, + initialFolderToOpen: null, externalEditSession: null, activeAiTasks: {}, From 69c8865c96fcc53012487b6416331fdb4e68b3e1 Mon Sep 17 00:00:00 2001 From: rthw <35174166+rthw@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:48:26 +0200 Subject: [PATCH 2/2] fix: reuse an existing library root and resolve folder paths on open - normalize trailing separators and canonicalize the launch path, so a folder nested in an existing root is selected instead of added as a second root - seed the library roots from settings on a cold start, so opening a folder no longer replaces the existing library roots --- src-tauri/src/launch_request.rs | 12 ++++++++++- src-tauri/src/lib.rs | 1 + src/hooks/useAppNavigation.ts | 36 +++++++++++++++++++++++++++------ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/launch_request.rs b/src-tauri/src/launch_request.rs index f6e705eb60..63f401dcf2 100644 --- a/src-tauri/src/launch_request.rs +++ b/src-tauri/src/launch_request.rs @@ -158,13 +158,23 @@ pub fn parse_launch_args(args: &[String]) -> LaunchRequest { } (Some(source), None) => LaunchRequest::OpenFile(source), _ => match plain { - Some(path) if PathBuf::from(&path).is_dir() => LaunchRequest::OpenFolder(path), + Some(path) if PathBuf::from(&path).is_dir() => { + LaunchRequest::OpenFolder(canonical_folder_path(path)) + } Some(path) => LaunchRequest::OpenFile(path), None => LaunchRequest::None, }, } } +/// Путь папки без симлинков и лишних разделителей (иначе та же папка добавится в корни дважды). +pub fn canonical_folder_path(path: String) -> String { + std::fs::canonicalize(&path) + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or(path) +} + fn emit_path_open(app_handle: &tauri::AppHandle, event: &str, path: PathBuf) { if let Some(path_str) = path.to_str() && let Err(e) = app_handle.emit(event, path_str) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f64a90d85d..a40468899a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2323,6 +2323,7 @@ pub fn run() { tauri::RunEvent::Opened { urls } => { if let Some(url) = urls.first() && let Ok(path) = url.to_file_path() + && let path = std::fs::canonicalize(&path).unwrap_or(path) && let Some(path_str) = path.to_str() { let state = app_handle.state::(); diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 43ed67342c..34b0077b77 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -511,12 +511,37 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro const handleOpenFolderPath = useCallback( async (selectedPath: string) => { const { appSettings, handleSettingsChange } = useSettingsStore.getState(); - const { rootPaths, folderTrees, setLibrary } = useLibraryStore.getState(); + const { setLibrary } = useLibraryStore.getState(); + let { rootPaths, folderTrees } = useLibraryStore.getState(); try { - const existingRoot = rootPaths.find( - (root) => selectedPath === root || selectedPath.startsWith(`${root}${selectedPath.includes('/') ? '/' : '\\'}`), - ); + const showImageCounts = + appSettings?.enableFolderImageCounts || appSettings?.folderTreeSort?.key === 'imageCount'; + + // На холодном старте стор библиотеки ещё пуст: берём корни из настроек, а не затираем их. + if (rootPaths.length === 0 && appSettings?.rootFolders?.length) { + rootPaths = appSettings.rootFolders; + setLibrary({ rootPaths, expandedFolders: new Set(rootPaths), isTreeLoading: true }); + try { + folderTrees = await invoke(Invokes.GetPinnedFolderTrees, { + paths: rootPaths, + expandedFolders: rootPaths, + showImageCounts, + }); + setLibrary({ folderTrees }); + } catch (err) { + console.error('Failed to load folder trees:', err); + } finally { + setLibrary({ isTreeLoading: false }); + } + } + + const normalizePath = (p: string) => p.replace(/[\\/]+$/, ''); + const targetPath = normalizePath(selectedPath); + const existingRoot = rootPaths.find((root) => { + const base = normalizePath(root); + return targetPath === base || targetPath.startsWith(`${base}/`) || targetPath.startsWith(`${base}\\`); + }); if (!existingRoot) { const newRootPaths = [...rootPaths, selectedPath]; setLibrary({ rootPaths: newRootPaths }); @@ -530,8 +555,7 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro const newTree = await invoke(Invokes.GetFolderTree, { path: selectedPath, expandedFolders: [selectedPath], - showImageCounts: - appSettings?.enableFolderImageCounts || appSettings?.folderTreeSort?.key === 'imageCount', + showImageCounts, }); setLibrary({ folderTrees: [...folderTrees, newTree] }); } finally {