fix(child-windows): reduce startup flicker with window preloading
This commit is contained in:
1 parent
f5e799ce85
commit
56adfd528a
9 files changed
+275
-36
No files matched your search
+18
-1
@@ -6,6 +6,23 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NyaTerm</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background-color: var(--df-bootstrap-bg, #0d1117);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
try {
|
||||
var bootstrapBg = localStorage.getItem("df-theme-background");
|
||||
if (bootstrapBg) {
|
||||
document.documentElement.style.setProperty("--df-bootstrap-bg", bootstrapBg);
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -13,4 +30,4 @@
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -24,6 +24,7 @@ pub struct ChildWindowOptions {
|
||||
height: Option<f64>,
|
||||
resizable: Option<bool>,
|
||||
always_on_top: Option<bool>,
|
||||
background_color: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -90,6 +91,14 @@ pub async fn open_child_window(
|
||||
.resizable(options.resizable.unwrap_or(true))
|
||||
.always_on_top(options.always_on_top.unwrap_or(false));
|
||||
|
||||
if let Some(color) = options
|
||||
.background_color
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<tauri::webview::Color>().ok())
|
||||
{
|
||||
builder = builder.background_color(color);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder
|
||||
|
||||
+28
@@ -24,6 +24,7 @@ import {
|
||||
NON_PANEL_IDS,
|
||||
type TrayAction,
|
||||
} from "./lib/appWorkspace";
|
||||
import { preloadModalChildWindowPages } from "./lib/childWindowPreload";
|
||||
import { getErrorMessage, shouldPromptConnectionEditOnFailure } from "./lib/errors";
|
||||
import { invoke } from "./lib/invoke";
|
||||
import { logger } from "./lib/logger";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
openNewSession,
|
||||
openNewSessionWithTarget,
|
||||
openSettings,
|
||||
prewarmSettingsWindow,
|
||||
} from "./lib/windowManager";
|
||||
import {
|
||||
collectSessionPanes,
|
||||
@@ -138,6 +140,32 @@ function App() {
|
||||
});
|
||||
}, [settingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
|
||||
const scheduleIdle =
|
||||
window.requestIdleCallback ??
|
||||
((callback: IdleRequestCallback) =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 0,
|
||||
}),
|
||||
800,
|
||||
));
|
||||
|
||||
const cancelIdle = window.cancelIdleCallback ?? window.clearTimeout;
|
||||
const idleId = scheduleIdle(() => {
|
||||
preloadModalChildWindowPages();
|
||||
void prewarmSettingsWindow();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelIdle(idleId);
|
||||
};
|
||||
}, [settingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSettings.ui.language && appSettings.ui.language !== i18n.language) {
|
||||
i18n.changeLanguage(appSettings.ui.language);
|
||||
|
||||
+57
-15
@@ -1,6 +1,9 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { lazy, type ReactNode, Suspense, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApp } from "./context/AppContext";
|
||||
import { loadChildWindowPage } from "./lib/childWindowPreload";
|
||||
import { isModalChildLabel, prepareForModalChildClose } from "./lib/windowManager";
|
||||
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
@@ -8,24 +11,63 @@ const NewSessionPage = lazy(() => import("./pages/NewSessionPage"));
|
||||
const QuickCommandPage = lazy(() => import("./pages/QuickCommandPage"));
|
||||
const AutoUploadPage = lazy(() => import("./pages/FileUploadPage"));
|
||||
|
||||
const PAGES: Record<string, React.ComponentType<any>> = {
|
||||
const PAGES: Record<string, React.ComponentType> = {
|
||||
settings: SettingsPage,
|
||||
"new-session": NewSessionPage,
|
||||
"quick-command": QuickCommandPage,
|
||||
"auto-upload": AutoUploadPage,
|
||||
};
|
||||
|
||||
function ReadyChildWindow({ children }: { children: ReactNode }) {
|
||||
const revealedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isPrewarm = params.get("prewarm") === "1";
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
if (revealedRef.current) return;
|
||||
revealedRef.current = true;
|
||||
|
||||
if (isPrewarm) {
|
||||
void emit("child-window-ready", { label: currentWindow.label });
|
||||
return;
|
||||
}
|
||||
|
||||
void currentWindow
|
||||
.show()
|
||||
.then(() => currentWindow.setFocus())
|
||||
.then(() => {
|
||||
void emit("child-window-ready", { label: currentWindow.label });
|
||||
})
|
||||
.then(() => {
|
||||
void emit("child-window-opened", { label: currentWindow.label });
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function ChildWindowRouter({ windowType }: { windowType: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { settingsLoaded } = useApp();
|
||||
const Page = PAGES[windowType];
|
||||
|
||||
useEffect(() => {
|
||||
void loadChildWindowPage(windowType);
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
let unlistenCloseRequested: (() => void) | undefined;
|
||||
let programmaticClose = false;
|
||||
|
||||
currentWindow.show().catch(() => {});
|
||||
|
||||
currentWindow
|
||||
.onCloseRequested(async (event) => {
|
||||
if (programmaticClose || !isModalChildLabel(currentWindow.label)) return;
|
||||
@@ -49,21 +91,21 @@ export default function ChildWindowRouter({ windowType }: { windowType: string }
|
||||
|
||||
if (!Page) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center text-muted-foreground">
|
||||
{t("common.unknownWindowType")}: {windowType}
|
||||
</div>
|
||||
<ReadyChildWindow>
|
||||
<div className="h-screen flex items-center justify-center text-muted-foreground">
|
||||
{t("common.unknownWindowType")}: {windowType}
|
||||
</div>
|
||||
</ReadyChildWindow>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settingsLoaded) return null;
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-screen flex items-center justify-center text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Page />
|
||||
<Suspense fallback={null}>
|
||||
<ReadyChildWindow>
|
||||
<Page />
|
||||
</ReadyChildWindow>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -382,15 +382,10 @@ export default function AppLayout({
|
||||
|
||||
{dialogs.modalChildWindowCount > 0 && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
className="fixed inset-0 z-[9998] bg-black/50"
|
||||
onMouseDown={() => {
|
||||
void bounceTopModalWindow();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ThemeContextType {
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
export const THEME_CACHE_KEY = "df-theme-id";
|
||||
export const THEME_BACKGROUND_CACHE_KEY = "df-theme-background";
|
||||
|
||||
/** Inject all theme colors as CSS custom properties on :root */
|
||||
export function applyThemeToDOM(colors: ThemeColors) {
|
||||
@@ -53,6 +54,9 @@ export function applyThemeToDOM(colors: ThemeColors) {
|
||||
root.setProperty("--df-shadow", colors.shadow);
|
||||
root.setProperty("--df-scroll-thumb", colors.scrollThumb);
|
||||
root.setProperty("--df-accent", colors.accent);
|
||||
try {
|
||||
localStorage.setItem(THEME_BACKGROUND_CACHE_KEY, colors.bg);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Provides theme, themeName, setTheme. Syncs with appSettings.appearance.theme from backend. */
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const PAGE_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||
settings: () => import("@/pages/SettingsPage"),
|
||||
"new-session": () => import("@/pages/NewSessionPage"),
|
||||
"quick-command": () => import("@/pages/QuickCommandPage"),
|
||||
"auto-upload": () => import("@/pages/FileUploadPage"),
|
||||
};
|
||||
|
||||
export function loadChildWindowPage(windowType: string) {
|
||||
return PAGE_LOADERS[windowType]?.();
|
||||
}
|
||||
|
||||
export function preloadModalChildWindowPages() {
|
||||
void PAGE_LOADERS.settings();
|
||||
void PAGE_LOADERS["new-session"]();
|
||||
void PAGE_LOADERS["quick-command"]();
|
||||
}
|
||||
+131
-14
@@ -1,4 +1,4 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import {
|
||||
getCurrentWindow,
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
UserAttentionType,
|
||||
} from "@tauri-apps/api/window";
|
||||
import i18n from "../i18n";
|
||||
import { isMacOS } from "./platform";
|
||||
import { invoke } from "./invoke";
|
||||
import { isMacOS } from "./platform";
|
||||
|
||||
interface ChildWindowOptions {
|
||||
label: string;
|
||||
@@ -21,6 +21,15 @@ interface ChildWindowOptions {
|
||||
const MAIN_WINDOW_LABEL = "main";
|
||||
const AUTO_UPLOAD_WINDOW_PREFIX = "auto-upload-";
|
||||
const MODAL_CHILD_LABELS = new Set(["settings", "new-session", "quick-command"]);
|
||||
const CHILD_WINDOW_READY_TIMEOUT_MS = 10000;
|
||||
const readyChildWindowLabels = new Set<string>();
|
||||
const registeredDestroyedHandlers = new Set<string>();
|
||||
|
||||
void listen<{ label: string }>("child-window-ready", ({ payload }) => {
|
||||
if (isModalChildLabel(payload.label)) {
|
||||
readyChildWindowLabels.add(payload.label);
|
||||
}
|
||||
});
|
||||
|
||||
export function isModalChildLabel(label: string) {
|
||||
return MODAL_CHILD_LABELS.has(label) || label.startsWith(AUTO_UPLOAD_WINDOW_PREFIX);
|
||||
@@ -30,6 +39,10 @@ function needsAlwaysOnTop(label: string) {
|
||||
return label.startsWith(AUTO_UPLOAD_WINDOW_PREFIX);
|
||||
}
|
||||
|
||||
function getChildWindowBackgroundColor() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--df-bg").trim() || "#0d1117";
|
||||
}
|
||||
|
||||
async function getMainWindow() {
|
||||
return (await WebviewWindow.getByLabel(MAIN_WINDOW_LABEL)) ?? getCurrentWindow();
|
||||
}
|
||||
@@ -67,9 +80,11 @@ async function applyModalWindowState(excludedLabel?: string) {
|
||||
|
||||
if (hasModalChild) {
|
||||
const topModalWindow = remainingModalWindows[remainingModalWindows.length - 1];
|
||||
await topModalWindow.show().catch(() => {});
|
||||
await topModalWindow.setAlwaysOnTop(needsAlwaysOnTop(topModalWindow.label)).catch(() => {});
|
||||
await topModalWindow.setFocus().catch(() => {});
|
||||
const isVisible = await topModalWindow.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await topModalWindow.setFocus().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,6 +92,61 @@ async function applyModalWindowState(excludedLabel?: string) {
|
||||
await mainWindow.setFocus().catch(() => {});
|
||||
}
|
||||
|
||||
function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) {
|
||||
if (registeredDestroyedHandlers.has(label)) return;
|
||||
registeredDestroyedHandlers.add(label);
|
||||
|
||||
win.once("tauri://destroyed", () => {
|
||||
registeredDestroyedHandlers.delete(label);
|
||||
readyChildWindowLabels.delete(label);
|
||||
emit("child-window-closed", { label });
|
||||
void syncMainWindowModalState();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForChildWindowReady(label: string) {
|
||||
if (readyChildWindowLabels.has(label)) return Promise.resolve(true);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
let unlistenReady: (() => void) | undefined;
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unlistenReady?.();
|
||||
resolve(false);
|
||||
}, CHILD_WINDOW_READY_TIMEOUT_MS);
|
||||
|
||||
listen<{ label: string }>("child-window-ready", ({ payload }) => {
|
||||
if (payload.label !== label || settled) return;
|
||||
|
||||
settled = true;
|
||||
window.clearTimeout(timeout);
|
||||
unlistenReady?.();
|
||||
resolve(true);
|
||||
}).then((unlisten) => {
|
||||
if (settled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
unlistenReady = unlisten;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function revealChildWindow(win: WebviewWindow, label: string) {
|
||||
const isReady = await waitForChildWindowReady(label);
|
||||
if (!isReady) {
|
||||
await win.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
await win.show().catch(() => {});
|
||||
await win.setFocus().catch(() => {});
|
||||
emit("child-window-opened", { label });
|
||||
await syncMainWindowModalState().catch(() => {});
|
||||
}
|
||||
|
||||
export async function syncMainWindowModalState() {
|
||||
await applyModalWindowState();
|
||||
}
|
||||
@@ -90,6 +160,9 @@ export async function bounceTopModalWindow() {
|
||||
const topModalWindow = modalWindows[modalWindows.length - 1];
|
||||
if (!topModalWindow) return;
|
||||
|
||||
const isVisible = await topModalWindow.isVisible().catch(() => false);
|
||||
if (!isVisible) return;
|
||||
|
||||
await topModalWindow.requestUserAttention(UserAttentionType.Critical).catch(() => {});
|
||||
await topModalWindow.setAlwaysOnTop(needsAlwaysOnTop(topModalWindow.label)).catch(() => {});
|
||||
await topModalWindow.setFocus().catch(() => {});
|
||||
@@ -99,10 +172,15 @@ export async function openChildWindow(opts: ChildWindowOptions) {
|
||||
const existing = await WebviewWindow.getByLabel(opts.label);
|
||||
if (existing) {
|
||||
await existing.setTitle(opts.title).catch(() => {});
|
||||
await existing.show().catch(() => {});
|
||||
await existing.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {});
|
||||
await existing.setFocus().catch(() => {});
|
||||
await syncMainWindowModalState().catch(() => {});
|
||||
const isVisible = await existing.isVisible().catch(() => true);
|
||||
if (isVisible) {
|
||||
await existing.show().catch(() => {});
|
||||
await existing.setFocus().catch(() => {});
|
||||
await syncMainWindowModalState().catch(() => {});
|
||||
} else {
|
||||
await revealChildWindow(existing, opts.label);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
await invoke("open_child_window", {
|
||||
@@ -114,6 +192,7 @@ export async function openChildWindow(opts: ChildWindowOptions) {
|
||||
height: opts.height ?? 560,
|
||||
resizable: opts.resizable ?? true,
|
||||
alwaysOnTop: needsAlwaysOnTop(opts.label),
|
||||
backgroundColor: getChildWindowBackgroundColor(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,29 +201,67 @@ export async function openChildWindow(opts: ChildWindowOptions) {
|
||||
throw new Error(`Failed to create child window: ${opts.label}`);
|
||||
}
|
||||
|
||||
emit("child-window-opened", { label: opts.label });
|
||||
await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {});
|
||||
await win.setFocus().catch(() => {});
|
||||
await syncMainWindowModalState().catch(() => {});
|
||||
attachChildWindowDestroyedHandler(opts.label, win);
|
||||
|
||||
const readyTimeout = window.setTimeout(async () => {
|
||||
const isVisible = await win.isVisible().catch(() => true);
|
||||
if (isVisible) return;
|
||||
|
||||
await win.close().catch(() => {});
|
||||
}, CHILD_WINDOW_READY_TIMEOUT_MS);
|
||||
|
||||
win.once("tauri://destroyed", () => {
|
||||
emit("child-window-closed", { label: opts.label });
|
||||
void syncMainWindowModalState();
|
||||
window.clearTimeout(readyTimeout);
|
||||
});
|
||||
return win;
|
||||
}
|
||||
|
||||
export function openSettings(tab?: string) {
|
||||
export async function openSettings(tab?: string) {
|
||||
const url = tab
|
||||
? `index.html?window=settings&tab=${encodeURIComponent(tab)}`
|
||||
: "index.html?window=settings";
|
||||
return openChildWindow({
|
||||
const win = await openChildWindow({
|
||||
label: "settings",
|
||||
title: i18n.t("settings.title"),
|
||||
url,
|
||||
width: 800,
|
||||
height: 560,
|
||||
});
|
||||
if (tab) {
|
||||
emit("settings-open-tab", { tab });
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
export async function prewarmSettingsWindow() {
|
||||
if (await WebviewWindow.getByLabel("settings")) return;
|
||||
|
||||
await invoke("open_child_window", {
|
||||
options: {
|
||||
label: "settings",
|
||||
title: i18n.t("settings.title"),
|
||||
url: "index.html?window=settings&prewarm=1",
|
||||
width: 800,
|
||||
height: 560,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
backgroundColor: getChildWindowBackgroundColor(),
|
||||
},
|
||||
});
|
||||
|
||||
const win = await WebviewWindow.getByLabel("settings");
|
||||
if (!win) return;
|
||||
|
||||
attachChildWindowDestroyedHandler("settings", win);
|
||||
|
||||
window.setTimeout(async () => {
|
||||
const isReady = readyChildWindowLabels.has("settings");
|
||||
const isVisible = await win.isVisible().catch(() => true);
|
||||
if (!isReady && !isVisible) {
|
||||
await win.close().catch(() => {});
|
||||
}
|
||||
}, CHILD_WINDOW_READY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export interface NewSessionTarget {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
type ComponentType,
|
||||
@@ -94,6 +95,16 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<{ tab: string }>("settings-open-tab", ({ payload }) => {
|
||||
setActiveTab(payload.tab === "ai" ? "ai-general" : payload.tab);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((dispose) => dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
type SettingsCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
Reference in new issue
Block a user