mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Add desktop multi-window support (#6463)
This commit is contained in:
@@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo"
|
|||||||
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||||
noValidFiles = "No valid files to process"
|
noValidFiles = "No valid files to process"
|
||||||
oops = "Oops!"
|
oops = "Oops!"
|
||||||
|
openInNewWindow = "Open in new window"
|
||||||
openInViewer = "Open in Viewer"
|
openInViewer = "Open in Viewer"
|
||||||
operationCancelled = "Operation cancelled"
|
operationCancelled = "Operation cancelled"
|
||||||
page = "Page"
|
page = "Page"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "enables the default permissions",
|
"description": "enables the default permissions",
|
||||||
"windows": ["main"],
|
"windows": ["main", "main-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-destroy",
|
"core:window:allow-destroy",
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ pub mod auth;
|
|||||||
pub mod default_app;
|
pub mod default_app;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod print;
|
pub mod print;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
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 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::{
|
pub use connection::{
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
|
|||||||
@@ -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<Option<HashMap<String, Vec<String>>>> = 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<String>) {
|
||||||
|
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<WebviewWindow, String> {
|
||||||
|
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<F, R>(app: &AppHandle, work: F) -> Result<R, String>
|
||||||
|
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<String>) -> Result<String, String> {
|
||||||
|
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<String>) -> Result<String, String> {
|
||||||
|
let valid_paths: Vec<String> = 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<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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<Vec<String>, 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<String> {
|
||||||
|
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<String>) {
|
||||||
|
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", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,16 @@ use commands::{
|
|||||||
clear_opened_files,
|
clear_opened_files,
|
||||||
clear_refresh_token,
|
clear_refresh_token,
|
||||||
clear_user_info,
|
clear_user_info,
|
||||||
|
forward_files_to_window,
|
||||||
is_default_pdf_handler,
|
is_default_pdf_handler,
|
||||||
get_auth_token,
|
get_auth_token,
|
||||||
get_backend_port,
|
get_backend_port,
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
get_opened_files,
|
get_opened_files,
|
||||||
|
open_files_in_new_window,
|
||||||
|
open_in_new_window,
|
||||||
pop_opened_files,
|
pop_opened_files,
|
||||||
|
pop_window_file_ids,
|
||||||
get_refresh_token,
|
get_refresh_token,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
@@ -31,6 +35,8 @@ use commands::{
|
|||||||
print_pdf_file_native,
|
print_pdf_file_native,
|
||||||
start_backend,
|
start_backend,
|
||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
|
target_window_label,
|
||||||
|
MAIN_WINDOW_LABEL,
|
||||||
};
|
};
|
||||||
use commands::connection::apply_provisioning_if_present;
|
use commands::connection::apply_provisioning_if_present;
|
||||||
use state::connection_state::AppConnectionState;
|
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<String> {
|
||||||
|
args
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.filter(|arg| std::path::Path::new(arg).exists())
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -66,38 +82,33 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.manage(AppConnectionState::default())
|
.manage(AppConnectionState::default())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.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));
|
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||||
|
|
||||||
// Scan args for PDF files (skip first arg which is the executable)
|
let files = parse_launch_files(&args);
|
||||||
for arg in args.iter().skip(1) {
|
// Route to the window the user is in (focused -> main -> any) so opens
|
||||||
if std::path::Path::new(arg).exists() {
|
// consolidate into one window instead of spawning a new one.
|
||||||
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
|
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)
|
if !files.is_empty() {
|
||||||
add_opened_file(arg.clone());
|
add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label));
|
||||||
|
forward_files_to_window(app, &label, files);
|
||||||
// Bring the existing window to front
|
} else if let Some(window) = app.get_webview_window(&label) {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
// No files: just bring the app to the front.
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
|
||||||
let _ = app.emit("files-changed", ());
|
|
||||||
}))
|
}))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
add_log("🚀 Tauri app setup started".to_string());
|
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<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
for arg in args.iter().skip(1) {
|
for path in parse_launch_files(&args) {
|
||||||
if std::path::Path::new(arg).exists() {
|
add_log(format!("📂 Initial file from command line: {}", path));
|
||||||
add_log(format!("📂 Initial file from command line: {}", arg));
|
add_opened_file(path);
|
||||||
add_opened_file(arg.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -147,6 +158,9 @@ pub fn run() {
|
|||||||
get_opened_files,
|
get_opened_files,
|
||||||
pop_opened_files,
|
pop_opened_files,
|
||||||
clear_opened_files,
|
clear_opened_files,
|
||||||
|
open_in_new_window,
|
||||||
|
open_files_in_new_window,
|
||||||
|
pop_window_file_ids,
|
||||||
get_tauri_logs,
|
get_tauri_logs,
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
set_connection_mode,
|
set_connection_mode,
|
||||||
@@ -183,57 +197,49 @@ pub fn run() {
|
|||||||
// Don't cleanup here - let JavaScript handler prevent close if needed
|
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||||
// Backend cleanup happens in ExitRequested when window actually closes
|
// 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;
|
use tauri::DragDropEvent;
|
||||||
match drag_drop_event {
|
if let DragDropEvent::Drop { paths, .. } = drag_drop_event {
|
||||||
DragDropEvent::Drop { paths, .. } => {
|
add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths));
|
||||||
add_log(format!("📂 Files dropped: {:?}", paths));
|
let file_paths: Vec<String> = paths
|
||||||
let mut added_files = false;
|
.iter()
|
||||||
|
.filter_map(|p| p.to_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
for path in paths {
|
// Route to the window the file was actually dropped on.
|
||||||
if let Some(path_str) = path.to_str() {
|
if !file_paths.is_empty() {
|
||||||
add_log(format!("📂 Processing dropped file: {}", path_str));
|
forward_files_to_window(app_handle, &label, file_paths);
|
||||||
add_opened_file(path_str.to_string());
|
|
||||||
added_files = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if added_files {
|
|
||||||
let _ = app_handle.emit("files-changed", ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
RunEvent::Opened { urls } => {
|
RunEvent::Opened { urls } => {
|
||||||
use urlencoding::decode;
|
use urlencoding::decode;
|
||||||
|
|
||||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||||
let mut added_files = false;
|
let file_paths: Vec<String> = urls
|
||||||
|
.iter()
|
||||||
for url in urls {
|
.filter_map(|url| {
|
||||||
let url_str = url.as_str();
|
let url_str = url.as_str();
|
||||||
if url_str.starts_with("file://") {
|
if !url_str.starts_with("file://") {
|
||||||
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
|
return None;
|
||||||
|
}
|
||||||
|
let encoded = url_str.strip_prefix("file://").unwrap_or(url_str);
|
||||||
// Decode URL-encoded characters (%20 -> space, etc.)
|
// Decode URL-encoded characters (%20 -> space, etc.)
|
||||||
let file_path = match decode(encoded_path) {
|
match decode(encoded) {
|
||||||
Ok(decoded) => decoded.into_owned(),
|
Ok(decoded) => Some(decoded.into_owned()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
|
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e));
|
||||||
encoded_path.to_string() // Fallback to encoded path
|
Some(encoded.to_string())
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
add_log(format!("📂 Processing opened file: {}", file_path));
|
if !file_paths.is_empty() {
|
||||||
add_opened_file(file_path);
|
// Route to the window the user is in (focused -> main -> any).
|
||||||
added_files = true;
|
let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
|
||||||
}
|
forward_files_to_window(app_handle, &label, file_paths);
|
||||||
}
|
|
||||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
|
||||||
if added_files {
|
|
||||||
let _ = app_handle.emit("files-changed", ());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
|||||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
||||||
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||||
|
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
|
||||||
|
|
||||||
export type FilesPageViewMode = "grid" | "list";
|
export type FilesPageViewMode = "grid" | "list";
|
||||||
|
|
||||||
@@ -758,6 +759,7 @@ function FileCard({
|
|||||||
>
|
>
|
||||||
{t("filesPage.quickView", "Quick view")}
|
{t("filesPage.quickView", "Quick view")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<OpenInNewWindowMenuItem file={file} />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1324,6 +1326,7 @@ function FileRow({
|
|||||||
>
|
>
|
||||||
{t("filesPage.quickView", "Quick view")}
|
{t("filesPage.quickView", "Quick view")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<OpenInNewWindowMenuItem file={file} />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInNewWindow(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("openInNewWindow", "Open in new window")}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
|
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
|
||||||
import { useExitWarning } from "@app/hooks/useExitWarning";
|
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
|
* Desktop-only component that sets up keyboard shortcuts and exit warnings
|
||||||
* - Ctrl/Cmd+S to save selected files
|
* - 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
|
* - Warning on app exit if unsaved files
|
||||||
* Renders nothing, just sets up the listeners
|
* Renders nothing, just sets up the listeners
|
||||||
*/
|
*/
|
||||||
export function SaveShortcutListener() {
|
export function SaveShortcutListener() {
|
||||||
useSaveShortcut();
|
useSaveShortcut();
|
||||||
|
useNewWindowShortcut();
|
||||||
|
useOpenWindowFiles();
|
||||||
useExitWarning();
|
useExitWarning();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<DesktopOs | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDesktopOs()
|
||||||
|
.then(setOs)
|
||||||
|
.catch(() => setOs(DesktopOs.Unknown));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return os === DesktopOs.Windows || os === DesktopOs.Mac;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { fileOpenService } from "@app/services/fileOpenService";
|
import { fileOpenService } from "@app/services/fileOpenService";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
|
||||||
export function useOpenedFile() {
|
export function useOpenedFile() {
|
||||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||||
@@ -45,16 +45,22 @@ export function useOpenedFile() {
|
|||||||
// Read files on mount
|
// Read files on mount
|
||||||
readFilesFromStorage();
|
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;
|
let unlisten: (() => void) | undefined;
|
||||||
listen("files-changed", async () => {
|
const currentWindow = getCurrentWebviewWindow();
|
||||||
console.log("📂 files-changed event received, re-reading storage...");
|
currentWindow
|
||||||
|
.listen("files-changed", async () => {
|
||||||
|
console.log(
|
||||||
|
`📂 files-changed event received on window '${currentWindow.label}', re-reading storage...`,
|
||||||
|
);
|
||||||
await readFilesFromStorage();
|
await readFilesFromStorage();
|
||||||
}).then((unlistenFn) => {
|
})
|
||||||
|
.then((unlistenFn) => {
|
||||||
unlisten = unlistenFn;
|
unlisten = unlistenFn;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) unlisten();
|
if (unlisten) unlisten();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface FileOpenService {
|
|||||||
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
|
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
|
||||||
clearOpenedFiles(): Promise<void>;
|
clearOpenedFiles(): Promise<void>;
|
||||||
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
|
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
|
||||||
|
openInNewWindow(paths?: string[]): Promise<void>;
|
||||||
|
/** Open already-stored files (by IndexedDB id) in a new window. */
|
||||||
|
openFilesInNewWindow(fileIds: string[]): Promise<void>;
|
||||||
|
/** Pop the stored-file ids queued for the current window (consumed on mount). */
|
||||||
|
popWindowFileIds(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TauriFileOpenService implements FileOpenService {
|
class TauriFileOpenService implements FileOpenService {
|
||||||
@@ -54,6 +59,35 @@ class TauriFileOpenService implements FileOpenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openInNewWindow(paths: string[] = []): Promise<void> {
|
||||||
|
try {
|
||||||
|
const label = await invoke<string>("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<void> {
|
||||||
|
try {
|
||||||
|
const label = await invoke<string>("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<string[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>("pop_window_file_ids");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to pop window file ids:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onFileOpened(callback: (filePath: string) => void): () => void {
|
onFileOpened(callback: (filePath: string) => void): () => void {
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
let isCleanedUp = false;
|
let isCleanedUp = false;
|
||||||
@@ -140,6 +174,18 @@ class WebFileOpenService implements FileOpenService {
|
|||||||
// No-op cleanup for web mode
|
// No-op cleanup for web mode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openInNewWindow(_paths: string[] = []): Promise<void> {
|
||||||
|
// Multi-window isn't a thing in browser mode.
|
||||||
|
}
|
||||||
|
|
||||||
|
async openFilesInNewWindow(_fileIds: string[]): Promise<void> {
|
||||||
|
// Multi-window isn't a thing in browser mode.
|
||||||
|
}
|
||||||
|
|
||||||
|
async popWindowFileIds(): Promise<string[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the appropriate service based on environment
|
// Export the appropriate service based on environment
|
||||||
|
|||||||
Reference in New Issue
Block a user