refactor(desktop): collapse the store snapshot publish loop

The remaining three snapshots turned out to be a closed loop, so the
whole publish path goes.

`WorkspaceSnapshot` and `SessionSnapshot` were read only by
`published_core_store_snapshots_are_current`, which decided whether to
republish them. `OverlaySnapshot` was a same-render round-trip: `Render`
published it in its prologue and `overlay_host` read it back a few calls
later, falling back — when the store was empty — to an expression that
recomputed every field from the same `self` fields. `overlay_host` now
evaluates those flags directly into a local `OverlayFlags`, which is
exactly what the fallback did.

None of it had an observer. `AppShell` deliberately does not observe the
stores; the comment there records that store-observe was amplifying each
publish into an extra shell paint. So every `cx.notify()` in the publish
path was landing on nothing.

Gone with it: `publish.rs`, the publish throttle
(`should_publish_store_snapshots`, `store_snapshot_publish_due`,
`STORE_SNAPSHOT_HEARTBEAT`, `last_store_snapshot_publish_at`),
`WorkspaceStore`, `SessionStore`, and the snapshot half of
`OverlayStore`. The runtime tick loses a per-tick snapshot build and
comparison; the two slow-tick diagnostic fields that reported on it are
dropped, and `output_pressure` is still computed for the rest.

The four surviving stores each own something `NyaTermApp` does not:
`Runtime` (app runtime and native services), `WindowRuntime` (the pump),
`StartupRestore` (the restore queue) and `Overlay` (quick switch state).
`entities/` drops from 959 to 412 lines and the projection layer is gone.

