feat(window-management): enhance child window state management and persistence (#177)

- Introduced a new `ChildWindowStateKey` type to manage the state of various child windows.
- Updated `openChildWindow` function to restore dimensions and maximized state from saved states based on the window label.
- Implemented state saving for child windows during application shutdown and on close events, improving user experience by preserving window states.
- Refactored window state handling in `window_state.rs` to support loading and saving of child window states, ensuring consistent behavior across the application.
This commit is contained in:
Kang committed 2026-06-26 16:18:55 +08:00
1 parent ce246203cc
commit fd7fb1e5ab
4 files changed
+422 -41

No files matched your search

+34
View File
@@ -259,6 +259,19 @@ pub fn prepare_app_shutdown(app: &tauri::AppHandle) {
}
}
for window in app.webview_windows().into_values() {
if crate::window_state::is_main_window_label(window.label()) {
continue;
}
if let Err(error) = crate::window_state::save_child_webview_window_state(&window) {
tracing::warn!(
window_label = window.label(),
"Failed to save child window state before shutdown: {}",
error
);
}
}
let session_manager = app.state::<Arc<SessionManager>>();
session_manager.flush_history_before_shutdown();
@@ -315,5 +328,26 @@ pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
}
_ => {}
}
return;
}
if crate::window_state::child_window_state_key_for_label(window.label()).is_none() {
return;
}
match event {
tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. } => {
crate::window_state::schedule_window_state_save(window.app_handle(), window.label());
}
tauri::WindowEvent::CloseRequested { .. } => {
if let Err(error) = crate::window_state::save_child_window_state(window) {
tracing::warn!(
window_label = window.label(),
"Failed to save child window state on close: {}",
error
);
}
}
_ => {}
}
}
+23 -3
View File
@@ -26,6 +26,7 @@ pub struct ChildWindowOptions {
height: Option<f64>,
resizable: Option<bool>,
always_on_top: Option<bool>,
state_key: Option<crate::window_state::ChildWindowStateKey>,
}
#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
@@ -83,8 +84,26 @@ pub async fn open_child_window(
return Ok(());
}
let width = options.width.unwrap_or(720.0);
let height = options.height.unwrap_or(560.0);
let requested_width = options.width.unwrap_or(720.0);
let requested_height = options.height.unwrap_or(560.0);
let resizable = options.resizable.unwrap_or(true);
let state_key = if resizable {
options
.state_key
.or_else(|| crate::window_state::child_window_state_key_for_label(&options.label))
} else {
None
};
let restored_state = state_key.map(|key| {
crate::window_state::load_child_window_state(key, requested_width, requested_height)
});
let width = restored_state
.as_ref()
.map_or(requested_width, |state| state.width);
let height = restored_state
.as_ref()
.map_or(requested_height, |state| state.height);
let maximized = restored_state.as_ref().is_some_and(|state| state.maximized);
let kind = options.kind.unwrap_or(ChildWindowKind::Modal);
let parent_label = options
.parent_label
@@ -100,9 +119,10 @@ pub async fn open_child_window(
)
.title(options.title)
.inner_size(width, height)
.maximized(maximized)
.visible(false)
.decorations(cfg!(target_os = "macos"))
.resizable(options.resizable.unwrap_or(true))
.resizable(resizable)
.always_on_top(options.always_on_top.unwrap_or(false));
#[cfg(target_os = "macos")]
+357 -38
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{Manager, PhysicalPosition, PhysicalRect, PhysicalSize, Position};
@@ -10,8 +11,15 @@ const DEFAULT_MAIN_WIDTH: f64 = 1280.0;
const DEFAULT_MAIN_HEIGHT: f64 = 800.0;
const MIN_MAIN_WIDTH: f64 = 720.0;
const MIN_MAIN_HEIGHT: f64 = 480.0;
const MIN_CHILD_WIDTH: f64 = 360.0;
const MIN_CHILD_HEIGHT: f64 = 240.0;
pub const MAIN_WINDOW_LABEL: &str = "main";
pub const MAIN_WINDOW_PREFIX: &str = "main-";
const SETTINGS_WINDOW_KEY: &str = "settings";
const NEW_SESSION_WINDOW_KEY: &str = "new-session";
const QUICK_COMMAND_WINDOW_KEY: &str = "quick-command";
const FILE_EDITOR_WINDOW_KEY: &str = "file-editor";
const AUTO_UPLOAD_WINDOW_PREFIX: &str = "auto-upload-";
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -42,6 +50,87 @@ impl MainWindowState {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct WindowStateDoc {
#[serde(flatten)]
main: MainWindowState,
#[serde(default)]
children: BTreeMap<String, ChildWindowState>,
}
impl WindowStateDoc {
fn normalized(mut self) -> Self {
self.main = self.main.normalized();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChildWindowState {
#[serde(default)]
pub width: f64,
#[serde(default)]
pub height: f64,
#[serde(default)]
pub maximized: bool,
}
impl Default for ChildWindowState {
fn default() -> Self {
Self {
width: 0.0,
height: 0.0,
maximized: false,
}
}
}
impl ChildWindowState {
fn normalized(mut self, fallback_width: f64, fallback_height: f64) -> Self {
self.width = normalize_child_dimension(self.width, MIN_CHILD_WIDTH, fallback_width);
self.height = normalize_child_dimension(self.height, MIN_CHILD_HEIGHT, fallback_height);
self
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChildWindowStateKey {
Settings,
NewSession,
QuickCommand,
FileEditor,
}
impl ChildWindowStateKey {
pub fn as_str(self) -> &'static str {
match self {
Self::Settings => SETTINGS_WINDOW_KEY,
Self::NewSession => NEW_SESSION_WINDOW_KEY,
Self::QuickCommand => QUICK_COMMAND_WINDOW_KEY,
Self::FileEditor => FILE_EDITOR_WINDOW_KEY,
}
}
fn default_size(self) -> (f64, f64) {
match self {
Self::Settings => (800.0, 560.0),
Self::NewSession => (520.0, 620.0),
Self::QuickCommand => (540.0, 640.0),
Self::FileEditor => (980.0, 720.0),
}
}
fn default_state(self) -> ChildWindowState {
let (width, height) = self.default_size();
ChildWindowState {
width,
height,
maximized: false,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ChildWindowPlacement {
pub x: i32,
@@ -51,11 +140,10 @@ pub struct ChildWindowPlacement {
#[derive(Debug)]
struct PendingSave {
app: tauri::AppHandle,
window_label: String,
scheduled_at: Instant,
}
static PENDING_SAVE: OnceLock<Mutex<Option<PendingSave>>> = OnceLock::new();
static PENDING_SAVES: OnceLock<Mutex<BTreeMap<String, PendingSave>>> = OnceLock::new();
fn default_main_width() -> f64 {
DEFAULT_MAIN_WIDTH
@@ -73,20 +161,87 @@ fn normalize_dimension(value: f64, min: f64, fallback: f64) -> f64 {
}
}
fn pending_save() -> &'static Mutex<Option<PendingSave>> {
PENDING_SAVE.get_or_init(|| Mutex::new(None))
fn normalize_child_dimension(value: f64, min: f64, fallback: f64) -> f64 {
let fallback = if fallback.is_finite() && fallback > 0.0 {
fallback.max(min)
} else {
min
};
if value.is_finite() && value > 0.0 {
value.max(min)
} else {
fallback
}
}
pub fn load_main_window_state() -> MainWindowState {
storage::load_settings_doc::<MainWindowState>(SettingsDocKey::WindowState)
fn pending_saves() -> &'static Mutex<BTreeMap<String, PendingSave>> {
PENDING_SAVES.get_or_init(|| Mutex::new(BTreeMap::new()))
}
fn load_window_state_doc() -> WindowStateDoc {
storage::load_settings_doc::<WindowStateDoc>(SettingsDocKey::WindowState)
.unwrap_or_default()
.normalized()
}
pub fn load_main_window_state() -> MainWindowState {
load_window_state_doc().main
}
pub fn is_main_window_label(label: &str) -> bool {
label == MAIN_WINDOW_LABEL || label.starts_with(MAIN_WINDOW_PREFIX)
}
fn is_scoped_child_label(label: &str, base: &str) -> bool {
if label == base {
return true;
}
label
.strip_prefix(base)
.and_then(|rest| rest.strip_prefix('-'))
.is_some_and(is_main_window_label)
}
pub fn child_window_state_key_for_label(label: &str) -> Option<ChildWindowStateKey> {
if label.starts_with(AUTO_UPLOAD_WINDOW_PREFIX) || is_main_window_label(label) {
return None;
}
if is_scoped_child_label(label, SETTINGS_WINDOW_KEY) {
return Some(ChildWindowStateKey::Settings);
}
if is_scoped_child_label(label, NEW_SESSION_WINDOW_KEY) {
return Some(ChildWindowStateKey::NewSession);
}
if is_scoped_child_label(label, QUICK_COMMAND_WINDOW_KEY) {
return Some(ChildWindowStateKey::QuickCommand);
}
if label.starts_with(&format!("{FILE_EDITOR_WINDOW_KEY}-")) {
return Some(ChildWindowStateKey::FileEditor);
}
None
}
pub fn load_child_window_state(
key: ChildWindowStateKey,
fallback_width: f64,
fallback_height: f64,
) -> ChildWindowState {
load_window_state_doc()
.children
.get(key.as_str())
.cloned()
.unwrap_or(ChildWindowState {
width: fallback_width,
height: fallback_height,
maximized: false,
})
.normalized(fallback_width, fallback_height)
}
pub fn save_main_window_state(window: &tauri::Window) -> AppResult<()> {
if !is_main_window_label(window.label()) {
return Ok(());
@@ -122,65 +277,153 @@ fn save_main_window_state_values(
inner_size: PhysicalSize<u32>,
maximized: bool,
) -> AppResult<()> {
let mut state = load_main_window_state();
state.maximized = maximized;
storage::update_settings_doc::<WindowStateDoc, _, _>(SettingsDocKey::WindowState, |doc| {
let mut state = doc.main.clone().normalized();
state.maximized = maximized;
if !maximized {
let scale = if scale_factor.is_finite() && scale_factor > 0.0 {
scale_factor
} else {
1.0
};
state.width =
normalize_dimension(inner_size.width as f64 / scale, MIN_MAIN_WIDTH, state.width);
state.height = normalize_dimension(
inner_size.height as f64 / scale,
MIN_MAIN_HEIGHT,
state.height,
);
}
if !maximized {
let scale = if scale_factor.is_finite() && scale_factor > 0.0 {
scale_factor
} else {
1.0
};
state.width =
normalize_dimension(inner_size.width as f64 / scale, MIN_MAIN_WIDTH, state.width);
state.height = normalize_dimension(
inner_size.height as f64 / scale,
MIN_MAIN_HEIGHT,
state.height,
);
}
storage::save_settings_doc(SettingsDocKey::WindowState, &state.normalized())
doc.main = state.normalized();
Ok(())
})
}
pub fn schedule_main_window_state_save(app: &tauri::AppHandle, window_label: &str) {
pub fn save_child_window_state(window: &tauri::Window) -> AppResult<()> {
let Some(key) = child_window_state_key_for_label(window.label()) else {
return Ok(());
};
let maximized = window.is_maximized().unwrap_or(false);
let scale_factor = window
.scale_factor()
.map_err(|error| AppError::Config(error.to_string()))?;
let inner_size = window
.inner_size()
.map_err(|error| AppError::Config(error.to_string()))?;
save_child_window_state_values(key, scale_factor, inner_size, maximized)
}
pub fn save_child_webview_window_state(window: &tauri::WebviewWindow) -> AppResult<()> {
let Some(key) = child_window_state_key_for_label(window.label()) else {
return Ok(());
};
let maximized = window.is_maximized().unwrap_or(false);
let scale_factor = window
.scale_factor()
.map_err(|error| AppError::Config(error.to_string()))?;
let inner_size = window
.inner_size()
.map_err(|error| AppError::Config(error.to_string()))?;
save_child_window_state_values(key, scale_factor, inner_size, maximized)
}
fn save_child_window_state_values(
key: ChildWindowStateKey,
scale_factor: f64,
inner_size: PhysicalSize<u32>,
maximized: bool,
) -> AppResult<()> {
storage::update_settings_doc::<WindowStateDoc, _, _>(SettingsDocKey::WindowState, |doc| {
let (fallback_width, fallback_height) = key.default_size();
let mut state = doc
.children
.get(key.as_str())
.cloned()
.unwrap_or_else(|| key.default_state())
.normalized(fallback_width, fallback_height);
state.maximized = maximized;
if !maximized {
let scale = if scale_factor.is_finite() && scale_factor > 0.0 {
scale_factor
} else {
1.0
};
state.width = normalize_child_dimension(
inner_size.width as f64 / scale,
MIN_CHILD_WIDTH,
state.width,
);
state.height = normalize_child_dimension(
inner_size.height as f64 / scale,
MIN_CHILD_HEIGHT,
state.height,
);
}
doc.children.insert(key.as_str().to_string(), state);
Ok(())
})
}
pub fn save_webview_window_state(window: &tauri::WebviewWindow) -> AppResult<()> {
if is_main_window_label(window.label()) {
return save_main_webview_window_state(window);
}
save_child_webview_window_state(window)
}
pub fn schedule_window_state_save(app: &tauri::AppHandle, window_label: &str) {
let scheduled_at = Instant::now();
let window_label = window_label.to_string();
{
let mut pending = pending_save()
let mut pending = pending_saves()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*pending = Some(PendingSave {
app: app.clone(),
window_label: window_label.to_string(),
scheduled_at,
});
pending.insert(
window_label.clone(),
PendingSave {
app: app.clone(),
scheduled_at,
},
);
}
tauri::async_runtime::spawn(async move {
tokio::time::sleep(SAVE_DEBOUNCE).await;
let (app, window_label) = {
let mut pending = pending_save()
let mut pending = pending_saves()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
match pending.as_ref() {
match pending.get(&window_label) {
Some(save) if save.scheduled_at == scheduled_at => {
let app = save.app.clone();
let window_label = save.window_label.clone();
*pending = None;
(app, window_label)
let save = pending.remove(&window_label).expect("pending save exists");
(save.app, window_label.clone())
}
_ => return,
}
};
if let Some(window) = app.get_webview_window(&window_label) {
if let Err(error) = save_main_webview_window_state(&window) {
tracing::warn!("Failed to save main window state: {}", error);
if let Err(error) = save_webview_window_state(&window) {
tracing::warn!(
window_label = window.label(),
"Failed to save window state: {}",
error
);
}
}
});
}
pub fn schedule_main_window_state_save(app: &tauri::AppHandle, window_label: &str) {
schedule_window_state_save(app, window_label);
}
pub fn center_child_in_parent_monitor(
app: &tauri::AppHandle,
parent_label: Option<&str>,
@@ -268,6 +511,82 @@ mod tests {
assert!(state.maximized);
}
#[test]
fn deserializes_legacy_main_window_state_doc() {
let doc: WindowStateDoc =
serde_json::from_str(r#"{ "width": 1000, "height": 700, "maximized": true }"#).unwrap();
assert_eq!(doc.main.width, 1000.0);
assert_eq!(doc.main.height, 700.0);
assert!(doc.main.maximized);
assert!(doc.children.is_empty());
}
#[test]
fn deserializes_child_window_state_doc() {
let doc: WindowStateDoc = serde_json::from_str(
r#"{
"width": 1280,
"height": 800,
"maximized": false,
"children": {
"file-editor": { "width": 1100, "height": 760, "maximized": true }
}
}"#,
)
.unwrap();
let state = doc
.children
.get(ChildWindowStateKey::FileEditor.as_str())
.cloned()
.unwrap()
.normalized(980.0, 720.0);
assert_eq!(state.width, 1100.0);
assert_eq!(state.height, 760.0);
assert!(state.maximized);
}
#[test]
fn normalizes_invalid_child_window_state_values() {
let state = ChildWindowState {
width: f64::NAN,
height: 100.0,
maximized: false,
}
.normalized(520.0, 620.0);
assert_eq!(state.width, 520.0);
assert_eq!(state.height, MIN_CHILD_HEIGHT);
assert!(!state.maximized);
}
#[test]
fn recognizes_child_window_state_keys() {
assert_eq!(
child_window_state_key_for_label("settings"),
Some(ChildWindowStateKey::Settings)
);
assert_eq!(
child_window_state_key_for_label("settings-main-abc"),
Some(ChildWindowStateKey::Settings)
);
assert_eq!(
child_window_state_key_for_label("new-session-main-abc"),
Some(ChildWindowStateKey::NewSession)
);
assert_eq!(
child_window_state_key_for_label("quick-command-main-abc"),
Some(ChildWindowStateKey::QuickCommand)
);
assert_eq!(
child_window_state_key_for_label("file-editor-abc"),
Some(ChildWindowStateKey::FileEditor)
);
assert_eq!(child_window_state_key_for_label("auto-upload-abc"), None);
assert_eq!(child_window_state_key_for_label("main"), None);
}
#[test]
fn recognizes_main_window_label_family() {
assert!(is_main_window_label("main"));
+8
View File
@@ -9,6 +9,8 @@ import i18n from "../i18n";
import { invoke } from "./invoke";
import { isMacOS } from "./platform";
type ChildWindowStateKey = "settings" | "new-session" | "quick-command" | "file-editor";
interface ChildWindowOptions {
label: string;
title: string;
@@ -18,6 +20,7 @@ interface ChildWindowOptions {
width?: number;
height?: number;
resizable?: boolean;
stateKey?: ChildWindowStateKey;
}
const MAIN_WINDOW_LABEL = "main";
@@ -292,6 +295,7 @@ export async function openChildWindow(opts: ChildWindowOptions) {
height: opts.height ?? 560,
resizable: opts.resizable ?? true,
alwaysOnTop: needsAlwaysOnTop(opts.label),
stateKey: opts.stateKey,
},
});
@@ -322,6 +326,7 @@ export async function openSettings(tab?: string) {
parentLabel: ownerMainWindowLabel,
width: 800,
height: 560,
stateKey: "settings",
});
if (tab) {
const payload = { tab, targetWindowLabel: ownerMainWindowLabel };
@@ -378,6 +383,7 @@ export function openNewSessionWithTarget(
parentLabel: ownerMainWindowLabel,
width: 520,
height: 620,
stateKey: "new-session",
});
}
@@ -392,6 +398,7 @@ export function openQuickCommand(editJson?: string) {
parentLabel: ownerMainWindowLabel,
width: 540,
height: 640,
stateKey: "quick-command",
});
}
@@ -431,6 +438,7 @@ export function openRemoteFileEditor(data: RemoteFileEditorWindowData) {
parentLabel: ownerMainWindowLabel,
width: 980,
height: 720,
stateKey: "file-editor",
}).then((win) => {
const payload = { targetLabel: label, data };
emit("remote-file-editor-open", payload);