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:
Kang
2026-09-20 10:10:39 +08:00
parent bef22402dd
commit 144ac9b665
85 changed files with 6117 additions and 466 deletions
+8 -35
View File
@@ -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);
});
+17
View File
@@ -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)]
+2
View File
@@ -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();
}
}
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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 接続を再利用してコマンドを実行",
+3
View File
@@ -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
+339 -185
View File
@@ -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(&copy_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");
}
}
@@ -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 {
@@ -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 {
+4 -1
View File
@@ -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);
+2 -2
View File
@@ -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."
),
+3 -2
View File
@@ -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,
};
+82 -5
View File
@@ -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,
+1
View File
@@ -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";
+217
View File
@@ -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(())
}
+46 -3
View File
@@ -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,
+46 -6
View File
@@ -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();