Add desktop multi-window support (#6463)

This commit is contained in:
Anthony Stirling
2026-05-29 19:35:54 +01:00
committed by GitHub
parent 28b81828b5
commit 2b0905887b
15 changed files with 571 additions and 70 deletions
@@ -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