mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add desktop multi-window support (#6463)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { 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<string[]>([]);
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface FileOpenService {
|
||||
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
|
||||
clearOpenedFiles(): Promise<void>;
|
||||
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 {
|
||||
@@ -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 {
|
||||
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<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
|
||||
|
||||
Reference in New Issue
Block a user