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:
@@ -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")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
@@ -1324,6 +1326,7 @@ function FileRow({
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
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 { 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