wisp-core 0.1.0

core domain model and projections for Wisp
Documentation
use std::{collections::BTreeMap, path::PathBuf};

pub type SessionId = String;
pub type WindowId = String;
pub type PaneId = String;
pub type ClientId = String;

#[derive(Debug, Clone, Default, PartialEq)]
pub struct DomainState {
    pub sessions: BTreeMap<SessionId, SessionRecord>,
    pub clients: BTreeMap<ClientId, ClientFocus>,
    pub previous_session_by_client: BTreeMap<ClientId, SessionId>,
    pub directories: Vec<DirectoryRecord>,
    pub config: DomainConfig,
}

impl DomainState {
    pub fn recompute_aggregates(&mut self) {
        let session_ids = self.sessions.keys().cloned().collect::<Vec<_>>();
        for session_id in session_ids {
            self.recompute_session_aggregate(&session_id);
        }
    }

    #[must_use]
    pub fn current_session_id(&self, client_id: Option<&str>) -> Option<&SessionId> {
        match client_id {
            Some(client_id) => self.clients.get(client_id).map(|focus| &focus.session_id),
            None => self.clients.values().next().map(|focus| &focus.session_id),
        }
    }

    #[must_use]
    pub fn previous_session_id(&self, client_id: Option<&str>) -> Option<&SessionId> {
        match client_id {
            Some(client_id) => self.previous_session_by_client.get(client_id),
            None => self.previous_session_by_client.values().next(),
        }
    }

    #[must_use]
    pub fn focused_session_for_window(&self, window_id: &str) -> Option<SessionId> {
        self.clients.values().find_map(|focus| {
            if focus.window_id == window_id {
                Some(focus.session_id.clone())
            } else {
                None
            }
        })
    }

    #[must_use]
    pub fn session_id_for_window(&self, window_id: &str) -> Option<SessionId> {
        self.sessions.iter().find_map(|(session_id, session)| {
            if session.windows.contains_key(window_id) {
                Some(session_id.clone())
            } else {
                None
            }
        })
    }

    #[must_use]
    pub fn session_window_for_pane(&self, pane_id: &str) -> Option<(SessionId, WindowId)> {
        self.sessions.iter().find_map(|(session_id, session)| {
            session.windows.iter().find_map(|(window_id, window)| {
                if window.panes.contains_key(pane_id) {
                    Some((session_id.clone(), window_id.clone()))
                } else {
                    None
                }
            })
        })
    }

    pub fn clear_unseen_for_window(&mut self, session_id: &str, window_id: &str) {
        if let Some(window) = self
            .sessions
            .get_mut(session_id)
            .and_then(|session| session.windows.get_mut(window_id))
        {
            window.has_unseen = false;
            window.alerts.unseen_output = false;
        }
        self.recompute_session_aggregate(session_id);
    }

