diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index a8e2e1ac4..d5e0280df 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo" noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" +openInNewWindow = "Open in new window" openInViewer = "Open in Viewer" operationCancelled = "Operation cancelled" page = "Page" diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json index 9259e4543..98df1fef9 100644 --- a/frontend/editor/src-tauri/capabilities/default.json +++ b/frontend/editor/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main"], + "windows": ["main", "main-*"], "permissions": [ "core:default", "core:window:allow-destroy", diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 27720de25..495b38aaf 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -5,9 +5,18 @@ pub mod auth; pub mod default_app; pub mod platform; pub mod print; +pub mod window; pub use backend::{cleanup_backend, get_backend_port, start_backend}; pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files}; +pub use window::{ + forward_files_to_window, + open_files_in_new_window, + open_in_new_window, + pop_window_file_ids, + target_window_label, + MAIN_WINDOW_LABEL, +}; pub use connection::{ get_connection_config, is_first_launch, diff --git a/frontend/editor/src-tauri/src/commands/window.rs b/frontend/editor/src-tauri/src/commands/window.rs new file mode 100644 index 000000000..761d47aee --- /dev/null +++ b/frontend/editor/src-tauri/src/commands/window.rs @@ -0,0 +1,216 @@ +use crate::commands::files::add_opened_file; +use crate::utils::add_log; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +// The primary window created from tauri.conf.json. +pub const MAIN_WINDOW_LABEL: &str = "main"; + +static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2); + +// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths +// (which use the global OPENED_FILES queue), these reference files already in +// the shared IndexedDB store, so a "new window" opened from the My Files page +// loads the same file by reference. Keyed by the new window's label. +static PENDING_FILE_IDS: Mutex>>> = Mutex::new(None); + +fn next_window_label() -> String { + let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst); + format!("main-{}", id) +} + +fn queue_file_ids(label: &str, ids: Vec) { + let mut guard = PENDING_FILE_IDS.lock().unwrap(); + let map = guard.get_or_insert_with(HashMap::new); + map.entry(label.to_string()).or_default().extend(ids); +} + +// Shared window builder: every Stirling window must use identical WebView2 +// browser args so they can share one user-data folder (see the note below), +// so all spawn paths funnel through here. +fn build_window(app: &AppHandle, label: &str, url: &str) -> Result { + let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into())) + .title("Stirling-PDF") + .inner_size(1280.0, 800.0) + // Below this width the file manager collapses to its mobile layout, + // so keep new windows above the breakpoint. + .min_inner_size(1030.0, 600.0) + .resizable(true); + + // WebView2 (Windows only) requires every webview sharing a user-data folder + // to use identical additional_browser_args. wry's behaviour + // (webview2/mod.rs:294): when the user provides args it uses them as-is and + // does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the + // main window's actual args are EXACTLY what tauri.conf.json declares - + // nothing more. We mirror that string byte-for-byte so windows share one data + // dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and + // Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only. + #[cfg(target_os = "windows")] + let builder = + builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature"); + + builder.build().map_err(|e| e.to_string()) +} + +// Run `work` on the main thread and await its result. WebView2 on Windows +// refuses to create a webview off the main thread (HRESULT 0x8007139F), but +// Tauri command handlers run on a worker thread - so any window creation has to +// hop over first. Centralised here so every command does it the same way. +async fn run_on_main_thread_result(app: &AppHandle, work: F) -> Result +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let _ = tx.send(work()); + }) + .map_err(|e| e.to_string())?; + rx.await.map_err(|e| e.to_string()) +} + +// Spawn a new webview window in the same Tauri process. +// The backend stays single; only the frontend is duplicated. +// If `paths` is non-empty, they're enqueued under the new window's label, +// so the React app pops them on mount just like a fresh launch with a file. +fn spawn_new_window(app: &AppHandle, paths: Vec) -> Result { + let label = next_window_label(); + + for path in &paths { + add_opened_file(path.clone()); + } + + match build_window(app, &label, "/") { + Ok(window) => { + add_log(format!( + "🪟 Spawned new window '{}' with {} initial file(s)", + label, + paths.len() + )); + // The new window pops the shared queue on mount, so the files are + // already waiting for it. We target the emit at this window only + // (not a broadcast) so already-open windows don't race to pop them. + if !paths.is_empty() { + let _ = window.emit_to(label.as_str(), "files-changed", ()); + } + Ok(label) + } + Err(err) => { + add_log(format!( + "❌ Failed to spawn new window '{}': {}", + label, err + )); + Err(err) + } + } +} + +#[tauri::command] +pub async fn open_in_new_window(app: AppHandle, paths: Vec) -> Result { + let valid_paths: Vec = paths + .into_iter() + .filter(|p| { + let exists = std::path::Path::new(p).exists(); + if !exists { + add_log(format!( + "⚠️ Ignoring non-existent path for new window: {}", + p + )); + } + exists + }) + .collect(); + + let app_clone = app.clone(); + run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await? +} + +// Open already-stored files (by IndexedDB id) in a fresh window. Used by the +// "Open in new window" action on the My Files page. The ids are queued under +// the new window's label; the new window pops them on mount and loads them from +// the shared store into its workspace. +#[tauri::command] +pub async fn open_files_in_new_window( + app: AppHandle, + file_ids: Vec, +) -> Result { + let label = next_window_label(); + let app_clone = app.clone(); + run_on_main_thread_result(&app, move || { + build_window(&app_clone, &label, "/").map(|window| { + let count = file_ids.len(); + // Queue the ids only after the window is created, so a failed build + // doesn't leave orphaned ids under a label no window will consume. + queue_file_ids(&label, file_ids); + add_log(format!( + "🪟 Spawned new window '{}' for {} stored file(s)", + label, count + )); + // The new window also pops on mount; this emit is a nudge in case it + // mounted before the ids were queued. + let _ = window.emit_to(label.as_str(), "window-files-ready", ()); + label.clone() + }) + }) + .await? +} + +// Pop (return and clear) the stored-file ids queued for the calling window. +#[tauri::command] +pub async fn pop_window_file_ids(window: WebviewWindow) -> Result, String> { + let label = window.label().to_string(); + let ids = { + let mut guard = PENDING_FILE_IDS.lock().unwrap(); + guard + .as_mut() + .and_then(|map| map.remove(&label)) + .unwrap_or_default() + }; + if !ids.is_empty() { + add_log(format!( + "📂 Returning {} stored file id(s) for window '{}'", + ids.len(), + label + )); + } + Ok(ids) +} + +// Pick the best existing window to receive an opened file: the focused one, +// else the main window, else any open window. Returns None only if there are +// no windows at all. Used so file-opens (file association, "open with") land in +// the window the user is actually looking at, and still work if the original +// "main" window has been closed. +pub fn target_window_label(app: &AppHandle) -> Option { + let windows = app.webview_windows(); + if let Some((label, _)) = windows + .iter() + .find(|(_, w)| w.is_focused().unwrap_or(false)) + { + return Some(label.clone()); + } + if windows.contains_key(MAIN_WINDOW_LABEL) { + return Some(MAIN_WINDOW_LABEL.to_string()); + } + windows.keys().next().cloned() +} + +// Add files to the shared queue and notify a specific window to consume them. +// Used by drag-drop, the macOS open event, and the second-instance callback +// (when --new-window is NOT set). The emit is targeted at `label` so only that +// window pops the queue - other windows ignore it and keep their own files. +pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec) { + for path in &paths { + add_opened_file(path.clone()); + } + if let Some(window) = app.get_webview_window(label) { + let _ = app.emit_to(label, "files-changed", ()); + let _ = window.set_focus(); + let _ = window.unminimize(); + } else { + // Target window is gone; let any window pick the files up. + let _ = app.emit("files-changed", ()); + } +} diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index f06633c22..aa73dc8a8 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -11,12 +11,16 @@ use commands::{ clear_opened_files, clear_refresh_token, clear_user_info, + forward_files_to_window, is_default_pdf_handler, get_auth_token, get_backend_port, get_connection_config, get_opened_files, + open_files_in_new_window, + open_in_new_window, pop_opened_files, + pop_window_file_ids, get_refresh_token, get_user_info, is_first_launch, @@ -31,6 +35,8 @@ use commands::{ print_pdf_file_native, start_backend, start_oauth_login, + target_window_label, + MAIN_WINDOW_LABEL, }; use commands::connection::apply_provisioning_if_present; use state::connection_state::AppConnectionState; @@ -47,6 +53,16 @@ fn dispatch_deep_link(app: &AppHandle, url: &str) { } } +// Extract existing file paths from CLI args (skips the executable name). +fn parse_launch_files(args: &[String]) -> Vec { + args + .iter() + .skip(1) + .filter(|arg| std::path::Path::new(arg).exists()) + .cloned() + .collect() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -66,38 +82,33 @@ pub fn run() { .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - // This callback runs when a second instance tries to start + // Runs in the existing instance when a second launch is attempted + // (e.g. "open with" / double-click while the app is running). add_log(format!("📂 Second instance detected with args: {:?}", args)); - // Scan args for PDF files (skip first arg which is the executable) - for arg in args.iter().skip(1) { - if std::path::Path::new(arg).exists() { - add_log(format!("📂 Forwarding file to existing instance: {}", arg)); + let files = parse_launch_files(&args); + // Route to the window the user is in (focused -> main -> any) so opens + // consolidate into one window instead of spawning a new one. + let label = target_window_label(app).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string()); - // Store file for later retrieval (in case frontend isn't ready yet) - add_opened_file(arg.clone()); - - // Bring the existing window to front - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_focus(); - let _ = window.unminimize(); - } - } + if !files.is_empty() { + add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label)); + forward_files_to_window(app, &label, files); + } else if let Some(window) = app.get_webview_window(&label) { + // No files: just bring the app to the front. + let _ = window.set_focus(); + let _ = window.unminimize(); } - - // Emit a generic notification that files were added (frontend will re-read storage) - let _ = app.emit("files-changed", ()); })) .setup(|app| { add_log("🚀 Tauri app setup started".to_string()); - // Process command line arguments on first launch + // Files passed on the command line at first launch load into the main + // window once the frontend mounts. let args: Vec = std::env::args().collect(); - for arg in args.iter().skip(1) { - if std::path::Path::new(arg).exists() { - add_log(format!("📂 Initial file from command line: {}", arg)); - add_opened_file(arg.clone()); - } + for path in parse_launch_files(&args) { + add_log(format!("📂 Initial file from command line: {}", path)); + add_opened_file(path); } { @@ -147,6 +158,9 @@ pub fn run() { get_opened_files, pop_opened_files, clear_opened_files, + open_in_new_window, + open_files_in_new_window, + pop_window_file_ids, get_tauri_logs, get_connection_config, set_connection_mode, @@ -183,26 +197,19 @@ pub fn run() { // Don't cleanup here - let JavaScript handler prevent close if needed // Backend cleanup happens in ExitRequested when window actually closes } - RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => { + RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => { use tauri::DragDropEvent; - match drag_drop_event { - DragDropEvent::Drop { paths, .. } => { - add_log(format!("📂 Files dropped: {:?}", paths)); - let mut added_files = false; + if let DragDropEvent::Drop { paths, .. } = drag_drop_event { + add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths)); + let file_paths: Vec = paths + .iter() + .filter_map(|p| p.to_str().map(|s| s.to_string())) + .collect(); - for path in paths { - if let Some(path_str) = path.to_str() { - add_log(format!("📂 Processing dropped file: {}", path_str)); - add_opened_file(path_str.to_string()); - added_files = true; - } - } - - if added_files { - let _ = app_handle.emit("files-changed", ()); - } + // Route to the window the file was actually dropped on. + if !file_paths.is_empty() { + forward_files_to_window(app_handle, &label, file_paths); } - _ => {} } } #[cfg(target_os = "macos")] @@ -210,30 +217,29 @@ pub fn run() { use urlencoding::decode; add_log(format!("📂 Tauri file opened event: {:?}", urls)); - let mut added_files = false; - - for url in urls { - let url_str = url.as_str(); - if url_str.starts_with("file://") { - let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str); - + let file_paths: Vec = urls + .iter() + .filter_map(|url| { + let url_str = url.as_str(); + if !url_str.starts_with("file://") { + return None; + } + let encoded = url_str.strip_prefix("file://").unwrap_or(url_str); // Decode URL-encoded characters (%20 -> space, etc.) - let file_path = match decode(encoded_path) { - Ok(decoded) => decoded.into_owned(), + match decode(encoded) { + Ok(decoded) => Some(decoded.into_owned()), Err(e) => { - add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e)); - encoded_path.to_string() // Fallback to encoded path + add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e)); + Some(encoded.to_string()) } - }; + } + }) + .collect(); - add_log(format!("📂 Processing opened file: {}", file_path)); - add_opened_file(file_path); - added_files = true; - } - } - // Emit a generic notification that files were added (frontend will re-read storage) - if added_files { - let _ = app_handle.emit("files-changed", ()); + if !file_paths.is_empty() { + // Route to the window the user is in (focused -> main -> any). + let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string()); + forward_files_to_window(app_handle, &label, file_paths); } } _ => { diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index ab8e4cacd..5de849241 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -32,6 +32,7 @@ import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; +import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; export type FilesPageViewMode = "grid" | "list"; @@ -758,6 +759,7 @@ function FileCard({ > {t("filesPage.quickView", "Quick view")} + } onClick={(e) => { @@ -1324,6 +1326,7 @@ function FileRow({ > {t("filesPage.quickView", "Quick view")} + } onClick={(e) => { diff --git a/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.tsx b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.tsx new file mode 100644 index 000000000..f4fa2dada --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.tsx @@ -0,0 +1,35 @@ +import { Menu } from "@mantine/core"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { useTranslation } from "react-i18next"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { useOpenInNewWindow } from "@app/extensions/openInNewWindow"; + +interface OpenInNewWindowMenuItemProps { + file: StirlingFileStub; +} + +/** + * Kebab menu item that opens a stored file in a separate window. Desktop-only: + * the underlying extension is a no-op on web, so this renders nothing there + * (and for any file that can't be opened in a new window). + */ +export function OpenInNewWindowMenuItem({ + file, +}: OpenInNewWindowMenuItemProps) { + const { t } = useTranslation(); + const { canOpenInNewWindow, openInNewWindow } = useOpenInNewWindow(); + + if (!canOpenInNewWindow(file)) return null; + + return ( + } + onClick={(e) => { + e.stopPropagation(); + openInNewWindow(file); + }} + > + {t("openInNewWindow", "Open in new window")} + + ); +} diff --git a/frontend/editor/src/core/extensions/openInNewWindow.ts b/frontend/editor/src/core/extensions/openInNewWindow.ts new file mode 100644 index 000000000..d3ab332ae --- /dev/null +++ b/frontend/editor/src/core/extensions/openInNewWindow.ts @@ -0,0 +1,20 @@ +import { StirlingFileStub } from "@app/types/fileContext"; + +export interface OpenInNewWindowApi { + /** Whether this file can be opened in a separate window. */ + canOpenInNewWindow: (file: StirlingFileStub) => boolean; + /** Open the file in a separate window. */ + openInNewWindow: (file: StirlingFileStub) => void; +} + +/** + * Core (web) build: multiple windows aren't a thing in the browser, so this is + * a no-op. The desktop build overrides this file via path resolution to spawn + * a real Tauri window. + */ +export function useOpenInNewWindow(): OpenInNewWindowApi { + return { + canOpenInNewWindow: () => false, + openInNewWindow: () => {}, + }; +} diff --git a/frontend/editor/src/desktop/components/SaveShortcutListener.tsx b/frontend/editor/src/desktop/components/SaveShortcutListener.tsx index 19540857c..e4204e6a2 100644 --- a/frontend/editor/src/desktop/components/SaveShortcutListener.tsx +++ b/frontend/editor/src/desktop/components/SaveShortcutListener.tsx @@ -1,14 +1,20 @@ import { useSaveShortcut } from "@app/hooks/useSaveShortcut"; import { useExitWarning } from "@app/hooks/useExitWarning"; +import { useNewWindowShortcut } from "@app/hooks/useNewWindowShortcut"; +import { useOpenWindowFiles } from "@app/hooks/useOpenWindowFiles"; /** * Desktop-only component that sets up keyboard shortcuts and exit warnings * - Ctrl/Cmd+S to save selected files + * - Ctrl/Cmd+N to open an empty new window + * - Loads files queued for this window ("Open in new window" from My Files) * - Warning on app exit if unsaved files * Renders nothing, just sets up the listeners */ export function SaveShortcutListener() { useSaveShortcut(); + useNewWindowShortcut(); + useOpenWindowFiles(); useExitWarning(); return null; } diff --git a/frontend/editor/src/desktop/extensions/openInNewWindow.ts b/frontend/editor/src/desktop/extensions/openInNewWindow.ts new file mode 100644 index 000000000..61ee2c2b6 --- /dev/null +++ b/frontend/editor/src/desktop/extensions/openInNewWindow.ts @@ -0,0 +1,24 @@ +import { StirlingFileStub } from "@app/types/fileContext"; +import { fileOpenService } from "@app/services/fileOpenService"; +import { useMultiWindowSupported } from "@app/hooks/useMultiWindowSupported"; +import type { OpenInNewWindowApi } from "@core/extensions/openInNewWindow"; + +/** + * Desktop build: open a stored file in a new Tauri window. The new window loads + * the file by id from the shared IndexedDB store (see useOpenWindowFiles), which + * only works where windows share one persistent web store - so this is gated on + * useMultiWindowSupported (disabled on Linux). + */ +export function useOpenInNewWindow(): OpenInNewWindowApi { + const supported = useMultiWindowSupported(); + + return { + canOpenInNewWindow: (file: StirlingFileStub) => + supported && Boolean(file.id), + openInNewWindow: (file: StirlingFileStub) => { + if (file.id) { + fileOpenService.openFilesInNewWindow([file.id]); + } + }, + }; +} diff --git a/frontend/editor/src/desktop/hooks/useMultiWindowSupported.ts b/frontend/editor/src/desktop/hooks/useMultiWindowSupported.ts new file mode 100644 index 000000000..658ab35d8 --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useMultiWindowSupported.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { getDesktopOs, DesktopOs } from "@app/services/platformService"; + +/** + * Whether multiple windows are supported on the current OS. + * + * Multi-window relies on every window sharing one persistent web store, so a + * new window sees the same login / files / settings: + * - Windows: shared WebView2 user-data dir ✅ + * - macOS: shared WKWebsiteDataStore.default() ✅ + * - Linux: WebKitGTK gives each window its own store and Tauri exposes no way + * to share it, so a new window would start blank. Multi-window is disabled + * there. + * + * Uses an allowlist of known-good platforms, so anything unresolved (null) or + * unknown (detection failed) stays disabled rather than risking a blank window. + */ +export function useMultiWindowSupported(): boolean { + const [os, setOs] = useState(null); + + useEffect(() => { + getDesktopOs() + .then(setOs) + .catch(() => setOs(DesktopOs.Unknown)); + }, []); + + return os === DesktopOs.Windows || os === DesktopOs.Mac; +} diff --git a/frontend/editor/src/desktop/hooks/useNewWindowShortcut.ts b/frontend/editor/src/desktop/hooks/useNewWindowShortcut.ts new file mode 100644 index 000000000..b6f9da265 --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useNewWindowShortcut.ts @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { fileOpenService } from "@app/services/fileOpenService"; +import { useMultiWindowSupported } from "@app/hooks/useMultiWindowSupported"; + +/** + * Desktop-only keyboard shortcut: Ctrl+N (Cmd+N on macOS) opens an empty new + * window. The new window runs in the same Tauri process and shares the bundled + * backend. Disabled on platforms where multi-window isn't supported (Linux) so + * the new window doesn't start blank - see useMultiWindowSupported. + * + * Safe to bind plain Ctrl/Cmd+N here: the listener is only registered inside the + * Tauri desktop app (useMultiWindowSupported is false on web), so the browser's + * native Ctrl+N is never intercepted in the web build. + */ +export function useNewWindowShortcut() { + const supported = useMultiWindowSupported(); + + useEffect(() => { + if (!supported) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) return; + const modifier = event.ctrlKey || event.metaKey; + if ( + modifier && + !event.shiftKey && + !event.altKey && + event.key.toLowerCase() === "n" + ) { + event.preventDefault(); + fileOpenService.openInNewWindow([]); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [supported]); +} diff --git a/frontend/editor/src/desktop/hooks/useOpenWindowFiles.ts b/frontend/editor/src/desktop/hooks/useOpenWindowFiles.ts new file mode 100644 index 000000000..14169b10c --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useOpenWindowFiles.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from "react"; +import { fileOpenService } from "@app/services/fileOpenService"; +import { fileStorage } from "@app/services/fileStorage"; +import { materializeServerStubs } from "@app/services/fileSyncService"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { FileId } from "@app/types/file"; + +/** + * Desktop-only: when a window is spawned via "Open in new window" from the My + * Files page, the file ids are queued in the Rust backend under this window's + * label. On mount we pop them and load the matching stored files from the + * shared IndexedDB store into this window's workspace. + * + * Mirrors the files-page "add to workspace" path (FileManagerView): stored + * stubs may be server-only (no local bytes), so they go through + * materializeServerStubs before being added. + */ +export function useOpenWindowFiles() { + const { actions } = useFileActions(); + const consumedRef = useRef(false); + + useEffect(() => { + if (consumedRef.current) return; + + const loadPendingFiles = async () => { + const fileIds = await fileOpenService.popWindowFileIds(); + if (fileIds.length === 0) return; + consumedRef.current = true; + + const stubs = ( + await Promise.all( + fileIds.map((id) => fileStorage.getStirlingFileStub(id as FileId)), + ) + ).filter((s): s is StirlingFileStub => Boolean(s)); + + if (stubs.length === 0) { + console.warn( + "[Desktop] open-in-new-window: no stored files found for ids", + fileIds, + ); + return; + } + + // Download + ingest any server-only stubs first; local stubs pass through. + const materialized = await materializeServerStubs(stubs, { + addFiles: actions.addFilesWithOptions, + updateStub: actions.updateStirlingFileStub, + }); + + if (materialized.length > 0) { + await actions.addStirlingFileStubs(materialized, { selectFiles: true }); + console.log( + `[Desktop] Opened ${materialized.length} stored file(s) in new window`, + ); + } + }; + + loadPendingFiles().catch((error) => { + console.error("[Desktop] Failed to load files for new window:", error); + }); + }, [actions]); +} diff --git a/frontend/editor/src/desktop/hooks/useOpenedFile.ts b/frontend/editor/src/desktop/hooks/useOpenedFile.ts index 0ce115384..1e2ddc7e6 100644 --- a/frontend/editor/src/desktop/hooks/useOpenedFile.ts +++ b/frontend/editor/src/desktop/hooks/useOpenedFile.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { fileOpenService } from "@app/services/fileOpenService"; -import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; export function useOpenedFile() { const [openedFilePaths, setOpenedFilePaths] = useState([]); @@ -45,16 +45,22 @@ export function useOpenedFile() { // Read files on mount readFilesFromStorage(); - // Listen for files-changed events (when new files are added to storage) + // Listen for files-changed events scoped to THIS window only. + // Rust emits via window.emit(...) / app.emit_to(label, ...) so each + // Tauri window sees only its own queue updates. let unlisten: (() => void) | undefined; - listen("files-changed", async () => { - console.log("📂 files-changed event received, re-reading storage..."); - await readFilesFromStorage(); - }).then((unlistenFn) => { - unlisten = unlistenFn; - }); + const currentWindow = getCurrentWebviewWindow(); + currentWindow + .listen("files-changed", async () => { + console.log( + `📂 files-changed event received on window '${currentWindow.label}', re-reading storage...`, + ); + await readFilesFromStorage(); + }) + .then((unlistenFn) => { + unlisten = unlistenFn; + }); - // Cleanup function return () => { if (unlisten) unlisten(); }; diff --git a/frontend/editor/src/desktop/services/fileOpenService.ts b/frontend/editor/src/desktop/services/fileOpenService.ts index f3c7c7066..47df31439 100644 --- a/frontend/editor/src/desktop/services/fileOpenService.ts +++ b/frontend/editor/src/desktop/services/fileOpenService.ts @@ -7,6 +7,11 @@ export interface FileOpenService { ): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>; clearOpenedFiles(): Promise; onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function + openInNewWindow(paths?: string[]): Promise; + /** Open already-stored files (by IndexedDB id) in a new window. */ + openFilesInNewWindow(fileIds: string[]): Promise; + /** Pop the stored-file ids queued for the current window (consumed on mount). */ + popWindowFileIds(): Promise; } class TauriFileOpenService implements FileOpenService { @@ -54,6 +59,35 @@ class TauriFileOpenService implements FileOpenService { } } + async openInNewWindow(paths: string[] = []): Promise { + try { + const label = await invoke("open_in_new_window", { paths }); + console.log(`🪟 Spawned new window: ${label}`); + } catch (error) { + console.error("❌ Failed to open in new window:", error); + } + } + + async openFilesInNewWindow(fileIds: string[]): Promise { + try { + const label = await invoke("open_files_in_new_window", { + fileIds, + }); + console.log(`🪟 Spawned new window ${label} for stored files:`, fileIds); + } catch (error) { + console.error("❌ Failed to open files in new window:", error); + } + } + + async popWindowFileIds(): Promise { + try { + return await invoke("pop_window_file_ids"); + } catch (error) { + console.error("❌ Failed to pop window file ids:", error); + return []; + } + } + onFileOpened(callback: (filePath: string) => void): () => void { let cleanup: (() => void) | null = null; let isCleanedUp = false; @@ -140,6 +174,18 @@ class WebFileOpenService implements FileOpenService { // No-op cleanup for web mode }; } + + async openInNewWindow(_paths: string[] = []): Promise { + // Multi-window isn't a thing in browser mode. + } + + async openFilesInNewWindow(_fileIds: string[]): Promise { + // Multi-window isn't a thing in browser mode. + } + + async popWindowFileIds(): Promise { + return []; + } } // Export the appropriate service based on environment