From 144ac9b66522c717536e69ce24a32eb587cd5ce5 Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 20 Sep 2026 10:10:39 +0800 Subject: [PATCH] feat(workspace): implement workspace management and window handling - Added `WorkspaceId` and `WorkspaceRestoreManifest` for managing workspace states. - Introduced `DesktopController` to handle window lifecycle and activation requests. - Updated `AppShell` to support multiple workspaces and improved window state persistence. - Enhanced localization files to include new window management options. - Refactored activation handling to support reusing existing windows based on user commands. --- crates/nyaterm-app/src/main.rs | 43 +- crates/nyaterm-core/src/activation.rs | 17 + crates/nyaterm-core/src/models/mod.rs | 2 + .../src/models/workspace_manifest.rs | 477 ++++++ crates/nyaterm-desktop/locales/en.json | 3 + crates/nyaterm-desktop/locales/fr.json | 3 + crates/nyaterm-desktop/locales/ja.json | 3 + crates/nyaterm-desktop/locales/ko.json | 3 + crates/nyaterm-desktop/locales/zh-CN.json | 3 + crates/nyaterm-desktop/locales/zh-TW.json | 3 + .../src/app_shell/controller.rs | 1420 +++++++++++++++++ crates/nyaterm-desktop/src/app_shell/mod.rs | 524 +++--- .../src/app_shell/process_state.rs | 285 ++++ .../src/app_shell/session_hub.rs | 30 + .../src/app_shell/window_state.rs | 148 +- .../src/entities/startup_restore.rs | 25 +- .../ai/ai_runtime/chat/settings_actions.rs | 1 + .../src/features/app_state/construct.rs | 53 +- .../src/features/app_state/mod.rs | 7 + .../src/features/app_state/store_runtime.rs | 221 ++- .../src/features/app_state/types.rs | 1 + .../commands/command_runtime/history.rs | 5 + .../quick_command_runtime/import/dialog.rs | 4 + .../src/features/layout/title_bar/bar.rs | 3 + .../src/features/layout/title_bar/menu.rs | 23 +- .../features/layout/workspace/surface/tabs.rs | 54 +- crates/nyaterm-desktop/src/features/mod.rs | 3 + .../src/features/pages/settings/flush.rs | 9 + .../pages/workspace/terminal_windows.rs | 51 +- .../panels/tab_actions_overlay/compact.rs | 42 +- .../src/features/session/mod.rs | 4 +- .../session/startup_restore_runtime.rs | 32 +- .../src/features/session/state/mod.rs | 205 +++ .../src/features/session/state/tests.rs | 36 + .../src/features/settings/config_runtime.rs | 165 +- .../settings/lock_diagnostics_runtime.rs | 31 + .../settings/security_runtime/credentials.rs | 12 + .../settings/security_runtime/delete.rs | 4 + .../settings/security_runtime/keys.rs | 4 + .../features/settings/security_runtime/otp.rs | 4 + .../settings/security_runtime/passwords.rs | 4 + .../settings/settings_runtime/draft.rs | 267 +++- .../settings_runtime/general_interaction.rs | 32 +- .../settings_runtime/recording_transfer.rs | 15 +- .../settings_runtime/terminal_remote.rs | 5 +- .../settings/settings_runtime/window.rs | 11 +- .../src/features/settings/state.rs | 56 +- .../src/features/shell/appearance.rs | 5 +- .../shell/keybinding_runtime/keybindings.rs | 5 +- .../keybinding_runtime/keyword_highlights.rs | 2 + .../nyaterm-desktop/src/features/shell/mod.rs | 2 +- .../src/features/shell/navigation_runtime.rs | 10 + .../features/shell/persistence_debounce.rs | 2 +- .../src/features/shell/state.rs | 9 +- .../src/features/shell/tab_mouse.rs | 3 + .../src/features/shell/tab_windows_runtime.rs | 20 + .../src/features/shell/tray.rs | 145 +- .../src/features/shell/workspace_runtime.rs | 21 + .../src/features/sync_input.rs | 94 ++ .../src/features/tab_transfer.rs | 367 +++++ .../src/features/terminal/mod.rs | 1 + .../src/features/terminal/state.rs | 2 +- .../terminal/terminal_runtime/sessions.rs | 14 +- .../src/features/terminal/view_state.rs | 189 ++- .../src/features/terminal/window_state.rs | 18 + .../src/features/transfers/mod.rs | 1 + .../src/features/transfers/state/browser.rs | 31 + .../src/features/transfers/state/mod.rs | 19 + .../src/features/transfers/state/tests.rs | 37 + .../translation/translation_runtime.rs | 4 + crates/nyaterm-desktop/src/lib.rs | 5 +- .../src/models/session_event_bridge.rs | 135 +- .../src/models/terminal/mod.rs | 131 +- .../src/models/terminal/tests.rs | 57 + crates/nyaterm-desktop/src/shortcuts/mod.rs | 4 +- crates/nyaterm-store/src/lib.rs | 5 +- crates/nyaterm-store/src/runtime.rs | 87 +- .../nyaterm-store/src/storage/app_settings.rs | 113 +- crates/nyaterm-store/src/storage/mod.rs | 1 + crates/nyaterm-store/src/storage/tests.rs | 217 +++ .../nyaterm-store/src/storage/window_state.rs | 212 ++- crates/nyaterm-transport/src/lib.rs | 49 +- .../src/session_event_queue.rs | 158 +- crates/nyaterm-transport/src/session_types.rs | 3 + crates/nyaterm-transport/src/tests.rs | 52 +- 85 files changed, 6117 insertions(+), 466 deletions(-) create mode 100644 crates/nyaterm-core/src/models/workspace_manifest.rs create mode 100644 crates/nyaterm-desktop/src/app_shell/controller.rs create mode 100644 crates/nyaterm-desktop/src/app_shell/process_state.rs create mode 100644 crates/nyaterm-desktop/src/app_shell/session_hub.rs create mode 100644 crates/nyaterm-desktop/src/features/tab_transfer.rs diff --git a/crates/nyaterm-app/src/main.rs b/crates/nyaterm-app/src/main.rs index 9884f667..b9eec1c7 100644 --- a/crates/nyaterm-app/src/main.rs +++ b/crates/nyaterm-app/src/main.rs @@ -3,12 +3,11 @@ mod single_instance; use anyhow::Context as _; -use gpui::{App, AppContext, TitlebarOptions, WindowOptions, point, px}; +use gpui::{App, AppContext}; use nyaterm_app::assets; use nyaterm_core::app_identity::AppFlavor; use nyaterm_core::{ActivationRequest, AppRuntime, LOG_FILE_PREFIX, LOG_FILE_SUFFIX}; -use nyaterm_desktop::{AppShell, AppShellStartup}; -use nyaterm_ui::nya_root; +use nyaterm_desktop::{AppShellStartup, DesktopController, DesktopControllerGlobal}; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; @@ -54,40 +53,14 @@ fn main() -> anyhow::Result<()> { let flavor = AppFlavor::current(); cx.set_app_identity(flavor.application_identifier(), flavor.display_name()); gpui_component::init(cx); + cx.set_quit_mode(gpui::QuitMode::Explicit); nyaterm_desktop::init(cx); let startup = AppShellStartup::prepare(&runtime); - let placement = startup.main_window_placement(cx); - let app_runtime = runtime.clone(); - - cx.open_window( - WindowOptions { - app_id: Some(flavor.desktop_id().to_string()), - titlebar: Some(TitlebarOptions { - title: Some(flavor.display_name().into()), - appears_transparent: true, - traffic_light_position: cfg!(target_os = "macos") - .then(|| point(px(9.), px(11.))), - }), - #[cfg(target_os = "linux")] - window_decorations: Some(gpui::WindowDecorations::Client), - window_bounds: Some(placement.window_bounds), - display_id: placement.display_id, - ..Default::default() - }, - move |window, cx| { - let shell = cx.new(|cx| AppShell::new(app_runtime, activation_rx, startup, cx)); - let close_shell = shell.clone(); - window.on_window_should_close(cx, move |window, cx| { - close_shell.update(cx, |shell, cx| shell.request_window_close(window, cx)); - false - }); - shell.update(cx, |shell, cx| { - shell.start_after_window_open(window, cx); - }); - cx.new(|cx| nya_root(shell, window, cx)) - }, - ) - .expect("failed to open NyaTerm window"); + let controller = cx.new(|cx| DesktopController::new(runtime.clone(), startup, cx)); + cx.set_global(DesktopControllerGlobal(controller.clone())); + controller + .update(cx, |controller, cx| controller.launch(activation_rx, cx)) + .expect("failed to open NyaTerm windows"); cx.activate(true); }); diff --git a/crates/nyaterm-core/src/activation.rs b/crates/nyaterm-core/src/activation.rs index f3e1482d..27f10055 100644 --- a/crates/nyaterm-core/src/activation.rs +++ b/crates/nyaterm-core/src/activation.rs @@ -74,6 +74,23 @@ impl ActivationRequest { .collect(), } } + + pub fn open_behavior(&self) -> crate::ActivationOpenBehavior { + if self.args.iter().any(|arg| arg.matches("--reuse-window")) { + crate::ActivationOpenBehavior::ReuseMostRecent + } else { + crate::ActivationOpenBehavior::NewWindow + } + } +} + +impl RawActivationArg { + fn matches(&self, expected: &str) -> bool { + match self { + Self::Bytes(bytes) => String::from_utf8_lossy(bytes) == expected, + Self::Wide(units) => String::from_utf16_lossy(units) == expected, + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/nyaterm-core/src/models/mod.rs b/crates/nyaterm-core/src/models/mod.rs index a3221e86..3c261164 100644 --- a/crates/nyaterm-core/src/models/mod.rs +++ b/crates/nyaterm-core/src/models/mod.rs @@ -7,6 +7,7 @@ pub mod sessions; pub mod settings; pub mod window_state; pub mod workspace; +pub mod workspace_manifest; pub use connection::*; pub use credentials::*; pub use network::*; @@ -16,6 +17,7 @@ pub use sessions::*; pub use settings::*; pub use window_state::*; pub use workspace::*; +pub use workspace_manifest::*; fn default_ssh_port() -> u16 { 22 diff --git a/crates/nyaterm-core/src/models/workspace_manifest.rs b/crates/nyaterm-core/src/models/workspace_manifest.rs new file mode 100644 index 00000000..fbbd31c0 --- /dev/null +++ b/crates/nyaterm-core/src/models/workspace_manifest.rs @@ -0,0 +1,477 @@ +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +use super::{ + MainWindowState, RestorableOpenTab, RestorableTerminalWindowNode, RestorableWorkspacePaneNode, +}; +use crate::ActivationRequest; + +pub const WORKSPACE_RESTORE_MANIFEST_VERSION: u8 = 1; +pub const DEVICE_WINDOW_MANIFEST_VERSION: u8 = 2; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(transparent)] +pub struct WorkspaceId(pub Uuid); + +impl WorkspaceId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub const fn legacy() -> Self { + Self(Uuid::from_u128(0x6e796174_6572_6d2d_6c65_676163790001)) + } +} + +impl Default for WorkspaceId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ActivationOpenBehavior { + #[default] + NewWindow, + ReuseMostRecent, +} + +#[derive(Debug, Clone)] +pub struct OpenWorkspaceRequest { + pub activation: Option, + pub activate: bool, +} + +impl Default for OpenWorkspaceRequest { + fn default() -> Self { + Self { + activation: None, + activate: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveTabDockEdge { + Left, + Right, + Top, + Bottom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum MoveTabPlacement { + #[default] + Append, + BeforeTab(String), + AfterTab(String), + TerminalLeaf { + leaf_id: String, + edge: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveTabTreeRequest { + pub source_workspace_id: WorkspaceId, + pub target_workspace_id: WorkspaceId, + pub root_tab_id: String, + pub source_revision: u64, + pub placement: MoveTabPlacement, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct WorkspaceSessionState { + #[serde(default)] + pub open_tabs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_window_layout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_pane_layout: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkspaceUiState { + #[serde(default = "default_left_panel_width")] + pub left_panel_width: u32, + #[serde(default = "default_right_panel_width")] + pub right_panel_width: u32, + #[serde(default = "default_bottom_panel_height")] + pub bottom_panel_height: u32, + #[serde(default)] + pub active_left_panel: Option, + #[serde(default)] + pub active_right_panel: Option, + #[serde(default)] + pub left_panel_collapsed: bool, + #[serde(default)] + pub right_panel_collapsed: bool, + #[serde(default = "default_current_page")] + pub current_page: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Default for WorkspaceUiState { + fn default() -> Self { + Self { + left_panel_width: default_left_panel_width(), + right_panel_width: default_right_panel_width(), + bottom_panel_height: default_bottom_panel_height(), + active_left_panel: None, + active_right_panel: None, + left_panel_collapsed: false, + right_panel_collapsed: false, + current_page: default_current_page(), + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkspaceRestoreState { + pub id: WorkspaceId, + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub sessions: WorkspaceSessionState, + #[serde(default)] + pub ui: WorkspaceUiState, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl WorkspaceRestoreState { + pub fn empty(id: WorkspaceId) -> Self { + Self { + id, + revision: 0, + sessions: WorkspaceSessionState::default(), + ui: WorkspaceUiState::default(), + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkspaceRestoreManifest { + pub version: u8, + #[serde(default)] + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub most_recent_workspace_id: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl WorkspaceRestoreManifest { + pub fn single(workspace: WorkspaceRestoreState) -> Self { + Self { + version: WORKSPACE_RESTORE_MANIFEST_VERSION, + most_recent_workspace_id: Some(workspace.id), + workspaces: vec![workspace], + extra: BTreeMap::new(), + } + } + + pub fn validate(&self) -> Result<(), WorkspaceManifestValidationError> { + if self.version != WORKSPACE_RESTORE_MANIFEST_VERSION { + return Err( + WorkspaceManifestValidationError::UnsupportedWorkspaceVersion(self.version), + ); + } + let mut ids = HashSet::with_capacity(self.workspaces.len()); + for workspace in &self.workspaces { + if !ids.insert(workspace.id) { + return Err(WorkspaceManifestValidationError::DuplicateWorkspaceId( + workspace.id, + )); + } + validate_session_layout(&workspace.sessions)?; + validate_ui(&workspace.ui)?; + } + if let Some(id) = self.most_recent_workspace_id + && !ids.contains(&id) + { + return Err(WorkspaceManifestValidationError::UnknownMostRecentWorkspace(id)); + } + Ok(()) + } + + pub fn most_recent(&self) -> Option<&WorkspaceRestoreState> { + self.most_recent_workspace_id + .and_then(|id| self.workspaces.iter().find(|workspace| workspace.id == id)) + .or_else(|| self.workspaces.last()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeviceWindowState { + pub workspace_id: WorkspaceId, + #[serde(flatten)] + pub window: MainWindowState, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeviceWindowManifest { + pub version: u8, + #[serde(default)] + pub windows: Vec, + #[serde(default)] + pub window_order: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub most_recent_workspace_id: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl DeviceWindowManifest { + pub fn empty() -> Self { + Self { + version: DEVICE_WINDOW_MANIFEST_VERSION, + windows: Vec::new(), + window_order: Vec::new(), + most_recent_workspace_id: None, + extra: BTreeMap::new(), + } + } + + pub fn validate(&self) -> Result<(), WorkspaceManifestValidationError> { + if self.version != DEVICE_WINDOW_MANIFEST_VERSION { + return Err( + WorkspaceManifestValidationError::UnsupportedDeviceWindowVersion(self.version), + ); + } + let mut ids = HashSet::with_capacity(self.windows.len()); + for state in &self.windows { + if !ids.insert(state.workspace_id) { + return Err(WorkspaceManifestValidationError::DuplicateWorkspaceId( + state.workspace_id, + )); + } + state.window.validate().map_err(|error| { + WorkspaceManifestValidationError::InvalidWindowGeometry(error.to_string()) + })?; + } + let mut ordered = HashSet::with_capacity(self.window_order.len()); + for id in &self.window_order { + if !ordered.insert(*id) { + return Err(WorkspaceManifestValidationError::DuplicateWindowOrderId( + *id, + )); + } + if !ids.contains(id) { + return Err(WorkspaceManifestValidationError::UnknownWindowOrderId(*id)); + } + } + if let Some(id) = self.most_recent_workspace_id + && !ids.contains(&id) + { + return Err(WorkspaceManifestValidationError::UnknownMostRecentWorkspace(id)); + } + Ok(()) + } + + pub fn state_for(&self, workspace_id: WorkspaceId) -> Option<&MainWindowState> { + self.windows + .iter() + .find(|state| state.workspace_id == workspace_id) + .map(|state| &state.window) + } +} + +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum WorkspaceManifestValidationError { + #[error("unsupported workspace manifest version {0}")] + UnsupportedWorkspaceVersion(u8), + #[error("unsupported device window manifest version {0}")] + UnsupportedDeviceWindowVersion(u8), + #[error("duplicate workspace id {0:?}")] + DuplicateWorkspaceId(WorkspaceId), + #[error("most recent workspace {0:?} is not present")] + UnknownMostRecentWorkspace(WorkspaceId), + #[error("window order contains duplicate workspace {0:?}")] + DuplicateWindowOrderId(WorkspaceId), + #[error("window order references unknown workspace {0:?}")] + UnknownWindowOrderId(WorkspaceId), + #[error("layout references tab index {index}, but only {tab_count} tabs exist")] + InvalidTabIndex { index: usize, tab_count: usize }, + #[error("invalid workspace UI dimensions")] + InvalidUiDimensions, + #[error("invalid window geometry: {0}")] + InvalidWindowGeometry(String), +} + +fn validate_session_layout( + sessions: &WorkspaceSessionState, +) -> Result<(), WorkspaceManifestValidationError> { + let tab_count = sessions.open_tabs.len(); + if let Some(layout) = &sessions.terminal_window_layout { + validate_terminal_layout(layout, tab_count)?; + } + if let Some(layout) = &sessions.workspace_pane_layout { + validate_workspace_layout(layout, tab_count)?; + } + Ok(()) +} + +fn validate_terminal_layout( + node: &RestorableTerminalWindowNode, + tab_count: usize, +) -> Result<(), WorkspaceManifestValidationError> { + match node { + RestorableTerminalWindowNode::Leaf { + tab_indexes, + active_tab_index, + } => { + for index in tab_indexes.iter().copied().chain(*active_tab_index) { + validate_tab_index(index, tab_count)?; + } + } + RestorableTerminalWindowNode::Split { first, second, .. } => { + validate_terminal_layout(first, tab_count)?; + validate_terminal_layout(second, tab_count)?; + } + } + Ok(()) +} + +fn validate_workspace_layout( + node: &RestorableWorkspacePaneNode, + tab_count: usize, +) -> Result<(), WorkspaceManifestValidationError> { + match node { + RestorableWorkspacePaneNode::Leaf { tab_index } => { + validate_tab_index(*tab_index, tab_count)?; + } + RestorableWorkspacePaneNode::Split { first, second, .. } => { + validate_workspace_layout(first, tab_count)?; + validate_workspace_layout(second, tab_count)?; + } + } + Ok(()) +} + +fn validate_tab_index( + index: usize, + tab_count: usize, +) -> Result<(), WorkspaceManifestValidationError> { + if index >= tab_count { + return Err(WorkspaceManifestValidationError::InvalidTabIndex { index, tab_count }); + } + Ok(()) +} + +fn validate_ui(ui: &WorkspaceUiState) -> Result<(), WorkspaceManifestValidationError> { + if ui.left_panel_width == 0 || ui.right_panel_width == 0 || ui.bottom_panel_height == 0 { + return Err(WorkspaceManifestValidationError::InvalidUiDimensions); + } + Ok(()) +} + +fn default_left_panel_width() -> u32 { + 256 +} + +fn default_right_panel_width() -> u32 { + 288 +} + +fn default_bottom_panel_height() -> u32 { + 180 +} + +fn default_current_page() -> String { + "workspace".to_string() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::MainWindowBounds; + + #[test] + fn workspace_manifest_round_trips_unknown_fields() { + let id = WorkspaceId::new(); + let raw = json!({ + "version": 1, + "workspaces": [{ + "id": id, + "revision": 7, + "sessions": {"open_tabs": [], "future_sessions": {"enabled": true}}, + "ui": {"future_ui": "kept"}, + "future_workspace": 42 + }], + "most_recent_workspace_id": id, + "future_manifest": [1, 2, 3] + }); + let manifest: WorkspaceRestoreManifest = serde_json::from_value(raw.clone()).unwrap(); + manifest.validate().unwrap(); + let encoded = serde_json::to_value(manifest).unwrap(); + assert_eq!(encoded["future_manifest"], raw["future_manifest"]); + assert_eq!( + encoded["workspaces"][0]["future_workspace"], + raw["workspaces"][0]["future_workspace"] + ); + assert_eq!( + encoded["workspaces"][0]["sessions"]["future_sessions"], + raw["workspaces"][0]["sessions"]["future_sessions"] + ); + assert_eq!( + encoded["workspaces"][0]["ui"]["future_ui"], + raw["workspaces"][0]["ui"]["future_ui"] + ); + } + + #[test] + fn workspace_manifest_rejects_invalid_layout_indexes() { + let mut workspace = WorkspaceRestoreState::empty(WorkspaceId::new()); + workspace.sessions.workspace_pane_layout = + Some(RestorableWorkspacePaneNode::Leaf { tab_index: 0 }); + let manifest = WorkspaceRestoreManifest::single(workspace); + assert_eq!( + manifest.validate(), + Err(WorkspaceManifestValidationError::InvalidTabIndex { + index: 0, + tab_count: 0 + }) + ); + } + + #[test] + fn device_window_manifest_validates_geometry_and_order() { + let id = WorkspaceId::new(); + let manifest = DeviceWindowManifest { + version: DEVICE_WINDOW_MANIFEST_VERSION, + windows: vec![DeviceWindowState { + workspace_id: id, + window: MainWindowState::new( + None, + MainWindowBounds { + x: 10, + y: 20, + width: 1280, + height: 800, + }, + false, + ), + extra: BTreeMap::new(), + }], + window_order: vec![id], + most_recent_workspace_id: Some(id), + extra: BTreeMap::new(), + }; + manifest.validate().unwrap(); + } +} diff --git a/crates/nyaterm-desktop/locales/en.json b/crates/nyaterm-desktop/locales/en.json index 59598f53..0a99a58c 100644 --- a/crates/nyaterm-desktop/locales/en.json +++ b/crates/nyaterm-desktop/locales/en.json @@ -1,6 +1,7 @@ { "tray": { "show": "Show NyaTerm", + "newWindow": "New Window", "newConnection": "New connection", "push": "Push cloud sync", "pull": "Pull cloud sync", @@ -2736,6 +2737,8 @@ "locked": "Locked", "lockedCloseBlocked": "Unlock this tab before closing it", "lockedTabsSkipped": "Locked tabs were skipped", + "moveToWindow": "Move to", + "moveToNewWindow": "Move to New Window", "multiplexSsh": "Reuse SSH Connection", "multiplexSshFailed": "Failed to reuse SSH channel", "multiplexSshWithCommand": "Reuse SSH Connection and Run Command", diff --git a/crates/nyaterm-desktop/locales/fr.json b/crates/nyaterm-desktop/locales/fr.json index 6b37fffa..837e865a 100644 --- a/crates/nyaterm-desktop/locales/fr.json +++ b/crates/nyaterm-desktop/locales/fr.json @@ -1,6 +1,7 @@ { "tray": { "show": "Afficher NyaTerm", + "newWindow": "Nouvelle fenêtre", "newConnection": "Nouvelle connexion", "push": "Envoyer vers le cloud", "pull": "Récupérer du cloud", @@ -2688,6 +2689,8 @@ "locked": "Verrouillé", "lockedCloseBlocked": "Déverrouillez cet onglet avant de le fermer", "lockedTabsSkipped": "Les onglets verrouillés ont été ignorés", + "moveToWindow": "Déplacer vers", + "moveToNewWindow": "Déplacer vers une nouvelle fenêtre", "multiplexSsh": "Réutiliser la connexion SSH", "multiplexSshFailed": "Échec de la réutilisation du canal SSH", "multiplexSshWithCommand": "Réutiliser la connexion SSH et exécuter la commande", diff --git a/crates/nyaterm-desktop/locales/ja.json b/crates/nyaterm-desktop/locales/ja.json index a09c6a64..6729da49 100644 --- a/crates/nyaterm-desktop/locales/ja.json +++ b/crates/nyaterm-desktop/locales/ja.json @@ -1,6 +1,7 @@ { "tray": { "show": "NyaTerm を表示", + "newWindow": "新しいウィンドウ", "newConnection": "新しい接続", "push": "クラウドへ送信", "pull": "クラウドから取得", @@ -2697,6 +2698,8 @@ "locked": "ロック中", "lockedCloseBlocked": "閉じる前にこのタブのロックを解除してください", "lockedTabsSkipped": "ロックされたタブはスキップされました", + "moveToWindow": "移動先", + "moveToNewWindow": "新しいウィンドウに移動", "multiplexSsh": "SSH 接続を再利用", "multiplexSshFailed": "SSH チャネルの再利用に失敗しました", "multiplexSshWithCommand": "SSH 接続を再利用してコマンドを実行", diff --git a/crates/nyaterm-desktop/locales/ko.json b/crates/nyaterm-desktop/locales/ko.json index 65bfef5e..45e13254 100644 --- a/crates/nyaterm-desktop/locales/ko.json +++ b/crates/nyaterm-desktop/locales/ko.json @@ -1,6 +1,7 @@ { "tray": { "show": "NyaTerm 표시", + "newWindow": "새 창", "newConnection": "새 연결", "push": "클라우드에 보내기", "pull": "클라우드에서 가져오기", @@ -2688,6 +2689,8 @@ "locked": "잠김", "lockedCloseBlocked": "이 탭을 닫으려면 먼저 잠금을 해제하세요", "lockedTabsSkipped": "잠긴 탭은 건너뛰었습니다", + "moveToWindow": "이동할 창", + "moveToNewWindow": "새 창으로 이동", "multiplexSsh": "SSH 연결 재사용", "multiplexSshFailed": "SSH 채널을 재사용하지 못했습니다", "multiplexSshWithCommand": "SSH 연결 재사용 및 명령 실행", diff --git a/crates/nyaterm-desktop/locales/zh-CN.json b/crates/nyaterm-desktop/locales/zh-CN.json index ba5c0e9e..ae2e6151 100644 --- a/crates/nyaterm-desktop/locales/zh-CN.json +++ b/crates/nyaterm-desktop/locales/zh-CN.json @@ -1,6 +1,7 @@ { "tray": { "show": "显示 NyaTerm", + "newWindow": "新建窗口", "newConnection": "新建连接", "push": "推送云同步", "pull": "拉取云同步", @@ -2735,6 +2736,8 @@ "locked": "已锁定", "lockedCloseBlocked": "请先解锁此标签再关闭", "lockedTabsSkipped": "已跳过锁定的标签", + "moveToWindow": "移动到", + "moveToNewWindow": "移动到新窗口", "multiplexSsh": "复用 SSH 连接新建会话", "multiplexSshFailed": "SSH 通道复用失败", "multiplexSshWithCommand": "复用 SSH 连接并执行命令", diff --git a/crates/nyaterm-desktop/locales/zh-TW.json b/crates/nyaterm-desktop/locales/zh-TW.json index 7e427104..19c86003 100644 --- a/crates/nyaterm-desktop/locales/zh-TW.json +++ b/crates/nyaterm-desktop/locales/zh-TW.json @@ -1,6 +1,7 @@ { "tray": { "show": "顯示 NyaTerm", + "newWindow": "新增視窗", "newConnection": "新增連線", "push": "推送雲端同步", "pull": "拉取雲端同步", @@ -2688,6 +2689,8 @@ "locked": "已鎖定", "lockedCloseBlocked": "請先解鎖此標籤再關閉", "lockedTabsSkipped": "已跳過鎖定的標籤", + "moveToWindow": "移動到", + "moveToNewWindow": "移動到新視窗", "multiplexSsh": "複用 SSH 連線建立工作階段", "multiplexSshFailed": "SSH 通道複用失敗", "multiplexSshWithCommand": "複用 SSH 連線並執行命令", diff --git a/crates/nyaterm-desktop/src/app_shell/controller.rs b/crates/nyaterm-desktop/src/app_shell/controller.rs new file mode 100644 index 00000000..839e5a03 --- /dev/null +++ b/crates/nyaterm-desktop/src/app_shell/controller.rs @@ -0,0 +1,1420 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::time::Duration; + +use gpui::{ + AnyWindowHandle, AppContext as _, Context, TitlebarOptions, WeakEntity, WindowOptions, point, + px, +}; +use nyaterm_core::{ + ACTIVATION_QUEUE_CAPACITY, ActivationOpenBehavior, ActivationReceiver, ActivationRequest, + AppRuntime, AppSettingsSummary, DeviceWindowManifest, DeviceWindowState, MainWindowState, + MoveTabTreeRequest, OpenWorkspaceRequest, WorkspaceId, +}; +use nyaterm_store::{ + BootstrapSnapshot, FlushBarrier, LoadBootstrap, StoreDomain, StoreOperationError, + StoreSubmitError, StoreTask, store_request, +}; +use nyaterm_ui::{NyaRoot, nya_root}; + +use super::{ + AppShell, AppShellStartup, GlobalStateMutation, ProcessStateStore, SessionHub, + SharedStateDomain, SharedStateEvent, +}; +use crate::features::{SystemTray, TraySnapshot, WorkspaceCloseSnapshot, show_tray_window}; + +pub struct DesktopControllerGlobal(pub gpui::Entity); +impl gpui::Global for DesktopControllerGlobal {} + +struct WorkspaceWindow { + handle: AnyWindowHandle, + shell: WeakEntity, +} + +#[derive(Default)] +struct RecentActivationCache { + ids: HashSet<[u8; 16]>, + order: VecDeque<[u8; 16]>, +} + +impl RecentActivationCache { + fn remember(&mut self, id: [u8; 16]) -> bool { + const CAPACITY: usize = ACTIVATION_QUEUE_CAPACITY * 4; + if !self.ids.insert(id) { + return false; + } + self.order.push_back(id); + while self.order.len() > CAPACITY { + if let Some(expired) = self.order.pop_front() { + self.ids.remove(&expired); + } + } + true + } +} + +pub struct DesktopController { + runtime: AppRuntime, + startup: AppShellStartup, + session_hub: gpui::Entity, + device_windows: DeviceWindowManifest, + windows: HashMap, + pending_tab_moves: HashMap, + settings_owner_workspace_id: Option, + tray: Option, + screen_locked: bool, + most_recent_workspace_id: Option, + recent_activations: RecentActivationCache, + closing_workspaces: HashSet, + process_quitting: bool, + pending_bootstrap: Option>, + bootstrap_in_flight: bool, + process_state: Option>, + shared_refresh_generation: u64, + applied_shared_refresh_generation: u64, +} + +impl DesktopController { + pub fn new(runtime: AppRuntime, mut startup: AppShellStartup, cx: &mut Context) -> Self { + let device_windows = startup.device_windows.clone(); + let pending_bootstrap = startup.take_pending_bootstrap(); + Self { + runtime, + session_hub: cx.new(|_| SessionHub::new()), + most_recent_workspace_id: startup + .device_windows + .most_recent_workspace_id + .or(Some(startup.workspace_id())), + startup, + device_windows, + windows: HashMap::new(), + pending_tab_moves: HashMap::new(), + settings_owner_workspace_id: None, + tray: None, + screen_locked: false, + recent_activations: RecentActivationCache::default(), + closing_workspaces: HashSet::new(), + process_quitting: false, + pending_bootstrap, + bootstrap_in_flight: false, + process_state: None, + shared_refresh_generation: 0, + applied_shared_refresh_generation: 0, + } + } + + pub fn launch( + &mut self, + activation_rx: ActivationReceiver, + cx: &mut Context, + ) -> anyhow::Result<()> { + let mut ids = self.startup.restored_workspace_ids(); + if let Some(recent) = self.most_recent_workspace_id + && let Some(index) = ids.iter().position(|id| *id == recent) + { + ids.swap(0, index); + } + for workspace_id in ids { + let startup = self.startup.for_workspace(workspace_id); + self.open_workspace_with_startup(startup, None, false, cx)?; + } + self.launch_initial_bootstrap(cx); + if let Some(recent) = self.most_recent_workspace_id + && let Some(entry) = self.windows.get(&recent) + { + let _ = entry.handle.update(cx, |_, window, cx| { + window.activate_window(); + cx.activate(true); + }); + } + cx.spawn(async move |this, cx| { + let mut activation_rx = activation_rx; + while let Some(request) = activation_rx.recv().await { + if this + .update(cx, |controller, cx| { + controller.route_activation(request, cx) + }) + .is_err() + { + break; + } + } + }) + .detach(); + self.start_tray(cx); + Ok(()) + } + + fn launch_initial_bootstrap(&mut self, cx: &mut Context) { + let Some(task) = self.pending_bootstrap.take() else { + return; + }; + self.bootstrap_in_flight = true; + self.await_process_bootstrap(task, cx); + } + + fn await_process_bootstrap( + &mut self, + task: StoreTask, + cx: &mut Context, + ) { + cx.spawn(async move |this, cx| { + let event = task.await; + let _ = this.update(cx, |controller, cx| { + controller.bootstrap_in_flight = false; + match event.outcome { + Ok(snapshot) => controller.install_process_snapshot(snapshot, cx), + Err(error) => controller.report_process_bootstrap_failure(error, cx), + } + }); + }) + .detach(); + } + + fn install_process_snapshot(&mut self, snapshot: BootstrapSnapshot, cx: &mut Context) { + if let Some(process_state) = self.process_state.clone() { + let event = process_state.update(cx, |state, cx| { + state.mutate( + GlobalStateMutation::ReplaceSnapshot { + snapshot, + domain: SharedStateDomain::All, + }, + cx, + ) + }); + self.broadcast_shared_state(event, cx); + return; + } + let process_state = cx.new(|_| ProcessStateStore::new(snapshot)); + self.process_state = Some(process_state.clone()); + let workspace_ids = self.windows.keys().copied().collect::>(); + for workspace_id in workspace_ids { + self.deliver_process_state(workspace_id, process_state.clone(), cx); + } + } + + fn broadcast_shared_state(&self, event: SharedStateEvent, cx: &mut Context) { + if !event.changed { + return; + } + let Some(process_state) = self.process_state.clone() else { + return; + }; + for entry in self.windows.values() { + let shell = entry.shell.clone(); + let process_state = process_state.clone(); + cx.defer(move |cx| { + let _ = shell.update(cx, |shell, cx| { + shell.apply_shared_state(process_state, event, cx) + }); + }); + } + } + + pub(crate) fn request_shared_state_refresh( + &mut self, + domain: SharedStateDomain, + cx: &mut Context, + ) { + if self.process_quitting { + return; + } + let Some(store_runtime) = self.startup.shared_store_runtime() else { + return; + }; + let task = match store_runtime.ui_client().try_submit(0, LoadBootstrap) { + Ok(task) => task, + Err(error) => { + tracing::warn!(%error, "shared state refresh was not submitted"); + return; + } + }; + self.shared_refresh_generation = self.shared_refresh_generation.saturating_add(1); + let generation = self.shared_refresh_generation; + cx.spawn(async move |this, cx| { + let event = task.await; + let _ = this.update(cx, |controller, cx| match event.outcome { + Ok(snapshot) if generation >= controller.applied_shared_refresh_generation => { + controller.applied_shared_refresh_generation = generation; + controller.apply_shared_snapshot(snapshot, domain, cx); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(category = error.category(), "shared state refresh failed") + } + }); + }) + .detach(); + } + + pub(crate) fn replace_shared_snapshot( + &mut self, + snapshot: BootstrapSnapshot, + domain: SharedStateDomain, + cx: &mut Context, + ) { + self.shared_refresh_generation = self.shared_refresh_generation.saturating_add(1); + self.applied_shared_refresh_generation = self.shared_refresh_generation; + self.apply_shared_snapshot(snapshot, domain, cx); + } + + fn apply_shared_snapshot( + &mut self, + snapshot: BootstrapSnapshot, + domain: SharedStateDomain, + cx: &mut Context, + ) { + let Some(process_state) = self.process_state.clone() else { + self.install_process_snapshot(snapshot, cx); + return; + }; + let event = process_state.update(cx, |state, cx| { + state.mutate( + GlobalStateMutation::ReplaceSnapshot { snapshot, domain }, + cx, + ) + }); + self.broadcast_shared_state(event, cx); + } + + fn report_process_bootstrap_failure( + &mut self, + error: StoreOperationError, + cx: &mut Context, + ) { + for entry in self.windows.values() { + let _ = entry + .shell + .update(cx, |shell, cx| shell.enter_recovery(error.clone(), cx)); + } + } + + fn deliver_process_state( + &self, + workspace_id: WorkspaceId, + process_state: gpui::Entity, + cx: &mut Context, + ) { + let Some(entry) = self.windows.get(&workspace_id) else { + return; + }; + let shell = entry.shell.clone(); + let handle = entry.handle; + cx.defer(move |cx| { + let _ = handle.update(cx, move |_, window, cx| { + let _ = shell.update(cx, |shell, cx| { + shell.complete_bootstrap(process_state, window, cx) + }); + }); + }); + } + + pub(super) fn retry_process_bootstrap(&mut self, cx: &mut Context) { + if self.bootstrap_in_flight || self.process_quitting { + return; + } + let Some(store_runtime) = self.startup.shared_store_runtime() else { + return; + }; + match store_runtime.ui_client().try_submit(0, LoadBootstrap) { + Ok(task) => { + self.bootstrap_in_flight = true; + self.await_process_bootstrap(task, cx); + } + Err(error) => { + tracing::error!(%error, "process bootstrap retry was not submitted"); + } + } + } + + fn start_tray(&mut self, cx: &mut Context) { + cx.spawn(async move |this, cx| { + let image = cx + .background_spawn(async { + image::load_from_memory(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../nyaterm-app/resources/icons/32x32.png" + ))) + .map(|image| { + let image = image.to_rgba8(); + (image.width(), image.height(), image.into_raw()) + }) + }) + .await; + let Ok((width, height, pixels)) = image else { + return; + }; + let initialized = this + .update(cx, |controller, cx| { + let snapshot = controller.tray_snapshot(cx); + controller.tray = SystemTray::new(snapshot, pixels, width, height).ok(); + controller.tray.is_some() + }) + .unwrap_or(false); + if !initialized { + return; + } + loop { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + if this + .update(cx, |controller, cx| controller.poll_tray(cx)) + .is_err() + { + break; + } + } + }) + .detach(); + } + + fn recent_app( + &self, + cx: &mut Context, + ) -> Option> { + let id = self + .most_recent_workspace_id + .or_else(|| self.windows.keys().next().copied())?; + self.windows + .get(&id)? + .shell + .update(cx, |shell, _| shell.app.clone()) + .ok() + .flatten() + } + + fn tray_snapshot(&self, cx: &mut Context) -> TraySnapshot { + self.recent_app(cx) + .map(|app| app.read(cx).tray_snapshot()) + .unwrap_or_else(TraySnapshot::empty) + } + + fn poll_tray(&mut self, cx: &mut Context) { + let snapshot = self.tray_snapshot(cx); + if let Some(tray) = self.tray.as_mut() { + tray.update(snapshot) + } + while let Ok(event) = tray_icon::menu::MenuEvent::receiver().try_recv() { + self.handle_tray_action(event.id.as_ref(), cx); + } + while let Ok(event) = tray_icon::TrayIconEvent::receiver().try_recv() { + if matches!(event, tray_icon::TrayIconEvent::DoubleClick { .. }) { + self.handle_tray_action("show", cx); + } + } + } + + fn handle_tray_action(&mut self, action: &str, cx: &mut Context) { + match action { + "show" => { + if let Some(id) = self + .most_recent_workspace_id + .or_else(|| self.windows.keys().next().copied()) + { + if let Some(entry) = self.windows.get(&id) { + let _ = entry.handle.update(cx, |_, window, cx| { + show_tray_window(window, cx); + }); + } + } else if let Err(error) = self.open_workspace(OpenWorkspaceRequest::default(), cx) + { + tracing::error!(%error, "could not show NyaTerm window"); + } + } + "new-window" => { + if let Err(error) = self.open_workspace(OpenWorkspaceRequest::default(), cx) { + tracing::error!(%error, "could not open NyaTerm window"); + } + } + "quit" => self.request_quit(cx), + _ => { + if let Some(app) = self.recent_app(cx) { + let action = action.to_string(); + app.update(cx, |app, cx| app.handle_tray_action(action, cx)); + } + } + } + } + + pub fn tray_available(&self) -> bool { + self.tray.is_some() + } + + pub fn screen_locked(&self) -> bool { + self.screen_locked + } + + pub fn set_screen_locked(&mut self, locked: bool, source: WorkspaceId, cx: &mut Context) { + if self.screen_locked == locked { + return; + } + self.screen_locked = locked; + for (id, entry) in &self.windows { + if *id == source { + continue; + } + let app = entry + .shell + .update(cx, |shell, _| shell.app.clone()) + .ok() + .flatten(); + if let Some(app) = app { + let _ = entry.handle.update(cx, |_, window, cx| { + app.update(cx, |app, cx| { + app.apply_shared_screen_lock(locked, window, cx) + }); + }); + } + } + } + + pub fn open_workspace( + &mut self, + request: OpenWorkspaceRequest, + cx: &mut Context, + ) -> anyhow::Result { + anyhow::ensure!(!self.process_quitting, "the application is closing"); + let workspace_id = WorkspaceId::new(); + let startup = self.startup.for_new_workspace(workspace_id); + self.open_workspace_with_startup(startup, request.activation, request.activate, cx)?; + Ok(workspace_id) + } + + fn open_workspace_with_startup( + &mut self, + startup: AppShellStartup, + initial_activation: Option, + activate: bool, + cx: &mut Context, + ) -> anyhow::Result<()> { + let workspace_id = startup.workspace_id(); + let placement = startup.main_window_placement(cx); + let runtime = self.runtime.clone(); + let controller = cx.entity(); + let session_hub = self.session_hub.clone(); + let shell_slot = Rc::new(RefCell::new(None)); + let shell_slot_for_window = shell_slot.clone(); + let flavor = nyaterm_core::app_identity::AppFlavor::current(); + let handle = cx.open_window( + WindowOptions { + app_id: Some(flavor.desktop_id().to_string()), + titlebar: Some(TitlebarOptions { + title: Some(flavor.display_name().into()), + appears_transparent: true, + traffic_light_position: cfg!(target_os = "macos") + .then(|| point(px(9.), px(11.))), + }), + #[cfg(target_os = "linux")] + window_decorations: Some(gpui::WindowDecorations::Client), + window_bounds: Some(placement.window_bounds), + display_id: placement.display_id, + ..Default::default() + }, + move |window, cx| { + let shell = cx.new(|cx| { + AppShell::new( + runtime, + initial_activation, + startup, + workspace_id, + controller, + session_hub, + cx, + ) + }); + *shell_slot_for_window.borrow_mut() = Some(shell.downgrade()); + let close_shell = shell.clone(); + window.on_window_should_close(cx, move |window, cx| { + close_shell.update(cx, |shell, cx| shell.request_window_close(window, cx)); + false + }); + shell.update(cx, |shell, cx| shell.start_after_window_open(window, cx)); + cx.new(|cx| nya_root(shell, window, cx)) + }, + )?; + let shell = shell_slot + .borrow_mut() + .take() + .expect("window must construct its workspace shell"); + self.windows.insert( + workspace_id, + WorkspaceWindow { + handle: handle.into(), + shell, + }, + ); + if let Some(process_state) = self.process_state.clone() { + self.deliver_process_state(workspace_id, process_state, cx); + } + if !self.device_windows.window_order.contains(&workspace_id) { + self.device_windows.window_order.push(workspace_id); + } + if activate || self.most_recent_workspace_id.is_none() { + self.most_recent_workspace_id = Some(workspace_id); + } + if activate { + let _ = handle.update(cx, |_, window, cx| { + window.activate_window(); + cx.activate(true); + }); + } + Ok(()) + } + + pub fn route_activation(&mut self, request: ActivationRequest, cx: &mut Context) { + if self.process_quitting { + return; + } + if !self.recent_activations.remember(request.request_id) { + return; + } + if request.open_behavior() == ActivationOpenBehavior::ReuseMostRecent + && self.activate_and_deliver(self.most_recent_workspace_id, request.clone(), cx) + { + return; + } + if let Err(error) = self.open_workspace( + OpenWorkspaceRequest { + activation: Some(request), + ..Default::default() + }, + cx, + ) { + tracing::error!(%error, "failed to open workspace for activation"); + } + } + + fn activate_and_deliver( + &mut self, + workspace_id: Option, + request: ActivationRequest, + cx: &mut Context, + ) -> bool { + let Some(workspace_id) = workspace_id else { + return false; + }; + let Some(entry) = self.windows.get(&workspace_id) else { + return false; + }; + if entry + .shell + .update(cx, |shell, cx| shell.receive_activation_direct(request, cx)) + .is_err() + { + return false; + } + let _ = entry.handle.update(cx, |_, window, cx| { + window.activate_window(); + cx.activate(true); + }); + self.most_recent_workspace_id = Some(workspace_id); + true + } + + pub fn mark_active(&mut self, workspace_id: WorkspaceId) { + if self.windows.contains_key(&workspace_id) { + self.most_recent_workspace_id = Some(workspace_id); + self.device_windows.most_recent_workspace_id = Some(workspace_id); + } + } + + pub fn publish_settings( + &mut self, + source: WorkspaceId, + settings: AppSettingsSummary, + cx: &mut Context, + ) { + let _ = source; + let Some(process_state) = self.process_state.clone() else { + return; + }; + self.shared_refresh_generation = self.shared_refresh_generation.saturating_add(1); + self.applied_shared_refresh_generation = self.shared_refresh_generation; + let event = process_state.update(cx, |state, cx| { + state.mutate(GlobalStateMutation::UpdateSettings(settings), cx) + }); + self.broadcast_shared_state(event, cx); + } + + pub fn activate_or_claim_settings( + &mut self, + requester: WorkspaceId, + cx: &mut Context, + ) -> bool { + let Some(owner) = self.settings_owner_workspace_id else { + self.settings_owner_workspace_id = Some(requester); + return false; + }; + if owner == requester { + return false; + } + let Some(entry) = self.windows.get(&owner) else { + self.settings_owner_workspace_id = Some(requester); + return false; + }; + let app = entry + .shell + .update(cx, |shell, _| shell.app.clone()) + .ok() + .flatten(); + if let Some(app) = app { + app.update(cx, |app, cx| { + app.activate_settings_window(cx); + }); + let _ = entry.handle.update(cx, |_, window, cx| { + window.activate_window(); + cx.activate(true); + }); + self.mark_active(owner); + true + } else { + self.settings_owner_workspace_id = Some(requester); + false + } + } + + pub fn release_settings_owner(&mut self, workspace_id: WorkspaceId) { + if self.settings_owner_workspace_id == Some(workspace_id) { + self.settings_owner_workspace_id = None; + } + } + + pub fn submit_window_state( + &mut self, + workspace_id: WorkspaceId, + state: MainWindowState, + generation: u64, + ) -> Result, StoreSubmitError> { + if let Some(existing) = self + .device_windows + .windows + .iter_mut() + .find(|entry| entry.workspace_id == workspace_id) + { + existing.window = state; + } else { + self.device_windows.windows.push(DeviceWindowState { + workspace_id, + window: state, + extra: Default::default(), + }); + } + if !self.device_windows.window_order.contains(&workspace_id) { + self.device_windows.window_order.push(workspace_id); + } + self.device_windows.most_recent_workspace_id = self.most_recent_workspace_id; + let store = self + .startup + .shared_store_runtime() + .expect("desktop controller requires a store runtime") + .ui_client(); + let device_windows = self.device_windows.clone(); + let recent = self.most_recent_workspace_id; + store.try_submit( + generation, + store_request(StoreDomain::WindowState, move |store| { + let mut manifest = store.load_workspace_restore_manifest()?; + if let Some(recent) = recent { + if !manifest + .workspaces + .iter() + .any(|workspace| workspace.id == recent) + { + manifest + .workspaces + .push(nyaterm_core::WorkspaceRestoreState::empty(recent)); + } + manifest.most_recent_workspace_id = Some(recent); + } + store.save_restore_manifests_atomically(&manifest, &device_windows) + }), + ) + } + + pub fn request_close_workspace( + &mut self, + workspace_id: WorkspaceId, + persistence_task: StoreTask<()>, + cx: &mut Context, + ) -> Result<(), StoreSubmitError> { + if !self.windows.contains_key(&workspace_id) { + return Ok(()); + } + if self.process_quitting || !self.closing_workspaces.is_empty() { + return Err(StoreSubmitError::ShuttingDown); + } + self.closing_workspaces.insert(workspace_id); + cx.spawn(async move |this, cx| { + let persistence = persistence_task.await; + if let Err(error) = persistence.outcome { + let _ = this.update(cx, |controller, cx| { + controller.report_workspace_close_failure(workspace_id, error.to_string(), cx) + }); + return; + } + + let barrier = match this.update(cx, |controller, _| { + controller + .startup + .shared_store_runtime() + .expect("desktop controller requires a store runtime") + .ui_client() + .try_submit(0, FlushBarrier) + }) { + Ok(Ok(task)) => task, + Ok(Err(error)) => { + let _ = this.update(cx, |controller, cx| { + controller.report_workspace_close_failure( + workspace_id, + error.to_string(), + cx, + ) + }); + return; + } + Err(_) => return, + }; + let barrier = barrier.await; + if let Err(error) = barrier.outcome { + let _ = this.update(cx, |controller, cx| { + controller.report_workspace_close_failure(workspace_id, error.to_string(), cx) + }); + return; + } + + let submitted = this.update(cx, |controller, _| { + controller.submit_closed_workspace_manifests(workspace_id) + }); + let (task, device_windows, next_recent) = match submitted { + Ok(Ok(submitted)) => submitted, + Ok(Err(error)) => { + let _ = this.update(cx, |controller, cx| { + controller.report_workspace_close_failure( + workspace_id, + error.to_string(), + cx, + ) + }); + return; + } + Err(_) => return, + }; + let event = task.await; + if let Err(error) = event.outcome { + let _ = this.update(cx, |controller, cx| { + controller.report_workspace_close_failure(workspace_id, error.to_string(), cx) + }); + return; + } + let _ = this.update(cx, |controller, cx| { + controller.finish_workspace_close(workspace_id, device_windows, next_recent, cx) + }); + }) + .detach(); + Ok(()) + } + + fn submit_closed_workspace_manifests( + &self, + workspace_id: WorkspaceId, + ) -> Result<(StoreTask<()>, DeviceWindowManifest, Option), StoreSubmitError> { + let mut device_windows = self.device_windows.clone(); + device_windows + .windows + .retain(|entry| entry.workspace_id != workspace_id); + device_windows.window_order.retain(|id| *id != workspace_id); + let next_recent = next_recent_after_close( + self.most_recent_workspace_id, + workspace_id, + &device_windows.window_order, + |id| self.windows.contains_key(&id), + ); + device_windows.most_recent_workspace_id = next_recent; + let store = self + .startup + .shared_store_runtime() + .expect("desktop controller requires a store runtime") + .ui_client(); + let persisted_device_windows = device_windows.clone(); + let task = store.try_submit( + 0, + store_request(StoreDomain::Sessions, move |store| { + let mut manifest = store.load_workspace_restore_manifest()?; + manifest + .workspaces + .retain(|workspace| workspace.id != workspace_id); + manifest.most_recent_workspace_id = next_recent + .filter(|id| { + manifest + .workspaces + .iter() + .any(|workspace| workspace.id == *id) + }) + .or_else(|| manifest.workspaces.last().map(|workspace| workspace.id)); + store.save_restore_manifests_atomically(&manifest, &persisted_device_windows) + }), + )?; + Ok((task, device_windows, next_recent)) + } + + fn finish_workspace_close( + &mut self, + workspace_id: WorkspaceId, + device_windows: DeviceWindowManifest, + next_recent: Option, + cx: &mut Context, + ) { + self.closing_workspaces.remove(&workspace_id); + let Some(entry) = self.windows.remove(&workspace_id) else { + return; + }; + let _ = entry.shell.update(cx, |shell, cx| { + if let Some(app) = &shell.app { + app.update(cx, |app, _| { + app.shutdown_workspace_sessions(); + app.shutdown_blocking_jobs(); + }); + } + }); + self.release_settings_owner(workspace_id); + self.pending_tab_moves.remove(&workspace_id); + cx.defer(move |cx| { + let _ = entry + .handle + .update(cx, |_, window, _| window.remove_window()); + }); + self.most_recent_workspace_id = next_recent; + self.device_windows = device_windows; + } + + fn report_workspace_close_failure( + &mut self, + workspace_id: WorkspaceId, + message: String, + cx: &mut Context, + ) { + self.closing_workspaces.remove(&workspace_id); + if let Some(entry) = self.windows.get(&workspace_id) { + let _ = entry.shell.update(cx, |shell, cx| { + shell.finish_workspace_close_failure(message, cx) + }); + } + } + + fn defer_workspace_close_failure( + &self, + workspace_id: WorkspaceId, + message: String, + cx: &mut Context, + ) { + let controller = cx.entity().downgrade(); + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.report_workspace_close_failure(workspace_id, message, cx) + }); + }); + } + + pub fn request_close_unready_workspace( + &mut self, + workspace_id: WorkspaceId, + cx: &mut Context, + ) { + if self.process_quitting || self.closing_workspaces.contains(&workspace_id) { + return; + } + if self.windows.len() == 1 { + self.process_quitting = true; + let Some(store_runtime) = self.startup.shared_store_runtime() else { + self.process_quitting = false; + self.defer_workspace_close_failure( + workspace_id, + "The storage runtime is unavailable".to_string(), + cx, + ); + return; + }; + store_runtime.begin_shutdown(); + let task = store_runtime + .ui_client() + .try_submit_shutdown(0, FlushBarrier); + match task { + Ok(task) => { + cx.spawn(async move |this, cx| { + let outcome = task.await.outcome; + let _ = this.update(cx, |controller, cx| match outcome { + Ok(()) => { + controller.shutdown_all_workspaces(cx); + cx.quit(); + } + Err(error) => { + controller.process_quitting = false; + store_runtime.resume_after_failed_shutdown(); + controller.report_workspace_close_failure( + workspace_id, + error.to_string(), + cx, + ); + } + }); + }) + .detach(); + } + Err(error) => { + self.process_quitting = false; + store_runtime.resume_after_failed_shutdown(); + self.defer_workspace_close_failure(workspace_id, error.to_string(), cx); + } + } + return; + } + let Some(store_runtime) = self.startup.shared_store_runtime() else { + self.defer_workspace_close_failure( + workspace_id, + "The storage runtime is unavailable".to_string(), + cx, + ); + return; + }; + let persistence_task = store_runtime.ui_client().try_submit(0, FlushBarrier); + match persistence_task { + Ok(task) => { + if let Err(error) = self.request_close_workspace(workspace_id, task, cx) { + self.defer_workspace_close_failure(workspace_id, error.to_string(), cx); + } + } + Err(error) => { + self.defer_workspace_close_failure(workspace_id, error.to_string(), cx); + } + } + } + + pub fn request_quit(&mut self, cx: &mut Context) { + if self.process_quitting { + return; + } + let ready = self + .device_windows + .window_order + .iter() + .rev() + .copied() + .filter(|id| { + self.windows.get(id).is_some_and(|entry| { + entry + .shell + .update(cx, |shell, _| shell.can_coordinate_quit()) + .unwrap_or(false) + }) + }) + .collect::>(); + let workspace_id = ready + .iter() + .copied() + .find(|id| { + self.windows.get(id).is_some_and(|entry| { + entry + .shell + .update(cx, |shell, cx| { + shell + .app + .as_ref() + .is_some_and(|app| app.read(cx).has_live_sessions()) + }) + .unwrap_or(false) + }) + }) + .or_else(|| { + self.most_recent_workspace_id + .filter(|id| ready.contains(id)) + }) + .or_else(|| ready.first().copied()); + let Some(workspace_id) = workspace_id else { + self.request_quit_without_ready(cx); + return; + }; + if let Some(entry) = self.windows.get(&workspace_id) { + let shell = entry.shell.clone(); + let handle = entry.handle; + cx.defer(move |cx| { + let _ = handle.update(cx, move |_, window, cx| { + let _ = + shell.update(cx, |shell, cx| shell.request_application_quit(window, cx)); + }); + }); + } + } + + pub fn live_session_count(&self, cx: &mut Context) -> usize { + self.windows + .values() + .filter_map(|entry| { + entry + .shell + .update(cx, |shell, cx| { + shell + .app + .as_ref() + .map(|app| app.read(cx).live_session_count()) + }) + .ok() + .flatten() + }) + .sum() + } + + pub fn request_quit_without_ready(&mut self, cx: &mut Context) { + if !self.begin_process_quit() { + return; + } + let Some(store_runtime) = self.startup.shared_store_runtime() else { + self.shutdown_all_workspaces(cx); + cx.quit(); + return; + }; + store_runtime.begin_shutdown(); + let barrier = store_runtime + .ui_client() + .try_submit_shutdown(0, FlushBarrier); + let workspace_id = self.most_recent_workspace_id; + match barrier { + Ok(barrier) => { + cx.spawn(async move |this, cx| { + let outcome = barrier.await.outcome; + let _ = this.update(cx, |controller, cx| match outcome { + Ok(()) => { + controller.shutdown_all_workspaces(cx); + cx.quit(); + } + Err(error) => { + store_runtime.resume_after_failed_shutdown(); + controller.cancel_process_quit(); + if let Some(id) = workspace_id { + controller.report_workspace_close_failure( + id, + error.to_string(), + cx, + ); + } + } + }); + }) + .detach(); + } + Err(error) => { + store_runtime.resume_after_failed_shutdown(); + self.cancel_process_quit(); + if let Some(id) = workspace_id { + self.report_workspace_close_failure(id, error.to_string(), cx); + } + } + } + } + + pub fn prepare_other_workspaces_for_quit( + &mut self, + current_workspace_id: WorkspaceId, + cx: &mut Context, + ) -> Result>, StoreSubmitError> { + let mut tasks = Vec::new(); + for (workspace_id, entry) in &self.windows { + if *workspace_id == current_workspace_id { + continue; + } + let task = entry + .shell + .update(cx, |shell, cx| shell.submit_process_quit_persistence(cx)) + .map_err(|_| StoreSubmitError::Disconnected)??; + if let Some(task) = task { + tasks.push(task); + } + } + Ok(tasks) + } + + pub fn submit_process_restore_snapshot( + &mut self, + current_workspace_id: WorkspaceId, + current_state: Option, + cx: &mut Context, + ) -> Result, StoreSubmitError> { + let mut device_windows = self.device_windows.clone(); + let mut workspace_snapshots: Vec = Vec::new(); + for (workspace_id, entry) in &self.windows { + if let Some(app) = entry + .shell + .update(cx, |shell, _| shell.app.clone()) + .ok() + .flatten() + { + workspace_snapshots + .push(app.update(cx, |app, _| app.capture_workspace_close_snapshot())); + } + let state = if *workspace_id == current_workspace_id { + current_state.clone() + } else { + entry + .shell + .update(cx, |shell, _| shell.main_window_state.latest_for_shutdown()) + .ok() + .flatten() + }; + if let Some(state) = state { + if let Some(existing) = device_windows + .windows + .iter_mut() + .find(|window| window.workspace_id == *workspace_id) + { + existing.window = state; + } else { + device_windows.windows.push(DeviceWindowState { + workspace_id: *workspace_id, + window: state, + extra: Default::default(), + }); + } + } + if !device_windows.window_order.contains(workspace_id) { + device_windows.window_order.push(*workspace_id); + } + } + device_windows.most_recent_workspace_id = self.most_recent_workspace_id; + let recent = self.most_recent_workspace_id; + let store = self + .startup + .shared_store_runtime() + .expect("desktop controller requires a store runtime") + .ui_client(); + store.try_submit_shutdown( + u64::MAX - 1, + store_request(StoreDomain::Shutdown, move |store| { + let mut manifest = store.load_workspace_restore_manifest()?; + for snapshot in workspace_snapshots { + let workspace = if let Some(index) = manifest + .workspaces + .iter() + .position(|workspace| workspace.id == snapshot.workspace_id) + { + &mut manifest.workspaces[index] + } else { + manifest + .workspaces + .push(nyaterm_core::WorkspaceRestoreState::empty( + snapshot.workspace_id, + )); + manifest.workspaces.last_mut().expect("workspace inserted") + }; + snapshot.apply_to(workspace); + } + manifest.most_recent_workspace_id = recent.filter(|id| { + manifest + .workspaces + .iter() + .any(|workspace| workspace.id == *id) + }); + store.save_restore_manifests_atomically(&manifest, &device_windows) + }), + ) + } + + pub fn shutdown_all_workspaces(&mut self, cx: &mut Context) { + for entry in self.windows.values() { + let _ = entry.shell.update(cx, |shell, cx| { + if let Some(app) = &shell.app { + app.update(cx, |app, _| { + app.shutdown_workspace_sessions(); + app.shutdown_blocking_jobs(); + }); + } + }); + } + } + + pub fn workspace_count(&self) -> usize { + self.windows.len() + } + + pub fn begin_process_quit(&mut self) -> bool { + if self.process_quitting || !self.closing_workspaces.is_empty() { + return false; + } + self.process_quitting = true; + true + } + + pub fn cancel_process_quit(&mut self) { + self.process_quitting = false; + } + + pub fn is_most_recent_workspace(&self, workspace_id: WorkspaceId) -> bool { + self.most_recent_workspace_id == Some(workspace_id) + } + + pub fn workspace_targets(&self, source: WorkspaceId) -> Vec<(WorkspaceId, String)> { + self.device_windows + .window_order + .iter() + .copied() + .chain(self.windows.keys().copied()) + .filter(|id| *id != source && self.windows.contains_key(id)) + .fold(Vec::new(), |mut targets, id| { + if !targets.iter().any(|(existing, _)| *existing == id) { + targets.push((id, format!("Window {}", targets.len() + 1))); + } + targets + }) + } + + pub fn move_tab_tree( + &mut self, + request: MoveTabTreeRequest, + cx: &mut Context, + ) -> Result<(), String> { + if request.source_workspace_id == request.target_workspace_id { + return Err("source and target workspaces must differ".into()); + } + let source = self + .windows + .get(&request.source_workspace_id) + .ok_or("the source window closed during the drag")?; + let target = self + .windows + .get(&request.target_workspace_id) + .ok_or("the target window closed during the drag")?; + let source_app = source + .shell + .update(cx, |shell, _| shell.app.clone()) + .map_err(|_| "the source window closed during the drag")? + .ok_or("the source window is still loading")?; + let target_app = target + .shell + .update(cx, |shell, _| shell.app.clone()) + .map_err(|_| "the target window closed during the drag")? + .ok_or("the target window is still loading")?; + let session_ids = source_app + .read(cx) + .can_transfer_tab_tree(&request.root_tab_id, request.source_revision)?; + target_app + .read(cx) + .can_accept_tab_tree(&session_ids, &request.placement)?; + let bundle = source_app.update(cx, |app, cx| { + app.detach_tab_tree_for_transfer(&request.root_tab_id, request.source_revision, cx) + })?; + if let Err(failure) = target_app.update(cx, |app, cx| { + app.attach_tab_tree_from_transfer(bundle, &request.placement, cx) + }) { + let (error, bundle) = *failure; + source_app + .update(cx, |app, cx| { + app.restore_tab_tree_after_failed_transfer(bundle, cx) + }) + .map_err(|restore_error| format!("{error}; rollback failed: {restore_error}"))?; + return Err(error); + } + let target = self + .windows + .get(&request.target_workspace_id) + .ok_or("the target window closed during the move")?; + let _ = target.handle.update(cx, |_, window, cx| { + window.activate_window(); + cx.activate(true); + }); + self.mark_active(request.target_workspace_id); + Ok(()) + } + + pub fn open_workspace_for_tab( + &mut self, + source_workspace_id: WorkspaceId, + root_tab_id: String, + source_revision: u64, + cx: &mut Context, + ) -> anyhow::Result { + let target_workspace_id = self.open_workspace(OpenWorkspaceRequest::default(), cx)?; + self.pending_tab_moves.insert( + target_workspace_id, + MoveTabTreeRequest { + source_workspace_id, + target_workspace_id, + root_tab_id, + source_revision, + placement: Default::default(), + }, + ); + Ok(target_workspace_id) + } + + pub fn workspace_ready(&mut self, workspace_id: WorkspaceId, cx: &mut Context) { + let Some(request) = self.pending_tab_moves.remove(&workspace_id) else { + return; + }; + if let Err(error) = self.move_tab_tree(request, cx) { + tracing::warn!(%error, "could not move tab to newly opened window"); + } + } +} + +fn next_recent_after_close( + current: Option, + closing: WorkspaceId, + window_order: &[WorkspaceId], + is_open: impl Fn(WorkspaceId) -> bool, +) -> Option { + current + .filter(|id| *id != closing && is_open(*id)) + .or_else(|| { + window_order + .iter() + .rev() + .copied() + .find(|id| *id != closing && is_open(*id)) + }) +} + +#[allow(dead_code)] +fn _assert_root_type(_: gpui::WindowHandle) {} + +#[cfg(test)] +mod tests { + use super::{RecentActivationCache, next_recent_after_close}; + use nyaterm_core::{ACTIVATION_QUEUE_CAPACITY, WorkspaceId}; + + #[test] + fn activation_ids_are_deduplicated_until_fifo_eviction() { + let mut cache = RecentActivationCache::default(); + let id = |index: u64| { + let mut id = [0_u8; 16]; + id[..8].copy_from_slice(&index.to_le_bytes()); + id + }; + assert!(cache.remember(id(0))); + assert!(!cache.remember(id(0))); + for index in 1..=(ACTIVATION_QUEUE_CAPACITY * 4) as u64 { + assert!(cache.remember(id(index))); + } + assert!(!cache.remember(id(1))); + assert!(cache.remember(id(0)), "the oldest ID was evicted"); + } + + #[test] + fn closing_recent_workspace_uses_reverse_window_order_not_hash_iteration() { + let first = WorkspaceId::new(); + let second = WorkspaceId::new(); + let third = WorkspaceId::new(); + let order = [first, second, third]; + assert_eq!( + next_recent_after_close(Some(third), third, &order, |id| id != third), + Some(second) + ); + assert_eq!( + next_recent_after_close(Some(third), third, &order, |id| id == first), + Some(first) + ); + assert_eq!( + next_recent_after_close(Some(third), third, &order, |_| false), + None + ); + } +} diff --git a/crates/nyaterm-desktop/src/app_shell/mod.rs b/crates/nyaterm-desktop/src/app_shell/mod.rs index ff6ba82b..d395c792 100644 --- a/crates/nyaterm-desktop/src/app_shell/mod.rs +++ b/crates/nyaterm-desktop/src/app_shell/mod.rs @@ -1,28 +1,33 @@ //! Root GPUI shell boundary. +mod controller; +mod process_state; +mod session_hub; mod window_state; use self::window_state::{ MAIN_WINDOW_STATE_SAVE_DEBOUNCE, MainWindowStateController, capture_main_window_state, }; +pub use controller::{DesktopController, DesktopControllerGlobal}; +pub(crate) use process_state::{ + GlobalStateMutation, ProcessStateStore, SettingsDraftRevisions, SharedStateDomain, + SharedStateEvent, WorkspaceInitSnapshot, +}; +pub use session_hub::SessionHub; pub use window_state::{AppShellStartup, MainWindowPlacement}; -use std::collections::{HashSet, VecDeque}; -use std::time::Duration; +use std::collections::VecDeque; use gpui::{ - AnyElement, AppContext, Context, Entity, InteractiveElement, IntoElement, Menu, MenuItem, - OsAction, ParentElement, Render, Styled, Subscription, SystemMenuType, WeakEntity, Window, - actions, div, prelude::FluentBuilder, px, rgb, + AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, KeyBinding, + Menu, MenuItem, OsAction, ParentElement, Render, Styled, Subscription, SystemMenuType, + WeakEntity, Window, actions, div, prelude::FluentBuilder, px, rgb, }; use nyaterm_core::{ - ACTIVATION_QUEUE_CAPACITY, ActivationReceiver, ActivationRequest, AppRuntime, - DiagnosticsExportOptions, DiagnosticsRuntimeSnapshot, export_diagnostics_archive, -}; -use nyaterm_store::{ - FlushBarrier, LoadBootstrap, SaveMainWindowState, StoreConfig, StoreOperationError, - StoreRuntime, StoreTask, + ActivationRequest, AppRuntime, DiagnosticsExportOptions, DiagnosticsRuntimeSnapshot, + WorkspaceId, export_diagnostics_archive, }; +use nyaterm_store::{FlushBarrier, StoreOperationError, StoreRuntime, StoreSubmitError, StoreTask}; use nyaterm_ui::{ NyaAppMenu, NyaAppMenuBar, NyaButton, NyaButtonVariant, NyaCopy, NyaCut, NyaPaste, NyaRedo, NyaSelectAll, NyaUndo, @@ -40,6 +45,7 @@ actions!( NativeHide, NativeHideOthers, NativeShowAll, + NativeNewWindow, NativeNewSession, NativeQuickSwitch, NativeImportConfig, @@ -64,6 +70,19 @@ actions!( ] ); +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([native_new_window_key_binding()]); +} + +fn native_new_window_key_binding() -> KeyBinding { + let keystroke = if cfg!(target_os = "macos") { + "cmd-shift-n" + } else { + "ctrl-shift-n" + }; + KeyBinding::new(keystroke, NativeNewWindow, None) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NativeMenuCommand { NewSession, @@ -85,16 +104,17 @@ pub(crate) enum NativeMenuCommand { #[allow(dead_code)] pub struct AppShell { runtime: AppRuntime, + workspace_id: WorkspaceId, + controller: Entity, + session_hub: Entity, + quit_requested: bool, lifecycle: AppShellLifecycle, app: Option>, store_runtime: Option, - pending_bootstrap: Option>, startup_restore: Entity, overlays: Entity, - activation_rx: Option, pending_activations: VecDeque, - recent_activation_ids: HashSet<[u8; 16]>, - recent_activation_order: VecDeque<[u8; 16]>, + pending_process_quit_tasks: Vec>, main_window_state: MainWindowStateController, _subscriptions: Vec, } @@ -116,8 +136,11 @@ struct RecoveryState { impl AppShell { pub fn new( runtime: AppRuntime, - activation_rx: ActivationReceiver, + initial_activation: Option, startup: AppShellStartup, + workspace_id: WorkspaceId, + controller: Entity, + session_hub: Entity, cx: &mut Context, ) -> Self { let startup_restore = cx.new(|_| StartupRestoreStore::default()); @@ -139,44 +162,34 @@ impl AppShell { }) .unwrap_or(AppShellLifecycle::Loading); - let mut shell = Self { + Self { runtime, + workspace_id, + controller, + session_hub, + quit_requested: false, lifecycle, app: None, store_runtime: startup.store_runtime, - pending_bootstrap: startup.pending_bootstrap, startup_restore, overlays, - activation_rx: Some(activation_rx), - pending_activations: VecDeque::new(), - recent_activation_ids: HashSet::new(), - recent_activation_order: VecDeque::new(), + pending_activations: initial_activation.into_iter().collect(), + pending_process_quit_tasks: Vec::new(), main_window_state: MainWindowStateController::default(), _subscriptions: subscriptions, - }; - let quit_subscription = cx.on_app_quit(|this, cx| { - let store = this - .store_runtime - .as_ref() - .map(StoreRuntime::blocking_client); - let window_state = this.main_window_state.latest_for_shutdown(); - cx.background_executor().spawn(async move { - if let Some(store) = store { - if let Some(state) = window_state { - let _ = store.request_shutdown(u64::MAX - 1, SaveMainWindowState(state)); - } - let _ = store.request_shutdown(u64::MAX, FlushBarrier); - } - }) - }); - shell._subscriptions.push(quit_subscription); - shell + } } pub fn start_after_window_open(&mut self, window: &mut Window, cx: &mut Context) { + let workspace_id = self.workspace_id; + let controller = self.controller.clone(); + let activation_subscription = cx.observe_window_activation(window, move |_, window, cx| { + if window.is_window_active() { + controller.update(cx, |controller, _| controller.mark_active(workspace_id)); + } + }); + self._subscriptions.push(activation_subscription); self.start_main_window_state_persistence(window, cx); - self.start_activation_drain(window, cx); - self.launch_pending_bootstrap(window, cx); } fn start_main_window_state_persistence(&mut self, window: &mut Window, cx: &mut Context) { @@ -204,10 +217,11 @@ impl AppShell { .timer(MAIN_WINDOW_STATE_SAVE_DEBOUNCE) .await; let pending = this - .update(cx, |this, _| { + .update(cx, |this, cx| { let (generation, state) = this.main_window_state.take_debounced_save()?; - let store = this.store_runtime.as_ref()?.ui_client(); - match store.try_submit(generation, SaveMainWindowState(state)) { + match this.controller.update(cx, |controller, _| { + controller.submit_window_state(this.workspace_id, state, generation) + }) { Ok(task) => Some((generation, task)), Err(error) => { tracing::warn!(category = %error, "main window state save was not submitted"); @@ -236,46 +250,11 @@ impl AppShell { .detach(); } - fn start_activation_drain(&mut self, window: &mut Window, cx: &mut Context) { - let Some(mut activation_rx) = self.activation_rx.take() else { - return; - }; - cx.spawn_in(window, async move |this, cx| { - loop { - let can_receive = this - .update(cx, |this, _| { - matches!(this.lifecycle, AppShellLifecycle::Ready) - || this.pending_activations.len() < ACTIVATION_QUEUE_CAPACITY - }) - .unwrap_or(false); - if !can_receive { - cx.background_executor() - .timer(Duration::from_millis(40)) - .await; - continue; - } - let Some(request) = activation_rx.recv().await else { - break; - }; - if cx - .update(|window, cx| { - window.activate_window(); - cx.activate(true); - this.update(cx, |this, cx| this.receive_activation(request, cx)) - }) - .is_err() - { - break; - } - } - }) - .detach(); - } - - fn receive_activation(&mut self, request: ActivationRequest, cx: &mut Context) { - if !self.remember_activation(request.request_id) { - return; - } + pub(super) fn receive_activation_direct( + &mut self, + request: ActivationRequest, + cx: &mut Context, + ) { if matches!(self.lifecycle, AppShellLifecycle::Ready) { if let Some(app) = &self.app { app.update(cx, |app, cx| app.handle_activation(request, cx)); @@ -285,21 +264,6 @@ impl AppShell { } } - fn remember_activation(&mut self, request_id: [u8; 16]) -> bool { - const RECENT_ACTIVATION_CAPACITY: usize = ACTIVATION_QUEUE_CAPACITY * 4; - - if !self.recent_activation_ids.insert(request_id) { - return false; - } - self.recent_activation_order.push_back(request_id); - while self.recent_activation_order.len() > RECENT_ACTIVATION_CAPACITY { - if let Some(expired) = self.recent_activation_order.pop_front() { - self.recent_activation_ids.remove(&expired); - } - } - true - } - fn drain_pending_activations(&mut self, cx: &mut Context) { let Some(app) = self.app.clone() else { return; @@ -309,60 +273,27 @@ impl AppShell { } } - fn begin_bootstrap(&mut self) { + fn begin_bootstrap(&mut self, cx: &mut Context) { self.app = None; self.lifecycle = AppShellLifecycle::Loading; - let store_runtime = match StoreRuntime::spawn(StoreConfig { - config_dir: self.runtime.config_dir().to_path_buf(), - portable_key_path: self.runtime.portable_key_path().map(ToOwned::to_owned), - }) { - Ok(runtime) => runtime, - Err(error) => { - self.lifecycle = AppShellLifecycle::Recovery(RecoveryState { - category: "worker_start".to_string(), - message: error.to_string(), - diagnostics_status: None, - }); - return; - } - }; - match store_runtime.ui_client().try_submit(0, LoadBootstrap) { - Ok(task) => { - self.pending_bootstrap = Some(task); - self.store_runtime = Some(store_runtime); - } - Err(error) => { - self.lifecycle = AppShellLifecycle::Recovery(RecoveryState { - category: "request_submit".to_string(), - message: error.to_string(), - diagnostics_status: None, - }); - } - } + let controller = self.controller.clone(); + cx.defer(move |cx| { + controller.update(cx, |controller, cx| controller.retry_process_bootstrap(cx)); + }); } - fn launch_pending_bootstrap(&mut self, window: &mut Window, cx: &mut Context) { - let Some(task) = self.pending_bootstrap.take() else { - return; - }; - cx.spawn_in(window, async move |this, cx| { - let event = task.await; - let _ = cx.update(|window, cx| { - this.update(cx, |this, cx| match event.outcome { - Ok(bootstrap) => this.complete_bootstrap(bootstrap, window, cx), - Err(error) => this.enter_recovery(error, cx), - }) - }); - }) - .detach(); - } - - fn complete_bootstrap( + pub(super) fn complete_bootstrap( &mut self, - bootstrap: nyaterm_store::BootstrapSnapshot, + process_state: Entity, window: &mut Window, cx: &mut Context, ) { + if matches!( + self.lifecycle, + AppShellLifecycle::Flushing | AppShellLifecycle::Ready + ) { + return; + } let Some(store_runtime) = &self.store_runtime else { self.lifecycle = AppShellLifecycle::Recovery(RecoveryState { category: "runtime_missing".to_string(), @@ -372,40 +303,119 @@ impl AppShell { cx.notify(); return; }; + let workspace_init = process_state.read(cx).workspace_init(self.workspace_id); + let workspace_revision = if let Some(workspace) = workspace_init.state.as_ref() { + self.startup_restore.update(cx, |store, _| { + store.set_loaded_window_layouts( + workspace.sessions.terminal_window_layout.clone(), + workspace.sessions.workspace_pane_layout.clone(), + ); + }); + workspace.revision + } else { + self.startup_restore + .update(cx, |store, _| store.set_loaded_window_layouts(None, None)); + 0 + }; let stores = UiStoreHandles { startup_restore: self.startup_restore.clone(), overlays: self.overlays.clone(), }; let app = cx.new(|cx| { + let session_manager = self.session_hub.read(cx).manager(); NyaTermApp::from_bootstrap( self.runtime.clone(), stores, - bootstrap, + process_state, + workspace_init, store_runtime.ui_client(), store_runtime.blocking_client(), + session_manager, cx, ) }); let title_menu_bar = build_title_menu_bar(app.downgrade(), cx); + let screen_locked = self.controller.read(cx).screen_locked(); app.update(cx, |app, cx| { + app.set_workspace_identity(self.workspace_id, workspace_revision); + app.set_desktop_controller(self.controller.downgrade()); app.set_title_menu_bar(title_menu_bar); app.start_shell_environment_preload(cx); - app.start_system_tray(cx); + if screen_locked { + app.apply_shared_screen_lock(true, window, cx); + } }); let shutdown_subscription = cx.subscribe(&app, |this, _, event: &AppLifecycleEvent, cx| match event { - AppLifecycleEvent::ShutdownRequested => this.request_close(cx), + AppLifecycleEvent::ShutdownRequested => { + if this.quit_requested || this.controller.read(cx).workspace_count() == 1 { + if !this + .controller + .update(cx, |controller, _| controller.begin_process_quit()) + { + return; + } + let workspace_id = this.workspace_id; + let tasks = this.controller.update(cx, |controller, cx| { + controller.prepare_other_workspaces_for_quit(workspace_id, cx) + }); + match tasks { + Ok(tasks) => this.pending_process_quit_tasks = tasks, + Err(error) => { + this.controller + .update(cx, |controller, _| controller.cancel_process_quit()); + if let Some(app) = &this.app { + app.update(cx, |app, cx| { + app.report_close_save_failed(error.to_string(), cx) + }); + } + return; + } + } + this.request_close(cx); + } else { + let Some(app) = this.app.clone() else { return }; + let persistence_task = match app.update(cx, |app, cx| { + app.submit_shutdown_persistence(false).inspect_err(|error| { + app.report_close_save_failed(error.to_string(), cx); + }) + }) { + Ok(task) => task, + Err(_) => return, + }; + this.lifecycle = AppShellLifecycle::Flushing; + let workspace_id = this.workspace_id; + if let Err(error) = this.controller.update(cx, |controller, cx| { + controller.request_close_workspace(workspace_id, persistence_task, cx) + }) { + tracing::error!(%error, "could not close workspace"); + this.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); + app.update(cx, |app, cx| { + app.report_close_save_failed(error.to_string(), cx) + }); + return; + } + cx.notify(); + } + } + AppLifecycleEvent::NewWindowRequested => this.request_new_window(cx), }); self._subscriptions.push(shutdown_subscription); self.app = Some(app); self.lifecycle = AppShellLifecycle::Ready; self.start_ready_app(window, cx); self.drain_pending_activations(cx); + let controller = self.controller.downgrade(); + let workspace_id = self.workspace_id; + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.workspace_ready(workspace_id, cx) + }); + }); cx.notify(); } - fn enter_recovery(&mut self, error: StoreOperationError, cx: &mut Context) { - self.store_runtime = None; + pub(super) fn enter_recovery(&mut self, error: StoreOperationError, cx: &mut Context) { self.lifecycle = AppShellLifecycle::Recovery(RecoveryState { category: error.category().to_string(), message: error.user_message().to_string(), @@ -460,8 +470,8 @@ impl AppShell { } fn retry_bootstrap(&mut self, window: &mut Window, cx: &mut Context) { - self.begin_bootstrap(); - self.launch_pending_bootstrap(window, cx); + let _ = window; + self.begin_bootstrap(cx); cx.notify(); } @@ -519,14 +529,75 @@ impl AppShell { pub fn request_close(&mut self, cx: &mut Context) { match self.lifecycle { - AppShellLifecycle::Ready | AppShellLifecycle::FlushFailed(_) => self.begin_shutdown(cx), + AppShellLifecycle::Ready | AppShellLifecycle::FlushFailed(_) if self.app.is_some() => { + self.begin_shutdown(cx) + } AppShellLifecycle::Flushing => {} - AppShellLifecycle::Loading | AppShellLifecycle::Recovery(_) => { - self.quit_after_worker_shutdown(cx); + AppShellLifecycle::Ready + | AppShellLifecycle::FlushFailed(_) + | AppShellLifecycle::Loading + | AppShellLifecycle::Recovery(_) => { + self.lifecycle = AppShellLifecycle::Flushing; + cx.notify(); + let workspace_id = self.workspace_id; + self.controller.update(cx, |controller, cx| { + controller.request_close_unready_workspace(workspace_id, cx) + }); } } } + pub fn request_new_window(&mut self, cx: &mut Context) { + if let Err(error) = self.controller.update(cx, |controller, cx| { + controller.open_workspace(Default::default(), cx) + }) { + tracing::error!(%error, "failed to open a new NyaTerm window"); + } + } + + pub(super) fn request_application_quit(&mut self, window: &mut Window, cx: &mut Context) { + self.quit_requested = true; + if let Some(app) = &self.app { + let count = self + .controller + .update(cx, |controller, cx| controller.live_session_count(cx)); + app.update(cx, |app, cx| { + app.handle_window_close_request_with_count(count, window, cx) + }); + } else { + self.controller.update(cx, |controller, cx| { + controller.request_quit_without_ready(cx) + }); + } + } + + pub(super) fn can_coordinate_quit(&self) -> bool { + self.app.is_some() + } + + pub(super) fn apply_shared_state( + &mut self, + process_state: Entity, + event: SharedStateEvent, + cx: &mut Context, + ) { + if let Some(app) = &self.app { + app.update(cx, |app, cx| { + app.apply_shared_state(process_state.read(cx).snapshot().clone(), event, cx) + }); + } + } + + pub(super) fn submit_process_quit_persistence( + &mut self, + cx: &mut Context, + ) -> Result>, StoreSubmitError> { + self.app + .as_ref() + .map(|app| app.update(cx, |app, _| app.submit_shutdown_persistence(false))) + .transpose() + } + /// Handles a close request from the primary window or native Quit command. /// /// Once the application is ready, the feature layer owns the confirmation @@ -547,14 +618,15 @@ impl AppShell { } fn quit_after_worker_shutdown(&mut self, cx: &mut Context) { - if let Some(app) = &self.app { - app.update(cx, |app, _| app.shutdown_blocking_jobs()); - } + self.controller + .update(cx, |controller, cx| controller.shutdown_all_workspaces(cx)); cx.quit(); } fn begin_shutdown(&mut self, cx: &mut Context) { let Some(store_runtime) = &self.store_runtime else { + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); self.lifecycle = AppShellLifecycle::FlushFailed( "The storage runtime is unavailable; pending changes cannot be verified." .to_string(), @@ -564,19 +636,17 @@ impl AppShell { }; store_runtime.begin_shutdown(); let store_ui = store_runtime.ui_client(); - let window_state_task = if let Some(state) = self.main_window_state.latest_for_shutdown() { - match store_ui.try_submit_shutdown(u64::MAX - 2, SaveMainWindowState(state)) { - Ok(task) => Some(task), - Err(error) => { - self.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); - cx.notify(); - return; - } - } - } else { - None - }; + let recent = self + .controller + .read(cx) + .is_most_recent_workspace(self.workspace_id); + let latest_window_state = recent + .then(|| self.main_window_state.latest_for_shutdown()) + .flatten(); let Some(app) = &self.app else { + store_runtime.resume_after_failed_shutdown(); + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); self.lifecycle = AppShellLifecycle::FlushFailed( "The application state is unavailable; pending changes cannot be captured." .to_string(), @@ -584,9 +654,25 @@ impl AppShell { cx.notify(); return; }; - let snapshot_task = match app.update(cx, |app, _| app.submit_shutdown_persistence()) { + let snapshot_task = match app.update(cx, |app, _| app.submit_shutdown_persistence(false)) { Ok(task) => task, Err(error) => { + store_runtime.resume_after_failed_shutdown(); + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); + self.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); + cx.notify(); + return; + } + }; + let restore_task = match self.controller.update(cx, |controller, cx| { + controller.submit_process_restore_snapshot(self.workspace_id, latest_window_state, cx) + }) { + Ok(task) => task, + Err(error) => { + store_runtime.resume_after_failed_shutdown(); + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); self.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); cx.notify(); return; @@ -595,23 +681,43 @@ impl AppShell { let task = match store_ui.try_submit_shutdown(u64::MAX, FlushBarrier) { Ok(task) => task, Err(error) => { + store_runtime.resume_after_failed_shutdown(); + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); self.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); cx.notify(); return; } }; - drop(window_state_task); - drop(snapshot_task); + let pending_tasks = std::mem::take(&mut self.pending_process_quit_tasks); self.lifecycle = AppShellLifecycle::Flushing; cx.spawn(async move |this, cx| { - let event = task.await; - let _ = this.update(cx, |this, cx| match event.outcome { - Ok(()) => { - this.quit_after_worker_shutdown(cx); + let mut failure = None; + for pending in pending_tasks { + if let Err(error) = pending.await.outcome { + failure.get_or_insert(error); } - Err(error) => { + } + if let Err(error) = snapshot_task.await.outcome { + failure.get_or_insert(error); + } + if let Err(error) = restore_task.await.outcome { + failure.get_or_insert(error); + } + if let Err(error) = task.await.outcome { + failure.get_or_insert(error); + } + let _ = this.update(cx, |this, cx| { + if let Some(error) = failure { + if let Some(store_runtime) = &this.store_runtime { + store_runtime.resume_after_failed_shutdown(); + } + this.controller + .update(cx, |controller, _| controller.cancel_process_quit()); this.lifecycle = AppShellLifecycle::FlushFailed(error.to_string()); cx.notify(); + } else { + this.quit_after_worker_shutdown(cx); } }); }) @@ -624,13 +730,48 @@ impl AppShell { return; }; store_runtime.resume_after_failed_shutdown(); - self.lifecycle = AppShellLifecycle::Ready; + self.controller + .update(cx, |controller, _| controller.cancel_process_quit()); + self.lifecycle = if self.app.is_some() { + AppShellLifecycle::Ready + } else { + AppShellLifecycle::Recovery(RecoveryState { + category: "close_failed".to_string(), + message: "The window was not closed; retry loading or closing it.".to_string(), + diagnostics_status: None, + }) + }; if let Some(app) = &self.app { app.update(cx, NyaTermApp::report_shutdown_retry_required); } cx.notify(); } + pub(super) fn finish_workspace_close_failure( + &mut self, + message: String, + cx: &mut Context, + ) { + self.lifecycle = AppShellLifecycle::FlushFailed(message.clone()); + if let Some(app) = &self.app { + app.update(cx, |app, cx| app.report_close_save_failed(message, cx)); + } + cx.notify(); + } + + fn retry_close(&mut self, window: &mut Window, cx: &mut Context) { + if self.app.is_none() { + self.request_close(cx); + return; + } + if self.quit_requested || self.controller.read(cx).workspace_count() == 1 { + self.begin_shutdown(cx); + return; + } + self.return_to_app_after_flush_failure(cx); + self.request_window_close(window, cx); + } + fn lifecycle_view(&self, cx: &mut Context) -> AnyElement { match &self.lifecycle { AppShellLifecycle::Loading => div() @@ -707,7 +848,9 @@ impl AppShell { NyaButton::new("recovery-quit", "Quit") .variant(NyaButtonVariant::Danger) .on_click(cx.listener(|this, _, _, cx| { - this.quit_after_worker_shutdown(cx); + this.controller.update(cx, |controller, cx| { + controller.request_quit(cx) + }); })), ), ), @@ -758,8 +901,8 @@ impl AppShell { .child( NyaButton::new("shutdown-retry", "Retry") .variant(NyaButtonVariant::Primary) - .on_click(cx.listener(|this, _, _, cx| { - this.begin_shutdown(cx); + .on_click(cx.listener(|this, _, window, cx| { + this.retry_close(window, cx); })), ) .child(NyaButton::new("shutdown-return", "Return to App").on_click( @@ -807,6 +950,7 @@ fn native_app_menus_for(flavor: nyaterm_core::app_identity::AppFlavor) -> Vec Vec<&str> { @@ -1083,4 +1232,9 @@ mod tests { assert!(item_names(app).contains(&"About NyaTerm")); assert!(!item_names(help).contains(&"About NyaTerm")); } + + #[test] + fn native_new_window_shortcut_is_valid_for_the_current_platform() { + let _ = native_new_window_key_binding(); + } } diff --git a/crates/nyaterm-desktop/src/app_shell/process_state.rs b/crates/nyaterm-desktop/src/app_shell/process_state.rs new file mode 100644 index 00000000..2480b53d --- /dev/null +++ b/crates/nyaterm-desktop/src/app_shell/process_state.rs @@ -0,0 +1,285 @@ +use gpui::Context; +use nyaterm_core::{WorkspaceId, WorkspaceRestoreState}; +use nyaterm_store::{BootstrapSnapshot, StoreDomain}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum SharedStateDomain { + Settings, + Connections, + Security, + Tunnels, + Commands, + Ai, + Translation, + CloudSync, + All, +} + +impl SharedStateDomain { + pub(crate) fn from_store_domain(domain: StoreDomain) -> Option { + match domain { + StoreDomain::Settings => Some(Self::Settings), + StoreDomain::Connections => Some(Self::Connections), + StoreDomain::Security => Some(Self::Security), + StoreDomain::Tunnels => Some(Self::Tunnels), + StoreDomain::Commands => Some(Self::Commands), + StoreDomain::Ai => Some(Self::Ai), + StoreDomain::CloudSync => Some(Self::CloudSync), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SharedStateEvent { + pub(crate) domain: SharedStateDomain, + pub(crate) revision: u64, + pub(crate) changed: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct SettingsDraftRevisions { + pub(crate) settings: u64, + pub(crate) ai: u64, + pub(crate) translation: u64, + pub(crate) cloud_sync: u64, +} + +pub(crate) enum GlobalStateMutation { + UpdateSettings(nyaterm_core::AppSettingsSummary), + ReplaceSnapshot { + snapshot: BootstrapSnapshot, + domain: SharedStateDomain, + }, +} + +#[derive(Clone)] +pub(crate) struct WorkspaceInitSnapshot { + pub(crate) workspace_id: WorkspaceId, + pub(crate) state: Option, +} + +pub(crate) struct ProcessStateStore { + snapshot: BootstrapSnapshot, + revision: u64, + settings_revisions: SettingsDraftRevisions, +} + +impl ProcessStateStore { + pub(crate) fn new(snapshot: BootstrapSnapshot) -> Self { + Self { + snapshot, + revision: 0, + settings_revisions: SettingsDraftRevisions::default(), + } + } + + pub(crate) fn snapshot(&self) -> &BootstrapSnapshot { + &self.snapshot + } + + pub(crate) fn workspace_init(&self, workspace_id: WorkspaceId) -> WorkspaceInitSnapshot { + WorkspaceInitSnapshot { + workspace_id, + state: self + .snapshot + .workspace_restore + .workspaces + .iter() + .find(|workspace| workspace.id == workspace_id) + .cloned(), + } + } + + pub(crate) fn settings_draft_revisions(&self) -> SettingsDraftRevisions { + self.settings_revisions + } + + pub(crate) fn mutate( + &mut self, + mutation: GlobalStateMutation, + cx: &mut Context, + ) -> SharedStateEvent { + let event = self.apply_mutation(mutation); + if event.changed { + cx.notify(); + } + event + } + + fn apply_mutation(&mut self, mutation: GlobalStateMutation) -> SharedStateEvent { + let mut changed = true; + let domain = match mutation { + GlobalStateMutation::UpdateSettings(settings) => { + let local = &self.snapshot.settings; + let mut settings = settings; + settings.ui_left_panel_width = local.ui_left_panel_width; + settings.ui_right_panel_width = local.ui_right_panel_width; + settings.ui_quick_cmd_height = local.ui_quick_cmd_height; + settings.ui_active_left_panel = local.ui_active_left_panel.clone(); + settings.ui_active_right_panel = local.ui_active_right_panel.clone(); + settings.ui_left_panel_collapsed = local.ui_left_panel_collapsed; + settings.ui_right_panel_collapsed = local.ui_right_panel_collapsed; + changed = self.snapshot.settings != settings; + if changed { + self.snapshot.settings = settings; + } + SharedStateDomain::Settings + } + GlobalStateMutation::ReplaceSnapshot { snapshot, domain } => { + if domain == SharedStateDomain::Settings { + let mut normalized = snapshot.settings.clone(); + let local = &self.snapshot.settings; + normalized.ui_left_panel_width = local.ui_left_panel_width; + normalized.ui_right_panel_width = local.ui_right_panel_width; + normalized.ui_quick_cmd_height = local.ui_quick_cmd_height; + normalized.ui_active_left_panel = local.ui_active_left_panel.clone(); + normalized.ui_active_right_panel = local.ui_active_right_panel.clone(); + normalized.ui_left_panel_collapsed = local.ui_left_panel_collapsed; + normalized.ui_right_panel_collapsed = local.ui_right_panel_collapsed; + changed = normalized != self.snapshot.settings + || snapshot.keyword_highlights != self.snapshot.keyword_highlights; + } + self.snapshot = snapshot; + domain + } + }; + if !changed { + return SharedStateEvent { + domain, + revision: self.revision, + changed: false, + }; + } + self.revision = self.revision.saturating_add(1); + match domain { + SharedStateDomain::Settings => { + self.settings_revisions.settings = + self.settings_revisions.settings.saturating_add(1); + } + SharedStateDomain::Ai => { + self.settings_revisions.ai = self.settings_revisions.ai.saturating_add(1); + } + SharedStateDomain::Translation => { + self.settings_revisions.translation = + self.settings_revisions.translation.saturating_add(1); + } + SharedStateDomain::CloudSync => { + self.settings_revisions.cloud_sync = + self.settings_revisions.cloud_sync.saturating_add(1); + } + SharedStateDomain::All => { + self.settings_revisions.settings = + self.settings_revisions.settings.saturating_add(1); + self.settings_revisions.ai = self.settings_revisions.ai.saturating_add(1); + self.settings_revisions.translation = + self.settings_revisions.translation.saturating_add(1); + self.settings_revisions.cloud_sync = + self.settings_revisions.cloud_sync.saturating_add(1); + } + SharedStateDomain::Connections + | SharedStateDomain::Security + | SharedStateDomain::Tunnels + | SharedStateDomain::Commands => {} + } + SharedStateEvent { + domain, + revision: self.revision, + changed: true, + } + } +} + +#[cfg(test)] +mod tests { + use nyaterm_store::{LoadBootstrap, StoreConfig, StoreRuntime}; + + use super::{ + GlobalStateMutation, ProcessStateStore, SettingsDraftRevisions, SharedStateDomain, + }; + use crate::test_support::TestConfigDir; + + fn process_state() -> (TestConfigDir, ProcessStateStore) { + let root = TestConfigDir::new("nyaterm-process-state"); + let runtime = StoreRuntime::spawn(StoreConfig { + config_dir: root.path().join("config"), + portable_key_path: None, + }) + .expect("spawn store"); + let snapshot = runtime + .blocking_client() + .request(0, LoadBootstrap) + .expect("receive bootstrap") + .outcome + .expect("load bootstrap"); + (root, ProcessStateStore::new(snapshot)) + } + + #[test] + fn settings_mutation_preserves_workspace_local_projection() { + let (_root, mut state) = process_state(); + state.snapshot.settings.ui_left_panel_width = 731; + state.snapshot.settings.ui_active_left_panel = Some("sessions".to_string()); + let mut settings = state.snapshot.settings.clone(); + settings.language = "ja".to_string(); + settings.ui_left_panel_width = 999; + settings.ui_active_left_panel = Some("notes".to_string()); + + let event = state.apply_mutation(GlobalStateMutation::UpdateSettings(settings)); + + assert_eq!(event.domain, SharedStateDomain::Settings); + assert_eq!(state.snapshot.settings.language, "ja"); + assert_eq!(state.snapshot.settings.ui_left_panel_width, 731); + assert_eq!( + state.snapshot.settings.ui_active_left_panel.as_deref(), + Some("sessions") + ); + assert_eq!( + state.settings_draft_revisions(), + SettingsDraftRevisions { + settings: 1, + ..SettingsDraftRevisions::default() + } + ); + } + + #[test] + fn full_refresh_advances_every_settings_domain_revision() { + let (_root, mut state) = process_state(); + let snapshot = state.snapshot.clone(); + + let event = state.apply_mutation(GlobalStateMutation::ReplaceSnapshot { + snapshot, + domain: SharedStateDomain::All, + }); + + assert_eq!(event.revision, 1); + assert_eq!( + state.settings_draft_revisions(), + SettingsDraftRevisions { + settings: 1, + ai: 1, + translation: 1, + cloud_sync: 1, + } + ); + } + + #[test] + fn unchanged_settings_refresh_does_not_invalidate_a_draft() { + let (_root, mut state) = process_state(); + let snapshot = state.snapshot.clone(); + + let event = state.apply_mutation(GlobalStateMutation::ReplaceSnapshot { + snapshot, + domain: SharedStateDomain::Settings, + }); + + assert!(!event.changed); + assert_eq!(event.revision, 0); + assert_eq!( + state.settings_draft_revisions(), + SettingsDraftRevisions::default() + ); + } +} diff --git a/crates/nyaterm-desktop/src/app_shell/session_hub.rs b/crates/nyaterm-desktop/src/app_shell/session_hub.rs new file mode 100644 index 00000000..23746685 --- /dev/null +++ b/crates/nyaterm-desktop/src/app_shell/session_hub.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use nyaterm_transport::SessionManager; + +/// Process-level owner for transport runtimes shared by every workspace window. +/// +/// Presentation, focus and prompt state remain window-local. The manager is +/// shared so moving a tab only changes ownership and never tears down its PTY, +/// SSH, Telnet or serial transport. +pub struct SessionHub { + manager: Arc, +} + +impl SessionHub { + pub fn new() -> Self { + Self { + manager: Arc::new(SessionManager::new()), + } + } + + pub fn manager(&self) -> Arc { + Arc::clone(&self.manager) + } +} + +impl Default for SessionHub { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/nyaterm-desktop/src/app_shell/window_state.rs b/crates/nyaterm-desktop/src/app_shell/window_state.rs index af6fd8b4..68d04a42 100644 --- a/crates/nyaterm-desktop/src/app_shell/window_state.rs +++ b/crates/nyaterm-desktop/src/app_shell/window_state.rs @@ -1,7 +1,11 @@ use gpui::{App, Bounds, DisplayId, Pixels, WindowBounds, point, px, size}; -use nyaterm_core::{AppRuntime, MainWindowState}; +use nyaterm_core::{ + AppRuntime, DeviceWindowManifest, MainWindowState, WorkspaceId, WorkspaceRestoreManifest, + WorkspaceRestoreState, +}; use nyaterm_store::{ - BootstrapSnapshot, LoadBootstrap, LoadMainWindowState, StoreConfig, StoreRuntime, StoreTask, + BootstrapSnapshot, LoadBootstrap, LoadDeviceWindowManifest, LoadMainWindowState, + LoadWorkspaceRestoreManifest, StoreConfig, StoreRuntime, StoreTask, }; const DEFAULT_MAIN_WINDOW_WIDTH: f32 = 1280.; @@ -12,8 +16,12 @@ pub struct AppShellStartup { pub(super) pending_bootstrap: Option>, pub(super) recovery: Option, main_window_state: Option, + pub(super) workspace_id: WorkspaceId, + pub(super) workspace_restore: WorkspaceRestoreManifest, + pub(super) device_windows: DeviceWindowManifest, } +#[derive(Clone)] pub(super) struct StartupRecovery { pub category: String, pub message: String, @@ -40,6 +48,7 @@ impl AppShellStartup { }) { Ok(runtime) => runtime, Err(error) => { + let workspace_id = WorkspaceId::new(); return Self { store_runtime: None, pending_bootstrap: None, @@ -48,10 +57,58 @@ impl AppShellStartup { message: error.to_string(), }), main_window_state: None, + workspace_id, + workspace_restore: WorkspaceRestoreManifest::single( + WorkspaceRestoreState::empty(workspace_id), + ), + device_windows: DeviceWindowManifest::empty(), }; } }; + let workspace_restore = match store_runtime + .blocking_client() + .request(0, LoadWorkspaceRestoreManifest) + { + Ok(event) => match event.outcome { + Ok(manifest) => manifest, + Err(error) => { + return Self::recovery_with_runtime( + store_runtime, + error.category(), + error.user_message(), + ); + } + }, + Err(error) => { + return Self::recovery_with_runtime( + store_runtime, + "request_submit", + &error.to_string(), + ); + } + }; + let workspace_id = workspace_restore + .most_recent() + .map(|workspace| workspace.id) + .unwrap_or_default(); + let device_windows = match store_runtime + .blocking_client() + .request(0, LoadDeviceWindowManifest(workspace_id)) + { + Ok(event) => event.outcome.unwrap_or_else(|error| { + tracing::warn!( + category = error.category(), + "window manifest could not be restored" + ); + DeviceWindowManifest::empty() + }), + Err(error) => { + tracing::warn!(category = %error, "window manifest request failed"); + DeviceWindowManifest::empty() + } + }; + let main_window_state = match store_runtime .blocking_client() .request(0, LoadMainWindowState) @@ -78,6 +135,9 @@ impl AppShellStartup { pending_bootstrap: Some(task), recovery: None, main_window_state, + workspace_id, + workspace_restore, + device_windows, }, Err(error) => Self { store_runtime: None, @@ -87,10 +147,94 @@ impl AppShellStartup { message: error.to_string(), }), main_window_state, + workspace_id, + workspace_restore, + device_windows, }, } } + fn recovery_with_runtime(store_runtime: StoreRuntime, category: &str, message: &str) -> Self { + let workspace_id = WorkspaceId::new(); + Self { + store_runtime: Some(store_runtime), + pending_bootstrap: None, + recovery: Some(StartupRecovery { + category: category.to_string(), + message: message.to_string(), + }), + main_window_state: None, + workspace_id, + workspace_restore: WorkspaceRestoreManifest::single(WorkspaceRestoreState::empty( + workspace_id, + )), + device_windows: DeviceWindowManifest::empty(), + } + } + + pub fn workspace_id(&self) -> WorkspaceId { + self.workspace_id + } + + pub fn restored_workspace_ids(&self) -> Vec { + let mut ids = self.device_windows.window_order.clone(); + for workspace in &self.workspace_restore.workspaces { + if !ids.contains(&workspace.id) { + ids.push(workspace.id); + } + } + if ids.is_empty() { + ids.push(self.workspace_id); + } + ids + } + + pub fn shared_store_runtime(&self) -> Option { + self.store_runtime.clone() + } + + pub(super) fn take_pending_bootstrap(&mut self) -> Option> { + self.pending_bootstrap.take() + } + + pub fn for_workspace(&self, workspace_id: WorkspaceId) -> Self { + let main_window_state = self.device_windows.state_for(workspace_id).cloned(); + let Some(store_runtime) = self.store_runtime.clone() else { + return Self { + store_runtime: None, + pending_bootstrap: None, + recovery: Some(StartupRecovery { + category: "runtime_missing".to_string(), + message: "storage runtime is unavailable".to_string(), + }), + main_window_state, + workspace_id, + workspace_restore: self.workspace_restore.clone(), + device_windows: self.device_windows.clone(), + }; + }; + Self { + store_runtime: Some(store_runtime), + pending_bootstrap: None, + recovery: self.recovery.clone(), + main_window_state, + workspace_id, + workspace_restore: self.workspace_restore.clone(), + device_windows: self.device_windows.clone(), + } + } + + pub fn for_new_workspace(&self, workspace_id: WorkspaceId) -> Self { + let mut startup = self.for_workspace(workspace_id); + startup + .workspace_restore + .workspaces + .push(WorkspaceRestoreState::empty(workspace_id)); + startup.workspace_restore.most_recent_workspace_id = Some(workspace_id); + startup.main_window_state = None; + startup + } + pub fn main_window_placement(&self, cx: &App) -> MainWindowPlacement { let displays = cx .displays() diff --git a/crates/nyaterm-desktop/src/entities/startup_restore.rs b/crates/nyaterm-desktop/src/entities/startup_restore.rs index f588bf2f..55e7ea5d 100644 --- a/crates/nyaterm-desktop/src/entities/startup_restore.rs +++ b/crates/nyaterm-desktop/src/entities/startup_restore.rs @@ -1,4 +1,4 @@ -use nyaterm_core::{RestorableOpenTab, RestorableWorkspacePaneNode}; +use nyaterm_core::{RestorableOpenTab, RestorableTerminalWindowNode, RestorableWorkspacePaneNode}; #[derive(Debug, Default)] pub struct StartupRestoreStore { @@ -8,6 +8,8 @@ pub struct StartupRestoreStore { pending_pane_layouts: Vec, pending_active_pane_indexes: Vec, loaded_open_tabs: Option>, + loaded_terminal_window_layout: Option>, + loaded_workspace_pane_layout: Option>, } impl StartupRestoreStore { @@ -51,6 +53,27 @@ impl StartupRestoreStore { self.loaded_open_tabs.take() } + pub fn set_loaded_window_layouts( + &mut self, + terminal: Option, + workspace: Option, + ) { + self.loaded_terminal_window_layout = Some(terminal); + self.loaded_workspace_pane_layout = Some(workspace); + } + + pub fn take_loaded_terminal_window_layout( + &mut self, + ) -> Option> { + self.loaded_terminal_window_layout.take() + } + + pub fn take_loaded_workspace_pane_layout( + &mut self, + ) -> Option> { + self.loaded_workspace_pane_layout.take() + } + pub fn queue_empty(&self) -> bool { self.queue.is_empty() } diff --git a/crates/nyaterm-desktop/src/features/ai/ai_runtime/chat/settings_actions.rs b/crates/nyaterm-desktop/src/features/ai/ai_runtime/chat/settings_actions.rs index 017e8a2e..89d5e0aa 100644 --- a/crates/nyaterm-desktop/src/features/ai/ai_runtime/chat/settings_actions.rs +++ b/crates/nyaterm-desktop/src/features/ai/ai_runtime/chat/settings_actions.rs @@ -61,6 +61,7 @@ impl NyaTermApp { { this.ai.accept_saved_settings(saved.clone()); this.refresh_ai_usage_counts(cx); + this.request_shared_state_refresh(crate::app_shell::SharedStateDomain::Ai, cx); } if completion.report_result { match event.outcome { diff --git a/crates/nyaterm-desktop/src/features/app_state/construct.rs b/crates/nyaterm-desktop/src/features/app_state/construct.rs index 0dcd0200..a53ca540 100644 --- a/crates/nyaterm-desktop/src/features/app_state/construct.rs +++ b/crates/nyaterm-desktop/src/features/app_state/construct.rs @@ -52,15 +52,39 @@ use crate::features::update::UpdateFeatureState; use crate::models::panel_collapsed_from_persistence; use crate::terminal::INITIAL_TERMINAL_BANNER; impl NyaTermApp { - pub fn from_bootstrap( + pub(crate) fn from_bootstrap( runtime: AppRuntime, stores: crate::entities::UiStoreHandles, - bootstrap: BootstrapSnapshot, + process_state: gpui::Entity, + workspace_init: crate::app_shell::WorkspaceInitSnapshot, store_ui: StoreUiClient, store_blocking: StoreBlockingClient, + session_manager: Arc, cx: &mut Context, ) -> Self { nyaterm_core::warm_terminal_input_tracker(); + let workspace_id = workspace_init.workspace_id; + let mut bootstrap = process_state.read(cx).snapshot().clone(); + if let Some(workspace) = workspace_init.state.as_ref() { + bootstrap.open_tabs = workspace.sessions.open_tabs.clone(); + bootstrap.settings.ui_left_panel_width = workspace.ui.left_panel_width; + bootstrap.settings.ui_right_panel_width = workspace.ui.right_panel_width; + bootstrap.settings.ui_quick_cmd_height = workspace.ui.bottom_panel_height; + bootstrap.settings.ui_active_left_panel = workspace.ui.active_left_panel.clone(); + bootstrap.settings.ui_active_right_panel = workspace.ui.active_right_panel.clone(); + bootstrap.settings.ui_left_panel_collapsed = workspace.ui.left_panel_collapsed; + bootstrap.settings.ui_right_panel_collapsed = workspace.ui.right_panel_collapsed; + } else { + bootstrap.open_tabs.clear(); + let ui = nyaterm_core::WorkspaceUiState::default(); + bootstrap.settings.ui_left_panel_width = ui.left_panel_width; + bootstrap.settings.ui_right_panel_width = ui.right_panel_width; + bootstrap.settings.ui_quick_cmd_height = ui.bottom_panel_height; + bootstrap.settings.ui_active_left_panel = ui.active_left_panel; + bootstrap.settings.ui_active_right_panel = ui.active_right_panel; + bootstrap.settings.ui_left_panel_collapsed = ui.left_panel_collapsed; + bootstrap.settings.ui_right_panel_collapsed = ui.right_panel_collapsed; + } let BootstrapSnapshot { database_path, custom_icons, @@ -87,6 +111,7 @@ impl NyaTermApp { ai_message_count, ai_audit_count, open_tabs, + .. } = bootstrap; let mut settings = settings; // Localized child views cache placeholders while they are constructed, so @@ -184,7 +209,6 @@ impl NyaTermApp { terminal_output_decoder.set_encoding(&settings.interaction_default_encoding); let mut terminal_screen = initial_terminal_screen(); terminal_screen.set_encoding(&settings.interaction_default_encoding); - let session_manager = Arc::new(SessionManager::new()); let terminal_frame_pipeline = TerminalFramePipeline::spawn(recording_writer); let session_event_bridge = SessionEventBridge::spawn( Arc::clone(&session_manager), @@ -216,6 +240,10 @@ impl NyaTermApp { let blocking_jobs = crate::blocking_jobs::BlockingJobScheduler::new(); let mut app = Self { + workspace_id, + workspace_revision: 0, + desktop_controller: None, + process_state, blocking_jobs: blocking_jobs.clone(), stores, store_ui, @@ -431,8 +459,23 @@ impl NyaTermApp { .expect("receive test bootstrap") .outcome .expect("load test bootstrap"); - let mut app = - Self::from_bootstrap(runtime, stores, bootstrap, store_ui, store_blocking, cx); + let workspace_id = bootstrap + .workspace_restore + .most_recent() + .map(|workspace| workspace.id) + .unwrap_or_default(); + let process_state = cx.new(|_| crate::app_shell::ProcessStateStore::new(bootstrap)); + let workspace_init = process_state.read(cx).workspace_init(workspace_id); + let mut app = Self::from_bootstrap( + runtime, + stores, + process_state, + workspace_init, + store_ui, + store_blocking, + Arc::new(SessionManager::new()), + cx, + ); app._test_config_dir = Some(test_config_dir); app } diff --git a/crates/nyaterm-desktop/src/features/app_state/mod.rs b/crates/nyaterm-desktop/src/features/app_state/mod.rs index 7e01bdda..708b0f77 100644 --- a/crates/nyaterm-desktop/src/features/app_state/mod.rs +++ b/crates/nyaterm-desktop/src/features/app_state/mod.rs @@ -30,11 +30,17 @@ use super::update::UpdateFeatureState; mod construct; mod store_runtime; +pub(crate) use store_runtime::WorkspaceCloseSnapshot; mod types; pub(in crate::features) use types::SettingsDraftSnapshot; pub struct NyaTermApp { + pub(in crate::features) workspace_id: nyaterm_core::WorkspaceId, + pub(in crate::features) workspace_revision: u64, + pub(in crate::features) desktop_controller: + Option>, + pub(in crate::features) process_state: gpui::Entity, pub(in crate::features) blocking_jobs: crate::blocking_jobs::BlockingJobScheduler, pub(in crate::features) stores: crate::entities::UiStoreHandles, pub(in crate::features) store_ui: StoreUiClient, @@ -91,6 +97,7 @@ impl gpui::EventEmitter for NyaTermApp {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AppLifecycleEvent { ShutdownRequested, + NewWindowRequested, } impl gpui::EventEmitter for NyaTermApp {} diff --git a/crates/nyaterm-desktop/src/features/app_state/store_runtime.rs b/crates/nyaterm-desktop/src/features/app_state/store_runtime.rs index 4e9cb77a..ddf7c8ff 100644 --- a/crates/nyaterm-desktop/src/features/app_state/store_runtime.rs +++ b/crates/nyaterm-desktop/src/features/app_state/store_runtime.rs @@ -1,7 +1,7 @@ use gpui::Context; use nyaterm_core::{ - AiSettings, AppSettingsSummary, KeywordHighlightConfig, RestorableOpenTab, - RestorableTerminalWindowNode, RestorableWorkspacePaneNode, TranslationSettings, + AiSettings, AppSettingsSummary, KeywordHighlightConfig, TranslationSettings, + WorkspaceRestoreState, WorkspaceSessionState, WorkspaceUiState, }; use nyaterm_store::{ StoreDomain, StoreEvent, StoreRequest, StoreSubmitError, StoreTask, store_request, @@ -13,24 +13,110 @@ use crate::features::settings::SettingsPersistenceDomain; struct ShutdownPersistenceSnapshot { settings: AppSettingsSummary, settings_domains: Vec, - keyword_highlights: KeywordHighlightConfig, + keyword_highlights: Option, ai_settings: Option, translation_settings: Option, - session: Option, + workspace: Option, } -struct ShutdownSessionSnapshot { - open_tabs: Vec, - terminal_layout: Option, - workspace_layout: Option, +pub(crate) struct WorkspaceCloseSnapshot { + pub(crate) workspace_id: nyaterm_core::WorkspaceId, + sessions: Option, + ui: WorkspaceUiState, +} + +impl WorkspaceCloseSnapshot { + pub(crate) fn apply_to(self, workspace: &mut WorkspaceRestoreState) { + workspace.revision = workspace.revision.saturating_add(1); + if let Some(mut sessions) = self.sessions { + sessions.extra = std::mem::take(&mut workspace.sessions.extra); + workspace.sessions = sessions; + } + let mut ui = self.ui; + ui.extra = std::mem::take(&mut workspace.ui.extra); + workspace.ui = ui; + } } impl NyaTermApp { + pub(crate) fn capture_workspace_close_snapshot(&mut self) -> WorkspaceCloseSnapshot { + let settings = self.settings.summary().clone(); + let sessions = if settings.startup_restore { + let open_tabs = self.serialize_open_tabs(); + let ordered = self + .ordered_tab_sessions() + .into_iter() + .map(|session| session.id) + .collect::>(); + let terminal_window_layout = settings + .startup_restore_window_layout + .then(|| self.terminal.serialize_terminal_window_layout(&ordered)) + .flatten(); + let workspace_pane_layout = if settings.startup_restore_window_layout { + self.sync_workspace_split_from_active_tab(); + let ordered = self + .session + .ordered_sessions() + .into_iter() + .map(|session| session.id) + .collect::>(); + self.shell + .workspace_split() + .as_ref() + .filter(|root| root.is_split()) + .and_then(|root| root.serialize_layout(&ordered)) + .or_else(|| { + self.shell + .workspace_pane_roots() + .values() + .find(|root| root.is_split()) + .and_then(|root| root.serialize_layout(&ordered)) + }) + } else { + None + }; + Some(WorkspaceSessionState { + open_tabs, + terminal_window_layout, + workspace_pane_layout, + extra: Default::default(), + }) + } else { + None + }; + WorkspaceCloseSnapshot { + workspace_id: self.workspace_id, + sessions, + ui: WorkspaceUiState { + left_panel_width: settings.ui_left_panel_width, + right_panel_width: settings.ui_right_panel_width, + bottom_panel_height: settings.ui_quick_cmd_height, + active_left_panel: settings.ui_active_left_panel, + active_right_panel: settings.ui_active_right_panel, + left_panel_collapsed: settings.ui_left_panel_collapsed, + right_panel_collapsed: settings.ui_right_panel_collapsed, + ..WorkspaceUiState::default() + }, + } + } + + pub(crate) fn report_close_save_failed(&mut self, error: String, cx: &mut Context) { + let message = format!("Could not save before closing: {error}"); + self.settings.update_store_status(message.clone(), false); + self.shell.set_status(message); + cx.notify(); + } + + pub(crate) fn shutdown_workspace_sessions(&mut self) { + for session in self.session.ordered_sessions() { + let _ = self.session.manager().close(&session.id); + } + } + pub(crate) fn shutdown_blocking_jobs(&mut self) { self.update.download_cancel.cancel(); self.remote_desktop.routes.clear(); self.remote_desktop.prepared_routes.clear(); - self.shell.system_tray = None; self.shutdown_remote_desktop_workers(); self.session.shutdown_workers(); self.terminal.shutdown_workers(); @@ -62,6 +148,13 @@ impl NyaTermApp { cx.spawn(async move |this, cx| { let event = task.await; let _ = this.update(cx, |this, cx| { + let shared_domain = event + .outcome + .is_ok() + .then(|| { + crate::app_shell::SharedStateDomain::from_store_domain(event.domain) + }) + .flatten(); apply(this, event, cx); // Every async reply lands here, after the whole handler body // has run. Handlers that mutate list state *after* swapping @@ -69,6 +162,9 @@ impl NyaTermApp { // flush inside `apply_loaded_sessions` could not promise. this.flush_connection_panel_snapshot(cx); this.flush_transfer_panel_snapshot(cx); + if let Some(domain) = shared_domain { + this.request_shared_state_refresh(domain, cx); + } }); }) .detach(); @@ -84,12 +180,42 @@ impl NyaTermApp { } } + pub(crate) fn request_shared_state_refresh( + &self, + domain: crate::app_shell::SharedStateDomain, + cx: &mut Context, + ) { + if let Some(controller) = self.desktop_controller.clone() { + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.request_shared_state_refresh(domain, cx) + }); + }); + } + } + + pub(crate) fn replace_shared_snapshot( + &self, + snapshot: nyaterm_store::BootstrapSnapshot, + domain: crate::app_shell::SharedStateDomain, + cx: &mut Context, + ) { + if let Some(controller) = self.desktop_controller.clone() { + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.replace_shared_snapshot(snapshot, domain, cx) + }); + }); + } + } + pub(in crate::features) fn store_blocking_client(&self) -> nyaterm_store::StoreBlockingClient { self.store_blocking.clone() } pub(crate) fn submit_shutdown_persistence( &mut self, + preserve_workspace: bool, ) -> Result, StoreSubmitError> { // A UI-layout change still inside its debounce window has no dirty settings // domain yet, so fold it in here or quitting inside that window loses it. @@ -101,7 +227,10 @@ impl NyaTermApp { } let settings_domains = self.settings.dirty_persistence_domains(); let settings = self.settings.summary().clone(); - let keyword_highlights = self.settings.keyword_config().clone(); + let keyword_highlights = self + .settings + .keyword_persistence_dirty() + .then(|| self.settings.keyword_config().clone()); let ai_settings = self .ai .settings_persistence_is_dirty() @@ -110,55 +239,14 @@ impl NyaTermApp { .translation .settings_persistence_is_dirty() .then(|| self.translation.pending_settings()); - let session = if settings.startup_restore { - let open_tabs = self.serialize_open_tabs(); - let ordered = self - .ordered_tab_sessions() - .into_iter() - .map(|session| session.id) - .collect::>(); - let terminal_layout = settings - .startup_restore_window_layout - .then(|| self.terminal.serialize_terminal_window_layout(&ordered)) - .flatten(); - let workspace_layout = if settings.startup_restore_window_layout { - self.sync_workspace_split_from_active_tab(); - let ordered = self - .session - .ordered_sessions() - .into_iter() - .map(|session| session.id) - .collect::>(); - self.shell - .workspace_split() - .as_ref() - .filter(|root| root.is_split()) - .and_then(|root| root.serialize_layout(&ordered)) - .or_else(|| { - self.shell - .workspace_pane_roots() - .values() - .find(|root| root.is_split()) - .and_then(|root| root.serialize_layout(&ordered)) - }) - } else { - None - }; - Some(ShutdownSessionSnapshot { - open_tabs, - terminal_layout, - workspace_layout, - }) - } else { - None - }; + let workspace = preserve_workspace.then(|| self.capture_workspace_close_snapshot()); let snapshot = ShutdownPersistenceSnapshot { settings, settings_domains, keyword_highlights, ai_settings, translation_settings, - session, + workspace, }; self.store_ui.try_submit_shutdown( u64::MAX - 1, @@ -206,17 +294,34 @@ impl NyaTermApp { } } } - store.save_keyword_highlights(&snapshot.keyword_highlights)?; + if let Some(keyword_highlights) = snapshot.keyword_highlights { + store.save_keyword_highlights(&keyword_highlights)?; + } if let Some(settings) = snapshot.ai_settings { store.save_ai_settings(settings)?; } if let Some(settings) = snapshot.translation_settings { store.save_translation_settings(settings)?; } - if let Some(session) = snapshot.session { - store.save_open_tabs(&session.open_tabs)?; - store.save_terminal_window_layout(session.terminal_layout.as_ref())?; - store.save_workspace_pane_layout(session.workspace_layout.as_ref())?; + if let Some(close_snapshot) = snapshot.workspace { + let mut manifest = store.load_workspace_restore_manifest()?; + let workspace_index = manifest + .workspaces + .iter() + .position(|workspace| workspace.id == close_snapshot.workspace_id); + let workspace = if let Some(index) = workspace_index { + &mut manifest.workspaces[index] + } else { + manifest + .workspaces + .push(WorkspaceRestoreState::empty(close_snapshot.workspace_id)); + manifest + .workspaces + .last_mut() + .expect("workspace was inserted") + }; + close_snapshot.apply_to(workspace); + store.save_workspace_restore_manifest(&manifest)?; } Ok(()) }), diff --git a/crates/nyaterm-desktop/src/features/app_state/types.rs b/crates/nyaterm-desktop/src/features/app_state/types.rs index f324612b..a012f430 100644 --- a/crates/nyaterm-desktop/src/features/app_state/types.rs +++ b/crates/nyaterm-desktop/src/features/app_state/types.rs @@ -6,6 +6,7 @@ use crate::models::{CloudSyncSecretDraft, TranslationSecretDraft}; #[derive(Debug, Clone)] pub(in crate::features) struct SettingsDraftSnapshot { + pub revisions: crate::app_shell::SettingsDraftRevisions, pub settings: AppSettingsSummary, pub ai_settings: AiSettings, pub ai_model_draft: String, diff --git a/crates/nyaterm-desktop/src/features/commands/command_runtime/history.rs b/crates/nyaterm-desktop/src/features/commands/command_runtime/history.rs index a16b36da..f652031d 100644 --- a/crates/nyaterm-desktop/src/features/commands/command_runtime/history.rs +++ b/crates/nyaterm-desktop/src/features/commands/command_runtime/history.rs @@ -314,6 +314,11 @@ impl NyaTermApp { this.commands.note_persistence_event_delivered(); if let Err(message) = this.commands.apply_persistence_result(event) { this.settings.update_store_status(message, false); + } else { + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Commands, + cx, + ); } cx.notify(); }) diff --git a/crates/nyaterm-desktop/src/features/commands/quick_command_runtime/import/dialog.rs b/crates/nyaterm-desktop/src/features/commands/quick_command_runtime/import/dialog.rs index 3c1e1cbd..81cab664 100644 --- a/crates/nyaterm-desktop/src/features/commands/quick_command_runtime/import/dialog.rs +++ b/crates/nyaterm-desktop/src/features/commands/quick_command_runtime/import/dialog.rs @@ -197,6 +197,10 @@ impl NyaTermApp { total_categories, } => { self.refresh_quick_commands(cx); + self.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Commands, + cx, + ); self.shell.set_status(format!( "imported {imported_commands} quick command(s), updated {updated_commands}, categories +{imported_categories}, total {total_commands}/{total_categories}" )); diff --git a/crates/nyaterm-desktop/src/features/layout/title_bar/bar.rs b/crates/nyaterm-desktop/src/features/layout/title_bar/bar.rs index 4b9ca6a2..1086757d 100644 --- a/crates/nyaterm-desktop/src/features/layout/title_bar/bar.rs +++ b/crates/nyaterm-desktop/src/features/layout/title_bar/bar.rs @@ -863,6 +863,9 @@ impl NyaTermApp { /// date/time clock has to be reconsidered when the header changes shape. fn persist_header_status_settings(&mut self) { if self.shell.has_settings_draft() { + self.settings.mark_draft_domain_dirty( + crate::features::settings::SettingsPersistenceDomain::UiLayout, + ); self.shell .set_status("header status changed; apply settings to persist".to_string()); } else { diff --git a/crates/nyaterm-desktop/src/features/layout/title_bar/menu.rs b/crates/nyaterm-desktop/src/features/layout/title_bar/menu.rs index 5befb3a8..17df0a66 100644 --- a/crates/nyaterm-desktop/src/features/layout/title_bar/menu.rs +++ b/crates/nyaterm-desktop/src/features/layout/title_bar/menu.rs @@ -13,6 +13,21 @@ use crate::models::{ }; impl NyaTermApp { + pub(crate) fn set_desktop_controller( + &mut self, + controller: gpui::WeakEntity, + ) { + self.desktop_controller = Some(controller); + } + pub(crate) fn set_workspace_identity( + &mut self, + workspace_id: nyaterm_core::WorkspaceId, + revision: u64, + ) { + self.workspace_id = workspace_id; + self.workspace_revision = revision; + } + pub(crate) fn set_title_menu_bar(&mut self, menu_bar: gpui::Entity) { self.shell.set_title_menu_bar(menu_bar); } @@ -154,9 +169,15 @@ impl NyaTermApp { fn title_file_menu_items(&self, cx: &mut Context) -> Vec { vec![ + NyaMenuItem::action("New Window") + .icon("icons/window/restore.svg") + .shortcut("Ctrl+Shift+N") + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(crate::features::AppLifecycleEvent::NewWindowRequested); + })), NyaMenuItem::action(t!("menu.newSession")) .icon("icons/conn/add.svg") - .shortcut(self.display_shortcut_for("tab.newSession", "Ctrl+Shift+N")) + .shortcut(self.display_shortcut_for("tab.newSession", "Ctrl+Shift+T")) .on_click(cx.listener(|this, _, window, cx| { this.shell.close_open_tabs_menu(); this.shell.close_new_session_menu(); diff --git a/crates/nyaterm-desktop/src/features/layout/workspace/surface/tabs.rs b/crates/nyaterm-desktop/src/features/layout/workspace/surface/tabs.rs index 358c0856..0dd9754a 100644 --- a/crates/nyaterm-desktop/src/features/layout/workspace/surface/tabs.rs +++ b/crates/nyaterm-desktop/src/features/layout/workspace/surface/tabs.rs @@ -608,7 +608,11 @@ impl NyaTermApp { // the whole target uses the theme hover surface and the insertion // edge uses the focus/drag border color. let drag_target_bg = self.shell_surface_color(palette.hover); + let drop_workspace_id = self.workspace_id; let drag_payload = SessionTabDragPayload { + source_workspace_id: self.workspace_id, + root_tab_id: session.id.clone(), + source_revision: self.workspace_revision(), session_id: session.id.clone(), order_index: tab_index, display_name: display_name.clone(), @@ -651,6 +655,12 @@ impl NyaTermApp { cx.new(|_| SessionTabDragPreview::new(payload.clone(), position)) }) .drag_over::(move |this, payload, _, _| { + if payload.source_workspace_id != drop_workspace_id { + return this + .bg(drag_target_bg) + .border_l_2() + .border_color(rgb(palette.focus_ring)); + } let Some(insert_after) = tab_drop_insert_after(payload.order_index, tab_index) else { return this; @@ -676,6 +686,16 @@ impl NyaTermApp { )) .on_drop( cx.listener(move |this, payload: &SessionTabDragPayload, _, cx| { + if payload.source_workspace_id != this.workspace_id { + this.request_tab_tree_move( + payload, + nyaterm_core::MoveTabPlacement::BeforeTab( + drop_target_session_id.clone(), + ), + cx, + ); + return; + } let Some(insert_after) = tab_drop_insert_after(payload.order_index, tab_index) else { @@ -875,7 +895,8 @@ impl NyaTermApp { transient_cursor += 1; } - if session_count > 1 { + if session_count > 0 { + let drop_workspace_id = self.workspace_id; tabs = tabs.child( div() .id("session-tab-drop-end") @@ -886,7 +907,9 @@ impl NyaTermApp { .border_color(rgb(palette.border)) .hover(move |this| this.bg(shell_hover_bg)) .drag_over::(move |this, payload, _, _| { - if payload.order_index + 1 >= session_count { + if payload.source_workspace_id == drop_workspace_id + && payload.order_index + 1 >= session_count + { this } else { this.bg(shell_hover_bg) @@ -900,16 +923,39 @@ impl NyaTermApp { }, )) .on_drop(cx.listener(|this, payload: &SessionTabDragPayload, _, cx| { - this.reorder_session_to_end(payload.session_id.clone(), cx); + if payload.source_workspace_id == this.workspace_id { + this.reorder_session_to_end(payload.session_id.clone(), cx); + } else { + this.request_tab_tree_move( + payload, + nyaterm_core::MoveTabPlacement::Append, + cx, + ); + } })), ); } else { tabs = tabs.child( div() + .id("session-tab-drop-empty") .h_full() .flex_1() .border_b_1() - .border_color(rgb(palette.border)), + .border_color(rgb(palette.border)) + .drag_over::(move |this, _, _, _| { + this.bg(shell_hover_bg) + .border_l_2() + .border_color(rgb(palette.focus_ring)) + }) + .on_drop(cx.listener(|this, payload: &SessionTabDragPayload, _, cx| { + if payload.source_workspace_id != this.workspace_id { + this.request_tab_tree_move( + payload, + nyaterm_core::MoveTabPlacement::Append, + cx, + ); + } + })), ); } diff --git a/crates/nyaterm-desktop/src/features/mod.rs b/crates/nyaterm-desktop/src/features/mod.rs index b2672892..f6fb735b 100644 --- a/crates/nyaterm-desktop/src/features/mod.rs +++ b/crates/nyaterm-desktop/src/features/mod.rs @@ -25,6 +25,7 @@ mod settings; mod shell; mod sync; mod sync_input; +mod tab_transfer; mod terminal; #[cfg(test)] mod test_support; @@ -49,8 +50,10 @@ pub(crate) fn init_protection_key_bindings(cx: &mut gpui::App) { pub(crate) use app_state::AppLifecycleEvent; pub use app_state::NyaTermApp; +pub(crate) use app_state::WorkspaceCloseSnapshot; pub(in crate::features) use font_catalog::{ FontAvailability, FontAvailabilityReason, FontCatalogEntry, FontCatalogKind, FontCatalogLoadState, FontCatalogPresentation, FontCatalogSnapshot, FontCatalogState, FontResolutionSource, FontResolutionStatus, font_names_fingerprint, normalize_font_family, }; +pub(crate) use shell::tray::{SystemTray, TraySnapshot, show_window as show_tray_window}; diff --git a/crates/nyaterm-desktop/src/features/pages/settings/flush.rs b/crates/nyaterm-desktop/src/features/pages/settings/flush.rs index dad804a6..ce64c309 100644 --- a/crates/nyaterm-desktop/src/features/pages/settings/flush.rs +++ b/crates/nyaterm-desktop/src/features/pages/settings/flush.rs @@ -173,6 +173,15 @@ impl NyaTermApp { == Some(panel) { self.native_settings_panel = None; + if let Some(controller) = self + .desktop_controller + .as_ref() + .and_then(gpui::WeakEntity::upgrade) + { + controller.update(cx, |controller, _| { + controller.release_settings_owner(self.workspace_id) + }); + } self.request_settings_panel_refresh(cx); } } diff --git a/crates/nyaterm-desktop/src/features/pages/workspace/terminal_windows.rs b/crates/nyaterm-desktop/src/features/pages/workspace/terminal_windows.rs index 7b12fd5f..006a15e1 100644 --- a/crates/nyaterm-desktop/src/features/pages/workspace/terminal_windows.rs +++ b/crates/nyaterm-desktop/src/features/pages/workspace/terminal_windows.rs @@ -160,6 +160,9 @@ impl NyaTermApp { rgba(0x00000000) }; let drag_payload = SessionTabDragPayload { + source_workspace_id: self.workspace_id, + root_tab_id: tab_id.clone(), + source_revision: self.workspace_revision(), session_id: tab_id.clone(), order_index: tab_number.saturating_sub(1), display_name: title.clone(), @@ -254,6 +257,16 @@ impl NyaTermApp { }) .on_drop(cx.listener( move |this, payload: &SessionTabDragPayload, _, cx| { + if payload.source_workspace_id != this.workspace_id { + this.request_tab_tree_move( + payload, + nyaterm_core::MoveTabPlacement::BeforeTab( + drop_before_id.clone(), + ), + cx, + ); + return; + } this.place_tab_before_in_terminal_windows( payload.session_id.clone(), drop_before_id.clone(), @@ -450,12 +463,38 @@ impl NyaTermApp { .terminal .terminal_window_drop_for_leaf(&drop_leaf_id_drop) .unwrap_or(TabDockZone::Center); - this.dock_tab_on_terminal_window_leaf( - payload.session_id.clone(), - drop_leaf_id_drop.clone(), - zone, - cx, - ); + if payload.source_workspace_id == this.workspace_id { + this.dock_tab_on_terminal_window_leaf( + payload.session_id.clone(), + drop_leaf_id_drop.clone(), + zone, + cx, + ); + } else { + let edge = match zone { + TabDockZone::Center => None, + TabDockZone::Edge(TabDockEdge::Left) => { + Some(nyaterm_core::MoveTabDockEdge::Left) + } + TabDockZone::Edge(TabDockEdge::Right) => { + Some(nyaterm_core::MoveTabDockEdge::Right) + } + TabDockZone::Edge(TabDockEdge::Top) => { + Some(nyaterm_core::MoveTabDockEdge::Top) + } + TabDockZone::Edge(TabDockEdge::Bottom) => { + Some(nyaterm_core::MoveTabDockEdge::Bottom) + } + }; + this.request_tab_tree_move( + payload, + nyaterm_core::MoveTabPlacement::TerminalLeaf { + leaf_id: drop_leaf_id_drop.clone(), + edge, + }, + cx, + ); + } }), ) .child(canvas) diff --git a/crates/nyaterm-desktop/src/features/panels/tab_actions_overlay/compact.rs b/crates/nyaterm-desktop/src/features/panels/tab_actions_overlay/compact.rs index 566636cc..9b12eb81 100644 --- a/crates/nyaterm-desktop/src/features/panels/tab_actions_overlay/compact.rs +++ b/crates/nyaterm-desktop/src/features/panels/tab_actions_overlay/compact.rs @@ -63,7 +63,16 @@ impl NyaTermApp { let show_session_group = menu_groups.contains(&TabActionMenuGroup::Session); let show_split_group = menu_groups.contains(&TabActionMenuGroup::Split); let (viewport_w, viewport_h) = self.shell.viewport_size(); - let menu_visible_height = tab_actions_menu_visible_height(policy, viewport_h); + let targets = self + .desktop_controller + .as_ref() + .and_then(gpui::WeakEntity::upgrade) + .map(|controller| controller.read(cx).workspace_targets(self.workspace_id)) + .unwrap_or_default(); + let menu_visible_height = (tab_actions_menu_visible_height(policy, viewport_h) + + (targets.len() + 1) as f32 * 28. + + 9.) + .min((viewport_h - 16.).max(0.)); let (menu_x, menu_y) = if let Some((x, y)) = self.session.dialog_tab_actions_anchor() { clamp_tab_actions_position( x, @@ -162,6 +171,36 @@ impl NyaTermApp { let explain_session_id = session_id.clone(); let analyze_session_id = session_id.clone(); let secure_attention_session_id = session_id.clone(); + let mut move_window_rows = div().flex_col().child(tab_menu_separator(palette)); + let new_window_tab_id = tab_root_id.clone(); + move_window_rows = move_window_rows.child(tab_menu_item( + palette, + "tab-ctx-move-new-window", + t!("tabCtx.moveToNewWindow"), + cx.listener(move |this, _, _, cx| { + this.close_tab_actions(cx); + this.move_tab_tree_to_new_window(new_window_tab_id.clone(), cx); + }), + )); + for (workspace_id, label) in targets { + let moved_tab_id = tab_root_id.clone(); + move_window_rows = move_window_rows.child(tab_menu_item( + palette, + format!("tab-ctx-move-window-{workspace_id:?}"), + format!("{} {label}", t!("tabCtx.moveToWindow")), + cx.listener(move |this, _, _, cx| { + this.close_tab_actions(cx); + this.move_tab_tree_to_workspace( + this.workspace_id, + moved_tab_id.clone(), + this.workspace_revision(), + workspace_id, + nyaterm_core::MoveTabPlacement::Append, + cx, + ); + }), + )); + } let submenu_panel = active_submenu .filter(|submenu| policy.supports_submenu(*submenu)) @@ -446,6 +485,7 @@ impl NyaTermApp { this.copy_session_name(©_name_session_id, cx); }), )) + .child(move_window_rows) .when(support.copy_ssh_host, |this| { this.child(tab_menu_item( palette, diff --git a/crates/nyaterm-desktop/src/features/session/mod.rs b/crates/nyaterm-desktop/src/features/session/mod.rs index fce755b0..74de1483 100644 --- a/crates/nyaterm-desktop/src/features/session/mod.rs +++ b/crates/nyaterm-desktop/src/features/session/mod.rs @@ -97,6 +97,6 @@ pub(in crate::features) use prompt_runtime::{ keyboard_interactive_text_input_id, sftp_duplicate_prompt_id, uuid_like_prompt_id, }; pub(in crate::features) use state::{ - PendingSessionStart, SavedConnectionStartOptions, SessionFeatureFocus, SessionFeatureState, - SessionStartEventRequest, SessionStartTabPlacement, + PendingSessionStart, SavedConnectionStartOptions, SessionCatalogTransferBundle, + SessionFeatureFocus, SessionFeatureState, SessionStartEventRequest, SessionStartTabPlacement, }; diff --git a/crates/nyaterm-desktop/src/features/session/startup_restore_runtime.rs b/crates/nyaterm-desktop/src/features/session/startup_restore_runtime.rs index 347e7829..eb95f914 100644 --- a/crates/nyaterm-desktop/src/features/session/startup_restore_runtime.rs +++ b/crates/nyaterm-desktop/src/features/session/startup_restore_runtime.rs @@ -24,6 +24,7 @@ impl NyaTermApp { /// Connect/register must not open the config database or rewrite settings on /// the UI thread — that path was a major connect-time freeze source. pub(in crate::features) fn persist_open_tabs(&mut self) { + self.workspace_revision = self.workspace_revision.saturating_add(1); if !self.settings.summary().startup_restore { return; } @@ -100,14 +101,35 @@ impl NyaTermApp { let Some(generation) = self.shell.begin_session_persistence(dirty) else { return; }; + let workspace_id = self.workspace_id; let request = store_request(StoreDomain::Sessions, move |store| { - if let Some(tabs) = tabs.as_ref() { - store.save_open_tabs(tabs)?; + let mut manifest = store.load_workspace_restore_manifest()?; + let workspace = if let Some(index) = manifest + .workspaces + .iter() + .position(|workspace| workspace.id == workspace_id) + { + &mut manifest.workspaces[index] + } else { + manifest + .workspaces + .push(nyaterm_core::WorkspaceRestoreState::empty(workspace_id)); + manifest + .workspaces + .last_mut() + .expect("workspace was inserted") + }; + workspace.revision = workspace.revision.saturating_add(1); + if let Some(tabs) = tabs { + workspace.sessions.open_tabs = tabs; } - if let Some(layout) = layout.as_ref() { - store.save_terminal_window_layout(layout.as_ref())?; + if let Some(layout) = layout { + workspace.sessions.terminal_window_layout = layout; } - store.save_workspace_pane_layout(workspace_layout.as_ref()) + if dirty.window_layout() { + workspace.sessions.workspace_pane_layout = workspace_layout; + } + store.save_workspace_restore_manifest(&manifest) }); let task = match self.store_ui.try_submit(generation, request) { Ok(task) => task, diff --git a/crates/nyaterm-desktop/src/features/session/state/mod.rs b/crates/nyaterm-desktop/src/features/session/state/mod.rs index 8eadab5d..4981660d 100644 --- a/crates/nyaterm-desktop/src/features/session/state/mod.rs +++ b/crates/nyaterm-desktop/src/features/session/state/mod.rs @@ -74,6 +74,29 @@ struct SessionTabDragState { source_id: String, } +pub(crate) struct SessionCatalogTransferBundle { + entries: Vec, + ordered_ids: Vec, + pending_events: VecDeque, +} + +struct SessionCatalogTransferEntry { + session_id: String, + metadata: SessionRuntimeMetadata, + start_tab_placement: Option, + custom_name: Option, + dynamic_title: Option, + cwd: Option, + tab_color: Option, + locked: bool, + command_history: Option>, + busy_action: Option, + remote_file: Option, + xymodem: Option, + zmodem: Option, + trzsz: Option, +} + #[derive(Default)] struct SessionRestoreState { complete: bool, @@ -172,6 +195,178 @@ pub(in crate::features) struct SessionDisconnectUpdate { } impl SessionFeatureState { + pub(crate) fn can_transfer_sessions(&self, session_ids: &[String]) -> Result<(), &'static str> { + if self.start.has_pending() { + return Err("wait for the session connection attempt to finish"); + } + if self.prompts.has_blocking_prompt() { + return Err("finish the active authentication or security prompt first"); + } + if session_ids + .iter() + .any(|session_id| self.busy_actions.contains_key(session_id)) + { + return Err("wait for the active session operation to finish"); + } + if session_ids + .iter() + .any(|session_id| !self.metadata.contains_key(session_id)) + { + return Err("the tab no longer exists"); + } + Ok(()) + } + + pub(crate) fn pause_sessions_for_transfer( + &self, + session_ids: &[String], + ) -> VecDeque { + self.event_bridge.pause_sessions_for_transfer(session_ids) + } + + pub(crate) fn resume_sessions_after_failed_transfer( + &mut self, + session_ids: &[String], + bridge_events: VecDeque, + ) { + self.events.pending.extend(bridge_events); + for session_id in session_ids { + self.event_bridge.claim_session(session_id); + } + } + + pub(crate) fn detach_sessions_for_transfer( + &mut self, + session_ids: &[String], + bridge_events: VecDeque, + ) -> Option { + let selected = session_ids.iter().cloned().collect::>(); + if selected.is_empty() || selected.iter().any(|id| !self.metadata.contains_key(id)) { + return None; + } + let ordered_ids = self + .order + .iter() + .filter(|id| selected.contains(*id)) + .cloned() + .collect::>(); + let mut entries = Vec::with_capacity(session_ids.len()); + for session_id in session_ids { + let metadata = self.metadata.remove(session_id)?; + entries.push(SessionCatalogTransferEntry { + session_id: session_id.clone(), + metadata, + start_tab_placement: self.start_tab_placements.remove(session_id), + custom_name: self.custom_names.remove(session_id), + dynamic_title: self.dynamic_titles.remove(session_id), + cwd: self.cwds.remove(session_id), + tab_color: self.tab_colors.remove(session_id), + locked: self.locked_tabs.remove(session_id), + command_history: self.command_history.remove(session_id), + busy_action: self.busy_actions.remove(session_id), + remote_file: self.protocols.remote_files.remove(session_id), + xymodem: self.protocols.xymodem.remove(session_id), + zmodem: self.protocols.zmodem.remove(session_id), + trzsz: self.protocols.trzsz.remove(session_id), + }); + } + self.order.retain(|id| !selected.contains(id)); + self.active.history.retain(|id| !selected.contains(id)); + if self + .active + .id + .as_ref() + .is_some_and(|id| selected.contains(id)) + { + self.active.id = None; + } + let mut pending_events = VecDeque::new(); + let mut retained_events = VecDeque::new(); + while let Some(event) = self.events.pending.pop_front() { + let session_id = match &event { + SessionEvent::Output { session_id, .. } + | SessionEvent::OutputDropped { session_id, .. } + | SessionEvent::CwdChanged { session_id, .. } + | SessionEvent::CommandAccepted { session_id, .. } + | SessionEvent::Exited { session_id, .. } + | SessionEvent::Error { session_id, .. } => session_id, + }; + if selected.contains(session_id) { + pending_events.push_back(event); + } else { + retained_events.push_back(event); + } + } + self.events.pending = retained_events; + pending_events.extend(bridge_events); + Some(SessionCatalogTransferBundle { + entries, + ordered_ids, + pending_events, + }) + } + + pub(crate) fn attach_sessions_from_transfer( + &mut self, + bundle: SessionCatalogTransferBundle, + insert_index: Option, + ) { + let mut ordered_ids = bundle.ordered_ids; + let insert_index = insert_index + .unwrap_or(self.order.len()) + .min(self.order.len()); + for entry in bundle.entries { + let session_id = entry.session_id; + self.metadata.insert(session_id.clone(), entry.metadata); + if let Some(value) = entry.start_tab_placement { + self.start_tab_placements.insert(session_id.clone(), value); + } + if let Some(value) = entry.custom_name { + self.custom_names.insert(session_id.clone(), value); + } + if let Some(value) = entry.dynamic_title { + self.dynamic_titles.insert(session_id.clone(), value); + } + if let Some(value) = entry.cwd { + self.cwds.insert(session_id.clone(), value); + } + if let Some(value) = entry.tab_color { + self.tab_colors.insert(session_id.clone(), value); + } + if entry.locked { + self.locked_tabs.insert(session_id.clone()); + } + if let Some(value) = entry.command_history { + self.command_history.insert(session_id.clone(), value); + } + if let Some(value) = entry.busy_action { + self.busy_actions.insert(session_id.clone(), value); + } + if let Some(value) = entry.remote_file { + self.protocols + .remote_files + .insert(session_id.clone(), value); + } + if let Some(value) = entry.xymodem { + self.protocols.xymodem.insert(session_id.clone(), value); + } + if let Some(value) = entry.zmodem { + self.protocols.zmodem.insert(session_id.clone(), value); + } + if let Some(value) = entry.trzsz { + self.protocols.trzsz.insert(session_id.clone(), value); + } + self.event_bridge.claim_session(&session_id); + } + ordered_ids.retain(|id| self.metadata.contains_key(id)); + self.order + .splice(insert_index..insert_index, ordered_ids.iter().cloned()); + self.events.pending.extend(bundle.pending_events); + if let Some(first) = ordered_ids.first() { + self.select_active_session(first.clone()); + } + } + pub(in crate::features) fn disconnect_multiplex_handle(&mut self, handle: SshMultiplexHandle) { self.protocols.spawn_multiplex_disconnect(handle); } @@ -1101,6 +1296,7 @@ impl SessionFeatureState { tab_placement: Option, insert_index: Option, ) { + self.event_bridge.claim_session(session_id); if !self.order.iter().any(|id| id == session_id) { self.order.push(session_id.to_string()); } @@ -1564,6 +1760,7 @@ impl SessionFeatureState { &mut self, session_id: &str, ) -> Option { + self.event_bridge.release_session(session_id); self.remove_xymodem_session_runtime(session_id); self.remove_zmodem_session_runtime(session_id); self.remove_trzsz_session_runtime(session_id); @@ -1668,6 +1865,14 @@ impl SessionRestoreState { } impl SessionPromptState { + fn has_blocking_prompt(&self) -> bool { + self.active_duplicate_prompt.is_some() + || self.active_host_key_prompt.is_some() + || self.active_credential_prompt.is_some() + || self.active_keyboard_interactive_prompt.is_some() + || self.active_agent_prompt.is_some() + } + pub(in crate::features) fn duplicate_broker(&self) -> Arc { Arc::clone(&self.duplicate_prompts) } diff --git a/crates/nyaterm-desktop/src/features/session/state/tests.rs b/crates/nyaterm-desktop/src/features/session/state/tests.rs index 624b3052..5521ca05 100644 --- a/crates/nyaterm-desktop/src/features/session/state/tests.rs +++ b/crates/nyaterm-desktop/src/features/session/state/tests.rs @@ -621,6 +621,42 @@ fn session_catalog_registration_and_reordering_stay_synchronized() { assert!(!sessions.move_session_after("missing", "session-a")); } +#[test] +fn transferring_split_session_catalog_preserves_order_metadata_and_pending_events() { + let test_dir = TestConfigDir::new("nyaterm-session-transfer-test"); + let target_dir = TestConfigDir::new("nyaterm-session-transfer-target-test"); + let cx = TestAppContext::single(); + let mut source = session_state(&cx, test_dir.path()); + let mut target = session_state(&cx, target_dir.path()); + source.register_session_metadata("root", session_metadata("root session", None)); + source.register_session_metadata("leaf", session_metadata("split leaf", None)); + source.register_session_metadata("keep", session_metadata("unmoved", None)); + target.register_session_metadata("existing", session_metadata("existing", None)); + source.set_custom_name("root".into(), "renamed".into()); + source.set_tab_color("root", Some(0x123456)); + source.set_tab_locked("root", true); + source.extend_pending_events([SessionEvent::CwdChanged { + session_id: "leaf".into(), + cwd: "/workspace".into(), + }]); + + let ids = vec!["root".to_string(), "leaf".to_string()]; + assert!(source.can_transfer_sessions(&ids).is_ok()); + let bridge_events = source.pause_sessions_for_transfer(&ids); + let bundle = source + .detach_sessions_for_transfer(&ids, bridge_events) + .unwrap(); + target.attach_sessions_from_transfer(bundle, Some(1)); + + assert_eq!(source.session_order(), ["keep"]); + assert_eq!(target.session_order(), ["existing", "root", "leaf"]); + assert_eq!(target.display_name("root").as_deref(), Some("renamed")); + assert_eq!(target.tab_color("root"), Some(0x123456)); + assert!(target.tab_is_locked("root")); + assert_eq!(source.pending_event_count(), 0); + assert_eq!(target.pending_event_count(), 1); +} + #[test] fn session_catalog_inserts_out_of_order_completions_at_reserved_positions() { let test_dir = TestConfigDir::new("nyaterm-session-state-test"); diff --git a/crates/nyaterm-desktop/src/features/settings/config_runtime.rs b/crates/nyaterm-desktop/src/features/settings/config_runtime.rs index 77cb6ab8..eb15cf28 100644 --- a/crates/nyaterm-desktop/src/features/settings/config_runtime.rs +++ b/crates/nyaterm-desktop/src/features/settings/config_runtime.rs @@ -588,7 +588,12 @@ impl NyaTermApp { LoadBootstrap, move |this, event, cx| match event.outcome { Ok(snapshot) => { - this.apply_store_refresh(snapshot, cx); + this.apply_store_refresh(snapshot.clone(), cx); + this.replace_shared_snapshot( + snapshot, + crate::app_shell::SharedStateDomain::All, + cx, + ); this.rebase_open_settings_draft(cx); this.settings.update_store_status(success_message, true); this.request_settings_panel_refresh(cx); @@ -616,7 +621,12 @@ impl NyaTermApp { LoadBootstrap, |this, event, cx| match event.outcome { Ok(snapshot) => { - this.apply_store_refresh(snapshot, cx); + this.apply_store_refresh(snapshot.clone(), cx); + this.replace_shared_snapshot( + snapshot, + crate::app_shell::SharedStateDomain::All, + cx, + ); cx.notify(); } Err(error) => { @@ -631,6 +641,16 @@ impl NyaTermApp { } fn apply_store_refresh(&mut self, snapshot: BootstrapSnapshot, cx: &mut Context) { + let mut shared_settings = snapshot.settings; + let local_settings = self.settings.summary(); + shared_settings.ui_left_panel_width = local_settings.ui_left_panel_width; + shared_settings.ui_right_panel_width = local_settings.ui_right_panel_width; + shared_settings.ui_quick_cmd_height = local_settings.ui_quick_cmd_height; + shared_settings.ui_active_left_panel = local_settings.ui_active_left_panel.clone(); + shared_settings.ui_active_right_panel = local_settings.ui_active_right_panel.clone(); + shared_settings.ui_left_panel_collapsed = local_settings.ui_left_panel_collapsed; + shared_settings.ui_right_panel_collapsed = local_settings.ui_right_panel_collapsed; + self.update_custom_icons(snapshot.custom_icons, cx); self.connection_state .replace_loaded(snapshot.connections, snapshot.connection_groups); self.security.replace_catalog( @@ -652,8 +672,7 @@ impl NyaTermApp { ); self.settings .replace_keyword_config(snapshot.keyword_highlights); - self.apply_gpui_settings(snapshot.settings, cx); - self.apply_ui_layout_from_settings(); + self.apply_gpui_settings(shared_settings, cx); self.translation.replace_settings( snapshot.translation_settings, TranslationSecretDraft::default(), @@ -680,4 +699,142 @@ impl NyaTermApp { self.refresh_notes(cx); self.request_settings_panel_refresh(cx); } + + pub(crate) fn apply_shared_state( + &mut self, + snapshot: BootstrapSnapshot, + event: crate::app_shell::SharedStateEvent, + cx: &mut Context, + ) { + use crate::app_shell::SharedStateDomain; + + let has_clean_settings_draft = + self.shell.has_settings_draft() && !self.settings_draft_dirty(); + let settings_blocked = self.settings_draft_dirty() + && matches!( + event.domain, + SharedStateDomain::Settings + | SharedStateDomain::Ai + | SharedStateDomain::Translation + | SharedStateDomain::CloudSync + | SharedStateDomain::All + ); + if settings_blocked { + self.shell.set_status(format!( + "shared settings changed at revision {}; reload before applying", + event.revision + )); + } + + if matches!( + event.domain, + SharedStateDomain::Connections | SharedStateDomain::All + ) { + self.update_custom_icons(snapshot.custom_icons.clone(), cx); + self.connection_state.replace_loaded( + snapshot.connections.clone(), + snapshot.connection_groups.clone(), + ); + self.start_workspace + .sync_group_options(&snapshot.connection_groups, cx); + } + if matches!( + event.domain, + SharedStateDomain::Security | SharedStateDomain::All + ) { + self.security.replace_catalog( + snapshot.ssh_keys.clone(), + snapshot.otp_entries.clone(), + snapshot.saved_passwords.clone(), + snapshot.saved_credentials.clone(), + ); + } + if matches!( + event.domain, + SharedStateDomain::Tunnels | SharedStateDomain::All + ) { + self.tunnel_state.replace_loaded_catalog( + snapshot.tunnels.clone(), + snapshot.tunnel_groups.clone(), + snapshot.proxies.clone(), + snapshot.proxy_groups.clone(), + ); + } + if matches!( + event.domain, + SharedStateDomain::Commands | SharedStateDomain::All + ) { + self.commands.replace_loaded( + snapshot.quick_commands.clone(), + snapshot.quick_command_categories.clone(), + snapshot.command_history.clone(), + ); + } + if !settings_blocked + && matches!( + event.domain, + SharedStateDomain::Settings | SharedStateDomain::All + ) + { + let mut settings = snapshot.settings.clone(); + let local = self.settings.summary(); + settings.ui_left_panel_width = local.ui_left_panel_width; + settings.ui_right_panel_width = local.ui_right_panel_width; + settings.ui_quick_cmd_height = local.ui_quick_cmd_height; + settings.ui_active_left_panel = local.ui_active_left_panel.clone(); + settings.ui_active_right_panel = local.ui_active_right_panel.clone(); + settings.ui_left_panel_collapsed = local.ui_left_panel_collapsed; + settings.ui_right_panel_collapsed = local.ui_right_panel_collapsed; + self.settings + .replace_keyword_config(snapshot.keyword_highlights.clone()); + self.apply_gpui_settings(settings, cx); + self.recording + .set_memory_limit(self.settings.summary().recording_memory_limit_bytes as usize); + self.transfer + .set_duplicate_policy(SftpDuplicatePolicy::from_legacy_value( + &self.settings.summary().transfer_duplicate_strategy, + )); + } + if !settings_blocked + && matches!(event.domain, SharedStateDomain::Ai | SharedStateDomain::All) + { + self.ai.replace_settings_config(snapshot.ai_settings, true); + self.sync_ai_drafts_from_active_profile(); + } + if !settings_blocked + && matches!( + event.domain, + SharedStateDomain::Translation | SharedStateDomain::All + ) + { + self.translation.replace_settings( + snapshot.translation_settings, + TranslationSecretDraft::default(), + ); + } + if !settings_blocked + && matches!( + event.domain, + SharedStateDomain::CloudSync | SharedStateDomain::All + ) + { + self.cloud_sync + .replace_loaded(snapshot.cloud_sync_settings, snapshot.cloud_sync_state); + } + if has_clean_settings_draft + && matches!( + event.domain, + SharedStateDomain::Settings + | SharedStateDomain::Ai + | SharedStateDomain::Translation + | SharedStateDomain::CloudSync + | SharedStateDomain::All + ) + { + self.rebase_open_settings_draft(cx); + } + self.flush_connection_panel_snapshot(cx); + self.request_settings_panel_refresh(cx); + cx.notify(); + } } diff --git a/crates/nyaterm-desktop/src/features/settings/lock_diagnostics_runtime.rs b/crates/nyaterm-desktop/src/features/settings/lock_diagnostics_runtime.rs index 75d9cdf7..f49efa9f 100644 --- a/crates/nyaterm-desktop/src/features/settings/lock_diagnostics_runtime.rs +++ b/crates/nyaterm-desktop/src/features/settings/lock_diagnostics_runtime.rs @@ -13,6 +13,24 @@ use crate::models::TransferJobStatus; impl NyaTermApp { pub(in crate::features) fn lock_app(&mut self, window: &mut Window, cx: &mut Context) { + self.apply_shared_screen_lock(true, window, cx); + self.broadcast_screen_lock(true, cx); + } + + pub(crate) fn apply_shared_screen_lock( + &mut self, + locked: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !locked { + self.security.deactivate_screen_lock(); + self.ensure_idle_lock_clock(cx); + self.forget_text_inputs("lock-screen.password"); + self.shell.set_status("screen unlocked".to_string()); + cx.notify(); + return; + } let lock_status = if self.settings.summary().has_master_password { t!("lockScreen.passwordPlaceholder").to_string() } else { @@ -36,9 +54,22 @@ impl NyaTermApp { self.ensure_idle_lock_clock(cx); self.forget_text_inputs("lock-screen.password"); self.shell.set_status("screen unlocked".to_string()); + self.broadcast_screen_lock(false, cx); cx.notify(); } + fn broadcast_screen_lock(&self, locked: bool, cx: &mut Context) { + let Some(controller) = self.desktop_controller.clone() else { + return; + }; + let workspace_id = self.workspace_id; + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.set_screen_locked(locked, workspace_id, cx) + }); + }); + } + pub(in crate::features) fn submit_lock_unlock(&mut self, cx: &mut Context) { if !self.settings.summary().has_master_password { self.unlock_app(cx); diff --git a/crates/nyaterm-desktop/src/features/settings/security_runtime/credentials.rs b/crates/nyaterm-desktop/src/features/settings/security_runtime/credentials.rs index 4f2874f1..393bbb08 100644 --- a/crates/nyaterm-desktop/src/features/settings/security_runtime/credentials.rs +++ b/crates/nyaterm-desktop/src/features/settings/security_runtime/credentials.rs @@ -203,6 +203,10 @@ impl NyaTermApp { match result { Ok((name, enabled, catalog)) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); this.security.set_status(format!( "credential {name} {}", if enabled { "enabled" } else { "disabled" } @@ -319,6 +323,10 @@ impl NyaTermApp { match result { Ok((id, catalog)) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); this.security.finish_credential_editor(format!( "credential saved ({})", compact_id(&id) @@ -508,6 +516,10 @@ impl NyaTermApp { let status = match result { Ok(catalog) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); t!("credentialManager.reorderSuccess").to_string() } Err(error) => { diff --git a/crates/nyaterm-desktop/src/features/settings/security_runtime/delete.rs b/crates/nyaterm-desktop/src/features/settings/security_runtime/delete.rs index c736348a..634ee83c 100644 --- a/crates/nyaterm-desktop/src/features/settings/security_runtime/delete.rs +++ b/crates/nyaterm-desktop/src/features/settings/security_runtime/delete.rs @@ -111,6 +111,10 @@ impl NyaTermApp { this.security .clear_revealed_for_deleted(kind, &request_item_id); this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); let status = format!("{label} deleted"); this.security.set_status(status.clone()); this.shell.set_status(status); diff --git a/crates/nyaterm-desktop/src/features/settings/security_runtime/keys.rs b/crates/nyaterm-desktop/src/features/settings/security_runtime/keys.rs index 8a36c301..01f5898b 100644 --- a/crates/nyaterm-desktop/src/features/settings/security_runtime/keys.rs +++ b/crates/nyaterm-desktop/src/features/settings/security_runtime/keys.rs @@ -279,6 +279,10 @@ impl NyaTermApp { match result { Ok((id, catalog)) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); this.security .finish_key_editor(format!("SSH key saved ({})", compact_id(&id))); this.shell.set_status("SSH key saved".to_string()); diff --git a/crates/nyaterm-desktop/src/features/settings/security_runtime/otp.rs b/crates/nyaterm-desktop/src/features/settings/security_runtime/otp.rs index 2080a81d..4ef4cac4 100644 --- a/crates/nyaterm-desktop/src/features/settings/security_runtime/otp.rs +++ b/crates/nyaterm-desktop/src/features/settings/security_runtime/otp.rs @@ -363,6 +363,10 @@ impl NyaTermApp { match result { Ok((id, catalog)) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); this.security .finish_otp_editor(format!("OTP entry saved ({})", compact_id(&id))); this.shell.set_status("OTP entry saved".to_string()); diff --git a/crates/nyaterm-desktop/src/features/settings/security_runtime/passwords.rs b/crates/nyaterm-desktop/src/features/settings/security_runtime/passwords.rs index 0e7c83a8..31b219cd 100644 --- a/crates/nyaterm-desktop/src/features/settings/security_runtime/passwords.rs +++ b/crates/nyaterm-desktop/src/features/settings/security_runtime/passwords.rs @@ -203,6 +203,10 @@ impl NyaTermApp { match result { Ok((id, catalog)) => { this.security.replace_catalog_state(catalog); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Security, + cx, + ); this.security.finish_password_editor(format!( "password saved ({})", compact_id(&id) diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/draft.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/draft.rs index 07f6d111..c500c5ff 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/draft.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/draft.rs @@ -11,6 +11,7 @@ impl NyaTermApp { if self.shell.has_settings_draft() { return; } + self.settings.clear_draft_dirty_domains(); let (translation_settings, translation_secret_draft) = self.translation.settings_draft_snapshot(); let (cloud_sync_settings, cloud_sync_secret_draft) = @@ -20,6 +21,7 @@ impl NyaTermApp { let master_password = self.settings.master_password(); self.shell .set_settings_draft_snapshot(SettingsDraftSnapshot { + revisions: self.process_state.read(cx).settings_draft_revisions(), settings: self.settings.summary().clone(), ai_settings, ai_model_draft, @@ -78,6 +80,18 @@ impl NyaTermApp { true } + pub(in crate::features) fn defer_settings_domain_persistence( + &mut self, + domain: crate::features::settings::SettingsPersistenceDomain, + cx: &mut Context, + ) -> bool { + if !self.defer_settings_persistence(cx) { + return false; + } + self.settings.mark_draft_domain_dirty(domain); + true + } + pub(in crate::features) fn pending_settings_cloud_error(&self) -> Option { let settings = self.cloud_sync.pending_settings(); if !settings.enabled { @@ -224,11 +238,60 @@ impl NyaTermApp { } let settings = self.settings.summary().clone(); + let base_settings = self + .shell + .settings_draft_snapshot() + .expect("settings draft checked above") + .settings + .clone(); + let base = self + .shell + .settings_draft_snapshot() + .expect("settings draft checked above") + .clone(); + let settings_domains = self.settings.draft_dirty_domains(); + let settings_changed = !settings_domains.is_empty(); + let keyword_changed = self.settings.keyword_config() != &base.keyword_highlights; + let ai_changed = !self.ai.settings_draft_matches( + &base.ai_settings, + &base.ai_model_draft, + &base.ai_base_url_draft, + &base.ai_secret_draft, + ); + let cloud_changed = !self + .cloud_sync + .settings_draft_matches(&base.cloud_sync_settings, &base.cloud_sync_secret_draft); + let translation_changed = !self + .translation + .settings_draft_matches(&base.translation_settings, &base.translation_secret_draft); + let master_password = self.settings.master_password(); + let master_password_changed = base.master_password_enabled != master_password.enabled + || base.master_password_draft.expose_secret() != master_password.draft; + let revisions = self.process_state.read(cx).settings_draft_revisions(); + let settings_revision_conflict = + (settings_changed || keyword_changed || master_password_changed) + && revisions.settings != base.revisions.settings; + let ai_revision_conflict = ai_changed && revisions.ai != base.revisions.ai; + let cloud_revision_conflict = + cloud_changed && revisions.cloud_sync != base.revisions.cloud_sync; + let translation_revision_conflict = + translation_changed && revisions.translation != base.revisions.translation; + if settings_revision_conflict + || ai_revision_conflict + || cloud_revision_conflict + || translation_revision_conflict + { + let message = "settings changed in another window; reload before applying".to_string(); + self.settings.update_store_status(message.clone(), false); + self.shell.set_status(message); + self.request_settings_panel_refresh(cx); + cx.notify(); + return; + } let ai_settings = self.pending_ai_settings(); let cloud_sync_settings = self.cloud_sync.pending_settings(); let translation_settings = self.translation.pending_settings(); let keyword_highlights = self.settings.keyword_config().clone(); - let master_password = self.settings.master_password(); let master_password_update = if master_password.draft.is_empty() { (self.settings.summary().has_master_password && !master_password.enabled) .then_some(None) @@ -240,36 +303,124 @@ impl NyaTermApp { self.submit_store_request( 0, store_request(StoreDomain::Settings, move |store| { + let conflict = || { + nyaterm_store::StorageError::InvalidData( + "settings changed in another window; reload before applying".to_string(), + ) + }; + let mut shared_settings = settings.clone(); + if settings_changed { + let persisted_settings = store.load_app_settings_summary()?; + shared_settings.ui_left_panel_width = persisted_settings.ui_left_panel_width; + shared_settings.ui_right_panel_width = persisted_settings.ui_right_panel_width; + shared_settings.ui_quick_cmd_height = persisted_settings.ui_quick_cmd_height; + shared_settings.ui_active_left_panel = + persisted_settings.ui_active_left_panel.clone(); + shared_settings.ui_active_right_panel = + persisted_settings.ui_active_right_panel.clone(); + shared_settings.ui_left_panel_collapsed = + persisted_settings.ui_left_panel_collapsed; + shared_settings.ui_right_panel_collapsed = + persisted_settings.ui_right_panel_collapsed; + let mut persisted = persisted_settings; + // These fields are workspace-local projections, not shared settings. + persisted.ui_left_panel_width = base_settings.ui_left_panel_width; + persisted.ui_right_panel_width = base_settings.ui_right_panel_width; + persisted.ui_quick_cmd_height = base_settings.ui_quick_cmd_height; + persisted.ui_active_left_panel = base_settings.ui_active_left_panel.clone(); + persisted.ui_active_right_panel = base_settings.ui_active_right_panel.clone(); + persisted.ui_left_panel_collapsed = base_settings.ui_left_panel_collapsed; + persisted.ui_right_panel_collapsed = base_settings.ui_right_panel_collapsed; + if persisted != base_settings { + return Err(conflict()); + } + } + if keyword_changed && store.load_keyword_highlights()? != base.keyword_highlights { + return Err(conflict()); + } + if ai_changed && store.load_ai_settings()? != base.ai_settings { + return Err(conflict()); + } + if cloud_changed && store.load_cloud_sync_settings()? != base.cloud_sync_settings { + return Err(conflict()); + } + if translation_changed + && store.load_translation_settings()? != base.translation_settings + { + return Err(conflict()); + } if let Some(next_password) = master_password_update.as_ref() { store.save_master_password(next_password.as_deref())?; } - store.save_appearance_settings(&settings)?; - store.save_terminal_settings(&settings)?; - store.save_interaction_settings(&settings)?; - store.save_general_settings(&settings)?; - // The header-status mode and visibility are edited on the General tab - // but stored by the UI-layout writer, which is otherwise driven by - // layout gestures through `persist_ui_layout`. Without this the draft's - // choice is never written, and the `load_app_settings_summary` below - // hands the stale value straight back to `apply_gpui_settings`. - store.save_ui_layout_settings(&settings)?; - store.save_diagnostics_settings(&settings)?; - store.save_screen_lock_settings(&settings)?; - store.save_recording_settings(&settings)?; - store.save_transfer_settings(&settings)?; - store.save_host_key_policy(&settings.host_key_policy)?; - store.save_keybindings(&settings.keybindings)?; - let saved_keyword_highlights = - store.save_keyword_highlights(&keyword_highlights)?; - let saved_translation_settings = - store.save_translation_settings(translation_settings)?; - let saved_cloud_sync_settings = - store.save_cloud_sync_settings(cloud_sync_settings)?; - let saved_ai_settings = store.save_ai_settings(ai_settings)?; - if !settings.startup_restore_window_layout { - store.save_terminal_window_layout(None)?; - store.save_workspace_pane_layout(None)?; + for domain in settings_domains { + match domain { + crate::features::settings::SettingsPersistenceDomain::Diagnostics => { + store.save_diagnostics_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::General => { + store.save_general_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::Interaction => { + store.save_interaction_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::ScreenLock => { + store.save_screen_lock_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::HostKey => { + store.save_host_key_policy(&shared_settings.host_key_policy)?; + } + crate::features::settings::SettingsPersistenceDomain::Recording => { + store.save_recording_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::Transfer => { + store.save_transfer_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::Terminal => { + store.save_terminal_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::QuickCommands => { + store.save_quick_command_ui_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::Appearance => { + store.save_appearance_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::UiLayout => { + store.save_ui_layout_settings(&shared_settings)?; + } + crate::features::settings::SettingsPersistenceDomain::Keybindings => { + store.save_keybindings(&shared_settings.keybindings)?; + } + crate::features::settings::SettingsPersistenceDomain::FileExplorer => { + store.save_file_explorer_favorite_dirs(&shared_settings)?; + } + } } + if settings_changed { + if !settings.startup_restore_window_layout { + store.save_terminal_window_layout(None)?; + store.save_workspace_pane_layout(None)?; + } + } + let saved_keyword_highlights = if keyword_changed { + store.save_keyword_highlights(&keyword_highlights)? + } else { + store.load_keyword_highlights()? + }; + let saved_translation_settings = if translation_changed { + store.save_translation_settings(translation_settings)? + } else { + store.load_translation_settings()? + }; + let saved_cloud_sync_settings = if cloud_changed { + store.save_cloud_sync_settings(cloud_sync_settings)? + } else { + store.load_cloud_sync_settings()? + }; + let saved_ai_settings = if ai_changed { + store.save_ai_settings(ai_settings)? + } else { + store.load_ai_settings()? + }; Ok(( store.load_app_settings_summary()?, saved_keyword_highlights, @@ -286,7 +437,9 @@ impl NyaTermApp { saved_cloud_sync_settings, saved_ai_settings, )) => { - this.apply_gpui_settings(saved_settings, cx); + this.apply_gpui_settings(saved_settings.clone(), cx); + this.publish_shared_settings(saved_settings, cx); + this.request_shared_state_refresh(crate::app_shell::SharedStateDomain::All, cx); this.settings.rebase_master_password(); this.ai.replace_settings_config(saved_ai_settings, true); this.cloud_sync @@ -317,6 +470,7 @@ impl NyaTermApp { this.invalidate_terminal_cell_metrics(cx); this.refresh_visible_terminal_surfaces(cx); this.shell.clear_settings_draft_snapshot(); + this.settings.clear_draft_dirty_domains(); this.settings.update_store_status("settings applied", true); this.shell.set_status("settings applied".to_string()); if close_after_apply { @@ -374,6 +528,7 @@ impl NyaTermApp { self.sync_ai_drafts_from_active_profile(); self.refresh_visible_terminal_surfaces(cx); } + self.settings.clear_draft_dirty_domains(); self.finish_settings_page(cx); self.request_settings_panel_refresh(cx); } @@ -383,6 +538,7 @@ impl NyaTermApp { self.apply_settings_draft(true, cx); } else { self.shell.clear_settings_draft_snapshot(); + self.settings.clear_draft_dirty_domains(); self.finish_settings_page(cx); } self.request_settings_panel_refresh(cx); @@ -608,4 +764,59 @@ mod tests { "apply must write the header back on, not leave it hidden on disk" ); } + + #[test] + fn stale_draft_cannot_overwrite_settings_saved_by_another_window() { + let mut cx = TestAppContext::single(); + let app = app(&mut cx); + cx.update_entity(&app, |app, cx| { + app.begin_settings_draft(cx); + app.set_header_status_mode(HeaderStatusMode::Host, cx); + let mut other_window = app.settings.summary().clone(); + other_window.ui_header_status_mode = "session".to_string(); + other_window.confirm_on_close = !other_window.confirm_on_close; + app.store_blocking_client() + .request_fn(nyaterm_store::StoreDomain::Settings, move |store| { + store.save_general_settings(&other_window) + }) + .expect("external save"); + app.apply_settings_draft(false, cx); + }); + cx.run_until_parked(); + assert_eq!(stored_header_status(&app, &mut cx).0, "session"); + cx.update_entity(&app, |app, _| { + assert!( + app.shell.has_settings_draft(), + "the rejected draft must survive" + ); + }); + } + + #[test] + fn applying_unrelated_settings_does_not_rewrite_updated_keyword_catalog() { + let mut cx = TestAppContext::single(); + let app = app(&mut cx); + cx.update_entity(&app, |app, cx| { + app.begin_settings_draft(cx); + app.set_header_status_mode(HeaderStatusMode::Host, cx); + let mut external = app.settings.keyword_config().clone(); + external.enabled = !external.enabled; + app.store_blocking_client() + .request_fn(nyaterm_store::StoreDomain::Settings, move |store| { + store.save_keyword_highlights(&external) + }) + .expect("external keyword save"); + app.apply_settings_draft(false, cx); + }); + cx.run_until_parked(); + let saved = cx.update_entity(&app, |app, _| { + app.store_blocking_client() + .request_fn(nyaterm_store::StoreDomain::Settings, |store| { + store.load_keyword_highlights() + }) + .expect("load keyword catalog") + }); + assert!(saved.enabled); + assert_eq!(stored_header_status(&app, &mut cx).0, "host"); + } } diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/general_interaction.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/general_interaction.rs index 128049b1..6162c418 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/general_interaction.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/general_interaction.rs @@ -61,6 +61,21 @@ impl SettingsSaveKind { } impl NyaTermApp { + pub(crate) fn publish_shared_settings( + &self, + settings: AppSettingsSummary, + cx: &mut Context, + ) { + if let Some(controller) = self.desktop_controller.clone() { + let source = self.workspace_id; + cx.defer(move |cx| { + let _ = controller.update(cx, |controller, cx| { + controller.publish_settings(source, settings, cx) + }); + }); + } + } + pub(in crate::features) fn update_ui_language( &mut self, language: &str, @@ -135,14 +150,14 @@ impl NyaTermApp { } pub(in crate::features) fn save_diagnostics_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence(SettingsPersistenceDomain::Diagnostics, cx) { return; } self.queue_settings_save(SettingsSaveKind::Diagnostics, cx); } pub(in crate::features) fn save_general_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence(SettingsPersistenceDomain::General, cx) { return; } self.queue_settings_save(SettingsSaveKind::General, cx); @@ -240,7 +255,7 @@ impl NyaTermApp { } pub(in crate::features) fn save_interaction_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence(SettingsPersistenceDomain::Interaction, cx) { return; } self.queue_settings_save(SettingsSaveKind::Interaction, cx); @@ -265,7 +280,7 @@ impl NyaTermApp { } pub(in crate::features) fn save_screen_lock_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence(SettingsPersistenceDomain::ScreenLock, cx) { return; } self.queue_settings_save(SettingsSaveKind::ScreenLock, cx); @@ -276,6 +291,14 @@ impl NyaTermApp { kind: SettingsSaveKind, cx: &mut Context, ) { + if self.shell.has_settings_draft() { + self.settings.mark_draft_domain_dirty(kind.domain()); + self.settings + .update_store_status(format!("{} changes staged", kind.label()), true); + self.request_settings_panel_refresh(cx); + cx.notify(); + return; + } let Some((generation, snapshot)) = self.settings.queue_persistence(kind.domain()) else { self.settings .update_store_status(format!("{} changes queued", kind.label()), false); @@ -332,6 +355,7 @@ impl NyaTermApp { && let Ok(settings) = event.outcome.as_ref() { this.apply_gpui_settings(settings.clone(), cx); + this.publish_shared_settings(settings.clone(), cx); } if completion.report_result { match event.outcome { diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/recording_transfer.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/recording_transfer.rs index 690711ab..73a149ed 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/recording_transfer.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/recording_transfer.rs @@ -13,7 +13,10 @@ impl NyaTermApp { cx: &mut Context, ) { self.settings.set_host_key_policy(policy); - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::HostKey, + cx, + ) { self.shell .set_status(format!("host key policy staged as {policy}")); return; @@ -109,7 +112,10 @@ impl NyaTermApp { pub(in crate::features) fn save_recording_settings(&mut self, cx: &mut Context) { self.recording .set_memory_limit(self.settings.summary().recording_memory_limit_bytes as usize); - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::Recording, + cx, + ) { return; } self.queue_settings_save(SettingsSaveKind::Recording, cx); @@ -220,7 +226,10 @@ impl NyaTermApp { success_status: &'static str, cx: &mut Context, ) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::Transfer, + cx, + ) { return; } let _ = success_status; diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/terminal_remote.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/terminal_remote.rs index 20fa393e..8ccbdec4 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/terminal_remote.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/terminal_remote.rs @@ -203,7 +203,10 @@ impl NyaTermApp { } pub(in crate::features) fn save_terminal_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::Terminal, + cx, + ) { return; } self.enforce_terminal_scrollback_limit(); diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/window.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/window.rs index a1195545..fe1db225 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/window.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/window.rs @@ -157,7 +157,7 @@ impl NyaTermApp { /// Returning `true` is what keeps a second "open settings" from starting a /// competing draft: there is one draft, so the request has to land on the /// window already holding it. - pub(in crate::features) fn activate_settings_window(&mut self, cx: &mut Context) -> bool { + pub(crate) fn activate_settings_window(&mut self, cx: &mut Context) -> bool { let Some(handle) = self.shell.settings_window() else { return false; }; @@ -232,6 +232,15 @@ fn open_settings_window_now_from_app(app: Entity, cx: &mut App) { } Err(error) => { app.shell.fail_settings_window_open(); + if let Some(controller) = app + .desktop_controller + .as_ref() + .and_then(|controller| controller.upgrade()) + { + controller.update(cx, |controller, _| { + controller.release_settings_owner(app.workspace_id) + }); + } app.shell .set_status(format!("failed to open settings window: {error}")); cx.notify(); diff --git a/crates/nyaterm-desktop/src/features/settings/state.rs b/crates/nyaterm-desktop/src/features/settings/state.rs index 624d88c2..23c12023 100644 --- a/crates/nyaterm-desktop/src/features/settings/state.rs +++ b/crates/nyaterm-desktop/src/features/settings/state.rs @@ -1,6 +1,6 @@ //! Authoritative application settings and grouped state for the settings experience. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gpui::FocusHandle; @@ -24,6 +24,7 @@ pub(in crate::features) struct SettingsFeatureState { /// Compatibility-sensitive values loaded and persisted through `nyaterm-core`. summary: AppSettingsSummary, keyword_config: KeywordHighlightConfig, + keyword_persistence_dirty: bool, master_password: SettingsMasterPasswordState, store_status: StoreStatus, search_engines: SearchEngineSettingsState, @@ -32,6 +33,7 @@ pub(in crate::features) struct SettingsFeatureState { keybindings: KeybindingSettingsState, prompts: SettingsPromptState, persistence: HashMap, + draft_dirty_domains: HashSet, panel_refresh_requested: bool, } @@ -200,6 +202,7 @@ impl SettingsFeatureState { Self { summary, keyword_config, + keyword_persistence_dirty: false, master_password, store_status: StoreStatus { path: store_path, @@ -229,6 +232,7 @@ impl SettingsFeatureState { }, prompts: SettingsPromptState::default(), persistence: HashMap::new(), + draft_dirty_domains: HashSet::new(), panel_refresh_requested: false, } } @@ -328,6 +332,39 @@ impl SettingsFeatureState { .dirty = true; } + pub(in crate::features) fn mark_draft_domain_dirty( + &mut self, + domain: SettingsPersistenceDomain, + ) { + self.draft_dirty_domains.insert(domain); + } + + pub(in crate::features) fn clear_draft_dirty_domains(&mut self) { + self.draft_dirty_domains.clear(); + } + + pub(in crate::features) fn draft_dirty_domains(&self) -> Vec { + const DOMAINS: [SettingsPersistenceDomain; 13] = [ + SettingsPersistenceDomain::Diagnostics, + SettingsPersistenceDomain::General, + SettingsPersistenceDomain::Interaction, + SettingsPersistenceDomain::ScreenLock, + SettingsPersistenceDomain::HostKey, + SettingsPersistenceDomain::Recording, + SettingsPersistenceDomain::Transfer, + SettingsPersistenceDomain::Terminal, + SettingsPersistenceDomain::QuickCommands, + SettingsPersistenceDomain::Appearance, + SettingsPersistenceDomain::UiLayout, + SettingsPersistenceDomain::Keybindings, + SettingsPersistenceDomain::FileExplorer, + ]; + DOMAINS + .into_iter() + .filter(|domain| self.draft_dirty_domains.contains(domain)) + .collect() + } + pub(in crate::features) fn dirty_persistence_domains(&self) -> Vec { const DOMAINS: [SettingsPersistenceDomain; 13] = [ SettingsPersistenceDomain::Diagnostics, @@ -713,6 +750,23 @@ impl SettingsFeatureState { &self.keyword_config } + pub(in crate::features) fn keyword_persistence_dirty(&self) -> bool { + self.keyword_persistence_dirty + } + + pub(in crate::features) fn mark_keyword_persistence_dirty(&mut self) { + self.keyword_persistence_dirty = true; + } + + pub(in crate::features) fn finish_keyword_persistence( + &mut self, + saved: &KeywordHighlightConfig, + ) { + if &self.keyword_config == saved { + self.keyword_persistence_dirty = false; + } + } + pub(in crate::features) fn replace_keyword_config(&mut self, config: KeywordHighlightConfig) { self.keyword_config = config; } diff --git a/crates/nyaterm-desktop/src/features/shell/appearance.rs b/crates/nyaterm-desktop/src/features/shell/appearance.rs index 08a24527..21aa21df 100644 --- a/crates/nyaterm-desktop/src/features/shell/appearance.rs +++ b/crates/nyaterm-desktop/src/features/shell/appearance.rs @@ -885,7 +885,10 @@ impl NyaTermApp { } fn save_appearance_settings(&mut self, cx: &mut Context) { - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::Appearance, + cx, + ) { self.refresh_visible_terminal_surfaces(cx); return; } diff --git a/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keybindings.rs b/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keybindings.rs index 0e71a751..03186fdf 100644 --- a/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keybindings.rs +++ b/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keybindings.rs @@ -112,7 +112,10 @@ impl NyaTermApp { ) { crate::shortcuts::rebuild_keymap(&keybindings, cx); self.settings.set_keybindings(keybindings.clone()); - if self.defer_settings_persistence(cx) { + if self.defer_settings_domain_persistence( + crate::features::settings::SettingsPersistenceDomain::Keybindings, + cx, + ) { self.settings.finish_keybinding_recording(); self.shell .set_status(success_message.replace("saved", "staged")); diff --git a/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keyword_highlights.rs b/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keyword_highlights.rs index 1490f0a2..ec7d5298 100644 --- a/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keyword_highlights.rs +++ b/crates/nyaterm-desktop/src/features/shell/keybinding_runtime/keyword_highlights.rs @@ -21,6 +21,7 @@ impl NyaTermApp { fn save_keyword_highlights(&mut self, cx: &mut Context) { self.invalidate_paint_theme_caches(); + self.settings.mark_keyword_persistence_dirty(); if self.defer_settings_persistence(cx) { return; } @@ -33,6 +34,7 @@ impl NyaTermApp { |this, event, cx| { match event.outcome { Ok(config) => { + this.settings.finish_keyword_persistence(&config); this.settings.replace_keyword_config(config); this.settings .update_store_status("keyword highlight settings saved", true); diff --git a/crates/nyaterm-desktop/src/features/shell/mod.rs b/crates/nyaterm-desktop/src/features/shell/mod.rs index c2d0dd12..292b2b3d 100644 --- a/crates/nyaterm-desktop/src/features/shell/mod.rs +++ b/crates/nyaterm-desktop/src/features/shell/mod.rs @@ -22,7 +22,7 @@ mod status_clocks; mod tab_mouse; mod tab_windows_runtime; mod terminal_recovery; -mod tray; +pub(crate) mod tray; mod workspace_runtime; pub(in crate::features) use activity_bar_runtime::{ diff --git a/crates/nyaterm-desktop/src/features/shell/navigation_runtime.rs b/crates/nyaterm-desktop/src/features/shell/navigation_runtime.rs index e3653be1..a713effc 100644 --- a/crates/nyaterm-desktop/src/features/shell/navigation_runtime.rs +++ b/crates/nyaterm-desktop/src/features/shell/navigation_runtime.rs @@ -6,6 +6,16 @@ use crate::models::{MainMode, NavItem, PanelSide, RightFocus}; impl NyaTermApp { pub(in crate::features) fn open_page(&mut self, item: NavItem, cx: &mut Context) { if item == NavItem::Settings || item.opens_settings() { + if let Some(controller) = self + .desktop_controller + .as_ref() + .and_then(|controller| controller.upgrade()) + && controller.update(cx, |controller, cx| { + controller.activate_or_claim_settings(self.workspace_id, cx) + }) + { + return; + } self.begin_settings_draft(cx); // Inputs are built where a tab is revealed, never in its render. let tab = self.shell.navigation.settings.active_tab; diff --git a/crates/nyaterm-desktop/src/features/shell/persistence_debounce.rs b/crates/nyaterm-desktop/src/features/shell/persistence_debounce.rs index e5357420..c4c950f1 100644 --- a/crates/nyaterm-desktop/src/features/shell/persistence_debounce.rs +++ b/crates/nyaterm-desktop/src/features/shell/persistence_debounce.rs @@ -329,7 +329,7 @@ mod tests { ); // Quit without ever advancing the clock. - let _ = app.submit_shutdown_persistence(); + let _ = app.submit_shutdown_persistence(true); assert!( app.settings diff --git a/crates/nyaterm-desktop/src/features/shell/state.rs b/crates/nyaterm-desktop/src/features/shell/state.rs index f8b05f65..75e0b75d 100644 --- a/crates/nyaterm-desktop/src/features/shell/state.rs +++ b/crates/nyaterm-desktop/src/features/shell/state.rs @@ -33,7 +33,6 @@ pub(in crate::features) enum NewSessionMenuAnchor { } pub(in crate::features) struct ShellFeatureState { - pub(in crate::features) system_tray: Option, /// Application-wide transient status shown by shell chrome and terminal overlays. status: String, /// GPUI event-pump, repaint, and shell-persistence scheduling bookkeeping. @@ -197,7 +196,6 @@ impl ShellFeatureState { pub(in crate::features) fn new(init: ShellFeatureInit) -> Self { Self { status: init.status, - system_tray: None, runtime: ShellRuntimeState::default(), bottom_panel: ShellBottomPanelState { mode: init.bottom_panel_mode, @@ -804,6 +802,13 @@ impl ShellFeatureState { &self.workspace.pane_roots } + pub(in crate::features) fn take_workspace_pane_root( + &mut self, + tab_root: &str, + ) -> Option { + self.workspace.pane_roots.remove(tab_root) + } + pub(in crate::features) fn insert_workspace_pane_root( &mut self, tab_root: String, diff --git a/crates/nyaterm-desktop/src/features/shell/tab_mouse.rs b/crates/nyaterm-desktop/src/features/shell/tab_mouse.rs index 6942db21..74cd0657 100644 --- a/crates/nyaterm-desktop/src/features/shell/tab_mouse.rs +++ b/crates/nyaterm-desktop/src/features/shell/tab_mouse.rs @@ -14,6 +14,9 @@ use crate::theme::ThemePalette; #[derive(Clone, Debug)] pub(in crate::features) struct SessionTabDragPayload { + pub source_workspace_id: nyaterm_core::WorkspaceId, + pub root_tab_id: String, + pub source_revision: u64, pub session_id: String, pub order_index: usize, pub display_name: String, diff --git a/crates/nyaterm-desktop/src/features/shell/tab_windows_runtime.rs b/crates/nyaterm-desktop/src/features/shell/tab_windows_runtime.rs index 82295082..5b5d712e 100644 --- a/crates/nyaterm-desktop/src/features/shell/tab_windows_runtime.rs +++ b/crates/nyaterm-desktop/src/features/shell/tab_windows_runtime.rs @@ -207,6 +207,7 @@ impl NyaTermApp { } pub(in crate::features) fn persist_terminal_window_layout(&mut self) { + self.workspace_revision = self.workspace_revision.saturating_add(1); if !self.settings.summary().startup_restore || !self.settings.summary().startup_restore_window_layout { @@ -250,6 +251,25 @@ impl NyaTermApp { } self.terminal.complete_terminal_windows_restore(); let active = self.session.active_id_owned(); + let loaded = self + .stores + .startup_restore + .update(cx, |store, _| store.take_loaded_terminal_window_layout()); + if let Some(layout) = loaded { + if let Some(layout) = layout + && let Some(focused_leaf_id) = self.terminal.restore_terminal_window_layout( + &layout, + &ordered, + active.as_deref(), + ) + { + self.shell.workspace.focused_terminal_leaf_id = focused_leaf_id; + self.shell + .set_status("restored multi-leaf window layout".to_string()); + } + cx.notify(); + return; + } self.submit_store_request( 0, store_request(StoreDomain::Sessions, |store| { diff --git a/crates/nyaterm-desktop/src/features/shell/tray.rs b/crates/nyaterm-desktop/src/features/shell/tray.rs index baa612fe..fc87e5be 100644 --- a/crates/nyaterm-desktop/src/features/shell/tray.rs +++ b/crates/nyaterm-desktop/src/features/shell/tray.rs @@ -1,17 +1,33 @@ use crate::features::NyaTermApp; -use gpui::{AppContext as _, Context, Window}; +use gpui::{Context, Window}; +#[cfg(target_os = "linux")] use std::time::Duration; use tray_icon::{ TrayIcon, TrayIconBuilder, - menu::{Menu, MenuEvent, MenuItem}, + menu::{Menu, MenuItem}, }; #[derive(Clone, PartialEq, Eq)] -struct TraySnapshot { +pub(crate) struct TraySnapshot { entries: Vec<(String, String)>, } -pub(in crate::features) struct SystemTray { +impl TraySnapshot { + pub(crate) fn empty() -> Self { + Self { + entries: vec![ + ("show".into(), rust_i18n::t!("tray.show").to_string()), + ( + "new-window".into(), + rust_i18n::t!("tray.newWindow").to_string(), + ), + ("quit".into(), rust_i18n::t!("tray.quit").to_string()), + ], + } + } +} + +pub(crate) struct SystemTray { #[cfg(not(target_os = "linux"))] icon: TrayIcon, #[cfg(target_os = "linux")] @@ -21,6 +37,40 @@ pub(in crate::features) struct SystemTray { snapshot: TraySnapshot, } +impl SystemTray { + pub(crate) fn new( + snapshot: TraySnapshot, + pixels: Vec, + width: u32, + height: u32, + ) -> Result { + #[cfg(not(target_os = "linux"))] + { + let icon = build_tray(&snapshot, pixels, width, height)?; + Ok(Self { icon, snapshot }) + } + #[cfg(target_os = "linux")] + { + linux_tray(snapshot, pixels, width, height) + } + } + + pub(crate) fn update(&mut self, snapshot: TraySnapshot) { + if self.snapshot == snapshot { + return; + } + #[cfg(not(target_os = "linux"))] + if let Ok(menu) = menu(&snapshot) { + self.icon.set_menu(Some(Box::new(menu))); + } + #[cfg(target_os = "linux")] + if let Some(updates) = &self.updates { + let _ = updates.send(snapshot.clone()); + } + self.snapshot = snapshot; + } +} + fn menu(snapshot: &TraySnapshot) -> Result { let menu = Menu::new(); for (id, label) in &snapshot.entries { @@ -48,9 +98,13 @@ fn build_tray( } impl NyaTermApp { - fn tray_snapshot(&self) -> TraySnapshot { + pub(crate) fn tray_snapshot(&self) -> TraySnapshot { let mut entries = vec![ ("show".into(), rust_i18n::t!("tray.show").to_string()), + ( + "new-window".into(), + rust_i18n::t!("tray.newWindow").to_string(), + ), ( "new".into(), rust_i18n::t!("tray.newConnection").to_string(), @@ -79,77 +133,7 @@ impl NyaTermApp { TraySnapshot { entries } } - pub(crate) fn start_system_tray(&mut self, cx: &mut Context) { - cx.spawn(async move |this, cx| { - let image = cx - .background_spawn(async { - image::load_from_memory(include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../nyaterm-app/resources/icons/32x32.png" - ))) - .map(|image| { - let image = image.to_rgba8(); - (image.width(), image.height(), image.into_raw()) - }) - }) - .await; - let Ok((width, height, pixels)) = image else { - return; - }; - let initialized = this - .update(cx, |app, _| { - let snapshot = app.tray_snapshot(); - #[cfg(not(target_os = "linux"))] - let tray = build_tray(&snapshot, pixels, width, height) - .map(|icon| SystemTray { icon, snapshot }); - #[cfg(target_os = "linux")] - let tray = linux_tray(snapshot, pixels, width, height); - app.shell.system_tray = tray.ok(); - app.shell.system_tray.is_some() - }) - .unwrap_or(false); - if !initialized { - return; - } - loop { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - if this - .update(cx, |app, cx| { - let snapshot = app.tray_snapshot(); - if let Some(tray) = app.shell.system_tray.as_mut() - && tray.snapshot != snapshot - { - #[cfg(not(target_os = "linux"))] - if let Ok(menu) = menu(&snapshot) { - tray.icon.set_menu(Some(Box::new(menu))); - } - #[cfg(target_os = "linux")] - if let Some(updates) = &tray.updates { - let _ = updates.send(snapshot.clone()); - } - tray.snapshot = snapshot; - } - while let Ok(event) = MenuEvent::receiver().try_recv() { - app.handle_tray_action(event.id.as_ref().to_owned(), cx); - } - while let Ok(event) = tray_icon::TrayIconEvent::receiver().try_recv() { - if matches!(event, tray_icon::TrayIconEvent::DoubleClick { .. }) { - app.handle_tray_action("show".into(), cx); - } - } - }) - .is_err() - { - break; - } - } - }) - .detach(); - } - - fn handle_tray_action(&mut self, action: String, cx: &mut Context) { + pub(crate) fn handle_tray_action(&mut self, action: String, cx: &mut Context) { let Some(window) = self.shell.main_window() else { return; }; @@ -186,7 +170,12 @@ impl NyaTermApp { window: &mut Window, cx: &mut Context, ) -> bool { - if self.shell.system_tray.is_none() { + if !self + .desktop_controller + .as_ref() + .and_then(gpui::WeakEntity::upgrade) + .is_some_and(|controller| controller.read(cx).tray_available()) + { return false; } #[cfg(windows)] @@ -217,7 +206,7 @@ impl NyaTermApp { } } -fn show_window(window: &mut Window, cx: &mut gpui::App) { +pub(crate) fn show_window(window: &mut Window, cx: &mut gpui::App) { #[cfg(windows)] if let Ok(handle) = raw_window_handle::HasWindowHandle::window_handle(window) && let raw_window_handle::RawWindowHandle::Win32(handle) = handle.as_raw() diff --git a/crates/nyaterm-desktop/src/features/shell/workspace_runtime.rs b/crates/nyaterm-desktop/src/features/shell/workspace_runtime.rs index 503e3918..e022d4c4 100644 --- a/crates/nyaterm-desktop/src/features/shell/workspace_runtime.rs +++ b/crates/nyaterm-desktop/src/features/shell/workspace_runtime.rs @@ -445,6 +445,7 @@ impl NyaTermApp { } } pub(in crate::features) fn persist_workspace_pane_layout(&mut self) { + self.workspace_revision = self.workspace_revision.saturating_add(1); if !self.settings.summary().startup_restore || !self.settings.summary().startup_restore_window_layout { @@ -538,6 +539,26 @@ impl NyaTermApp { } self.shell.workspace.pane_layout_restored = true; let active = self.session.active_id_owned(); + let loaded = self + .stores + .startup_restore + .update(cx, |store, _| store.take_loaded_workspace_pane_layout()); + if let Some(layout) = loaded { + let Some(layout) = layout else { + return; + }; + let Some(restored) = WorkspacePaneNode::restore_layout(&layout, &ordered) else { + return; + }; + if self.apply_restored_workspace_pane_layout(restored, active.as_deref(), cx) { + self.shell.navigation.selected_nav = NavItem::Workspace; + self.shell.navigation.main_mode = MainMode::Workspace; + self.shell + .set_status("restored workspace pane layout".to_string()); + cx.notify(); + } + return; + } self.submit_store_request( 0, store_request(StoreDomain::Sessions, |store| { diff --git a/crates/nyaterm-desktop/src/features/sync_input.rs b/crates/nyaterm-desktop/src/features/sync_input.rs index d9bea96e..fe564e6e 100644 --- a/crates/nyaterm-desktop/src/features/sync_input.rs +++ b/crates/nyaterm-desktop/src/features/sync_input.rs @@ -28,7 +28,67 @@ pub(in crate::features) enum SyncSessionPauseResult { NoGroup, } +pub(in crate::features) struct SyncInputTransferBundle { + original_groups: Vec, + moved_groups: Vec, +} + impl SyncInputFeatureState { + pub(in crate::features) fn detach_sessions_for_transfer( + &mut self, + session_ids: &[String], + ) -> SyncInputTransferBundle { + let original_groups = self.groups.clone(); + let mut moved_groups = Vec::new(); + for group in &mut self.groups { + let mut moved = group.clone(); + moved.session_ids.retain(|id| session_ids.contains(id)); + moved + .paused_session_ids + .retain(|id| session_ids.contains(id)); + if !moved.session_ids.is_empty() { + moved_groups.push(moved); + } + group.session_ids.retain(|id| !session_ids.contains(id)); + group + .paused_session_ids + .retain(|id| !session_ids.contains(id)); + } + self.groups.retain(|group| !group.session_ids.is_empty()); + self.repair_selection(); + SyncInputTransferBundle { + original_groups, + moved_groups, + } + } + + pub(in crate::features) fn attach_sessions_from_transfer( + &mut self, + bundle: SyncInputTransferBundle, + ) { + for mut group in bundle.moved_groups { + if self.groups.iter().any(|existing| existing.id == group.id) { + group.id = format!("sync-group-{}", uuid()); + } + self.groups.push(group); + } + self.repair_selection(); + } + + pub(in crate::features) fn restore_sessions_after_failed_transfer( + &mut self, + bundle: SyncInputTransferBundle, + ) { + self.groups = bundle.original_groups; + self.repair_selection(); + } + + pub(in crate::features) fn retains_transfer_session(&self, id: &str) -> bool { + self.groups + .iter() + .any(|group| group.session_ids.iter().any(|member| member == id)) + } + pub(in crate::features) fn new(focus: FocusHandle) -> Self { Self { groups: Vec::new(), @@ -793,6 +853,40 @@ mod tests { assert_eq!(state.groups()[0].paused_session_ids, ["new"]); } + #[test] + fn transfer_splits_group_and_preserves_pause_state_with_conflicting_target_id() { + let mut source = state(); + let mut original = group("shared", "Shared"); + original.session_ids = vec!["moved".into(), "remaining".into()]; + original.paused_session_ids = vec!["moved".into()]; + source.groups.push(original.clone()); + let mut target = state(); + target.groups.push(group("shared", "Existing")); + + let bundle = source.detach_sessions_for_transfer(&["moved".into()]); + assert_eq!(source.groups()[0].session_ids, ["remaining"]); + assert!(source.groups()[0].paused_session_ids.is_empty()); + target.attach_sessions_from_transfer(bundle); + assert_eq!(target.groups()[0].name, "Existing"); + assert_ne!(target.groups()[1].id, "shared"); + assert_eq!(target.groups()[1].name, "Shared"); + assert_eq!(target.groups()[1].session_ids, ["moved"]); + assert_eq!(target.groups()[1].paused_session_ids, ["moved"]); + } + + #[test] + fn failed_transfer_restores_original_sync_group_membership() { + let mut source = state(); + let mut original = group("shared", "Shared"); + original.session_ids = vec!["moved".into(), "remaining".into()]; + original.paused_session_ids = vec!["moved".into()]; + source.groups.push(original.clone()); + + let bundle = source.detach_sessions_for_transfer(&["moved".into()]); + source.restore_sessions_after_failed_transfer(bundle); + assert_eq!(source.groups(), [original]); + } + #[test] fn peer_selection_honors_pauses_and_broadcast_override() { let mut state = state(); diff --git a/crates/nyaterm-desktop/src/features/tab_transfer.rs b/crates/nyaterm-desktop/src/features/tab_transfer.rs new file mode 100644 index 00000000..6cb36213 --- /dev/null +++ b/crates/nyaterm-desktop/src/features/tab_transfer.rs @@ -0,0 +1,367 @@ +use gpui::Context; +use nyaterm_core::{MoveTabDockEdge, MoveTabPlacement, MoveTabTreeRequest, WorkspaceId}; +use nyaterm_transport::SessionKind; + +use super::NyaTermApp; +use super::session::SessionCatalogTransferBundle; +use super::sync_input::SyncInputTransferBundle; +use super::terminal::TerminalSessionTransferBundle; +use super::transfers::TransferSessionTransferBundle; +use crate::models::{TabDockEdge, TabDockZone, TerminalWindowNode, WorkspacePaneNode}; + +pub(crate) struct WorkspaceTabTransferBundle { + root_tab_id: String, + session_ids: Vec, + pane_root: Option, + catalog: SessionCatalogTransferBundle, + terminal: TerminalSessionTransferBundle, + transfer: TransferSessionTransferBundle, + sync_input: SyncInputTransferBundle, + source_index: usize, + source_active_id: Option, + source_terminal_tree: Option, +} + +impl NyaTermApp { + pub(crate) fn has_live_sessions(&self) -> bool { + !self.session.ordered_sessions().is_empty() + } + pub(crate) fn live_session_count(&self) -> usize { + self.session.ordered_sessions().len() + } + pub(in crate::features) fn request_tab_tree_move( + &mut self, + payload: &super::shell::SessionTabDragPayload, + placement: MoveTabPlacement, + cx: &mut Context, + ) { + self.move_tab_tree_to_workspace( + payload.source_workspace_id, + payload.root_tab_id.clone(), + payload.source_revision, + self.workspace_id, + placement, + cx, + ); + } + + pub(in crate::features) fn move_tab_tree_to_workspace( + &mut self, + source_workspace_id: WorkspaceId, + root_tab_id: String, + source_revision: u64, + target_workspace_id: WorkspaceId, + placement: MoveTabPlacement, + cx: &mut Context, + ) { + let Some(controller) = self.desktop_controller.clone() else { + return; + }; + let source_app = cx.entity().downgrade(); + cx.defer(move |cx| { + let result = controller.update(cx, |controller, cx| { + controller.move_tab_tree( + MoveTabTreeRequest { + source_workspace_id, + target_workspace_id, + root_tab_id, + source_revision, + placement, + }, + cx, + ) + }); + let error = match result { + Ok(Ok(())) => return, + Ok(Err(error)) => error, + Err(error) => error.to_string(), + }; + let _ = source_app.update(cx, |app, cx| { + app.shell.set_status(format!("Could not move tab: {error}")); + cx.notify(); + }); + }); + } + + pub(in crate::features) fn move_tab_tree_to_new_window( + &mut self, + root_tab_id: String, + cx: &mut Context, + ) { + let Some(controller) = self.desktop_controller.clone() else { + return; + }; + let source_workspace_id = self.workspace_id; + let source_revision = self.workspace_revision; + cx.defer(move |cx| { + if let Err(error) = controller.update(cx, |controller, cx| { + controller.open_workspace_for_tab( + source_workspace_id, + root_tab_id, + source_revision, + cx, + ) + }) { + tracing::warn!(%error, "could not open window for tab move"); + } + }); + } + + pub(crate) fn can_accept_tab_tree( + &self, + session_ids: &[String], + placement: &MoveTabPlacement, + ) -> Result<(), String> { + if session_ids.iter().any(|id| self.session.has_session(id)) { + return Err("the target workspace already contains this session".into()); + } + match placement { + MoveTabPlacement::Append => {} + MoveTabPlacement::BeforeTab(id) | MoveTabPlacement::AfterTab(id) => { + if !self.session.has_session(id) || self.tab_root_for_session(id) != *id { + return Err("the target tab no longer exists".into()); + } + } + MoveTabPlacement::TerminalLeaf { leaf_id, .. } => { + if !self.terminal.terminal_window_tree_is_some() + || !self.terminal.terminal_window_has_leaf(leaf_id) + { + return Err("the target split pane no longer exists".into()); + } + } + } + Ok(()) + } + + pub(crate) fn workspace_revision(&self) -> u64 { + self.workspace_revision + } + + pub(crate) fn tab_tree_transfer_session_ids(&self, root_tab_id: &str) -> Option> { + let root = self.tab_root_for_session(root_tab_id); + (root == root_tab_id && self.session.has_session(root_tab_id)) + .then(|| self.tab_tree_session_ids(root_tab_id)) + } + + pub(crate) fn can_transfer_tab_tree( + &self, + root_tab_id: &str, + source_revision: u64, + ) -> Result, String> { + if self.workspace_revision != source_revision { + return Err("the source workspace changed during the drag".to_string()); + } + let session_ids = self + .tab_tree_transfer_session_ids(root_tab_id) + .ok_or_else(|| "the source tab no longer exists".to_string())?; + self.session + .can_transfer_sessions(&session_ids) + .map_err(str::to_string)?; + if session_ids.iter().any(|session_id| { + self.session + .session_info(session_id) + .is_some_and(|session| matches!(session.kind, SessionKind::Rdp | SessionKind::Vnc)) + }) { + return Err("RDP and VNC tabs cannot be moved while connected yet".to_string()); + } + if session_ids + .iter() + .any(|session_id| self.recording.is_recording(session_id)) + { + return Err("stop the active recording before moving this tab".to_string()); + } + if self.transfer.session_has_active_transfer(&session_ids) { + return Err( + "wait for active file transfers to finish before moving this tab".to_string(), + ); + } + Ok(session_ids) + } + + pub(crate) fn detach_tab_tree_for_transfer( + &mut self, + root_tab_id: &str, + source_revision: u64, + cx: &mut Context, + ) -> Result { + let session_ids = self.can_transfer_tab_tree(root_tab_id, source_revision)?; + let source_index = self.session.session_index(root_tab_id).unwrap_or(0); + let source_active_id = self.session.active_id_owned(); + let source_terminal_tree = self.terminal.terminal_window_tree(); + if let Some(active) = self.session.active_id_owned() + && session_ids.contains(&active) + { + self.cache_transfer_browser_session(&active); + } + let bridge_events = self.session.pause_sessions_for_transfer(&session_ids); + let frames = match self.terminal.prepare_sessions_for_transfer(&session_ids) { + Ok(frames) => frames, + Err(error) => { + self.session + .resume_sessions_after_failed_transfer(&session_ids, bridge_events); + return Err(error.to_string()); + } + }; + let terminal = self.terminal.detach_sessions_for_transfer(frames); + let transfer = self.transfer.detach_sessions_for_transfer(&session_ids); + let sync_input = self.sync_input.detach_sessions_for_transfer(&session_ids); + let catalog = self + .session + .detach_sessions_for_transfer(&session_ids, bridge_events) + .ok_or_else(|| "the source tab changed during transfer".to_string())?; + let pane_root = self.shell.take_workspace_pane_root(root_tab_id); + self.rebuild_session_tab_owners(); + self.reconcile_terminal_windows(); + if source_active_id + .as_ref() + .is_some_and(|id| session_ids.contains(id)) + { + self.reset_transfer_browser_for_active_session(); + } + debug_assert!(session_ids.iter().all(|id| { + !self.terminal.retains_transfer_session(id) + && !self.transfer.retains_transfer_session(id) + && !self.sync_input.retains_transfer_session(id) + })); + self.sync_workspace_split_from_active_tab(); + if self.session.active_id().is_none() + && let Some(next) = self.session.next_live_session() + { + self.activate_session_id_with_surface_sync(&next, cx); + } + self.workspace_revision = self.workspace_revision.saturating_add(1); + self.persist_open_tabs(); + self.persist_terminal_window_layout(); + self.persist_workspace_pane_layout(); + cx.notify(); + Ok(WorkspaceTabTransferBundle { + root_tab_id: root_tab_id.to_string(), + session_ids, + pane_root, + catalog, + terminal, + transfer, + sync_input, + source_index, + source_active_id, + source_terminal_tree, + }) + } + + pub(crate) fn attach_tab_tree_from_transfer( + &mut self, + bundle: WorkspaceTabTransferBundle, + placement: &MoveTabPlacement, + cx: &mut Context, + ) -> Result<(), Box<(String, WorkspaceTabTransferBundle)>> { + if let Err(error) = self.can_accept_tab_tree(&bundle.session_ids, placement) { + return Err(Box::new((error, bundle))); + } + let insert_index = match placement { + MoveTabPlacement::BeforeTab(tab_id) => self.session.session_index(tab_id), + MoveTabPlacement::AfterTab(tab_id) => { + self.session.session_index(tab_id).map(|index| index + 1) + } + MoveTabPlacement::Append | MoveTabPlacement::TerminalLeaf { .. } => None, + }; + let WorkspaceTabTransferBundle { + root_tab_id, + catalog, + terminal, + transfer, + sync_input, + pane_root, + source_index, + source_active_id, + source_terminal_tree, + session_ids, + } = bundle; + if let Err(terminal) = self.terminal.attach_sessions_from_transfer(terminal) { + return Err(Box::new(( + "the target terminal pipeline is unavailable".to_string(), + WorkspaceTabTransferBundle { + root_tab_id, + session_ids, + pane_root, + catalog, + terminal, + transfer, + sync_input, + source_index, + source_active_id, + source_terminal_tree, + }, + ))); + } + self.session + .attach_sessions_from_transfer(catalog, insert_index); + self.transfer.attach_sessions_from_transfer(transfer); + self.sync_input.attach_sessions_from_transfer(sync_input); + if let Some(pane_root) = pane_root { + self.shell + .insert_workspace_pane_root(root_tab_id.clone(), pane_root); + } + self.rebuild_session_tab_owners(); + self.reconcile_terminal_windows(); + if let MoveTabPlacement::BeforeTab(tab_id) = placement { + let _ = self + .terminal + .place_tab_before_in_terminal_windows(&root_tab_id, tab_id); + } + if let MoveTabPlacement::TerminalLeaf { leaf_id, edge } = placement { + let zone = edge.map_or(TabDockZone::Center, |edge| { + TabDockZone::Edge(match edge { + MoveTabDockEdge::Left => TabDockEdge::Left, + MoveTabDockEdge::Right => TabDockEdge::Right, + MoveTabDockEdge::Top => TabDockEdge::Top, + MoveTabDockEdge::Bottom => TabDockEdge::Bottom, + }) + }); + self.ensure_terminal_windows_root(); + let _ = self + .terminal + .dock_tab_on_terminal_window_leaf(&root_tab_id, leaf_id, zone); + } + self.activate_session_id_with_surface_sync(&root_tab_id, cx); + self.workspace_revision = self.workspace_revision.saturating_add(1); + self.persist_open_tabs(); + self.persist_terminal_window_layout(); + self.persist_workspace_pane_layout(); + self.shell + .set_status("tab moved from another window".to_string()); + cx.notify(); + Ok(()) + } + + pub(crate) fn restore_tab_tree_after_failed_transfer( + &mut self, + bundle: WorkspaceTabTransferBundle, + cx: &mut Context, + ) -> Result<(), String> { + self.terminal + .attach_sessions_from_transfer(bundle.terminal) + .map_err(|_| "the source terminal pipeline is unavailable".to_string())?; + self.session + .attach_sessions_from_transfer(bundle.catalog, Some(bundle.source_index)); + self.transfer.attach_sessions_from_transfer(bundle.transfer); + self.sync_input + .restore_sessions_after_failed_transfer(bundle.sync_input); + if let Some(pane_root) = bundle.pane_root { + self.shell + .insert_workspace_pane_root(bundle.root_tab_id, pane_root); + } + self.terminal + .restore_terminal_window_tree(bundle.source_terminal_tree); + self.rebuild_session_tab_owners(); + self.reconcile_terminal_windows(); + if let Some(active_id) = bundle.source_active_id { + self.activate_session_id_with_surface_sync(&active_id, cx); + } + self.workspace_revision = self.workspace_revision.saturating_add(1); + self.persist_open_tabs(); + self.persist_terminal_window_layout(); + self.persist_workspace_pane_layout(); + cx.notify(); + Ok(()) + } +} diff --git a/crates/nyaterm-desktop/src/features/terminal/mod.rs b/crates/nyaterm-desktop/src/features/terminal/mod.rs index 7f171827..5acf5e23 100644 --- a/crates/nyaterm-desktop/src/features/terminal/mod.rs +++ b/crates/nyaterm-desktop/src/features/terminal/mod.rs @@ -42,6 +42,7 @@ pub(in crate::features) use terminal_selection_runtime::measure_terminal_font; pub(in crate::features) use terminal_surface_entity::{ FULL_SHELL_PAINT_COUNT, terminal_surface_paint_count, }; +pub(in crate::features) use view_state::TerminalSessionTransferBundle; pub(in crate::features) use window_state::{ TerminalWindowDockResult, TerminalWindowReconcileResult, }; diff --git a/crates/nyaterm-desktop/src/features/terminal/state.rs b/crates/nyaterm-desktop/src/features/terminal/state.rs index 360e4fd7..b4b951a9 100644 --- a/crates/nyaterm-desktop/src/features/terminal/state.rs +++ b/crates/nyaterm-desktop/src/features/terminal/state.rs @@ -53,7 +53,7 @@ pub(super) struct TerminalSearchState { pub(super) regex: bool, pub(super) whole_word: bool, /// Runtime-only per-session preference; missing sessions use the default `true`. - wrap_around_by_session: HashMap, + pub(super) wrap_around_by_session: HashMap, pub(super) active_index: usize, pub(super) history_pending_key: Option, pub(super) history_result: Option, diff --git a/crates/nyaterm-desktop/src/features/terminal/terminal_runtime/sessions.rs b/crates/nyaterm-desktop/src/features/terminal/terminal_runtime/sessions.rs index 1da8f686..aefab281 100644 --- a/crates/nyaterm-desktop/src/features/terminal/terminal_runtime/sessions.rs +++ b/crates/nyaterm-desktop/src/features/terminal/terminal_runtime/sessions.rs @@ -286,7 +286,19 @@ impl NyaTermApp { window: &mut Window, cx: &mut Context, ) { - let open_sessions = self.session.ordered_sessions().len(); + self.handle_window_close_request_with_count( + self.session.ordered_sessions().len(), + window, + cx, + ); + } + + pub(crate) fn handle_window_close_request_with_count( + &mut self, + open_sessions: usize, + window: &mut Window, + cx: &mut Context, + ) { if self.settings.summary().confirm_on_close && open_sessions > 0 { // Reuse the close-all confirmation as the quit-with-sessions gate. // A title-bar close control can also produce the native window-close diff --git a/crates/nyaterm-desktop/src/features/terminal/view_state.rs b/crates/nyaterm-desktop/src/features/terminal/view_state.rs index 5e971f1b..43efa24e 100644 --- a/crates/nyaterm-desktop/src/features/terminal/view_state.rs +++ b/crates/nyaterm-desktop/src/features/terminal/view_state.rs @@ -6,7 +6,23 @@ use std::time::Instant; use futures::channel::mpsc::UnboundedReceiver; use super::state::TerminalFeatureState; -use crate::models::TerminalViewState; +use crate::models::TerminalFrameEvent; +use crate::models::TerminalSelection; +use crate::models::{TerminalFrameSession, TerminalViewState}; + +pub(in crate::features) struct TerminalSessionTransferBundle { + entries: Vec, + pending_events: std::collections::VecDeque, +} + +struct TerminalSessionTransferEntry { + session_id: String, + frame: Option, + view: Option, + search_wrap: Option, + scroll_residual: Option, + selection: Option, +} #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(in crate::features) struct TerminalFrameQueueMetrics { @@ -18,6 +34,177 @@ pub(in crate::features) struct TerminalFrameQueueMetrics { } impl TerminalFeatureState { + pub(in crate::features) fn retains_transfer_session(&self, id: &str) -> bool { + self.view.views.contains_key(id) + || self.view.surfaces.contains_key(id) + || self.view.scroll_delta_residuals.contains_key(id) + || self.search.wrap_around_by_session.contains_key(id) + || self.layout.session_surface_bounds.contains_key(id) + || self.layout.session_scrollbar_track_bounds.contains_key(id) + || self.selection.session_id.as_deref() == Some(id) + } + + pub(in crate::features) fn prepare_sessions_for_transfer( + &self, + session_ids: &[String], + ) -> Result)>, &'static str> { + let mut frames = Vec::with_capacity(session_ids.len()); + for session_id in session_ids { + match self + .view + .frame_pipeline + .take_session_for_transfer(session_id.clone()) + { + Ok(frame) => frames.push((session_id.clone(), frame)), + Err(error) => { + let rollback = frames + .into_iter() + .filter_map(|(id, frame)| frame.map(|frame| (id, frame))) + .collect(); + let _ = self + .view + .frame_pipeline + .insert_sessions_from_transfer(rollback); + return Err(error); + } + } + } + Ok(frames) + } + + pub(in crate::features) fn detach_sessions_for_transfer( + &mut self, + frames: Vec<(String, Option)>, + ) -> TerminalSessionTransferBundle { + let session_ids = frames + .iter() + .map(|(session_id, _)| session_id.clone()) + .collect::>(); + let mut pending_events = std::collections::VecDeque::new(); + self.view.pending_frame_events.retain(|event| { + if session_ids.contains(event.session_id()) { + pending_events.push_back(event.clone()); + false + } else { + true + } + }); + let selected = frames.iter().map(|(id, _)| id.clone()).collect::>(); + pending_events.extend(self.view.frame_pipeline.take_events_for_transfer(&selected)); + let entries = frames + .into_iter() + .map(|(session_id, frame)| { + self.view.surfaces.remove(&session_id); + self.layout.session_surface_bounds.remove(&session_id); + self.layout + .session_scrollbar_track_bounds + .remove(&session_id); + let selection = if self.selection.session_id.as_deref() == Some(&session_id) { + self.selection.session_id = None; + self.selection.dragging = false; + self.selection.selection.take() + } else { + None + }; + TerminalSessionTransferEntry { + view: self.view.views.remove(&session_id), + search_wrap: self.search.wrap_around_by_session.remove(&session_id), + scroll_residual: self.view.scroll_delta_residuals.remove(&session_id), + selection, + session_id, + frame, + } + }) + .collect(); + self.layout.surface_bounds = None; + self.layout.scrollbar_track_bounds = None; + self.view.scrollbar_drag = None; + if self + .selection + .selected_occurrence + .session_id + .as_ref() + .is_some_and(|id| session_ids.contains(id)) + { + self.selection.selected_occurrence.session_id = None; + self.selection.selected_occurrence.query = None; + self.selection.selected_occurrence.generation = self + .selection + .selected_occurrence + .generation + .wrapping_add(1); + } + if self + .selection + .mouse_report_session_id + .as_deref() + .is_some_and(|id| session_ids.contains(id)) + { + self.selection.mouse_report_session_id = None; + self.selection.mouse_report_button = None; + self.selection.mouse_report_position = None; + } + self.selection + .mouse_report_peer_session_ids + .retain(|id| !session_ids.contains(id)); + TerminalSessionTransferBundle { + entries, + pending_events, + } + } + + pub(in crate::features) fn attach_sessions_from_transfer( + &mut self, + mut bundle: TerminalSessionTransferBundle, + ) -> Result<(), TerminalSessionTransferBundle> { + let frames = bundle + .entries + .iter_mut() + .filter_map(|entry| { + entry + .frame + .take() + .map(|frame| (entry.session_id.clone(), frame)) + }) + .collect(); + if let Err(frames) = self + .view + .frame_pipeline + .insert_sessions_from_transfer(frames) + { + let mut frames = frames + .into_iter() + .collect::>(); + for entry in &mut bundle.entries { + entry.frame = frames.remove(&entry.session_id); + } + return Err(bundle); + } + for entry in bundle.entries { + if let Some(wrap) = entry.search_wrap { + self.search + .wrap_around_by_session + .insert(entry.session_id.clone(), wrap); + } + if let Some(residual) = entry.scroll_residual { + self.view + .scroll_delta_residuals + .insert(entry.session_id.clone(), residual); + } + if let Some(selection) = entry.selection + && self.selection.selection.is_none() + { + self.selection.session_id = Some(entry.session_id.clone()); + self.selection.selection = Some(selection); + } + if let Some(view) = entry.view { + self.view.views.insert(entry.session_id, view); + } + } + self.view.pending_frame_events.extend(bundle.pending_events); + Ok(()) + } + pub(in crate::features) fn take_frame_event_wake_receiver( &self, ) -> Option> { diff --git a/crates/nyaterm-desktop/src/features/terminal/window_state.rs b/crates/nyaterm-desktop/src/features/terminal/window_state.rs index c46c6485..84fbcb61 100644 --- a/crates/nyaterm-desktop/src/features/terminal/window_state.rs +++ b/crates/nyaterm-desktop/src/features/terminal/window_state.rs @@ -121,6 +121,24 @@ impl TerminalFeatureState { self.windows.tree.is_some() } + pub(in crate::features) fn terminal_window_tree(&self) -> Option { + self.windows.tree.clone() + } + + pub(in crate::features) fn restore_terminal_window_tree( + &mut self, + tree: Option, + ) { + self.windows.tree = tree; + } + + pub(in crate::features) fn terminal_window_has_leaf(&self, leaf_id: &str) -> bool { + self.windows + .tree + .as_ref() + .is_some_and(|tree| tree.leaf_ids().iter().any(|id| id == leaf_id)) + } + pub(in crate::features) fn sync_terminal_windows_active_tab( &mut self, tab_id: &str, diff --git a/crates/nyaterm-desktop/src/features/transfers/mod.rs b/crates/nyaterm-desktop/src/features/transfers/mod.rs index 62531ab5..1612a8b0 100644 --- a/crates/nyaterm-desktop/src/features/transfers/mod.rs +++ b/crates/nyaterm-desktop/src/features/transfers/mod.rs @@ -7,6 +7,7 @@ mod external_sync_window; pub(in crate::features) mod preview; mod remote_text_editor; mod state; +pub(in crate::features) use state::TransferSessionTransferBundle; pub(in crate::features) use state::TransferTreePresentation; mod transfer_events; mod transfer_jobs; diff --git a/crates/nyaterm-desktop/src/features/transfers/state/browser.rs b/crates/nyaterm-desktop/src/features/transfers/state/browser.rs index 1296608a..0a9b9818 100644 --- a/crates/nyaterm-desktop/src/features/transfers/state/browser.rs +++ b/crates/nyaterm-desktop/src/features/transfers/state/browser.rs @@ -19,7 +19,38 @@ use crate::models::{ use super::browser_logic::BrowserFilterKey; use super::{TransferBrowserState, TransferBrowserView, TransferFeatureState}; +pub(in crate::features) struct TransferSessionTransferBundle { + caches: Vec<(String, TransferBrowserSessionCacheState)>, +} + impl TransferFeatureState { + pub(in crate::features) fn detach_sessions_for_transfer( + &mut self, + session_ids: &[String], + ) -> TransferSessionTransferBundle { + let mut caches = Vec::new(); + for id in session_ids { + if let Some(cache) = self.browser.session_cache.remove(id) { + caches.push((id.clone(), cache)); + } + if let Some(job_id) = self.browser.navigation_jobs.remove(id) { + self.browser.pending_navigations.remove(&job_id); + } + } + TransferSessionTransferBundle { caches } + } + + pub(in crate::features) fn attach_sessions_from_transfer( + &mut self, + bundle: TransferSessionTransferBundle, + ) { + self.browser.session_cache.extend(bundle.caches); + } + + pub(in crate::features) fn retains_transfer_session(&self, id: &str) -> bool { + self.browser.session_cache.contains_key(id) || self.browser.navigation_jobs.contains_key(id) + } + pub(in crate::features) fn browser_view(&self) -> TransferBrowserView<'_> { TransferBrowserView { path: &self.browser.path, diff --git a/crates/nyaterm-desktop/src/features/transfers/state/mod.rs b/crates/nyaterm-desktop/src/features/transfers/state/mod.rs index 35db705b..c689f491 100644 --- a/crates/nyaterm-desktop/src/features/transfers/state/mod.rs +++ b/crates/nyaterm-desktop/src/features/transfers/state/mod.rs @@ -6,6 +6,7 @@ //! lifetime visible; the flat `transfer_*` prefix did not. mod browser; +pub(in crate::features) use browser::TransferSessionTransferBundle; mod browser_logic; mod tree; pub(in crate::features) use tree::TransferTreePresentation; @@ -277,6 +278,24 @@ struct TransferPanelState { } impl TransferFeatureState { + pub(in crate::features) fn session_has_active_transfer(&self, session_ids: &[String]) -> bool { + let session_ids = session_ids + .iter() + .map(String::as_str) + .collect::>(); + self.queue.jobs.iter().any(|job| { + job.session_id + .as_deref() + .is_some_and(|session_id| session_ids.contains(session_id)) + && matches!( + job.status, + TransferJobStatus::Running + | TransferJobStatus::Paused + | TransferJobStatus::Cancelling + ) + }) + } + pub(in crate::features) fn new( remote_path: String, local_path: String, diff --git a/crates/nyaterm-desktop/src/features/transfers/state/tests.rs b/crates/nyaterm-desktop/src/features/transfers/state/tests.rs index 53ad98f3..2561c9cc 100644 --- a/crates/nyaterm-desktop/src/features/transfers/state/tests.rs +++ b/crates/nyaterm-desktop/src/features/transfers/state/tests.rs @@ -405,6 +405,43 @@ fn browser_session_restore_preserves_the_raw_directory_token() { assert_eq!(transfer.browser_remote_file_path(), remote); } +#[test] +fn transfer_moves_sftp_cache_and_invalidates_source_navigation() { + let cx = TestAppContext::single(); + let mut source = transfer_state(&cx); + let mut target = transfer_state(&cx); + source.store_browser_session_cache( + "moved".to_string(), + TransferBrowserSessionCacheState { + entries: Arc::new(vec![file_entry("/srv/file.txt")]), + current_path: "/srv".to_string(), + current_raw_path_token: Some("raw-path".to_string()), + home_dir: "/home".to_string(), + history: VecDeque::from(["/srv".to_string()]), + history_index: 0, + visited_history: VecDeque::new(), + }, + ); + source + .browser + .navigation_jobs + .insert("moved".to_string(), "old-job".to_string()); + let pending = source.prepare_browser_navigation("other", "/srv".to_string()); + source + .browser + .pending_navigations + .insert("old-job".to_string(), pending); + + let bundle = source.detach_sessions_for_transfer(&["moved".to_string()]); + assert!(!source.retains_transfer_session("moved")); + assert!(!source.browser.pending_navigations.contains_key("old-job")); + target.attach_sessions_from_transfer(bundle); + let cache = target.browser_session_cache("moved").expect("cache moved"); + assert_eq!(cache.current_path, "/srv"); + assert_eq!(cache.current_raw_path_token.as_deref(), Some("raw-path")); + assert_eq!(cache.entries.len(), 1); +} + #[test] fn browser_navigation_restores_the_stable_pending_snapshot() { let cx = TestAppContext::single(); diff --git a/crates/nyaterm-desktop/src/features/translation/translation_runtime.rs b/crates/nyaterm-desktop/src/features/translation/translation_runtime.rs index 87ed5e80..37133598 100644 --- a/crates/nyaterm-desktop/src/features/translation/translation_runtime.rs +++ b/crates/nyaterm-desktop/src/features/translation/translation_runtime.rs @@ -78,6 +78,10 @@ impl NyaTermApp { && let Ok(saved) = event.outcome.as_ref() { this.translation.settings_saved(saved.clone()); + this.request_shared_state_refresh( + crate::app_shell::SharedStateDomain::Translation, + cx, + ); } if completion.report_result() { if let Err(error) = event.outcome { diff --git a/crates/nyaterm-desktop/src/lib.rs b/crates/nyaterm-desktop/src/lib.rs index d50c2f0d..840e6db4 100644 --- a/crates/nyaterm-desktop/src/lib.rs +++ b/crates/nyaterm-desktop/src/lib.rs @@ -51,8 +51,11 @@ pub fn preload_i18n() -> Result<(), String> { .clone() } -pub use app_shell::{AppShell, AppShellStartup, MainWindowPlacement}; +pub use app_shell::{ + AppShell, AppShellStartup, DesktopController, DesktopControllerGlobal, MainWindowPlacement, +}; pub fn init(cx: &mut gpui::App) { + app_shell::init(cx); features::init(cx); } diff --git a/crates/nyaterm-desktop/src/models/session_event_bridge.rs b/crates/nyaterm-desktop/src/models/session_event_bridge.rs index f4ee931e..5b1b3e66 100644 --- a/crates/nyaterm-desktop/src/models/session_event_bridge.rs +++ b/crates/nyaterm-desktop/src/models/session_event_bridge.rs @@ -8,7 +8,8 @@ use std::time::{Duration, Instant}; use futures::channel::mpsc::UnboundedReceiver; use nyaterm_transport::{ - SessionDrainStats, SessionEvent, SessionManager, TrzszDetector, ZmodemDetector, + SessionDrainStats, SessionEvent, SessionEventConsumerId, SessionManager, TrzszDetector, + ZmodemDetector, }; use super::event_wake::{ANY_INTEREST, EventWake}; @@ -54,11 +55,14 @@ pub(crate) struct SessionEventBridgeDrain { pub(crate) struct SessionEventBridge { state: Arc, + session_manager: Arc, + consumer_id: SessionEventConsumerId, worker: Option>, } struct SessionEventBridgeState { control: Mutex, + transfer_gate: Mutex<()>, ui_queue: SessionEventBridgeQueue, /// Handed to `NyaTermApp::start_runtime_data_plane_drain` once, at window open. ui_queue_wake_rx: Mutex>>, @@ -73,6 +77,7 @@ struct SessionEventBridgeState { #[derive(Clone)] struct SessionEventBridgeControl { + owned_sessions: HashSet, ui_routed_sessions: HashSet, encoding: String, scrollback_limit: usize, @@ -114,14 +119,17 @@ impl SessionEventBridge { scrollback_limit: usize, ) -> Self { let (ui_queue, ui_queue_wake_rx) = SessionEventBridgeQueue::new_with_wake(); + let consumer_id = session_manager.register_event_consumer(); let state = Arc::new(SessionEventBridgeState { control: Mutex::new(SessionEventBridgeControl { + owned_sessions: HashSet::new(), ui_routed_sessions: HashSet::new(), encoding, scrollback_limit, source_queued_events: 0, source_queued_output_bytes: 0, }), + transfer_gate: Mutex::new(()), ui_queue, ui_queue_wake_rx: Mutex::new(Some(ui_queue_wake_rx)), source_queued_events: AtomicUsize::new(0), @@ -133,16 +141,65 @@ impl SessionEventBridge { stop: AtomicBool::new(false), }); let worker_state = state.clone(); + let worker_manager = Arc::clone(&session_manager); let worker = thread::Builder::new() .name("nyaterm-session-event-bridge".to_string()) - .spawn(move || run_session_event_bridge(session_manager, frame_pipeline, worker_state)) + .spawn(move || { + run_session_event_bridge(worker_manager, consumer_id, frame_pipeline, worker_state) + }) .expect("failed to spawn session event bridge"); Self { state, + session_manager, + consumer_id, worker: Some(worker), } } + pub(crate) fn claim_session(&self, session_id: &str) { + if session_id.is_empty() { + return; + } + if let Ok(mut control) = self.state.control.lock() { + control.owned_sessions.insert(session_id.to_string()); + } + self.session_manager + .assign_session_event_consumer(session_id, self.consumer_id); + } + + pub(crate) fn release_session(&self, session_id: &str) { + if let Ok(mut control) = self.state.control.lock() { + control.owned_sessions.remove(session_id); + control.ui_routed_sessions.remove(session_id); + } + self.session_manager + .clear_session_event_consumer(session_id, self.consumer_id); + } + + /// Stop this bridge from consuming the selected sessions after all work + /// already being processed by its worker has completed. Events produced + /// after this returns remain queued in the shared manager until another + /// workspace claims the sessions. + pub(crate) fn pause_sessions_for_transfer( + &self, + session_ids: &[String], + ) -> VecDeque { + let Ok(_gate) = self.state.transfer_gate.lock() else { + return VecDeque::new(); + }; + if let Ok(mut control) = self.state.control.lock() { + for session_id in session_ids { + control.owned_sessions.remove(session_id); + control.ui_routed_sessions.remove(session_id); + } + } + for session_id in session_ids { + self.session_manager + .clear_session_event_consumer(session_id, self.consumer_id); + } + self.state.ui_queue.take_sessions(session_ids) + } + /// Taken once, by the drain task that consumes this queue. pub(crate) fn take_ui_queue_wake_receiver(&self) -> Option> { self.state.ui_queue_wake_rx.lock().ok()?.take() @@ -277,6 +334,16 @@ impl SessionEventBridge { { tracing::warn!("session event bridge panicked during shutdown"); } + let owned_sessions = self + .state + .control + .lock() + .map(|mut control| std::mem::take(&mut control.owned_sessions)) + .unwrap_or_default(); + for session_id in owned_sessions { + self.session_manager + .clear_session_event_consumer(&session_id, self.consumer_id); + } } } @@ -315,6 +382,39 @@ impl SessionEventBridgeState { } impl SessionEventBridgeQueue { + fn take_sessions(&self, session_ids: &[String]) -> VecDeque { + let selected = session_ids + .iter() + .map(String::as_str) + .collect::>(); + let Ok(mut inner) = self.inner.lock() else { + return VecDeque::new(); + }; + let mut selected_events = VecDeque::new(); + let mut retained = VecDeque::new(); + while let Some(event) = inner.events.pop_front() { + let session_id = match &event { + SessionEvent::Output { session_id, .. } + | SessionEvent::OutputDropped { session_id, .. } + | SessionEvent::CwdChanged { session_id, .. } + | SessionEvent::CommandAccepted { session_id, .. } + | SessionEvent::Exited { session_id, .. } + | SessionEvent::Error { session_id, .. } => session_id, + }; + if selected.contains(session_id.as_str()) { + if let SessionEvent::Output { data, .. } = &event { + inner.queued_output_bytes = + inner.queued_output_bytes.saturating_sub(data.len()); + } + selected_events.push_back(event); + } else { + retained.push_back(event); + } + } + inner.events = retained; + selected_events + } + #[cfg(test)] fn new() -> Self { Self { @@ -472,6 +572,7 @@ impl SessionEventBridgeQueueInner { fn run_session_event_bridge( session_manager: Arc, + consumer_id: SessionEventConsumerId, frame_pipeline: TerminalFramePipeline, state: Arc, ) { @@ -479,6 +580,10 @@ fn run_session_event_bridge( HashMap::new(); let mut source_drain_backpressured = false; while !state.stop.load(Ordering::Relaxed) { + let Ok(_transfer_gate) = state.transfer_gate.lock() else { + thread::sleep(SESSION_EVENT_BRIDGE_IDLE_SLEEP); + continue; + }; let Some(control) = state.control_snapshot() else { thread::sleep(SESSION_EVENT_BRIDGE_IDLE_SLEEP); continue; @@ -495,7 +600,8 @@ fn run_session_event_bridge( // Park on the queue rather than polling it: a PTY read wakes this // thread directly, so the first hop of the echo path no longer spends // an arbitrary slice of the poll interval waiting to notice. - let Ok(drain) = session_manager.drain_events_blocking_with_output_budget( + let Ok(drain) = session_manager.drain_events_blocking_for_consumer_with_output_budget( + consumer_id, SESSION_EVENT_BRIDGE_DRAIN_BATCH, SESSION_EVENT_BRIDGE_OUTPUT_BUDGET, SESSION_EVENT_BRIDGE_WAIT_TIMEOUT, @@ -841,6 +947,29 @@ mod tests { assert_eq!(queue.wake_count(), 0); } + #[test] + fn bridge_ui_queue_transfer_keeps_other_sessions_and_output_accounting() { + let queue = SessionEventBridgeQueue::new(); + queue.push(SessionEvent::Output { + session_id: "source".into(), + data: b"before move".to_vec(), + }); + queue.push(SessionEvent::CwdChanged { + session_id: "other".into(), + cwd: "/tmp".into(), + }); + queue.push(SessionEvent::Exited { + session_id: "source".into(), + reason: "done".into(), + }); + let moved = queue.take_sessions(&["source".into()]); + assert_eq!(moved.len(), 2); + assert_eq!(queue.queued_output_bytes(), 0); + let remaining = queue.drain_with_output_budget(8, usize::MAX); + assert!(matches!(remaining.events.as_slice(), + [SessionEvent::CwdChanged { session_id, .. }] if session_id == "other")); + } + #[test] fn bridge_ui_queue_drains_metadata_when_output_budget_is_zero() { let queue = SessionEventBridgeQueue::new(); diff --git a/crates/nyaterm-desktop/src/models/terminal/mod.rs b/crates/nyaterm-desktop/src/models/terminal/mod.rs index b71772e4..60ded5ae 100644 --- a/crates/nyaterm-desktop/src/models/terminal/mod.rs +++ b/crates/nyaterm-desktop/src/models/terminal/mod.rs @@ -1311,6 +1311,13 @@ fn terminal_frame_output_commands( } impl TerminalFramePipeline { + pub(crate) fn take_events_for_transfer( + &self, + session_ids: &[String], + ) -> VecDeque { + self.event_queue.take_sessions(session_ids) + } + pub(crate) fn spawn(recording_writer: RecordingWriteHandle) -> Self { let (command_tx, command_rx) = terminal_frame_command_channel(); let (event_queue, event_wake_rx) = @@ -1387,6 +1394,37 @@ impl TerminalFramePipeline { }); } + pub(crate) fn take_session_for_transfer( + &self, + session_id: impl Into, + ) -> Result, &'static str> { + let (response_tx, response_rx) = std::sync::mpsc::sync_channel(0); + if !self.command_tx.send(TerminalFrameCommand::TakeSession { + session_id: session_id.into(), + response_tx, + }) { + return Err("the source terminal pipeline is unavailable"); + } + response_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| "the source terminal pipeline did not respond") + } + + pub(crate) fn insert_sessions_from_transfer( + &self, + sessions: Vec<(String, TerminalFrameSession)>, + ) -> Result<(), Vec<(String, TerminalFrameSession)>> { + if sessions.is_empty() { + return Ok(()); + } + self.command_tx + .send_owned(TerminalFrameCommand::InsertSessions { sessions }) + .map_err(|command| match command { + TerminalFrameCommand::InsertSessions { sessions } => sessions, + _ => unreachable!("failed command must be the insertion batch"), + }) + } + pub(crate) fn resize_session(&self, session_id: impl Into, cols: u16, rows: u16) { let _ = self.command_tx.send(TerminalFrameCommand::ResizeSession { session_id: session_id.into(), @@ -1614,6 +1652,13 @@ enum TerminalFrameCommand { RemoveSession { session_id: String, }, + TakeSession { + session_id: String, + response_tx: std::sync::mpsc::SyncSender>, + }, + InsertSessions { + sessions: Vec<(String, TerminalFrameSession)>, + }, ResizeSession { session_id: String, cols: u16, @@ -1662,6 +1707,16 @@ pub(crate) enum TerminalFrameEvent { Search(TerminalFrameSearchEvent), } +impl TerminalFrameEvent { + pub(crate) fn session_id(&self) -> &str { + match self { + Self::Output(event) => &event.session_id, + Self::Snapshot(event) => &event.session_id, + Self::Search(event) => &event.session_id, + } + } +} + #[derive(Clone, Debug)] struct TerminalFrameEventQueue { shared: Arc, @@ -1702,6 +1757,29 @@ enum TerminalFrameEventQueuePushOutcome { } impl TerminalFrameEventQueue { + fn take_sessions(&self, session_ids: &[String]) -> VecDeque { + let selected = session_ids + .iter() + .map(String::as_str) + .collect::>(); + let Ok(mut inner) = self.shared.inner.lock() else { + return VecDeque::new(); + }; + let mut moved = VecDeque::new(); + let mut retained = VecDeque::new(); + while let Some(event) = inner.events.pop_front() { + if selected.contains(event.session_id()) { + moved.push_back(event); + } else { + retained.push_back(event); + } + } + inner.events = retained; + drop(inner); + self.shared.space_available.notify_all(); + moved + } + #[cfg(test)] fn new(cap: usize) -> Self { Self::build(cap, None) @@ -2046,7 +2124,7 @@ fn terminal_frame_output_event_can_drop_under_pressure(frame: &TerminalFrameOutp && frame.effects.clipboard_loads.is_empty() } -struct TerminalFrameSession { +pub(crate) struct TerminalFrameSession { screen: TerminalScreen, output_decoder: TerminalOutputDecoder, recording_decoder: TerminalOutputDecoder, @@ -2056,6 +2134,16 @@ struct TerminalFrameSession { action_link_cache: Option, } +impl std::fmt::Debug for TerminalFrameSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TerminalFrameSession") + .field("revision", &self.revision) + .field("include_live_snapshot", &self.include_live_snapshot) + .finish_non_exhaustive() + } +} + impl TerminalFrameSession { fn new(encoding: &str, scrollback_limit: usize) -> Self { let mut screen = TerminalScreen::default(); @@ -2554,11 +2642,15 @@ fn terminal_frame_command_channel() -> (TerminalFrameCommandSender, TerminalFram impl TerminalFrameCommandSender { fn send(&self, command: TerminalFrameCommand) -> bool { + self.send_owned(command).is_ok() + } + + fn send_owned(&self, command: TerminalFrameCommand) -> Result<(), TerminalFrameCommand> { let Ok(mut inner) = self.shared.inner.lock() else { - return false; + return Err(command); }; if inner.closed { - return false; + return Err(command); } let output_bytes = terminal_frame_command_output_bytes(&command); push_terminal_frame_command(&mut inner.commands, command); @@ -2566,7 +2658,7 @@ impl TerminalFrameCommandSender { .queued_output_bytes .fetch_add(output_bytes, Ordering::Relaxed); self.shared.ready.notify_one(); - true + Ok(()) } fn send_many(&self, commands: I) -> bool @@ -2994,6 +3086,37 @@ fn run_terminal_frame_processor( sessions.remove(&session_id); snapshot_priority.remove(&session_id); } + TerminalFrameCommand::TakeSession { + session_id, + response_tx, + } => { + if let Some(stale) = cancel_selected_occurrence_search_job_for_session( + &mut selected_occurrence_search_jobs, + &session_id, + "selected occurrence session was moved to another workspace", + ) { + push_terminal_frame_worker_event( + &event_queue, + TerminalFrameEvent::Search(stale), + ); + } + snapshot_priority.remove(&session_id); + if let Err(std::sync::mpsc::SendError(Some(session))) = + response_tx.send(sessions.remove(&session_id)) + { + sessions.insert(session_id, session); + } + } + TerminalFrameCommand::InsertSessions { + sessions: transferred, + } => { + for (session_id, mut session) in transferred { + session.include_live_snapshot = terminal_frame_live_snapshot_enabled( + !priority_initialized || snapshot_priority.contains(&session_id), + ); + sessions.insert(session_id, session); + } + } TerminalFrameCommand::ResizeSession { session_id, cols, diff --git a/crates/nyaterm-desktop/src/models/terminal/tests.rs b/crates/nyaterm-desktop/src/models/terminal/tests.rs index b487b098..b290e6d5 100644 --- a/crates/nyaterm-desktop/src/models/terminal/tests.rs +++ b/crates/nyaterm-desktop/src/models/terminal/tests.rs @@ -1434,6 +1434,63 @@ fn terminal_frame_event_queue_coalesces_pure_output_to_latest() { assert!(queue.try_recv().is_none()); } +#[test] +fn terminal_frame_event_queue_moves_only_selected_sessions() { + let queue = TerminalFrameEventQueue::new(8); + let mut first = output_frame_with_sizes(1, 0); + first.session_id = "moved".into(); + let mut second = output_frame_with_sizes(1, 0); + second.session_id = "kept".into(); + queue.push(TerminalFrameEvent::Output(first)); + queue.push(TerminalFrameEvent::Output(second)); + + let moved = queue.take_sessions(&["moved".into()]); + assert_eq!(moved.len(), 1); + assert_eq!(moved.front().unwrap().session_id(), "moved"); + assert_eq!(queue.try_recv().unwrap().session_id(), "kept"); + assert!(queue.try_recv().is_none()); +} + +#[test] +fn terminal_frame_transfer_preserves_multiple_sessions_and_returns_rejected_batch() { + let source = TerminalFramePipeline::default(); + let target = TerminalFramePipeline::default(); + source.seed_session("first", "hello", "UTF-8", 1000); + source.seed_session("second", "world", "UTF-8", 1000); + let first = source.take_session_for_transfer("first").unwrap().unwrap(); + let second = source.take_session_for_transfer("second").unwrap().unwrap(); + + target + .insert_sessions_from_transfer(vec![("first".into(), first), ("second".into(), second)]) + .unwrap(); + let first = target.take_session_for_transfer("first").unwrap().unwrap(); + let second = target.take_session_for_transfer("second").unwrap().unwrap(); + assert!( + first + .screen + .snapshot() + .rows() + .iter() + .any(|row| row.text.contains("hello")) + ); + assert!( + second + .screen + .snapshot() + .rows() + .iter() + .any(|row| row.text.contains("world")) + ); + + target.command_tx.close(); + let rejected = target + .insert_sessions_from_transfer(vec![("first".into(), first), ("second".into(), second)]) + .unwrap_err(); + assert_eq!(rejected.len(), 2); + assert_eq!(rejected[0].0, "first"); + assert_eq!(rejected[1].0, "second"); +} + #[test] fn terminal_frame_event_queue_wakes_once_after_interest_is_armed() { let (queue, mut wake_rx) = TerminalFrameEventQueue::new_with_wake(8); diff --git a/crates/nyaterm-desktop/src/shortcuts/mod.rs b/crates/nyaterm-desktop/src/shortcuts/mod.rs index 4c3683c3..7bffd5cc 100644 --- a/crates/nyaterm-desktop/src/shortcuts/mod.rs +++ b/crates/nyaterm-desktop/src/shortcuts/mod.rs @@ -461,8 +461,8 @@ pub(crate) const SHORTCUT_REGISTRY: [ShortcutDefinition; 32] = [ "settings.shortcutLabels.newSession", Workspace, ShortcutKind::Direct, - "ctrl+shift+n", - "meta+shift+n", + "ctrl+shift+t", + "meta+shift+t", Supported, "Opens saved connections." ), diff --git a/crates/nyaterm-store/src/lib.rs b/crates/nyaterm-store/src/lib.rs index af239322..b9680563 100644 --- a/crates/nyaterm-store/src/lib.rs +++ b/crates/nyaterm-store/src/lib.rs @@ -5,8 +5,9 @@ mod runtime; mod storage; pub use runtime::{ - BootstrapSnapshot, FlushBarrier, LoadBootstrap, LoadMainWindowState, RequestId, - SaveMainWindowState, StoreBlockingClient, StoreClientError, StoreConfig, StoreDomain, + BootstrapSnapshot, FlushBarrier, LoadBootstrap, LoadDeviceWindowManifest, LoadMainWindowState, + LoadWorkspaceRestoreManifest, RequestId, SaveDeviceWindowManifest, SaveMainWindowState, + SaveWorkspaceRestoreManifest, StoreBlockingClient, StoreClientError, StoreConfig, StoreDomain, StoreEvent, StoreFnRequest, StoreOperationError, StoreRequest, StoreRuntime, StoreSubmitError, StoreTask, StoreUiClient, store_request, }; diff --git a/crates/nyaterm-store/src/runtime.rs b/crates/nyaterm-store/src/runtime.rs index 7afbaa05..11184abc 100644 --- a/crates/nyaterm-store/src/runtime.rs +++ b/crates/nyaterm-store/src/runtime.rs @@ -9,10 +9,11 @@ use std::task::{Context, Poll}; use futures::channel::oneshot; use nyaterm_core::{ - AiSettings, AppSettingsSummary, CloudSyncSettings, CloudSyncState, CommandHistoryEntry, Group, - KeywordHighlightConfig, MainWindowState, OtpEntry, ProxyConfig, ProxyGroup, QuickCommand, - QuickCommandCategory, SavedConnection, SavedCredential, SavedPassword, SshKey, - TranslationSettings, TunnelConfig, TunnelGroup, + AiSettings, AppSettingsSummary, CloudSyncSettings, CloudSyncState, CommandHistoryEntry, + DeviceWindowManifest, Group, KeywordHighlightConfig, MainWindowState, OtpEntry, ProxyConfig, + ProxyGroup, QuickCommand, QuickCommandCategory, SavedConnection, SavedCredential, + SavedPassword, SshKey, TranslationSettings, TunnelConfig, TunnelGroup, WorkspaceId, + WorkspaceRestoreManifest, }; use crate::storage::{ConnectionStore, StorageError}; @@ -419,6 +420,7 @@ impl fmt::Display for StoreClientError { impl std::error::Error for StoreClientError {} +#[derive(Clone)] pub struct StoreRuntime { ui_client: StoreUiClient, blocking_client: StoreBlockingClient, @@ -649,6 +651,7 @@ fn aggregate_barrier_failures( }) } +#[derive(Clone)] pub struct BootstrapSnapshot { pub database_path: PathBuf, pub connections: Vec, @@ -675,6 +678,8 @@ pub struct BootstrapSnapshot { pub ai_message_count: usize, pub ai_audit_count: usize, pub open_tabs: Vec, + pub workspace_restore: WorkspaceRestoreManifest, + pub device_windows: DeviceWindowManifest, } #[derive(Debug, Clone, Copy)] @@ -707,6 +712,66 @@ impl StoreRequest for SaveMainWindowState { } } +#[derive(Debug, Clone, Copy)] +pub struct LoadWorkspaceRestoreManifest; + +impl StoreRequest for LoadWorkspaceRestoreManifest { + type Response = WorkspaceRestoreManifest; + + fn domain(&self) -> StoreDomain { + StoreDomain::Sessions + } + + fn execute(self, store: &ConnectionStore) -> Result { + store.load_workspace_restore_manifest() + } +} + +#[derive(Debug, Clone)] +pub struct SaveWorkspaceRestoreManifest(pub WorkspaceRestoreManifest); + +impl StoreRequest for SaveWorkspaceRestoreManifest { + type Response = (); + + fn domain(&self) -> StoreDomain { + StoreDomain::Sessions + } + + fn execute(self, store: &ConnectionStore) -> Result { + store.save_workspace_restore_manifest(&self.0) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct LoadDeviceWindowManifest(pub WorkspaceId); + +impl StoreRequest for LoadDeviceWindowManifest { + type Response = DeviceWindowManifest; + + fn domain(&self) -> StoreDomain { + StoreDomain::WindowState + } + + fn execute(self, store: &ConnectionStore) -> Result { + store.load_device_window_manifest(self.0) + } +} + +#[derive(Debug, Clone)] +pub struct SaveDeviceWindowManifest(pub DeviceWindowManifest); + +impl StoreRequest for SaveDeviceWindowManifest { + type Response = (); + + fn domain(&self) -> StoreDomain { + StoreDomain::WindowState + } + + fn execute(self, store: &ConnectionStore) -> Result { + store.save_device_window_manifest(&self.0) + } +} + pub struct LoadBootstrap; impl StoreRequest for LoadBootstrap { @@ -720,6 +785,16 @@ impl StoreRequest for LoadBootstrap { let sessions = store.load_sessions()?; let quick_commands = store.load_quick_commands()?; let ai_history = store.load_ai_history()?; + let workspace_restore = store.load_workspace_restore_manifest()?; + let legacy_workspace_id = workspace_restore + .most_recent() + .map(|workspace| workspace.id) + .unwrap_or_default(); + let device_windows = store.load_device_window_manifest(legacy_workspace_id)?; + let open_tabs = workspace_restore + .most_recent() + .map(|workspace| workspace.sessions.open_tabs.clone()) + .unwrap_or_default(); Ok(BootstrapSnapshot { database_path: store.db_path().to_path_buf(), connections: sessions.connections, @@ -745,7 +820,9 @@ impl StoreRequest for LoadBootstrap { ai_session_count: ai_history.sessions.len(), ai_message_count: ai_history.messages.len(), ai_audit_count: store.list_ai_audit_logs(None)?.len(), - open_tabs: store.load_open_tabs()?, + open_tabs, + workspace_restore, + device_windows, }) } } diff --git a/crates/nyaterm-store/src/storage/app_settings.rs b/crates/nyaterm-store/src/storage/app_settings.rs index 101fad8b..075222ef 100644 --- a/crates/nyaterm-store/src/storage/app_settings.rs +++ b/crates/nyaterm-store/src/storage/app_settings.rs @@ -15,8 +15,9 @@ use super::{ use nyaterm_core::{ AppSettingsSummary, CredentialCrypto, DEFAULT_RECORDING_PATH_TEMPLATE, DEFAULT_TERMINAL_TIMESTAMP_FORMAT, ExistingFileBehavior, RecordingMode, - RecordingRotationPolicy, SearchEngineConfig, TransferBrowserViewMode, default_panel_open_mode, - default_search_engines, normalize_panel_open_mode, + RecordingRotationPolicy, SearchEngineConfig, TransferBrowserViewMode, WorkspaceId, + WorkspaceRestoreManifest, WorkspaceRestoreState, WorkspaceSessionState, WorkspaceUiState, + default_panel_open_mode, default_search_engines, normalize_panel_open_mode, }; impl ConnectionStore { @@ -1484,6 +1485,114 @@ impl ConnectionStore { Ok(()) } + pub fn load_workspace_restore_manifest( + &self, + ) -> Result { + let value = self.load_settings_value()?; + if let Some(raw) = json_path(&value, &["ui", "workspaces"]) + && !raw.is_null() + { + let manifest: WorkspaceRestoreManifest = serde_json::from_value(raw.clone())?; + manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + return Ok(manifest); + } + + let settings = self.load_app_settings_summary()?; + let workspace = WorkspaceRestoreState { + id: WorkspaceId::legacy(), + revision: 0, + sessions: WorkspaceSessionState { + open_tabs: self.load_open_tabs()?, + terminal_window_layout: self.load_terminal_window_layout()?, + workspace_pane_layout: self.load_workspace_pane_layout()?, + extra: Default::default(), + }, + ui: WorkspaceUiState { + left_panel_width: settings.ui_left_panel_width, + right_panel_width: settings.ui_right_panel_width, + bottom_panel_height: settings.ui_quick_cmd_height, + active_left_panel: settings.ui_active_left_panel, + active_right_panel: settings.ui_active_right_panel, + left_panel_collapsed: settings.ui_left_panel_collapsed, + right_panel_collapsed: settings.ui_right_panel_collapsed, + ..WorkspaceUiState::default() + }, + extra: Default::default(), + }; + Ok(WorkspaceRestoreManifest::single(workspace)) + } + + pub fn save_workspace_restore_manifest( + &self, + manifest: &WorkspaceRestoreManifest, + ) -> Result<(), StorageError> { + manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + let mut value = self.load_settings_value()?; + set_nested_json_value( + &mut value, + &["ui", "workspaces"], + serde_json::to_value(manifest)?, + ); + + if let Some(workspace) = manifest.most_recent() { + set_nested_json_value( + &mut value, + &["ui", "open_tabs"], + serde_json::to_value(&workspace.sessions.open_tabs)?, + ); + set_nested_json_value( + &mut value, + &["ui", "terminal_window_layout"], + serde_json::to_value(&workspace.sessions.terminal_window_layout)?, + ); + set_nested_json_value( + &mut value, + &["ui", "workspace_pane_layout"], + serde_json::to_value(&workspace.sessions.workspace_pane_layout)?, + ); + set_nested_json_value( + &mut value, + &["ui", "left_width"], + serde_json::Value::from(workspace.ui.left_panel_width), + ); + set_nested_json_value( + &mut value, + &["ui", "right_width"], + serde_json::Value::from(workspace.ui.right_panel_width), + ); + set_nested_json_value( + &mut value, + &["ui", "quick_cmd_height"], + serde_json::Value::from(workspace.ui.bottom_panel_height), + ); + set_nested_json_value( + &mut value, + &["ui", "active_left_panel"], + serde_json::to_value(&workspace.ui.active_left_panel)?, + ); + set_nested_json_value( + &mut value, + &["ui", "active_right_panel"], + serde_json::to_value(&workspace.ui.active_right_panel)?, + ); + set_nested_json_value( + &mut value, + &["ui", "left_panel_collapsed"], + serde_json::Value::Bool(workspace.ui.left_panel_collapsed), + ); + set_nested_json_value( + &mut value, + &["ui", "right_panel_collapsed"], + serde_json::Value::Bool(workspace.ui.right_panel_collapsed), + ); + } + self.save_settings_value(&value) + } + pub fn save_screen_lock_settings( &self, settings: &AppSettingsSummary, diff --git a/crates/nyaterm-store/src/storage/mod.rs b/crates/nyaterm-store/src/storage/mod.rs index 05010a0b..2c68473a 100644 --- a/crates/nyaterm-store/src/storage/mod.rs +++ b/crates/nyaterm-store/src/storage/mod.rs @@ -83,6 +83,7 @@ const SETTINGS_QUICK_COMMANDS: &str = "settings/doc/quick-command"; const SETTINGS_CLOUD_SYNC_STATE: &str = "settings/doc/cloud-sync-state"; const SETTINGS_REMOTE_FILE_BACKEND_CACHE: &str = "settings/doc/file-backend-cache"; const SETTINGS_MAIN_WINDOW_STATE: &str = "settings/window_state"; +const SETTINGS_DEVICE_WINDOW_MANIFEST: &str = "settings/window_states_v2"; const LEGACY_TEXT_CLOUD_SYNC_STATE: &str = "cloud-sync-state"; const LEGACY_TEXT_REMOTE_FILE_BACKEND_CACHE: &str = "file-backend-cache"; diff --git a/crates/nyaterm-store/src/storage/tests.rs b/crates/nyaterm-store/src/storage/tests.rs index 46184f9a..40af5308 100644 --- a/crates/nyaterm-store/src/storage/tests.rs +++ b/crates/nyaterm-store/src/storage/tests.rs @@ -3056,6 +3056,200 @@ fn terminal_window_layout_roundtrip() { std::fs::remove_dir_all(dir).ok(); } +#[test] +fn legacy_workspace_fields_migrate_and_new_manifest_dual_writes_recent_workspace() { + let dir = unique_temp_dir("workspace-manifest-migration"); + let store = ConnectionStore::open(&dir).expect("store"); + let legacy_tab = + nyaterm_core::RestorableOpenTab::with_leaf_root("legacy", "Local", None, None, None); + store + .save_open_tabs(std::slice::from_ref(&legacy_tab)) + .unwrap(); + + let migrated = store.load_workspace_restore_manifest().unwrap(); + assert_eq!(migrated.workspaces.len(), 1); + assert_eq!( + migrated.workspaces[0].id, + nyaterm_core::WorkspaceId::legacy() + ); + assert_eq!(migrated.workspaces[0].sessions.open_tabs, vec![legacy_tab]); + + let first_id = migrated.workspaces[0].id; + let second_id = nyaterm_core::WorkspaceId::new(); + let mut second = nyaterm_core::WorkspaceRestoreState::empty(second_id); + let recent_tab = nyaterm_core::RestorableOpenTab::with_leaf_root( + "recent", + "SSH", + Some("connection-2".to_string()), + None, + None, + ); + second.sessions.open_tabs.push(recent_tab.clone()); + let manifest = nyaterm_core::WorkspaceRestoreManifest { + version: nyaterm_core::WORKSPACE_RESTORE_MANIFEST_VERSION, + workspaces: vec![migrated.workspaces[0].clone(), second], + most_recent_workspace_id: Some(second_id), + extra: Default::default(), + }; + store.save_workspace_restore_manifest(&manifest).unwrap(); + + assert_eq!(store.load_open_tabs().unwrap(), vec![recent_tab]); + let loaded = store.load_workspace_restore_manifest().unwrap(); + assert_eq!(loaded, manifest); + assert_eq!(loaded.workspaces[0].id, first_id); + std::fs::remove_dir_all(dir).ok(); +} + +#[test] +fn device_window_manifest_roundtrips_and_projects_recent_window_to_legacy_key() { + let dir = unique_temp_dir("device-window-manifest"); + let store = ConnectionStore::open(&dir).expect("store"); + let first_id = nyaterm_core::WorkspaceId::new(); + let second_id = nyaterm_core::WorkspaceId::new(); + let first = MainWindowState::new( + None, + MainWindowBounds { + x: 0, + y: 0, + width: 900, + height: 700, + }, + false, + ); + let second = MainWindowState::new( + None, + MainWindowBounds { + x: 120, + y: 80, + width: 1280, + height: 800, + }, + true, + ); + let manifest = nyaterm_core::DeviceWindowManifest { + version: nyaterm_core::DEVICE_WINDOW_MANIFEST_VERSION, + windows: vec![ + nyaterm_core::DeviceWindowState { + workspace_id: first_id, + window: first, + extra: Default::default(), + }, + nyaterm_core::DeviceWindowState { + workspace_id: second_id, + window: second.clone(), + extra: Default::default(), + }, + ], + window_order: vec![first_id, second_id], + most_recent_workspace_id: Some(second_id), + extra: Default::default(), + }; + store.save_device_window_manifest(&manifest).unwrap(); + + assert_eq!( + store.load_device_window_manifest(first_id).unwrap(), + manifest + ); + assert_eq!(store.load_main_window_state().unwrap(), Some(second)); + std::fs::remove_dir_all(dir).ok(); +} + +#[test] +fn restore_manifests_rollback_every_document_on_failure() { + use nyaterm_core::{ + DeviceWindowManifest, DeviceWindowState, WorkspaceId, WorkspaceRestoreManifest, + WorkspaceRestoreState, + }; + + let dir = unique_temp_dir("restore-atomic-rollback"); + let store = ConnectionStore::open(&dir).unwrap(); + let first_id = WorkspaceId::new(); + let second_id = WorkspaceId::new(); + let first_window = MainWindowState::new( + None, + MainWindowBounds { + x: 10, + y: 20, + width: 900, + height: 700, + }, + false, + ); + let second_window = MainWindowState::new( + None, + MainWindowBounds { + x: 40, + y: 50, + width: 1100, + height: 800, + }, + true, + ); + let old_workspace = WorkspaceRestoreManifest::single(WorkspaceRestoreState::empty(first_id)); + let old_device = DeviceWindowManifest { + version: nyaterm_core::DEVICE_WINDOW_MANIFEST_VERSION, + windows: vec![DeviceWindowState { + workspace_id: first_id, + window: first_window.clone(), + extra: Default::default(), + }], + window_order: vec![first_id], + most_recent_workspace_id: Some(first_id), + extra: Default::default(), + }; + store + .save_restore_manifests_atomically(&old_workspace, &old_device) + .unwrap(); + let old_settings = store.load_settings_value().unwrap(); + let mut next_workspace = old_workspace.clone(); + next_workspace + .workspaces + .push(WorkspaceRestoreState::empty(second_id)); + next_workspace.most_recent_workspace_id = Some(second_id); + let mut next_device = old_device.clone(); + next_device.windows.push(DeviceWindowState { + workspace_id: second_id, + window: second_window.clone(), + extra: Default::default(), + }); + next_device.window_order.push(second_id); + next_device.most_recent_workspace_id = Some(second_id); + + for document in 1..=3 { + assert!( + store + .save_restore_manifests_failing_after(&next_workspace, &next_device, document) + .is_err() + ); + assert_eq!(store.load_settings_value().unwrap(), old_settings); + assert_eq!( + store.load_workspace_restore_manifest().unwrap(), + old_workspace + ); + assert_eq!( + store.load_device_window_manifest(first_id).unwrap(), + old_device + ); + assert_eq!( + store.load_main_window_state().unwrap(), + Some(first_window.clone()) + ); + } + store + .save_restore_manifests_atomically(&next_workspace, &next_device) + .unwrap(); + assert_eq!( + store.load_workspace_restore_manifest().unwrap(), + next_workspace + ); + assert_eq!( + store.load_device_window_manifest(first_id).unwrap(), + next_device + ); + assert_eq!(store.load_main_window_state().unwrap(), Some(second_window)); + std::fs::remove_dir_all(dir).ok(); +} + #[test] fn verifies_encrypted_master_password_from_settings() { let dir = unique_temp_dir("verify-master-password"); @@ -4880,6 +5074,29 @@ fn portable_snapshot_excludes_and_preserves_device_local_main_window_state() { true, )) .expect("save source window state"); + let workspace_id = nyaterm_core::WorkspaceId::new(); + source + .save_device_window_manifest(&nyaterm_core::DeviceWindowManifest { + version: nyaterm_core::DEVICE_WINDOW_MANIFEST_VERSION, + windows: vec![nyaterm_core::DeviceWindowState { + workspace_id, + window: MainWindowState::new( + None, + MainWindowBounds { + x: 30, + y: 40, + width: 800, + height: 600, + }, + false, + ), + extra: Default::default(), + }], + window_order: vec![workspace_id], + most_recent_workspace_id: Some(workspace_id), + extra: Default::default(), + }) + .expect("save device windows"); let mut snapshot = source .build_raw_portable_snapshot( nyaterm_core::PortableSnapshotKind::Backup, diff --git a/crates/nyaterm-store/src/storage/window_state.rs b/crates/nyaterm-store/src/storage/window_state.rs index 131b774a..9efe8087 100644 --- a/crates/nyaterm-store/src/storage/window_state.rs +++ b/crates/nyaterm-store/src/storage/window_state.rs @@ -1,8 +1,14 @@ //! Device-local main-window placement persistence. -use nyaterm_core::MainWindowState; +use nyaterm_core::{ + DEVICE_WINDOW_MANIFEST_VERSION, DeviceWindowManifest, DeviceWindowState, MainWindowState, + WorkspaceId, WorkspaceRestoreManifest, +}; -use super::{ConnectionStore, SETTINGS_MAIN_WINDOW_STATE, SETTINGS_TABLE, StorageError}; +use super::{ + ConnectionStore, SETTINGS_DEFAULT, SETTINGS_DEVICE_WINDOW_MANIFEST, SETTINGS_MAIN_WINDOW_STATE, + SETTINGS_TABLE, StorageError, set_nested_json_value, +}; impl ConnectionStore { pub fn load_main_window_state(&self) -> Result, StorageError> { @@ -22,4 +28,206 @@ impl ConnectionStore { .map_err(|error| StorageError::InvalidData(error.to_string()))?; self.save_settings_doc_value(SETTINGS_MAIN_WINDOW_STATE, &serde_json::to_value(state)?) } + + pub fn load_device_window_manifest( + &self, + legacy_workspace_id: WorkspaceId, + ) -> Result { + let manifest = self.read_json_table::( + SETTINGS_TABLE, + SETTINGS_DEVICE_WINDOW_MANIFEST, + )?; + if let Some(manifest) = manifest { + manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + return Ok(manifest); + } + + let Some(window) = self.load_main_window_state()? else { + return Ok(DeviceWindowManifest::empty()); + }; + Ok(DeviceWindowManifest { + version: DEVICE_WINDOW_MANIFEST_VERSION, + windows: vec![DeviceWindowState { + workspace_id: legacy_workspace_id, + window, + extra: Default::default(), + }], + window_order: vec![legacy_workspace_id], + most_recent_workspace_id: Some(legacy_workspace_id), + extra: Default::default(), + }) + } + + pub fn save_device_window_manifest( + &self, + manifest: &DeviceWindowManifest, + ) -> Result<(), StorageError> { + manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + let device_bytes = serde_json::to_vec(manifest)?; + let legacy_window = manifest + .most_recent_workspace_id + .and_then(|workspace_id| manifest.state_for(workspace_id)) + .map(serde_json::to_vec) + .transpose()?; + let txn = self.db.begin_write()?; + write_prepared_doc(&txn, SETTINGS_DEVICE_WINDOW_MANIFEST, &device_bytes)?; + if let Some(legacy_window) = legacy_window { + write_prepared_doc(&txn, SETTINGS_MAIN_WINDOW_STATE, &legacy_window)?; + } else { + txn.open_table(SETTINGS_TABLE)? + .remove(SETTINGS_MAIN_WINDOW_STATE)?; + } + txn.commit()?; + Ok(()) + } + + /// Saves every compatibility view of workspace/window restore state in one commit. + pub fn save_restore_manifests_atomically( + &self, + workspace_manifest: &WorkspaceRestoreManifest, + device_manifest: &DeviceWindowManifest, + ) -> Result<(), StorageError> { + self.save_restore_manifests_with_failpoint(workspace_manifest, device_manifest, None) + } + + #[cfg(test)] + pub(crate) fn save_restore_manifests_failing_after( + &self, + workspace_manifest: &WorkspaceRestoreManifest, + device_manifest: &DeviceWindowManifest, + document: usize, + ) -> Result<(), StorageError> { + self.save_restore_manifests_with_failpoint( + workspace_manifest, + device_manifest, + Some(document), + ) + } + + fn save_restore_manifests_with_failpoint( + &self, + workspace_manifest: &WorkspaceRestoreManifest, + device_manifest: &DeviceWindowManifest, + fail_after: Option, + ) -> Result<(), StorageError> { + workspace_manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + device_manifest + .validate() + .map_err(|error| StorageError::InvalidData(error.to_string()))?; + + let workspace_value = serde_json::to_value(workspace_manifest)?; + let device_bytes = serde_json::to_vec(device_manifest)?; + let legacy_window = device_manifest + .most_recent_workspace_id + .and_then(|workspace_id| device_manifest.state_for(workspace_id)) + .map(serde_json::to_vec) + .transpose()?; + + let mut settings = self.load_settings_value()?; + set_nested_json_value(&mut settings, &["ui", "workspaces"], workspace_value); + if let Some(workspace) = workspace_manifest.most_recent() { + apply_legacy_workspace_projection(&mut settings, workspace)?; + } + let settings_bytes = serde_json::to_vec(&settings)?; + + let txn = self.db.begin_write()?; + write_prepared_doc(&txn, SETTINGS_DEFAULT, &settings_bytes)?; + fail_restore_transaction_after(fail_after, 1)?; + write_prepared_doc(&txn, SETTINGS_DEVICE_WINDOW_MANIFEST, &device_bytes)?; + fail_restore_transaction_after(fail_after, 2)?; + if let Some(legacy_window) = legacy_window { + write_prepared_doc(&txn, SETTINGS_MAIN_WINDOW_STATE, &legacy_window)?; + fail_restore_transaction_after(fail_after, 3)?; + } else { + txn.open_table(SETTINGS_TABLE)? + .remove(SETTINGS_MAIN_WINDOW_STATE)?; + fail_restore_transaction_after(fail_after, 3)?; + } + txn.commit()?; + Ok(()) + } +} + +fn write_prepared_doc( + txn: &redb::WriteTransaction, + key: &str, + bytes: &[u8], +) -> Result<(), StorageError> { + txn.open_table(SETTINGS_TABLE)?.insert(key, bytes)?; + Ok(()) +} + +fn fail_restore_transaction_after( + fail_after: Option, + document: usize, +) -> Result<(), StorageError> { + if fail_after == Some(document) { + return Err(StorageError::InvalidData( + "injected restore transaction failure".to_string(), + )); + } + Ok(()) +} + +fn apply_legacy_workspace_projection( + settings: &mut serde_json::Value, + workspace: &nyaterm_core::WorkspaceRestoreState, +) -> Result<(), StorageError> { + set_nested_json_value( + settings, + &["ui", "open_tabs"], + serde_json::to_value(&workspace.sessions.open_tabs)?, + ); + set_nested_json_value( + settings, + &["ui", "terminal_window_layout"], + serde_json::to_value(&workspace.sessions.terminal_window_layout)?, + ); + set_nested_json_value( + settings, + &["ui", "workspace_pane_layout"], + serde_json::to_value(&workspace.sessions.workspace_pane_layout)?, + ); + set_nested_json_value( + settings, + &["ui", "left_width"], + serde_json::Value::from(workspace.ui.left_panel_width), + ); + set_nested_json_value( + settings, + &["ui", "right_width"], + serde_json::Value::from(workspace.ui.right_panel_width), + ); + set_nested_json_value( + settings, + &["ui", "quick_cmd_height"], + serde_json::Value::from(workspace.ui.bottom_panel_height), + ); + set_nested_json_value( + settings, + &["ui", "active_left_panel"], + serde_json::to_value(&workspace.ui.active_left_panel)?, + ); + set_nested_json_value( + settings, + &["ui", "active_right_panel"], + serde_json::to_value(&workspace.ui.active_right_panel)?, + ); + set_nested_json_value( + settings, + &["ui", "left_panel_collapsed"], + serde_json::Value::Bool(workspace.ui.left_panel_collapsed), + ); + set_nested_json_value( + settings, + &["ui", "right_panel_collapsed"], + serde_json::Value::Bool(workspace.ui.right_panel_collapsed), + ); + Ok(()) } diff --git a/crates/nyaterm-transport/src/lib.rs b/crates/nyaterm-transport/src/lib.rs index b6c53dc8..03712af2 100644 --- a/crates/nyaterm-transport/src/lib.rs +++ b/crates/nyaterm-transport/src/lib.rs @@ -9,7 +9,7 @@ use std::pin::Pin; use std::process::Stdio; use std::sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, }; use std::thread::JoinHandle; @@ -103,8 +103,8 @@ use session_event_queue::{ SESSION_EVENT_QUEUE_OUTPUT_EVENT_LIMIT, SESSION_EVENT_QUEUE_OUTPUT_LIMIT, }; pub use session_types::{ - SessionDrain, SessionDrainStats, SessionError, SessionEvent, SessionInfo, SessionKind, - TerminalTransport, + SessionDrain, SessionDrainStats, SessionError, SessionEvent, SessionEventConsumerId, + SessionInfo, SessionKind, TerminalTransport, }; pub use sftp::{ RemoteBinaryFile, RemoteFilePath, SFTP_TRANSFER_CANCELLED, SftpAttributeUpdate, SftpFileEntry, @@ -579,6 +579,7 @@ const XAUTH_TIMEOUT: Duration = Duration::from_secs(2); pub struct SessionManager { sessions: Mutex>, event_queue: SessionEventQueue, + next_event_consumer_id: AtomicU64, shell_environment: Arc, } @@ -769,10 +770,35 @@ impl SessionManager { Self { sessions: Mutex::new(HashMap::new()), event_queue: SessionEventQueue::new(), + next_event_consumer_id: AtomicU64::new(1), shell_environment: ShellEnvironmentCache::global(), } } + /// Allocate an independent event consumer. Sessions are invisible to the + /// consumer until explicitly assigned, which lets multiple desktop + /// workspaces share one transport manager without stealing each other's + /// output. + pub fn register_event_consumer(&self) -> SessionEventConsumerId { + SessionEventConsumerId(self.next_event_consumer_id.fetch_add(1, Ordering::Relaxed)) + } + + pub fn assign_session_event_consumer( + &self, + session_id: &str, + consumer_id: SessionEventConsumerId, + ) { + self.event_queue.assign_consumer(session_id, consumer_id); + } + + pub fn clear_session_event_consumer( + &self, + session_id: &str, + consumer_id: SessionEventConsumerId, + ) { + self.event_queue.clear_consumer(session_id, consumer_id); + } + /// Return the runtime-only shell environment cache shared by SSH tasks. pub fn shell_environment(&self) -> Arc { Arc::clone(&self.shell_environment) @@ -1237,6 +1263,23 @@ impl SessionManager { timeout, )) } + + pub fn drain_events_blocking_for_consumer_with_output_budget( + &self, + consumer_id: SessionEventConsumerId, + max_events: usize, + max_output_bytes: usize, + timeout: Duration, + ) -> Result { + Ok(self + .event_queue + .drain_blocking_for_consumer_with_output_budget( + consumer_id, + max_events, + Some(max_output_bytes), + timeout, + )) + } } impl ManagedSession { diff --git a/crates/nyaterm-transport/src/session_event_queue.rs b/crates/nyaterm-transport/src/session_event_queue.rs index a89c7b2e..22a59344 100644 --- a/crates/nyaterm-transport/src/session_event_queue.rs +++ b/crates/nyaterm-transport/src/session_event_queue.rs @@ -1,8 +1,8 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; -use crate::{SessionDrain, SessionDrainStats, SessionEvent}; +use crate::{SessionDrain, SessionDrainStats, SessionEvent, SessionEventConsumerId}; pub(super) const SESSION_EVENT_QUEUE_OUTPUT_LIMIT: usize = 8 * 1024 * 1024; pub(super) const SESSION_EVENT_QUEUE_OUTPUT_LOW_WATERMARK: usize = @@ -35,6 +35,7 @@ struct SessionEventQueueInner { producer_active: bool, closed: bool, cancelled_sessions: HashSet, + consumers: HashMap, #[cfg(test)] waiting_output_producers: usize, #[cfg(test)] @@ -157,6 +158,7 @@ impl SessionEventQueue { return; }; inner.cancelled_sessions.insert(session_id.to_string()); + inner.consumers.remove(session_id); let removed_output_bytes = inner .events .iter() @@ -181,6 +183,29 @@ impl SessionEventQueue { self.shared.ready.notify_all(); } + pub(super) fn assign_consumer(&self, session_id: &str, consumer_id: SessionEventConsumerId) { + if session_id.is_empty() { + return; + } + let Ok(mut inner) = self.shared.inner.lock() else { + return; + }; + inner.consumers.insert(session_id.to_string(), consumer_id); + drop(inner); + self.shared.ready.notify_all(); + } + + pub(super) fn clear_consumer(&self, session_id: &str, consumer_id: SessionEventConsumerId) { + let Ok(mut inner) = self.shared.inner.lock() else { + return; + }; + if inner.consumers.get(session_id) == Some(&consumer_id) { + inner.consumers.remove(session_id); + } + drop(inner); + self.shared.ready.notify_all(); + } + pub(super) fn close(&self) { let Ok(mut inner) = self.shared.inner.lock() else { return; @@ -255,6 +280,42 @@ impl SessionEventQueue { drain } + pub(super) fn drain_blocking_for_consumer_with_output_budget( + &self, + consumer_id: SessionEventConsumerId, + max_events: usize, + max_output_bytes: Option, + timeout: Duration, + ) -> SessionDrain { + let wait_started = Instant::now(); + let Ok(mut inner) = self.shared.inner.lock() else { + return SessionDrain::default(); + }; + while !inner.has_event_for_consumer(consumer_id) && !inner.closed { + let remaining = timeout.saturating_sub(wait_started.elapsed()); + if remaining.is_zero() { + break; + } + inner.consumer_wait_started(); + let waited = self.shared.ready.wait_timeout(inner, remaining); + let Ok((mut waited, wait_result)) = waited else { + return SessionDrain::default(); + }; + waited.consumer_wait_finished(); + inner = waited; + if wait_result.timed_out() { + break; + } + } + let drain = inner.drain_for_consumer(consumer_id, max_events, max_output_bytes); + let resumed = inner.resume_output_if_below_low_watermark(); + drop(inner); + if resumed { + self.shared.output_space.notify_all(); + } + drain + } + #[cfg(test)] pub(super) fn waiting_output_producers(&self) -> usize { self.shared @@ -275,6 +336,20 @@ impl SessionEventQueue { } impl SessionEventQueueInner { + fn event_belongs_to_consumer( + &self, + event: &SessionEvent, + consumer_id: SessionEventConsumerId, + ) -> bool { + self.consumers.get(event_session_id(event)) == Some(&consumer_id) + } + + fn has_event_for_consumer(&self, consumer_id: SessionEventConsumerId) -> bool { + self.events + .iter() + .any(|event| self.event_belongs_to_consumer(event, consumer_id)) + } + fn output_wait_started(&mut self) { #[cfg(test)] { @@ -379,6 +454,85 @@ impl SessionEventQueueInner { stats.queued_output_bytes = self.queued_output_bytes; SessionDrain { events, stats } } + + fn drain_for_consumer( + &mut self, + consumer_id: SessionEventConsumerId, + max_events: usize, + max_output_bytes: Option, + ) -> SessionDrain { + let mut events = Vec::new(); + let mut stats = SessionDrainStats::default(); + while events.len() < max_events { + let Some(index) = self + .events + .iter() + .position(|event| self.event_belongs_to_consumer(event, consumer_id)) + else { + break; + }; + if let Some(max_output_bytes) = max_output_bytes { + let remaining = max_output_bytes.saturating_sub(stats.drained_output_bytes); + let is_output = matches!(self.events.get(index), Some(SessionEvent::Output { .. })); + if remaining == 0 && is_output { + break; + } + if let Some(SessionEvent::Output { session_id, data }) = self.events.get_mut(index) + { + let take = data.len().min(remaining); + if data.len() > take { + let remaining_data = data.split_off(take); + let chunk = std::mem::replace(data, remaining_data); + let session_id = session_id.clone(); + stats.drained_events = stats.drained_events.saturating_add(1); + stats.drained_output_bytes = + stats.drained_output_bytes.saturating_add(chunk.len()); + self.queued_output_bytes = + self.queued_output_bytes.saturating_sub(chunk.len()); + events.push(SessionEvent::Output { + session_id, + data: chunk, + }); + continue; + } + } + } + let Some(event) = self.events.remove(index) else { + break; + }; + stats.drained_events = stats.drained_events.saturating_add(1); + match &event { + SessionEvent::Output { data, .. } => { + stats.drained_output_bytes = + stats.drained_output_bytes.saturating_add(data.len()); + self.queued_output_bytes = self.queued_output_bytes.saturating_sub(data.len()); + } + SessionEvent::OutputDropped { bytes, .. } => { + stats.dropped_output_bytes = stats.dropped_output_bytes.saturating_add(*bytes); + } + SessionEvent::CwdChanged { .. } + | SessionEvent::CommandAccepted { .. } + | SessionEvent::Exited { .. } + | SessionEvent::Error { .. } => {} + } + events.push(event); + } + stats.queued_events = self + .events + .iter() + .filter(|event| self.event_belongs_to_consumer(event, consumer_id)) + .count(); + stats.queued_output_bytes = self + .events + .iter() + .filter(|event| self.event_belongs_to_consumer(event, consumer_id)) + .filter_map(|event| match event { + SessionEvent::Output { data, .. } => Some(data.len()), + _ => None, + }) + .sum(); + SessionDrain { events, stats } + } } fn event_session_id(event: &SessionEvent) -> &str { diff --git a/crates/nyaterm-transport/src/session_types.rs b/crates/nyaterm-transport/src/session_types.rs index 86ce9fd8..e370cc90 100644 --- a/crates/nyaterm-transport/src/session_types.rs +++ b/crates/nyaterm-transport/src/session_types.rs @@ -2,6 +2,9 @@ use std::path::PathBuf; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SessionEventConsumerId(pub(crate) u64); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionInfo { pub id: String, diff --git a/crates/nyaterm-transport/src/tests.rs b/crates/nyaterm-transport/src/tests.rs index 45029849..9bfe68be 100644 --- a/crates/nyaterm-transport/src/tests.rs +++ b/crates/nyaterm-transport/src/tests.rs @@ -13,12 +13,12 @@ use super::{ DO, DockerService, ForwardedTcpIpDispatch, IAC, LocalSessionConfig, OPT_SUPPRESS_GO_AHEAD, PrimarySessionGate, QueuedTransportWriter, RemoteGpuService, RemoteNpuService, RemoteStatsService, SESSION_EVENT_QUEUE_OUTPUT_EVENT_LIMIT, SESSION_EVENT_QUEUE_OUTPUT_LIMIT, - SerialSessionConfig, SessionError, SessionEvent, SessionEventQueue, SessionManager, - SftpService, SftpSettings, SshAlgorithmListKind, SshAlgorithmMode, SshAlgorithmPreferences, - SshAlgorithmRisk, SshAlgorithmValidationError, SshCommand, SshKeyAuthConfig, SshProxyConfig, - SshPtyDimensions, SshSessionConfig, SshSessionProfile, TelnetSessionConfig, WILL, cipher, - defaults_from_preferred, drain_deferred_ssh_open_commands, expand_proxy_command, - forwarded_tcpip_sender_for, has_password_prompt, has_username_prompt, + SerialSessionConfig, SessionError, SessionEvent, SessionEventConsumerId, SessionEventQueue, + SessionManager, SftpService, SftpSettings, SshAlgorithmListKind, SshAlgorithmMode, + SshAlgorithmPreferences, SshAlgorithmRisk, SshAlgorithmValidationError, SshCommand, + SshKeyAuthConfig, SshProxyConfig, SshPtyDimensions, SshSessionConfig, SshSessionProfile, + TelnetSessionConfig, WILL, cipher, defaults_from_preferred, drain_deferred_ssh_open_commands, + expand_proxy_command, forwarded_tcpip_sender_for, has_password_prompt, has_username_prompt, is_process_list_unsupported, kex, local_pty_size, mac, normalize_process_signal, parse_process_output, register_x11_sender, remap_del_to_bs, resolve_preferred_algorithms, run_local_command, ssh_client_config, ssh_host_identifier, supported_ssh_algorithms, @@ -2180,6 +2180,46 @@ fn session_event_queue_keeps_sessions_separate() { )); } +#[test] +fn session_event_consumers_only_drain_assigned_sessions_and_follow_reassignment() { + let queue = SessionEventQueue::new(); + let first = SessionEventConsumerId(1); + let second = SessionEventConsumerId(2); + queue.assign_consumer("a", first); + queue.assign_consumer("b", second); + queue.push(SessionEvent::Output { + session_id: "a".to_string(), + data: b"a1".to_vec(), + }); + queue.push(SessionEvent::Output { + session_id: "b".to_string(), + data: b"b1".to_vec(), + }); + + let first_drain = + queue.drain_blocking_for_consumer_with_output_budget(first, 8, Some(1024), Duration::ZERO); + assert!(matches!( + first_drain.events.as_slice(), + [SessionEvent::Output { session_id, data }] + if session_id == "a" && data == b"a1" + )); + + queue.assign_consumer("b", first); + let reassigned = + queue.drain_blocking_for_consumer_with_output_budget(first, 8, Some(1024), Duration::ZERO); + assert!(matches!( + reassigned.events.as_slice(), + [SessionEvent::Output { session_id, data }] + if session_id == "b" && data == b"b1" + )); + assert!( + queue + .drain_blocking_for_consumer_with_output_budget(second, 8, Some(1024), Duration::ZERO,) + .events + .is_empty() + ); +} + #[test] fn session_event_queue_respects_output_drain_budget() { let queue = SessionEventQueue::new();