    pub fn recompute_session_aggregate(&mut self, session_id: &str) {
        let Some(session) = self.sessions.get_mut(session_id) else {
            return;
        };

        session.has_unseen = session.windows.values().any(|window| window.has_unseen);
        session.aggregate_alerts = aggregate_alerts(session.windows.values());
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DomainConfig {
    pub notifications: NotificationConfig,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotificationConfig {
    pub track_unseen_output: bool,
    pub clear_on_focus: bool,
    pub show_silence: bool,
}

impl Default for NotificationConfig {
    fn default() -> Self {
        Self {
            track_unseen_output: true,
            clear_on_focus: true,
            show_silence: true,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct DomainSnapshot {
    pub sessions: BTreeMap<SessionId, SessionRecord>,
    pub clients: BTreeMap<ClientId, ClientFocus>,
    pub directories: Vec<DirectoryRecord>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionRecord {
    pub id: SessionId,
    pub tmux_id: Option<String>,
    pub name: String,
    pub attached: bool,
    pub windows: BTreeMap<WindowId, WindowRecord>,
    pub aggregate_alerts: AlertAggregate,
    pub has_unseen: bool,
    pub sort_key: SessionSortKey,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionSortKey {
    pub last_activity: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowRecord {
    pub id: WindowId,
    pub index: i32,
    pub name: String,
    pub active: bool,
    pub panes: BTreeMap<PaneId, PaneRecord>,
    pub alerts: AlertState,
    pub has_unseen: bool,
    pub current_path: Option<PathBuf>,
    pub active_command: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneRecord {
    pub id: PaneId,
    pub index: i32,
    pub title: Option<String>,
    pub current_path: Option<PathBuf>,
    pub current_command: Option<String>,
    pub is_active: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientFocus {
    pub session_id: SessionId,
    pub window_id: WindowId,
    pub pane_id: Option<PaneId>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct DirectoryRecord {
    pub path: PathBuf,
    pub score: Option<f64>,
    pub exists: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AlertState {
    pub activity: bool,
    pub bell: bool,
    pub silence: bool,
    pub unseen_output: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlertAggregate {
    pub any_activity: bool,
    pub any_bell: bool,
    pub any_silence: bool,
    pub any_unseen: bool,
    pub attention_count: usize,
    pub highest_priority: AttentionBadge,
}

impl Default for AlertAggregate {
    fn default() -> Self {
        Self {
            any_activity: false,
            any_bell: false,
            any_silence: false,
            any_unseen: false,
            attention_count: 0,
            highest_priority: AttentionBadge::None,
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum AttentionBadge {
    #[default]
    None,
    Silence,
    Unseen,
    Activity,
    Bell,
}

impl AttentionBadge {
    #[must_use]
    pub fn from_alerts(alerts: AlertState) -> Self {
        if alerts.bell {
            Self::Bell
        } else if alerts.activity {
            Self::Activity
        } else if alerts.unseen_output {
            Self::Unseen
        } else if alerts.silence {
            Self::Silence
        } else {
            Self::None
        }
    }
}

#[must_use]
pub fn aggregate_alerts<'a>(windows: impl IntoIterator<Item = &'a WindowRecord>) -> AlertAggregate {
    let mut aggregate = AlertAggregate::default();

    for window in windows {
        aggregate.any_activity |= window.alerts.activity;
        aggregate.any_bell |= window.alerts.bell;
        aggregate.any_silence |= window.alerts.silence;
        aggregate.any_unseen |= window.alerts.unseen_output;

        let badge = AttentionBadge::from_alerts(window.alerts);
        if badge != AttentionBadge::None {
            aggregate.attention_count += 1;
            if badge > aggregate.highest_priority {
                aggregate.highest_priority = badge;
            }
        }
    }

    aggregate
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use crate::{
        AlertState, AttentionBadge, DomainState, PaneRecord, SessionRecord, SessionSortKey,
        WindowRecord, aggregate_alerts,
    };

    #[test]
    fn aggregates_alerts_using_consistent_priority() {
        let windows = BTreeMap::from([
            (
                "w1".to_string(),
                WindowRecord {
                    id: "w1".to_string(),
                    index: 1,
                    name: "dev".to_string(),
                    active: true,
                    panes: BTreeMap::new(),
                    alerts: AlertState {
                        activity: true,
                        ..AlertState::default()
                    },
                    has_unseen: false,
                    current_path: None,
                    active_command: None,
                },
            ),
            (
                "w2".to_string(),
                WindowRecord {
                    id: "w2".to_string(),
                    index: 2,
                    name: "ops".to_string(),
                    active: false,
                    panes: BTreeMap::from([(
                        "p1".to_string(),
                        PaneRecord {
                            id: "p1".to_string(),
                            index: 1,
                            title: None,
                            current_path: None,
                            current_command: None,
                            is_active: false,
                        },
                    )]),
                    alerts: AlertState {
                        bell: true,
                        unseen_output: true,
                        ..AlertState::default()
                    },
                    has_unseen: true,
                    current_path: None,
                    active_command: None,
                },
            ),
        ]);

        let aggregate = aggregate_alerts(windows.values());

        assert_eq!(aggregate.attention_count, 2);
        assert_eq!(aggregate.highest_priority, AttentionBadge::Bell);
        assert!(aggregate.any_unseen);
    }

    #[test]
    fn recomputes_session_aggregates_from_window_state() {
        let mut state = DomainState {
            sessions: BTreeMap::from([(
                "alpha".to_string(),
                SessionRecord {
                    id: "alpha".to_string(),
                    tmux_id: None,
                    name: "alpha".to_string(),
                    attached: true,
                    windows: BTreeMap::from([(
                        "alpha:1".to_string(),
                        WindowRecord {
                            id: "alpha:1".to_string(),
                            index: 1,
                            name: "shell".to_string(),
                            active: true,
                            panes: BTreeMap::new(),
                            alerts: AlertState {
                                unseen_output: true,
                                ..AlertState::default()
                            },
                            has_unseen: true,
                            current_path: None,
                            active_command: None,
                        },
                    )]),
                    aggregate_alerts: Default::default(),
                    has_unseen: false,
                    sort_key: SessionSortKey::default(),
                },
            )]),
            ..DomainState::default()
        };

        state.recompute_aggregates();

        assert!(state.sessions["alpha"].has_unseen);
        assert_eq!(
            state.sessions["alpha"].aggregate_alerts.highest_priority,
            AttentionBadge::Unseen
        );
    }
}