Ten entity tests covered only the deleted stores; 548 tests become 538.
This commit is contained in:
Kang
2026-07-26 16:19:42 +08:00
parent fe1e19f60a
commit c00d3c5963
14 changed files with 80 additions and 811 deletions
+1 -10
View File
@@ -8,8 +8,7 @@ use nyaterm_core::AppRuntime;
use crate::{
entities::{
OverlayStore, RuntimeStore, SessionStore, StartupRestoreStore, UiStoreHandles,
WindowRuntimeStore, WorkspaceStore,
OverlayStore, RuntimeStore, StartupRestoreStore, UiStoreHandles, WindowRuntimeStore,
},
features::NyaTermApp,
};
@@ -20,8 +19,6 @@ pub struct AppShell {
runtime: Entity<RuntimeStore>,
window_runtime: Entity<WindowRuntimeStore>,
startup_restore: Entity<StartupRestoreStore>,
workspace: Entity<WorkspaceStore>,
sessions: Entity<SessionStore>,
overlays: Entity<OverlayStore>,
_subscriptions: Vec<Subscription>,
}
@@ -30,13 +27,9 @@ impl AppShell {
pub fn new(runtime: AppRuntime, cx: &mut Context<Self>) -> Self {
let runtime_store = cx.new(|_| RuntimeStore::new(runtime.clone()));
let startup_restore = cx.new(|_| StartupRestoreStore::default());
let workspace = cx.new(|_| WorkspaceStore::default());
let sessions = cx.new(|_| SessionStore::default());
let overlays = cx.new(|_| OverlayStore::default());
let stores = UiStoreHandles {
startup_restore: startup_restore.clone(),
workspace: workspace.clone(),
sessions: sessions.clone(),
overlays: overlays.clone(),
};
let app = cx.new(|cx| NyaTermApp::new(runtime, stores, cx));
@@ -51,8 +44,6 @@ impl AppShell {
runtime: runtime_store,
window_runtime: cx.new(|_| WindowRuntimeStore::default()),
startup_restore,
workspace,
sessions,
overlays,
_subscriptions: subscriptions,
}
@@ -1,11 +1,9 @@
use gpui::Entity;
use super::{OverlayStore, SessionStore, StartupRestoreStore, WorkspaceStore};
use super::{OverlayStore, StartupRestoreStore};
#[derive(Clone)]
pub struct UiStoreHandles {
pub startup_restore: Entity<StartupRestoreStore>,
pub workspace: Entity<WorkspaceStore>,
pub sessions: Entity<SessionStore>,
pub overlays: Entity<OverlayStore>,
}
+6 -9
View File
@@ -1,25 +1,22 @@
//! GPUI entity-state boundaries for the native shell.
//!
//! During the migration, `NyaTermApp` and its feature-state structs remain the
//! default authoritative UI state. These stores mostly hold read-only snapshot
//! projections published from the app; explicitly migrated domains, such as the
//! quick switch state in `OverlayStore`, are the exceptions.
//! `NyaTermApp` and its feature-state structs are the authoritative UI state.
//! Every store here owns something the app does not: the app runtime and native
//! services, the window runtime pump, the startup-restore queue, and the quick
//! switch overlay state. Read-only snapshot projections used to live here too;
//! they were removed once it turned out nothing consumed them.
mod handles;
mod overlay;
mod runtime;
mod session;
mod startup_restore;
mod window_runtime;
mod workspace;
#[cfg(test)]
mod tests;
pub use handles::UiStoreHandles;
pub use overlay::{OverlaySnapshot, OverlayStore, QuickSwitchState};
pub use overlay::{OverlayStore, QuickSwitchState};
pub use runtime::RuntimeStore;
pub use session::{SessionSnapshot, SessionStore};
pub use startup_restore::StartupRestoreStore;
pub use window_runtime::WindowRuntimeStore;
pub use workspace::{WorkspaceSnapshot, WorkspaceStore};
@@ -1,44 +1,3 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverlaySnapshot {
pub tab_actions_open: bool,
pub rename_open: bool,
pub color_picker_open: bool,
pub session_info_open: bool,
pub startup_command_open: bool,
pub temporary_ssh_link_open: bool,
pub multi_line_paste_open: bool,
pub terminal_actions_open: bool,
pub terminal_context_menu_open: bool,
pub action_link_menu_open: bool,
pub action_link_tooltip_open: bool,
pub command_suggestions_open: bool,
pub credential_suggestions_open: bool,
pub close_all_sessions_confirm_open: bool,
pub locked: bool,
}
impl Default for OverlaySnapshot {
fn default() -> Self {
Self {
tab_actions_open: false,
rename_open: false,
color_picker_open: false,
session_info_open: false,
startup_command_open: false,
temporary_ssh_link_open: false,
multi_line_paste_open: false,
terminal_actions_open: false,
terminal_context_menu_open: false,
action_link_menu_open: false,
action_link_tooltip_open: false,
command_suggestions_open: false,
credential_suggestions_open: false,
close_all_sessions_confirm_open: false,
locked: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct QuickSwitchState {
open: bool,
@@ -68,7 +27,6 @@ impl QuickSwitchState {
#[derive(Debug, Default)]
pub struct OverlayStore {
quick_switch: QuickSwitchState,
snapshot: Option<OverlaySnapshot>,
}
impl OverlayStore {
@@ -76,18 +34,6 @@ impl OverlayStore {
&self.quick_switch
}
pub fn snapshot(&self) -> Option<&OverlaySnapshot> {
self.snapshot.as_ref()
}
pub fn replace_snapshot(&mut self, snapshot: OverlaySnapshot) -> bool {
if self.snapshot.as_ref() == Some(&snapshot) {
return false;
}
self.snapshot = Some(snapshot);
true
}
pub fn open_quick_switch(&mut self) -> bool {
let next = QuickSwitchState {
open: true,
@@ -166,46 +112,4 @@ impl OverlayStore {
self.quick_switch.selected_index = 0;
true
}
pub fn set_tab_actions_open(&mut self, open: bool) -> bool {
let snapshot = self.snapshot.get_or_insert_with(OverlaySnapshot::default);
if snapshot.tab_actions_open == open {
return false;
}
snapshot.tab_actions_open = open;
true
}
pub fn set_terminal_context_menu_open(&mut self, open: bool) -> bool {
let snapshot = self.snapshot.get_or_insert_with(OverlaySnapshot::default);
if snapshot.terminal_context_menu_open == open {
return false;
}
snapshot.terminal_context_menu_open = open;
if open {
snapshot.action_link_menu_open = false;
}
true
}
pub fn set_action_link_menu_open(&mut self, open: bool) -> bool {
let snapshot = self.snapshot.get_or_insert_with(OverlaySnapshot::default);
if snapshot.action_link_menu_open == open {
return false;
}
snapshot.action_link_menu_open = open;
if open {
snapshot.terminal_context_menu_open = false;
}
true
}
pub fn set_locked(&mut self, locked: bool) -> bool {
let snapshot = self.snapshot.get_or_insert_with(OverlaySnapshot::default);
if snapshot.locked == locked {
return false;
}
snapshot.locked = locked;
true
}
}
@@ -1,133 +0,0 @@
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionSnapshot {
pub active_session_id: Option<String>,
pub ordered_session_ids: Vec<String>,
pub live_session_ids: Vec<String>,
pub metadata_count: usize,
pub terminal_view_count: usize,
pub pending_start_count: usize,
pub host_prompt_active: bool,
pub credential_prompt_active: bool,
pub zmodem_session_count: usize,
}
#[derive(Debug, Default)]
pub struct SessionStore {
live_sessions: HashSet<String>,
active_session_id: Option<String>,
ordered_session_ids: Vec<String>,
snapshot: Option<SessionSnapshot>,
}
impl SessionStore {
pub fn live_session_count(&self) -> usize {
self.live_sessions.len()
}
pub fn is_live(&self, session_id: &str) -> bool {
self.live_sessions.contains(session_id)
}
pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref()
}
pub fn ordered_session_ids(&self) -> &[String] {
&self.ordered_session_ids
}
pub fn mark_live(&mut self, session_id: impl Into<String>) {
self.live_sessions.insert(session_id.into());
}
pub fn mark_closed(&mut self, session_id: &str) {
self.live_sessions.remove(session_id);
}
pub fn snapshot(&self) -> Option<&SessionSnapshot> {
self.snapshot.as_ref()
}
pub fn replace_snapshot(&mut self, snapshot: SessionSnapshot) -> bool {
if self.snapshot.as_ref() == Some(&snapshot) {
return false;
}
self.active_session_id = snapshot.active_session_id.clone();
self.ordered_session_ids = snapshot.ordered_session_ids.clone();
self.live_sessions = snapshot.live_session_ids.iter().cloned().collect();
self.snapshot = Some(snapshot);
true
}
pub fn activate(&mut self, session_id: impl Into<String>) -> bool {
let session_id = session_id.into();
if self.active_session_id.as_deref() == Some(session_id.as_str()) {
return false;
}
self.active_session_id = Some(session_id.clone());
let snapshot = self.snapshot.get_or_insert_with(SessionSnapshot::default);
snapshot.active_session_id = Some(session_id);
true
}
pub fn set_ordered_session_ids(&mut self, ordered_session_ids: Vec<String>) -> bool {
if self.ordered_session_ids == ordered_session_ids {
return false;
}
self.ordered_session_ids = ordered_session_ids.clone();
let snapshot = self.snapshot.get_or_insert_with(SessionSnapshot::default);
snapshot.ordered_session_ids = ordered_session_ids;
true
}
pub fn move_session_to_index(&mut self, session_id: &str, index: usize) -> bool {
let Some(current_index) = self
.ordered_session_ids
.iter()
.position(|id| id == session_id)
else {
return false;
};
let session_id = self.ordered_session_ids.remove(current_index);
let index = index.min(self.ordered_session_ids.len());
self.ordered_session_ids.insert(index, session_id);
let snapshot = self.snapshot.get_or_insert_with(SessionSnapshot::default);
snapshot.ordered_session_ids = self.ordered_session_ids.clone();
true
}
pub fn remove_session(&mut self, session_id: &str) -> bool {
let before_len = self.ordered_session_ids.len();
self.ordered_session_ids.retain(|id| id != session_id);
self.live_sessions.remove(session_id);
if self.active_session_id.as_deref() == Some(session_id) {
self.active_session_id = None;
}
let changed = before_len != self.ordered_session_ids.len();
if changed {
let snapshot = self.snapshot.get_or_insert_with(SessionSnapshot::default);
snapshot.ordered_session_ids = self.ordered_session_ids.clone();
snapshot.live_session_ids = self.live_sessions.iter().cloned().collect();
snapshot.active_session_id = self.active_session_id.clone();
}
changed
}
}
impl Default for SessionSnapshot {
fn default() -> Self {
Self {
active_session_id: None,
ordered_session_ids: Vec::new(),
live_session_ids: Vec::new(),
metadata_count: 0,
terminal_view_count: 0,
pending_start_count: 0,
host_prompt_active: false,
credential_prompt_active: false,
zmodem_session_count: 0,
}
}
}
+1 -147
View File
@@ -1,29 +1,4 @@
use super::{
OverlaySnapshot, OverlayStore, SessionSnapshot, SessionStore, StartupRestoreStore,
WindowRuntimeStore, WorkspaceSnapshot, WorkspaceStore,
};
#[test]
fn workspace_store_tracks_active_session_and_tab_order() {
let mut store = WorkspaceStore::default();
store.activate_session("session-a");
store.set_ordered_tab_roots(vec!["session-a".into(), "session-b".into()]);
assert_eq!(store.active_session_id(), Some("session-a"));
assert_eq!(store.ordered_tab_roots(), ["session-a", "session-b"]);
}
#[test]
fn session_store_tracks_live_sessions() {
let mut store = SessionStore::default();
store.mark_live("session-a");
store.mark_live("session-b");
store.mark_closed("session-a");
assert!(!store.is_live("session-a"));
assert!(store.is_live("session-b"));
assert_eq!(store.live_session_count(), 1);
}
use super::{OverlayStore, StartupRestoreStore, WindowRuntimeStore};
#[test]
fn window_runtime_store_starts_pump_once() {
@@ -63,127 +38,6 @@ fn startup_restore_store_tracks_queue_and_completion() {
assert!(!store.can_pump_queue(false));
}
#[test]
fn workspace_store_notifies_only_for_changed_snapshots() {
let mut store = WorkspaceStore::default();
let snapshot = WorkspaceSnapshot {
active_session_id: Some("session-a".into()),
ordered_tab_roots: vec!["session-a".into()],
selected_nav: "Workspace".into(),
main_mode: "Workspace".into(),
active_left_panel: Some("Saved Connections".into()),
active_right_panel: None,
left_sidebar_collapsed: false,
right_inspector_collapsed: false,
workspace_split_active: false,
terminal_windows_active: false,
};
assert!(store.replace_snapshot(snapshot.clone()));
assert!(!store.replace_snapshot(snapshot));
assert_eq!(store.active_session_id(), Some("session-a"));
assert_eq!(store.ordered_tab_roots(), ["session-a"]);
}
#[test]
fn workspace_store_mutates_navigation_and_left_panel() {
let mut store = WorkspaceStore::default();
assert!(store.select_nav("Settings", "Page"));
assert!(!store.select_nav("Settings", "Page"));
assert!(store.open_left_panel("File Explorer"));
assert!(!store.open_left_panel("File Explorer"));
assert!(store.close_left_panel());
assert!(!store.close_left_panel());
let snapshot = store.snapshot().expect("workspace snapshot");
assert_eq!(snapshot.selected_nav, "Settings");
assert_eq!(snapshot.main_mode, "Page");
assert!(snapshot.left_sidebar_collapsed);
assert_eq!(snapshot.active_left_panel, None);
}
#[test]
fn session_store_notifies_only_for_changed_snapshots() {
let mut store = SessionStore::default();
let snapshot = SessionSnapshot {
active_session_id: Some("session-a".into()),
ordered_session_ids: vec!["session-a".into(), "session-b".into()],
live_session_ids: vec!["session-b".into()],
metadata_count: 1,
terminal_view_count: 1,
pending_start_count: 0,
host_prompt_active: false,
credential_prompt_active: false,
zmodem_session_count: 0,
};
assert!(store.replace_snapshot(snapshot.clone()));
assert!(!store.replace_snapshot(snapshot));
assert!(!store.is_live("session-a"));
assert!(store.is_live("session-b"));
}
#[test]
fn session_store_mutates_active_order_and_removal() {
let mut store = SessionStore::default();
assert!(store.set_ordered_session_ids(vec![
"session-a".into(),
"session-b".into(),
"session-c".into(),
]));
assert!(store.activate("session-b"));
assert!(!store.activate("session-b"));
assert!(store.move_session_to_index("session-c", 0));
assert_eq!(
store.ordered_session_ids(),
["session-c", "session-a", "session-b"]
);
assert!(store.remove_session("session-b"));
assert_eq!(store.active_session_id(), None);
assert_eq!(store.ordered_session_ids(), ["session-c", "session-a"]);
}
#[test]
fn overlay_store_notifies_only_for_changed_snapshots() {
let mut store = OverlayStore::default();
let snapshot = OverlaySnapshot {
tab_actions_open: false,
rename_open: false,
color_picker_open: false,
session_info_open: false,
startup_command_open: false,
temporary_ssh_link_open: false,
multi_line_paste_open: false,
terminal_actions_open: false,
terminal_context_menu_open: false,
action_link_menu_open: false,
action_link_tooltip_open: false,
command_suggestions_open: false,
credential_suggestions_open: false,
close_all_sessions_confirm_open: false,
locked: false,
};
assert!(store.replace_snapshot(snapshot.clone()));
assert!(!store.replace_snapshot(snapshot));
assert!(store.snapshot().is_some());
}
#[test]
fn overlay_store_mutates_open_close_and_menu_exclusion() {
let mut store = OverlayStore::default();
assert!(store.set_terminal_context_menu_open(true));
assert!(store.set_action_link_menu_open(true));
let snapshot = store.snapshot().expect("overlay snapshot");
assert!(snapshot.action_link_menu_open);
assert!(!snapshot.terminal_context_menu_open);
assert!(store.set_locked(true));
assert!(!store.set_locked(true));
}
#[test]
fn overlay_store_owns_quick_switch_state() {
let mut store = OverlayStore::default();
@@ -1,102 +0,0 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceSnapshot {
pub active_session_id: Option<String>,
pub ordered_tab_roots: Vec<String>,
pub selected_nav: String,
pub main_mode: String,
pub active_left_panel: Option<String>,
pub active_right_panel: Option<String>,
pub left_sidebar_collapsed: bool,
pub right_inspector_collapsed: bool,
pub workspace_split_active: bool,
pub terminal_windows_active: bool,
}
#[derive(Debug, Default)]
pub struct WorkspaceStore {
active_session_id: Option<String>,
ordered_tab_roots: Vec<String>,
snapshot: Option<WorkspaceSnapshot>,
}
impl WorkspaceStore {
pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref()
}
pub fn ordered_tab_roots(&self) -> &[String] {
&self.ordered_tab_roots
}
pub fn activate_session(&mut self, session_id: impl Into<String>) {
self.active_session_id = Some(session_id.into());
}
pub fn set_ordered_tab_roots(&mut self, roots: Vec<String>) {
self.ordered_tab_roots = roots;
}
pub fn snapshot(&self) -> Option<&WorkspaceSnapshot> {
self.snapshot.as_ref()
}
pub fn replace_snapshot(&mut self, snapshot: WorkspaceSnapshot) -> bool {
if self.snapshot.as_ref() == Some(&snapshot) {
return false;
}
self.active_session_id = snapshot.active_session_id.clone();
self.ordered_tab_roots = snapshot.ordered_tab_roots.clone();
self.snapshot = Some(snapshot);
true
}
pub fn select_nav(&mut self, nav: impl Into<String>, main_mode: impl Into<String>) -> bool {
let snapshot = self.snapshot.get_or_insert_with(WorkspaceSnapshot::default);
let nav = nav.into();
let main_mode = main_mode.into();
if snapshot.selected_nav == nav && snapshot.main_mode == main_mode {
return false;
}
snapshot.selected_nav = nav;
snapshot.main_mode = main_mode;
true
}
pub fn open_left_panel(&mut self, panel: impl Into<String>) -> bool {
let snapshot = self.snapshot.get_or_insert_with(WorkspaceSnapshot::default);
let panel = Some(panel.into());
if snapshot.active_left_panel == panel && !snapshot.left_sidebar_collapsed {
return false;
}
snapshot.active_left_panel = panel;
snapshot.left_sidebar_collapsed = false;
true
}
pub fn close_left_panel(&mut self) -> bool {
let snapshot = self.snapshot.get_or_insert_with(WorkspaceSnapshot::default);
if snapshot.active_left_panel.is_none() && snapshot.left_sidebar_collapsed {
return false;
}
snapshot.active_left_panel = None;
snapshot.left_sidebar_collapsed = true;
true
}
}
impl Default for WorkspaceSnapshot {
fn default() -> Self {
Self {
active_session_id: None,
ordered_tab_roots: Vec::new(),
selected_nav: "Workspace".to_string(),
main_mode: "Workspace".to_string(),
active_left_panel: None,
active_right_panel: None,
left_sidebar_collapsed: true,
right_inspector_collapsed: true,
workspace_split_active: false,
terminal_windows_active: false,
}
}
}
@@ -28,7 +28,6 @@ pub(in crate::features) struct TerminalRuntimeUiState {
pub session_event_last_output_event_count: usize,
pub session_event_last_drained_output_bytes: usize,
pub last_session_start_drain_duration: Duration,
pub last_store_snapshot_publish_at: Option<Instant>,
pub last_pending_session_status_at: Option<Instant>,
pub last_terminal_resize_at: Option<Instant>,
pub last_terminal_frame_apply_at: Option<Instant>,
@@ -171,7 +170,6 @@ impl Default for TerminalRuntimeUiState {
session_event_last_output_event_count: 0,
session_event_last_drained_output_bytes: 0,
last_session_start_drain_duration: Duration::ZERO,
last_store_snapshot_publish_at: None,
last_pending_session_status_at: None,
last_terminal_resize_at: None,
last_terminal_frame_apply_at: None,
+40 -22
View File
@@ -9,6 +9,29 @@ use gpui::{
const WALLPAPER_TILE_ELEMENT_LIMIT: usize = 8192;
const WALLPAPER_TILE_MIN_SIZE: f32 = 8.;
/// Which overlays the root chrome should render this frame.
///
/// Computed from `NyaTermApp` directly. This used to be read back from a
/// snapshot the same `Render` pass had just published into `OverlayStore`,
/// with a fallback that recomputed exactly these expressions.
struct OverlayFlags {
tab_actions_open: bool,
rename_open: bool,
color_picker_open: bool,
session_info_open: bool,
startup_command_open: bool,
temporary_ssh_link_open: bool,
multi_line_paste_open: bool,
terminal_actions_open: bool,
terminal_context_menu_open: bool,
action_link_menu_open: bool,
action_link_tooltip_open: bool,
command_suggestions_open: bool,
credential_suggestions_open: bool,
close_all_sessions_confirm_open: bool,
locked: bool,
}
impl NyaTermApp {
pub(crate) fn start_after_window_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.refresh_window_render_inputs(window, cx);
@@ -22,7 +45,6 @@ impl NyaTermApp {
}
self.ensure_terminal_focus_reporting(window, cx);
self.publish_store_snapshots(cx);
}
fn root_chrome(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Stateful<Div> {
@@ -404,27 +426,23 @@ impl NyaTermApp {
window: &mut Window,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let overlay = self
.stores
.overlays
.read_with(cx, |store, _| store.snapshot().cloned())
.unwrap_or_else(|| crate::entities::OverlaySnapshot {
tab_actions_open: self.tab_actions_session_id.is_some(),
rename_open: self.rename_session_id.is_some(),
color_picker_open: self.color_picker_open,
session_info_open: self.session_info_open,
startup_command_open: self.startup_command_open,
temporary_ssh_link_open: self.temporary_ssh_link_open,
multi_line_paste_open: self.multi_line_paste.is_some(),
terminal_actions_open: self.terminal.menus.actions_open,
terminal_context_menu_open: self.terminal.menus.context_menu.is_some(),
action_link_menu_open: self.action_link_menu.is_some(),
action_link_tooltip_open: self.action_link_tooltip.is_some(),
command_suggestions_open: self.command_suggestions.is_some(),
credential_suggestions_open: self.credential_suggestions.is_some(),
close_all_sessions_confirm_open: self.close_all_sessions_confirm_open,
locked: self.is_locked,
});
let overlay = OverlayFlags {
tab_actions_open: self.tab_actions_session_id.is_some(),
rename_open: self.rename_session_id.is_some(),
color_picker_open: self.color_picker_open,
session_info_open: self.session_info_open,
startup_command_open: self.startup_command_open,
temporary_ssh_link_open: self.temporary_ssh_link_open,
multi_line_paste_open: self.multi_line_paste.is_some(),
terminal_actions_open: self.terminal.menus.actions_open,
terminal_context_menu_open: self.terminal.menus.context_menu.is_some(),
action_link_menu_open: self.action_link_menu.is_some(),
action_link_tooltip_open: self.action_link_tooltip.is_some(),
command_suggestions_open: self.command_suggestions.is_some(),
credential_suggestions_open: self.credential_suggestions.is_some(),
close_all_sessions_confirm_open: self.close_all_sessions_confirm_open,
locked: self.is_locked,
};
let quick_switch_open = self.quick_switch_open(cx);
let transfer_properties_open = self
.transfer
@@ -33,7 +33,6 @@ pub(super) const TERMINAL_PERF_HEARTBEAT_INTERVAL: Duration = Duration::from_sec
pub(super) const RUNTIME_TICK_SLOW_THRESHOLD: Duration = Duration::from_millis(40);
pub(super) const SESSION_EVENT_DRAIN_SLOW_TOTAL: Duration = Duration::from_millis(20);
pub(super) const SESSION_EVENT_DRAIN_SLOW_CHUNK: Duration = Duration::from_millis(8);
pub(super) const STORE_SNAPSHOT_HEARTBEAT: Duration = Duration::from_secs(1);
pub(super) const PENDING_SESSION_STILL_CONNECTING_AFTER: Duration = Duration::from_secs(15);
pub(super) const PENDING_SESSION_STATUS_INTERVAL: Duration = Duration::from_secs(1);
@@ -127,22 +126,10 @@ pub(super) fn diagnostic_log_due(
})
}
pub(super) fn store_snapshot_publish_due(last_at: Option<Instant>, now: Instant) -> bool {
diagnostic_log_due(last_at, now, STORE_SNAPSHOT_HEARTBEAT)
}
pub(super) fn terminal_cell_metrics_refresh_needed(metrics: Option<(f32, f32)>) -> bool {
metrics.is_none()
}
pub(super) fn should_publish_store_snapshots(
visual_dirty: bool,
output_pressure: bool,
heartbeat_due: bool,
) -> bool {
!output_pressure && (visual_dirty || heartbeat_due)
}
pub(super) fn pending_session_status_message(
name: &str,
auth_wait: Option<&PendingSessionAuthWait>,
@@ -476,30 +463,6 @@ mod tests {
));
}
#[test]
fn store_snapshot_publish_due_uses_low_frequency_heartbeat() {
let start = Instant::now();
assert!(store_snapshot_publish_due(None, start));
assert!(!store_snapshot_publish_due(
Some(start),
start + STORE_SNAPSHOT_HEARTBEAT - Duration::from_millis(1)
));
assert!(store_snapshot_publish_due(
Some(start),
start + STORE_SNAPSHOT_HEARTBEAT
));
}
#[test]
fn store_snapshot_publish_waits_until_output_pressure_clears() {
assert!(should_publish_store_snapshots(true, false, false));
assert!(should_publish_store_snapshots(false, false, true));
assert!(!should_publish_store_snapshots(false, false, false));
assert!(!should_publish_store_snapshots(true, true, false));
assert!(!should_publish_store_snapshots(false, true, true));
}
#[test]
fn terminal_cell_metrics_refreshes_only_after_invalidation() {
assert!(terminal_cell_metrics_refresh_needed(None));
@@ -6,7 +6,6 @@ use crate::models::{TerminalSearchMode, terminal_frame_search_result_is_current}
mod bridge;
mod helpers;
mod planes;
mod publish;
mod session_events;
use crate::features::terminal::terminal_runtime::TERMINAL_INPUT_LATENCY_WINDOW;
@@ -235,20 +235,7 @@ impl NyaTermApp {
self.terminal.view.runtime.pending_ui_notify = false;
}
let notify_duration = notify_started_at.elapsed();
let publish_started_at = Instant::now();
// Planes above do not drain more output; reuse one final pressure sample.
let output_pressure = self.runtime_output_pressure_active();
let heartbeat_due = store_snapshot_publish_due(
self.terminal.view.runtime.last_store_snapshot_publish_at,
publish_started_at,
);
let should_publish_snapshots =
should_publish_store_snapshots(visual_dirty, output_pressure, heartbeat_due);
if should_publish_snapshots {
// Core workspace/session/overlay always; sideband stores only on heartbeat.
self.publish_store_snapshots_with_scope(cx, heartbeat_due || !visual_dirty);
}
let publish_duration = publish_started_at.elapsed();
let tick_duration = tick_started_at.elapsed();
if tick_duration >= RUNTIME_TICK_SLOW_THRESHOLD
&& self.should_log_slow_diagnostic("runtime_tick", Instant::now())
@@ -280,7 +267,6 @@ impl NyaTermApp {
visual_runtime_ms = visual.duration.as_millis(),
pending_session_status_ms = pending_session_status_duration.as_millis(),
notify_ms = notify_duration.as_millis(),
publish_snapshots_ms = publish_duration.as_millis(),
queued_events = self.terminal.view.runtime.session_event_queued_events,
queued_output_bytes = self.terminal.view.runtime.session_event_queued_output_bytes,
frame_command_count = self.terminal.view.frame_pipeline.queued_command_count(),
@@ -307,7 +293,6 @@ impl NyaTermApp {
.terminal_chrome_frame_notify_count,
surface_paint_count = terminal_surface_paint_count(),
notify_requested = visual_dirty,
publish_snapshots = should_publish_snapshots,
"slow runtime tick"
);
}
@@ -392,8 +377,6 @@ impl NyaTermApp {
terminal_frames_deferred_for_pacing = data.terminal_frame_apply_paced,
visual_runtime_ms = visual.duration.as_millis(),
notify_ms = notify_duration.as_millis(),
publish_snapshots_ms = publish_duration.as_millis(),
publish_snapshots = should_publish_snapshots,
queued_session_events = self.terminal.view.runtime.session_event_queued_events,
queued_session_output_bytes =
self.terminal.view.runtime.session_event_queued_output_bytes,
@@ -1,201 +0,0 @@
use super::*;
use crate::models::MainMode;
impl NyaTermApp {
pub(in crate::features) fn publish_store_snapshots(&mut self, cx: &mut Context<Self>) {
self.publish_store_snapshots_with_scope(cx, true);
}
pub(super) fn publish_store_snapshots_with_scope(
&mut self,
cx: &mut Context<Self>,
include_sideband: bool,
) {
// Source of truth: NyaTermApp / FeatureState. Entity stores receive
// one-way read-model snapshots here; they do not drive app mutations.
if !include_sideband && self.published_core_store_snapshots_are_current(cx) {
return;
}
self.terminal.view.runtime.last_store_snapshot_publish_at = Some(Instant::now());
let workspace = crate::entities::WorkspaceSnapshot {
active_session_id: self.active_session_id.clone(),
// Local tab-root order only; avoid SessionManager::list_sessions on the UI tick.
ordered_tab_roots: self
.session_order
.iter()
.filter(|session_id| !self.is_secondary_pane_session(session_id))
.cloned()
.collect(),
selected_nav: self.selected_nav.label().to_string(),
main_mode: match self.main_mode {
MainMode::Workspace => "Workspace",
MainMode::Page => "Page",
}
.to_string(),
active_left_panel: self.active_left_panel.map(|item| item.label().to_string()),
active_right_panel: self.active_right_panel.map(|item| item.label().to_string()),
left_sidebar_collapsed: self.left_sidebar_collapsed,
right_inspector_collapsed: self.right_inspector_collapsed,
workspace_split_active: self.workspace_split.is_some(),
terminal_windows_active: self.terminal.windows.tree.is_some(),
};
// Prefer local metadata over SessionManager::list_sessions so publish
// never takes the transport session map lock on the UI tick.
let live_session_ids = self
.session_metadata
.iter()
.filter(|(_, metadata)| !metadata.disconnected)
.map(|(session_id, _)| session_id.clone())
.collect();
let pending_start_count =
self.pending_session_starts.len() + self.pending_saved_connection_queue.len();
let sessions = crate::entities::SessionSnapshot {
active_session_id: self.active_session_id.clone(),
ordered_session_ids: self.session_order.clone(),
live_session_ids,
metadata_count: self.session_metadata.len(),
terminal_view_count: self.terminal.view.views.len(),
pending_start_count,
host_prompt_active: self.active_host_key_prompt.is_some(),
credential_prompt_active: self.active_credential_prompt.is_some()
|| self.active_keyboard_interactive_prompt.is_some(),
zmodem_session_count: self.zmodem_sessions.len(),
};
let overlays = crate::entities::OverlaySnapshot {
tab_actions_open: self.tab_actions_session_id.is_some(),
rename_open: self.rename_session_id.is_some(),
color_picker_open: self.color_picker_open,
session_info_open: self.session_info_open,
startup_command_open: self.startup_command_open,
temporary_ssh_link_open: self.temporary_ssh_link_open,
multi_line_paste_open: self.multi_line_paste.is_some(),
terminal_actions_open: self.terminal.menus.actions_open,
terminal_context_menu_open: self.terminal.menus.context_menu.is_some(),
action_link_menu_open: self.action_link_menu.is_some(),
action_link_tooltip_open: self.action_link_tooltip.is_some(),
command_suggestions_open: self.command_suggestions.is_some(),
credential_suggestions_open: self.credential_suggestions.is_some(),
close_all_sessions_confirm_open: self.close_all_sessions_confirm_open,
locked: self.is_locked,
};
self.stores.workspace.update(cx, |store, cx| {
if store.replace_snapshot(workspace) {
cx.notify();
}
});
self.stores.sessions.update(cx, |store, cx| {
if store.replace_snapshot(sessions) {
cx.notify();
}
});
self.stores.overlays.update(cx, |store, cx| {
if store.replace_snapshot(overlays) {
cx.notify();
}
});
}
fn published_core_store_snapshots_are_current(&self, cx: &mut Context<Self>) -> bool {
let workspace_store = self.stores.workspace.clone();
let sessions_store = self.stores.sessions.clone();
let overlays_store = self.stores.overlays.clone();
let workspace_current = workspace_store.read_with(cx, |store, _| {
store
.snapshot()
.is_some_and(|snapshot| self.workspace_snapshot_is_current(snapshot))
});
let sessions_current =
sessions_store.read_with(cx, |store, _| self.session_store_snapshot_is_current(store));
let overlays_current = overlays_store.read_with(cx, |store, _| {
store
.snapshot()
.is_some_and(|snapshot| self.overlay_snapshot_is_current(snapshot))
});
workspace_current && sessions_current && overlays_current
}
fn workspace_snapshot_is_current(&self, snapshot: &crate::entities::WorkspaceSnapshot) -> bool {
let ordered_tab_roots_current = snapshot.ordered_tab_roots.len()
== self
.session_order
.iter()
.filter(|session_id| !self.is_secondary_pane_session(session_id))
.count()
&& snapshot
.ordered_tab_roots
.iter()
.map(String::as_str)
.eq(self
.session_order
.iter()
.filter(|session_id| !self.is_secondary_pane_session(session_id))
.map(String::as_str));
snapshot.active_session_id == self.active_session_id
&& ordered_tab_roots_current
&& snapshot.selected_nav == self.selected_nav.label()
&& snapshot.main_mode
== match self.main_mode {
MainMode::Workspace => "Workspace",
MainMode::Page => "Page",
}
&& snapshot.active_left_panel.as_deref()
== self.active_left_panel.map(|item| item.label())
&& snapshot.active_right_panel.as_deref()
== self.active_right_panel.map(|item| item.label())
&& snapshot.left_sidebar_collapsed == self.left_sidebar_collapsed
&& snapshot.right_inspector_collapsed == self.right_inspector_collapsed
&& snapshot.workspace_split_active == self.workspace_split.is_some()
&& snapshot.terminal_windows_active == self.terminal.windows.tree.is_some()
}
fn session_store_snapshot_is_current(&self, store: &crate::entities::SessionStore) -> bool {
let Some(snapshot) = store.snapshot() else {
return false;
};
let live_session_count = self
.session_metadata
.values()
.filter(|metadata| !metadata.disconnected)
.count();
snapshot.active_session_id == self.active_session_id
&& store.ordered_session_ids() == self.session_order.as_slice()
&& store.live_session_count() == live_session_count
&& self
.session_metadata
.iter()
.filter(|(_, metadata)| !metadata.disconnected)
.all(|(session_id, _)| store.is_live(session_id))
&& snapshot.metadata_count == self.session_metadata.len()
&& snapshot.terminal_view_count == self.terminal.view.views.len()
&& snapshot.pending_start_count
== self.pending_session_starts.len() + self.pending_saved_connection_queue.len()
&& snapshot.host_prompt_active == self.active_host_key_prompt.is_some()
&& snapshot.credential_prompt_active
== (self.active_credential_prompt.is_some()
|| self.active_keyboard_interactive_prompt.is_some())
&& snapshot.zmodem_session_count == self.zmodem_sessions.len()
}
fn overlay_snapshot_is_current(&self, snapshot: &crate::entities::OverlaySnapshot) -> bool {
snapshot.tab_actions_open == self.tab_actions_session_id.is_some()
&& snapshot.rename_open == self.rename_session_id.is_some()
&& snapshot.color_picker_open == self.color_picker_open
&& snapshot.session_info_open == self.session_info_open
&& snapshot.startup_command_open == self.startup_command_open
&& snapshot.temporary_ssh_link_open == self.temporary_ssh_link_open
&& snapshot.multi_line_paste_open == self.multi_line_paste.is_some()
&& snapshot.terminal_actions_open == self.terminal.menus.actions_open
&& snapshot.terminal_context_menu_open == self.terminal.menus.context_menu.is_some()
&& snapshot.action_link_menu_open == self.action_link_menu.is_some()
&& snapshot.action_link_tooltip_open == self.action_link_tooltip.is_some()
&& snapshot.command_suggestions_open == self.command_suggestions.is_some()
&& snapshot.credential_suggestions_open == self.credential_suggestions.is_some()
&& snapshot.close_all_sessions_confirm_open == self.close_all_sessions_confirm_open
&& snapshot.locked == self.is_locked
}
}
+31 -31
View File
@@ -14,10 +14,10 @@ Last updated from the working tree on 2026-07-26.
| `#[path = "..."]` declarations in desktop | 0 | Cleared. Every directory is a real module; the boundary script fails on any new occurrence. |
| `use super::*` imports in desktop | 355 | Includes indented test-module imports; historical migration debt, do not add new occurrences. |
| `features/prelude.rs` rough exported-token count | 230 | Still a broad shared prelude; two hundred fifteen low-frequency transport/core/http/model exports are now explicit imports. |
| Entity Store structs | 7 | `Runtime`, `WindowRuntime`, `StartupRestore` own real state; `Overlay` owns quick switch and is read for rendering; `Workspace` and `Session` remain projections. |
| Snapshot structs | 3 | Workspace, session, overlay. |
| `replace_snapshot` methods | 3 | Down from nine; the six write-only domain projections are gone. |
| Store snapshot publish calls | 3 | Published from `features/shell/event_pump/publish.rs`. |
| Entity Store structs | 4 | `Runtime`, `WindowRuntime`, `StartupRestore`, `Overlay`. Each owns state the app does not. |
| Snapshot structs | 0 | Cleared. No store is a projection of `NyaTermApp` any more. |
| `replace_snapshot` methods | 0 | Cleared. |
| Store snapshot publish calls | 0 | `publish.rs` and the publish throttle are gone. |
Large files currently over 4,000 lines:
@@ -117,31 +117,31 @@ these as staged extraction candidates, not as formatting-only refactor targets.
the three phases the bar actually has: `composer` (payload and caret),
`options` (how it is interpreted and delivered, plus the menus that set
those), and `progress` (in-flight send, cancellation, counters).
- The projection-only Entity Stores are gone. Tracing consumers showed the six
domain stores (`Ai`, `CloudSync`, `Connections`, `RemoteOps`, `Settings`,
`Transfer`) were write-only: published on every qualifying tick and read by
nothing outside `entities/`, so they cost a snapshot build plus a `cx.notify`
and returned nothing. They and their snapshots are deleted, along with six
accessors and one `SettingsTab::label` that existed only to feed them.
What remains has a reason to exist: `RuntimeStore`, `WindowRuntimeStore` and
`StartupRestoreStore` own real state, and `OverlayStore` both owns quick
switch authoritatively and is read by `root.rs` for overlay rendering.
`WorkspaceStore` and `SessionStore` are still projections, and are the honest
remaining question — their snapshots are read only by
`published_core_store_snapshots_are_current`, which decides whether to
republish them. That loop is self-referential, but it also gates the overlay
publish, so untangling it is a separate change.
- The Entity Store projection layer is gone entirely, in two steps.
Two findings for whoever takes that on. First, `OverlaySnapshot` is a
same-render round-trip: `Render` publishes it in its prologue, then
`overlay_host` reads it back and, when it is absent, falls back to an
expression that recomputes every field from the same `self` fields. The
fallback is proof the renderer never needed the store. It is not a staleness
bug today only because publish runs before `overlay_host` in the same pass.
Second, `AppShell` deliberately does not observe the stores — there is a
comment explaining that store-observe was amplifying each publish into an
extra shell paint — so the `cx.notify()` in each publish currently has no
subscriber at all.
First, the six domain stores (`Ai`, `CloudSync`, `Connections`, `RemoteOps`,
`Settings`, `Transfer`) turned out to be write-only: outside `entities/` they
appeared only in `app_shell::new`, and nothing read their snapshots. Every
qualifying tick built six snapshot structs, compared them, and called
`cx.notify()` for a reader that did not exist.
Then `Workspace`, `Session` and `OverlaySnapshot` went the same way once the
loop was traced end to end. `Workspace` and `Session` were read only by
`published_core_store_snapshots_are_current`, which decided whether to
republish them — a closed loop. `OverlaySnapshot` was a same-render
round-trip: `Render` published it in its prologue and `overlay_host` read it
back a few calls later, with a fallback that recomputed every field from the
same `self` fields. `overlay_host` now computes those flags directly into a
local `OverlayFlags`, which is what the fallback already did.
`AppShell` never observed the stores — a comment there records that
store-observe was amplifying each publish into an extra shell paint — so the
`cx.notify()` in every publish had no subscriber either.
What remains owns something the app does not: `RuntimeStore` (app runtime and
native services), `WindowRuntimeStore` (the window runtime pump),
`StartupRestoreStore` (the restore queue) and `OverlayStore` (quick switch
state, authoritative since the earlier migration).
- The connections UI state has started moving out of scattered `NyaTermApp`
fields and into `ConnectionFeatureState`.
- The current connections state split separates list UI, import UI, editor
@@ -858,9 +858,9 @@ real module tree first, then the remaining steps actually enforce something.
and semantic methods, covered by GPUI-free pure state tests. This is the
highest-value remaining item; roughly 590 fields and 236 `impl NyaTermApp`
blocks are the reason most desktop modules can reach most desktop state.
4. Done for the write-only stores. What is left is `WorkspaceStore` and
`SessionStore`: decide whether the publish/skip loop earns its keep, or
collapse it so the skip check reads app state directly.
4. Done. No store is a projection any more; the four that remain own real
state. If a future domain wants Entity ownership, migrate it authoritatively
rather than reintroducing a published read model.
5. Continue extracting schema-neutral internal modules from `core/storage.rs`
and schema/protocol-neutral modules from `nyaterm-transport/src/lib.rs`, by
domain rather than by individual type. Table definitions, serialized records,