use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tear_types::{
DefinitionId, LayoutPlan, PaneSlot, PlanError, SpawnSpec, TearSession, WindowPlan,
};
use crate::index::Searchable;
use crate::record::{display_name_for, identity_for, NameStyle, ThemeMirror};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionOrigin {
Project,
Adhoc {
theme: ThemeMirror,
seed: u64,
},
Authored,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DefinitionError {
NoWindows,
Layout(PlanError),
MissingSpec(PaneSlot),
ActiveSlotNotInWindow(PaneSlot),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SessionDefinition {
pub def_id: DefinitionId,
pub origin: SessionOrigin,
pub name_seed: u64,
pub name_style: NameStyle,
#[serde(default)]
pub theme: Option<ThemeMirror>,
#[serde(default)]
pub custom_name: Option<String>,
pub project_root: PathBuf,
pub windows: Vec<WindowPlan>,
pub pane_specs: BTreeMap<PaneSlot, SpawnSpec>,
#[serde(default)]
pub visits: u32,
#[serde(default)]
pub last_seen: u64,
#[serde(default)]
pub tags: Vec<String>,
}
impl SessionDefinition {
#[must_use]
pub fn single_pane(
project_root: impl Into<PathBuf>,
shell: impl Into<String>,
name_style: NameStyle,
last_seen: u64,
) -> Self {
let project_root = project_root.into();
let def_id = DefinitionId::from_project(&project_root);
let slot = PaneSlot(0);
let mut pane_specs = BTreeMap::new();
pane_specs.insert(slot, SpawnSpec::shell(slot, shell));
Self {
def_id,
origin: SessionOrigin::Project,
name_seed: def_id.0,
name_style,
theme: None,
custom_name: None,
project_root,
windows: vec![WindowPlan::single("main", slot)],
pane_specs,
visits: 1,
last_seen,
tags: Vec::new(),
}
}
#[must_use]
pub fn from_live(
session: &TearSession,
project_root: impl Into<PathBuf>,
last_seen: u64,
) -> Self {
let project_root = project_root.into();
let def_id = DefinitionId::from_project(&project_root);
let mut next = 0u32;
let mut pane_specs = BTreeMap::new();
let mut windows = Vec::new();
for win in session.windows.values() {
let mut slot_map = BTreeMap::new();
let plan = LayoutPlan::from_node_into(&win.layout, &mut next, &mut slot_map);
let mut active_slot = None;
for (slot, pane_id) in &slot_map {
let spec = match session.panes.get(pane_id) {
Some(p) => SpawnSpec {
slot: *slot,
shell: p.shell.clone(),
args: p.args.clone(),
cwd: p.cwd.clone(),
env: p.env.clone(),
title: p.title.clone(),
input_policy: p.input_policy,
},
None => SpawnSpec::shell(*slot, String::new()),
};
pane_specs.insert(*slot, spec);
if *pane_id == win.active_pane {
active_slot = Some(*slot);
}
}
let active_slot = active_slot
.or_else(|| slot_map.keys().next().copied())
.unwrap_or(PaneSlot(0));
windows.push(WindowPlan {
name: win.name.clone(),
layout: plan,
active_slot,
});
}
Self {
def_id,
origin: SessionOrigin::Authored,
name_seed: def_id.0,
name_style: NameStyle::Emoji,
theme: None,
custom_name: None,
project_root,
windows,
pane_specs,
visits: 1,
last_seen,
tags: Vec::new(),
}
}
#[must_use]
pub fn display_name(&self) -> String {
display_name_for(
self.name_seed,
self.name_style,
self.theme,
self.custom_name.as_deref(),
)
}
#[must_use]
pub fn identity(&self) -> ishou_tokens::SessionIdentity {
identity_for(self.name_seed, self.theme)
}
#[must_use]
pub fn name_word(&self) -> &'static str {
self.identity().word
}
#[must_use]
pub fn keywords(&self) -> &'static [&'static str] {
self.identity().keywords
}
pub fn validate(&self) -> Result<(), DefinitionError> {
if self.windows.is_empty() {
return Err(DefinitionError::NoWindows);
}
for w in &self.windows {
w.layout.validate().map_err(DefinitionError::Layout)?;
let slots = w.layout.slots();
for slot in &slots {
if !self.pane_specs.contains_key(slot) {
return Err(DefinitionError::MissingSpec(*slot));
}
}
if !slots.contains(&w.active_slot) {
return Err(DefinitionError::ActiveSlotNotInWindow(w.active_slot));
}
}
Ok(())
}
#[must_use]
pub fn slot_count(&self) -> usize {
self.windows.iter().map(|w| w.layout.slot_count()).sum()
}
}
impl Searchable for SessionDefinition {
fn custom_name(&self) -> Option<&str> {
self.custom_name.as_deref()
}
fn name_word(&self) -> &'static str {
SessionDefinition::name_word(self)
}
fn keywords(&self) -> &'static [&'static str] {
SessionDefinition::keywords(self)
}
fn tags(&self) -> &[String] {
&self.tags
}
fn path_str(&self) -> std::borrow::Cow<'_, str> {
self.project_root.to_string_lossy()
}
fn visits(&self) -> u32 {
self.visits
}
fn last_seen(&self) -> u64 {
self.last_seen
}
fn rank_key(&self) -> u64 {
self.def_id.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use tear_types::SplitOrientation;
#[test]
fn single_pane_definition_validates() {
let d = SessionDefinition::single_pane("/code/pleme-io/mado", "/bin/zsh", NameStyle::Emoji, 0);
d.validate().unwrap();
assert_eq!(d.slot_count(), 1);
assert_eq!(d.def_id, DefinitionId::from_project(std::path::Path::new("/code/pleme-io/mado")));
assert_eq!(d.name_seed, d.def_id.0);
}
#[test]
fn validate_rejects_slot_without_spec() {
let mut d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 0);
d.windows.push(WindowPlan::single("orphan", PaneSlot(9)));
assert_eq!(d.validate(), Err(DefinitionError::MissingSpec(PaneSlot(9))));
}
#[test]
fn validate_rejects_active_slot_not_in_window() {
let mut d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 0);
d.pane_specs.insert(PaneSlot(1), SpawnSpec::shell(PaneSlot(1), "/bin/sh"));
d.windows[0].active_slot = PaneSlot(1);
assert_eq!(d.validate(), Err(DefinitionError::ActiveSlotNotInWindow(PaneSlot(1))));
}
#[test]
fn multi_pane_definition_validates() {
let mut pane_specs = BTreeMap::new();
pane_specs.insert(PaneSlot(0), SpawnSpec::shell(PaneSlot(0), "/bin/zsh"));
pane_specs.insert(PaneSlot(1), SpawnSpec::shell(PaneSlot(1), "/bin/sh"));
let d = SessionDefinition {
def_id: DefinitionId::from_project(std::path::Path::new("/x")),
origin: SessionOrigin::Project,
name_seed: 0,
name_style: NameStyle::Emoji,
theme: None,
custom_name: None,
project_root: "/x".into(),
windows: vec![WindowPlan {
name: "work".into(),
layout: LayoutPlan::split(
SplitOrientation::Vertical,
LayoutPlan::leaf(PaneSlot(0)),
LayoutPlan::leaf(PaneSlot(1)),
),
active_slot: PaneSlot(0),
}],
pane_specs,
visits: 1,
last_seen: 0,
tags: Vec::new(),
};
d.validate().unwrap();
assert_eq!(d.slot_count(), 2);
}
#[test]
fn origin_adhoc_is_a_typed_arm() {
let o = SessionOrigin::Adhoc { theme: ThemeMirror::Brazil, seed: 42 };
match o {
SessionOrigin::Project | SessionOrigin::Authored => panic!("wrong arm"),
SessionOrigin::Adhoc { theme, seed } => {
assert_eq!(theme, ThemeMirror::Brazil);
assert_eq!(seed, 42);
}
}
}
#[test]
fn definition_serde_round_trips() {
let d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 7);
let json = serde_json::to_string(&d).unwrap();
let back: SessionDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(d, back);
}
#[test]
fn from_live_captures_a_running_multi_pane_session() {
use tear_core::inproc::InProcess;
use tear_types::{Direction, MultiplexerControl};
let inproc = InProcess::new();
let sid = inproc.new_session("cap", "/bin/sh").unwrap();
let s0 = inproc.get_session(sid).unwrap();
let wid = s0.active_window;
let p0 = s0.windows[&wid].active_pane;
inproc.split_pane(p0, Direction::Right, "/bin/sh").unwrap();
let session = inproc.get_session(sid).unwrap();
let def = SessionDefinition::from_live(&session, "/code/captured", 0);
def.validate().unwrap();
assert_eq!(def.slot_count(), 2);
assert_eq!(def.pane_specs.len(), 2);
assert_eq!(def.windows.len(), 1);
assert_eq!(def.origin, SessionOrigin::Authored);
assert_eq!(
def.def_id,
DefinitionId::from_project(std::path::Path::new("/code/captured"))
);
assert!(def.pane_specs.values().all(|s| s.shell == "/bin/sh"));
}
}