mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 00:01:30 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ActivationRequest>,
|
||||
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<MoveTabDockEdge>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<RestorableOpenTab>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal_window_layout: Option<RestorableTerminalWindowNode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_pane_layout: Option<RestorableWorkspacePaneNode>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub active_right_panel: Option<String>,
|
||||
#[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<String, Value>,
|
||||
}
|
||||
|
||||
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<String, Value>,
|
||||
}
|
||||
|
||||
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<WorkspaceRestoreState>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub most_recent_workspace_id: Option<WorkspaceId>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
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<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceWindowManifest {
|
||||
pub version: u8,
|
||||
#[serde(default)]
|
||||
pub windows: Vec<DeviceWindowState>,
|
||||
#[serde(default)]
|
||||
pub window_order: Vec<WorkspaceId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub most_recent_workspace_id: Option<WorkspaceId>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 接続を再利用してコマンドを実行",
|
||||
|
||||
@@ -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 연결 재사용 및 명령 실행",
|
||||
|
||||
@@ -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 连接并执行命令",
|
||||
|
||||
@@ -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 連線並執行命令",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<DesktopController>,
|
||||
session_hub: Entity<SessionHub>,
|
||||
quit_requested: bool,
|
||||
lifecycle: AppShellLifecycle,
|
||||
app: Option<Entity<NyaTermApp>>,
|
||||
store_runtime: Option<StoreRuntime>,
|
||||
pending_bootstrap: Option<StoreTask<nyaterm_store::BootstrapSnapshot>>,
|
||||
startup_restore: Entity<StartupRestoreStore>,
|
||||
overlays: Entity<OverlayStore>,
|
||||
activation_rx: Option<ActivationReceiver>,
|
||||
pending_activations: VecDeque<ActivationRequest>,
|
||||
recent_activation_ids: HashSet<[u8; 16]>,
|
||||
recent_activation_order: VecDeque<[u8; 16]>,
|
||||
pending_process_quit_tasks: Vec<StoreTask<()>>,
|
||||
main_window_state: MainWindowStateController,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -116,8 +136,11 @@ struct RecoveryState {
|
||||
impl AppShell {
|
||||
pub fn new(
|
||||
runtime: AppRuntime,
|
||||
activation_rx: ActivationReceiver,
|
||||
initial_activation: Option<ActivationRequest>,
|
||||
startup: AppShellStartup,
|
||||
workspace_id: WorkspaceId,
|
||||
controller: Entity<DesktopController>,
|
||||
session_hub: Entity<SessionHub>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) {
|
||||
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<Self>) {
|
||||
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
if !self.remember_activation(request.request_id) {
|
||||
return;
|
||||
}
|
||||
pub(super) fn receive_activation_direct(
|
||||
&mut self,
|
||||
request: ActivationRequest,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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<Self>) {
|
||||
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<ProcessStateStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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>) {
|
||||
self.store_runtime = None;
|
||||
pub(super) fn enter_recovery(&mut self, error: StoreOperationError, cx: &mut Context<Self>) {
|
||||
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>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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<ProcessStateStore>,
|
||||
event: SharedStateEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) -> Result<Option<StoreTask<()>>, 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<Self>) {
|
||||
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<Self>) {
|
||||
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>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) -> 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<Me
|
||||
MenuItem::action(format!("Quit {name}"), NativeQuit),
|
||||
]),
|
||||
Menu::new("File").items([
|
||||
MenuItem::action("New Window", NativeNewWindow),
|
||||
MenuItem::action("New Session", NativeNewSession),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Import Config", NativeImportConfig),
|
||||
@@ -895,6 +1039,9 @@ impl Render for AppShell {
|
||||
let show_app = matches!(self.lifecycle, AppShellLifecycle::Ready);
|
||||
div()
|
||||
.size_full()
|
||||
.on_action(cx.listener(|this, _: &NativeNewWindow, _window, cx| {
|
||||
this.request_new_window(cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &NativeNewSession, window, cx| {
|
||||
this.perform_native_menu_command(NativeMenuCommand::NewSession, window, cx);
|
||||
}))
|
||||
@@ -1002,7 +1149,9 @@ impl Render for AppShell {
|
||||
this.perform_native_menu_command(NativeMenuCommand::ManageSyncGroups, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &NativeQuit, window, cx| {
|
||||
this.request_window_close(window, cx);
|
||||
let _ = window;
|
||||
this.controller
|
||||
.update(cx, |controller, cx| controller.request_quit(cx));
|
||||
}))
|
||||
.when_some(self.app.clone().filter(|_| show_app), |root, app| {
|
||||
root.child(app)
|
||||
@@ -1015,7 +1164,7 @@ impl Render for AppShell {
|
||||
mod tests {
|
||||
use gpui::{Menu, MenuItem};
|
||||
|
||||
use crate::app_shell::native_app_menus_for;
|
||||
use crate::app_shell::{native_app_menus_for, native_new_window_key_binding};
|
||||
use nyaterm_core::app_identity::AppFlavor;
|
||||
|
||||
fn menu_names(menus: &[Menu]) -> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Self> {
|
||||
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<WorkspaceRestoreState>,
|
||||
}
|
||||
|
||||
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<Self>,
|
||||
) -> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SessionManager>,
|
||||
}
|
||||
|
||||
impl SessionHub {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manager: Arc::new(SessionManager::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn manager(&self) -> Arc<SessionManager> {
|
||||
Arc::clone(&self.manager)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -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<StoreTask<BootstrapSnapshot>>,
|
||||
pub(super) recovery: Option<StartupRecovery>,
|
||||
main_window_state: Option<MainWindowState>,
|
||||
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<WorkspaceId> {
|
||||
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<StoreRuntime> {
|
||||
self.store_runtime.clone()
|
||||
}
|
||||
|
||||
pub(super) fn take_pending_bootstrap(&mut self) -> Option<StoreTask<BootstrapSnapshot>> {
|
||||
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()
|
||||
|
||||
@@ -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<RestorableWorkspacePaneNode>,
|
||||
pending_active_pane_indexes: Vec<usize>,
|
||||
loaded_open_tabs: Option<Vec<RestorableOpenTab>>,
|
||||
loaded_terminal_window_layout: Option<Option<RestorableTerminalWindowNode>>,
|
||||
loaded_workspace_pane_layout: Option<Option<RestorableWorkspacePaneNode>>,
|
||||
}
|
||||
|
||||
impl StartupRestoreStore {
|
||||
@@ -51,6 +53,27 @@ impl StartupRestoreStore {
|
||||
self.loaded_open_tabs.take()
|
||||
}
|
||||
|
||||
pub fn set_loaded_window_layouts(
|
||||
&mut self,
|
||||
terminal: Option<RestorableTerminalWindowNode>,
|
||||
workspace: Option<RestorableWorkspacePaneNode>,
|
||||
) {
|
||||
self.loaded_terminal_window_layout = Some(terminal);
|
||||
self.loaded_workspace_pane_layout = Some(workspace);
|
||||
}
|
||||
|
||||
pub fn take_loaded_terminal_window_layout(
|
||||
&mut self,
|
||||
) -> Option<Option<RestorableTerminalWindowNode>> {
|
||||
self.loaded_terminal_window_layout.take()
|
||||
}
|
||||
|
||||
pub fn take_loaded_workspace_pane_layout(
|
||||
&mut self,
|
||||
) -> Option<Option<RestorableWorkspacePaneNode>> {
|
||||
self.loaded_workspace_pane_layout.take()
|
||||
}
|
||||
|
||||
pub fn queue_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<crate::app_shell::ProcessStateStore>,
|
||||
workspace_init: crate::app_shell::WorkspaceInitSnapshot,
|
||||
store_ui: StoreUiClient,
|
||||
store_blocking: StoreBlockingClient,
|
||||
session_manager: Arc<SessionManager>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
@@ -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<gpui::WeakEntity<crate::app_shell::DesktopController>>,
|
||||
pub(in crate::features) process_state: gpui::Entity<crate::app_shell::ProcessStateStore>,
|
||||
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<NotesCatalogEvent> for NyaTermApp {}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AppLifecycleEvent {
|
||||
ShutdownRequested,
|
||||
NewWindowRequested,
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<AppLifecycleEvent> for NyaTermApp {}
|
||||
|
||||
@@ -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<SettingsPersistenceDomain>,
|
||||
keyword_highlights: KeywordHighlightConfig,
|
||||
keyword_highlights: Option<KeywordHighlightConfig>,
|
||||
ai_settings: Option<AiSettings>,
|
||||
translation_settings: Option<TranslationSettings>,
|
||||
session: Option<ShutdownSessionSnapshot>,
|
||||
workspace: Option<WorkspaceCloseSnapshot>,
|
||||
}
|
||||
|
||||
struct ShutdownSessionSnapshot {
|
||||
open_tabs: Vec<RestorableOpenTab>,
|
||||
terminal_layout: Option<RestorableTerminalWindowNode>,
|
||||
workspace_layout: Option<RestorableWorkspacePaneNode>,
|
||||
pub(crate) struct WorkspaceCloseSnapshot {
|
||||
pub(crate) workspace_id: nyaterm_core::WorkspaceId,
|
||||
sessions: Option<WorkspaceSessionState>,
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<StoreTask<()>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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(())
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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}"
|
||||
));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,21 @@ use crate::models::{
|
||||
};
|
||||
|
||||
impl NyaTermApp {
|
||||
pub(crate) fn set_desktop_controller(
|
||||
&mut self,
|
||||
controller: gpui::WeakEntity<crate::app_shell::DesktopController>,
|
||||
) {
|
||||
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<NyaAppMenuBar>) {
|
||||
self.shell.set_title_menu_bar(menu_bar);
|
||||
}
|
||||
@@ -154,9 +169,15 @@ impl NyaTermApp {
|
||||
|
||||
fn title_file_menu_items(&self, cx: &mut Context<Self>) -> Vec<NyaMenuItem> {
|
||||
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();
|
||||
|
||||
@@ -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::<SessionTabDragPayload>(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::<SessionTabDragPayload>(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::<SessionTabDragPayload>(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,
|
||||
);
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,29 @@ struct SessionTabDragState {
|
||||
source_id: String,
|
||||
}
|
||||
|
||||
pub(crate) struct SessionCatalogTransferBundle {
|
||||
entries: Vec<SessionCatalogTransferEntry>,
|
||||
ordered_ids: Vec<String>,
|
||||
pending_events: VecDeque<SessionEvent>,
|
||||
}
|
||||
|
||||
struct SessionCatalogTransferEntry {
|
||||
session_id: String,
|
||||
metadata: SessionRuntimeMetadata,
|
||||
start_tab_placement: Option<SessionStartTabPlacement>,
|
||||
custom_name: Option<String>,
|
||||
dynamic_title: Option<String>,
|
||||
cwd: Option<String>,
|
||||
tab_color: Option<u32>,
|
||||
locked: bool,
|
||||
command_history: Option<Vec<String>>,
|
||||
busy_action: Option<String>,
|
||||
remote_file: Option<RemoteFileService>,
|
||||
xymodem: Option<super::xymodem_runtime::XymodemSessionState>,
|
||||
zmodem: Option<ZmodemSessionState>,
|
||||
trzsz: Option<TrzszSessionState>,
|
||||
}
|
||||
|
||||
#[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<SessionEvent> {
|
||||
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<SessionEvent>,
|
||||
) {
|
||||
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<SessionEvent>,
|
||||
) -> Option<SessionCatalogTransferBundle> {
|
||||
let selected = session_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<usize>,
|
||||
) {
|
||||
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<SessionStartTabPlacement>,
|
||||
insert_index: Option<usize>,
|
||||
) {
|
||||
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<String> {
|
||||
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<SftpDuplicatePromptBroker> {
|
||||
Arc::clone(&self.duplicate_prompts)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
if !self.settings.summary().has_master_password {
|
||||
self.unlock_app(cx);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Self>,
|
||||
) -> 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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-4
@@ -61,6 +61,21 @@ impl SettingsSaveKind {
|
||||
}
|
||||
|
||||
impl NyaTermApp {
|
||||
pub(crate) fn publish_shared_settings(
|
||||
&self,
|
||||
settings: AppSettingsSummary,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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 {
|
||||
|
||||
+12
-3
@@ -13,7 +13,10 @@ impl NyaTermApp {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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>) {
|
||||
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<Self>,
|
||||
) {
|
||||
if self.defer_settings_persistence(cx) {
|
||||
if self.defer_settings_domain_persistence(
|
||||
crate::features::settings::SettingsPersistenceDomain::Transfer,
|
||||
cx,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = success_status;
|
||||
|
||||
@@ -203,7 +203,10 @@ impl NyaTermApp {
|
||||
}
|
||||
|
||||
pub(in crate::features) fn save_terminal_settings(&mut self, cx: &mut Context<Self>) {
|
||||
if self.defer_settings_persistence(cx) {
|
||||
if self.defer_settings_domain_persistence(
|
||||
crate::features::settings::SettingsPersistenceDomain::Terminal,
|
||||
cx,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.enforce_terminal_scrollback_limit();
|
||||
|
||||
@@ -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<Self>) -> bool {
|
||||
pub(crate) fn activate_settings_window(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(handle) = self.shell.settings_window() else {
|
||||
return false;
|
||||
};
|
||||
@@ -232,6 +232,15 @@ fn open_settings_window_now_from_app(app: Entity<NyaTermApp>, 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();
|
||||
|
||||
@@ -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<SettingsPersistenceDomain, SettingsPersistenceSlot>,
|
||||
draft_dirty_domains: HashSet<SettingsPersistenceDomain>,
|
||||
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<SettingsPersistenceDomain> {
|
||||
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<SettingsPersistenceDomain> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -885,7 +885,10 @@ impl NyaTermApp {
|
||||
}
|
||||
|
||||
fn save_appearance_settings(&mut self, cx: &mut Context<Self>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -21,6 +21,7 @@ impl NyaTermApp {
|
||||
|
||||
fn save_keyword_highlights(&mut self, cx: &mut Context<Self>) {
|
||||
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);
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<Self>) {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,6 @@ pub(in crate::features) enum NewSessionMenuAnchor {
|
||||
}
|
||||
|
||||
pub(in crate::features) struct ShellFeatureState {
|
||||
pub(in crate::features) system_tray: Option<super::tray::SystemTray>,
|
||||
/// 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<WorkspacePaneNode> {
|
||||
self.workspace.pane_roots.remove(tab_root)
|
||||
}
|
||||
|
||||
pub(in crate::features) fn insert_workspace_pane_root(
|
||||
&mut self,
|
||||
tab_root: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Self, String> {
|
||||
#[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<Menu, String> {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
pub(crate) fn handle_tray_action(&mut self, action: String, cx: &mut Context<Self>) {
|
||||
let Some(window) = self.shell.main_window() else {
|
||||
return;
|
||||
};
|
||||
@@ -186,7 +170,12 @@ impl NyaTermApp {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -28,7 +28,67 @@ pub(in crate::features) enum SyncSessionPauseResult {
|
||||
NoGroup,
|
||||
}
|
||||
|
||||
pub(in crate::features) struct SyncInputTransferBundle {
|
||||
original_groups: Vec<SyncInputGroup>,
|
||||
moved_groups: Vec<SyncInputGroup>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
pane_root: Option<WorkspacePaneNode>,
|
||||
catalog: SessionCatalogTransferBundle,
|
||||
terminal: TerminalSessionTransferBundle,
|
||||
transfer: TransferSessionTransferBundle,
|
||||
sync_input: SyncInputTransferBundle,
|
||||
source_index: usize,
|
||||
source_active_id: Option<String>,
|
||||
source_terminal_tree: Option<TerminalWindowNode>,
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Vec<String>> {
|
||||
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<Vec<String>, 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<Self>,
|
||||
) -> Result<WorkspaceTabTransferBundle, String> {
|
||||
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<Self>,
|
||||
) -> 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<Self>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String, bool>,
|
||||
pub(super) wrap_around_by_session: HashMap<String, bool>,
|
||||
pub(super) active_index: usize,
|
||||
pub(super) history_pending_key: Option<RecordingHistorySearchKey>,
|
||||
pub(super) history_result: Option<RecordingHistorySearchEvent>,
|
||||
|
||||
@@ -286,7 +286,19 @@ impl NyaTermApp {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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<TerminalSessionTransferEntry>,
|
||||
pending_events: std::collections::VecDeque<TerminalFrameEvent>,
|
||||
}
|
||||
|
||||
struct TerminalSessionTransferEntry {
|
||||
session_id: String,
|
||||
frame: Option<TerminalFrameSession>,
|
||||
view: Option<TerminalViewState>,
|
||||
search_wrap: Option<bool>,
|
||||
scroll_residual: Option<f32>,
|
||||
selection: Option<TerminalSelection>,
|
||||
}
|
||||
|
||||
#[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<Vec<(String, Option<TerminalFrameSession>)>, &'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<TerminalFrameSession>)>,
|
||||
) -> TerminalSessionTransferBundle {
|
||||
let session_ids = frames
|
||||
.iter()
|
||||
.map(|(session_id, _)| session_id.clone())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<std::collections::HashMap<_, _>>();
|
||||
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<UnboundedReceiver<()>> {
|
||||
|
||||
@@ -121,6 +121,24 @@ impl TerminalFeatureState {
|
||||
self.windows.tree.is_some()
|
||||
}
|
||||
|
||||
pub(in crate::features) fn terminal_window_tree(&self) -> Option<TerminalWindowNode> {
|
||||
self.windows.tree.clone()
|
||||
}
|
||||
|
||||
pub(in crate::features) fn restore_terminal_window_tree(
|
||||
&mut self,
|
||||
tree: Option<TerminalWindowNode>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<HashSet<_>>();
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<SessionEventBridgeState>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
consumer_id: SessionEventConsumerId,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct SessionEventBridgeState {
|
||||
control: Mutex<SessionEventBridgeControl>,
|
||||
transfer_gate: Mutex<()>,
|
||||
ui_queue: SessionEventBridgeQueue,
|
||||
/// Handed to `NyaTermApp::start_runtime_data_plane_drain` once, at window open.
|
||||
ui_queue_wake_rx: Mutex<Option<UnboundedReceiver<()>>>,
|
||||
@@ -73,6 +77,7 @@ struct SessionEventBridgeState {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SessionEventBridgeControl {
|
||||
owned_sessions: HashSet<String>,
|
||||
ui_routed_sessions: HashSet<String>,
|
||||
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<SessionEvent> {
|
||||
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<UnboundedReceiver<()>> {
|
||||
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<SessionEvent> {
|
||||
let selected = session_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
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<SessionManager>,
|
||||
consumer_id: SessionEventConsumerId,
|
||||
frame_pipeline: TerminalFramePipeline,
|
||||
state: Arc<SessionEventBridgeState>,
|
||||
) {
|
||||
@@ -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();
|
||||
|
||||
@@ -1311,6 +1311,13 @@ fn terminal_frame_output_commands(
|
||||
}
|
||||
|
||||
impl TerminalFramePipeline {
|
||||
pub(crate) fn take_events_for_transfer(
|
||||
&self,
|
||||
session_ids: &[String],
|
||||
) -> VecDeque<TerminalFrameEvent> {
|
||||
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<String>,
|
||||
) -> Result<Option<TerminalFrameSession>, &'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<String>, 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<Option<TerminalFrameSession>>,
|
||||
},
|
||||
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<TerminalFrameEventQueueShared>,
|
||||
@@ -1702,6 +1757,29 @@ enum TerminalFrameEventQueuePushOutcome {
|
||||
}
|
||||
|
||||
impl TerminalFrameEventQueue {
|
||||
fn take_sessions(&self, session_ids: &[String]) -> VecDeque<TerminalFrameEvent> {
|
||||
let selected = session_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
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<TerminalFrameActionLinks>,
|
||||
}
|
||||
|
||||
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<I>(&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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<SavedConnection>,
|
||||
@@ -675,6 +678,8 @@ pub struct BootstrapSnapshot {
|
||||
pub ai_message_count: usize,
|
||||
pub ai_audit_count: usize,
|
||||
pub open_tabs: Vec<nyaterm_core::RestorableOpenTab>,
|
||||
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<Self::Response, StorageError> {
|
||||
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<Self::Response, StorageError> {
|
||||
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<Self::Response, StorageError> {
|
||||
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<Self::Response, StorageError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceRestoreManifest, StorageError> {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<MainWindowState>, 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<DeviceWindowManifest, StorageError> {
|
||||
let manifest = self.read_json_table::<DeviceWindowManifest>(
|
||||
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<usize>,
|
||||
) -> 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<usize>,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<HashMap<String, ManagedSession>>,
|
||||
event_queue: SessionEventQueue,
|
||||
next_event_consumer_id: AtomicU64,
|
||||
shell_environment: Arc<ShellEnvironmentCache>,
|
||||
}
|
||||
|
||||
@@ -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<ShellEnvironmentCache> {
|
||||
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<SessionDrain, SessionError> {
|
||||
Ok(self
|
||||
.event_queue
|
||||
.drain_blocking_for_consumer_with_output_budget(
|
||||
consumer_id,
|
||||
max_events,
|
||||
Some(max_output_bytes),
|
||||
timeout,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedSession {
|
||||
|
||||
@@ -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<String>,
|
||||
consumers: HashMap<String, SessionEventConsumerId>,
|
||||
#[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<usize>,
|
||||
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<usize>,
|
||||
) -> 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user