Skip to main content

onlyne_client/backend/tern/
session.rs

1//! The session layer: the backend's own type and its [`SessionBackend`]
2//! implementation.
3
4use super::cli::default_command;
5use super::policy::{TernRef, session_label, split_word};
6use crate::backend::*;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10pub struct TernBackend {
11    pub(super) runner: Arc<dyn Runner>,
12    pub(super) command: String,
13    pub(super) env: BTreeMap<String, String>,
14}
15
16impl TernBackend {
17    pub fn new(runner: Arc<dyn Runner>) -> Self {
18        Self::with_env(runner, process_env())
19    }
20
21    pub fn with_env(runner: Arc<dyn Runner>, env: BTreeMap<String, String>) -> Self {
22        let command = env
23            .get("TERN_COMMAND")
24            .cloned()
25            .filter(|value| !value.is_empty())
26            .unwrap_or_else(default_command);
27        Self {
28            runner,
29            command,
30            env,
31        }
32    }
33}
34
35impl SessionBackend for TernBackend {
36    fn name(&self) -> &'static str {
37        "tern"
38    }
39
40    fn capabilities(&self) -> Capabilities {
41        Capabilities {
42            spawn: true,
43            attach: true,
44            probe: true,
45            close: true,
46            focus: true,
47            // `tern rename BLOCK NAME` renames the block's *tab*, measured: a
48            // block id renames the tab that holds it, and a tab id is refused
49            // with `no block is called`. Renaming one session's pane would
50            // therefore rename every pane in that role's tab, so this backend
51            // answers unsupported rather than do it.
52            rename: false,
53        }
54    }
55
56    fn available(&self) -> Result<bool> {
57        // The only honest test: run the read every other call here begins with.
58        // A window key in the environment proves the client is inside a pane,
59        // not that the binary answers — and a `PATH` `tern` would fail this
60        // where a spawned pane's `ls` would too.
61        Ok(self.json(vec!["ls".into(), "--json".into()]).is_ok())
62    }
63
64    fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
65        if spec.command.is_empty() {
66            anyhow::bail!("tern spawn requires a command");
67        }
68        let label = session_label(&spec);
69        let session_id = self.find_or_create_session(&spec)?;
70        let tab = self.find_or_create_tab(&session_id, &spec)?;
71        // Tern takes no ratio, so the placement contributes its direction and
72        // nothing else. The pane count still decides the default direction, as
73        // it did for herdr: a split bringing the count to a power of two goes
74        // right, every other one down.
75        let placement = spec
76            .placement
77            .unwrap_or_else(|| PanePlacement::from_pane_count(tab.pane_count));
78        tracing::info!(
79            pane_count = tab.pane_count,
80            direction = split_word(placement.direction),
81            "tern block split (tern takes no split ratio)"
82        );
83        let pane_id =
84            self.split_and_start(&tab.base_pane, &spec, placement, &session_id, &tab.tab_id)?;
85        Ok(SessionRef {
86            task_id: spec.task_id.clone(),
87            backend: self.name().into(),
88            backend_ref: TernRef {
89                session_id,
90                tab_id: tab.tab_id,
91                pane_id,
92                session_label: label,
93                base_pane: tab.base_pane,
94                split_direction: split_word(placement.direction).to_string(),
95            }
96            .to_value(),
97            generation: 1,
98        })
99    }
100
101    fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
102        let probe = self.probe(session)?;
103        if !probe.alive {
104            anyhow::bail!("tern block is gone");
105        }
106        Ok(session.clone())
107    }
108
109    fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
110        let reference = TernRef::from_session(session)?;
111        // A listing that cannot be read is a probe that could not answer, not
112        // a verdict: the block's own state is unknown, so it reports the
113        // failure rather than claiming the pane died.
114        let listing = self.listing()?;
115        let Some((block, tab, host)) = listing.block(&reference.pane_id) else {
116            // The block is in no tab of any session: gone, or detached by the
117            // daemon. Both read the same to this backend, which can address
118            // neither.
119            return Ok(ResourceProbe {
120                alive: false,
121                attached: false,
122                detail: Some(serde_json::json!({
123                    "pane_id": reference.pane_id,
124                    "error": "block is in no tab",
125                })),
126            });
127        };
128        // Liveness from the block's own row: `exited` is null while the
129        // program runs and holds an exit code once it returns, and `live` says
130        // whether the daemon still holds the pty. A `--keep-open` block whose
131        // command has ended stays listed with `exited` set — the pane is
132        // addressable, and the session that owned it is over. So `exited`
133        // ends a session and `live` is the fallback for a build that reports
134        // one without the other.
135        let alive = block.exited.is_none() && block.live;
136        let mut detail = serde_json::json!({
137            "pane": {
138                "id": block.id,
139                "title": block.title,
140                "cwd": block.cwd,
141                "program": block.program,
142                "command": block.command,
143                "exited": block.exited,
144                "keep_open": block.keep_open,
145                "focused": block.focused,
146                "live": block.live,
147            },
148            "tab_id": tab.id,
149            "session_id": host.id,
150            "session_label": host.name,
151        });
152        if let Some(process) = self.process_of(&reference.pane_id) {
153            if let Some(map) = detail.as_object_mut() {
154                map.insert("process".into(), process);
155            }
156        }
157        Ok(ResourceProbe {
158            alive,
159            // A block still listed is one the daemon holds, so a live one is
160            // attached to a pty. A block whose program exited under
161            // `--keep-open` is addressable but holds nothing: attached follows
162            // `live`, which is what separates the two.
163            attached: block.live,
164            detail: Some(detail),
165        })
166    }
167
168    fn close(&self, session: &SessionRef, reason: CloseReason, force: bool) -> Result<()> {
169        // `tern close` has no force flag and no reason; both close paths are
170        // the same command.
171        tracing::debug!(task = %session.task_id, ?reason, force, "closing tern block");
172        let reference = TernRef::from_session(session)?;
173        match self.json(vec![
174            "close".into(),
175            reference.pane_id.clone(),
176            "--json".into(),
177        ]) {
178            Ok(_) => Ok(()),
179            // Measured: a closed block is refused with `no block is called
180            // \`N\`` and exit 1, with no JSON body to carry a code. Closing
181            // again is a no-op, which is what lets the client end a session
182            // twice without the second end failing.
183            Err(error) if self.is_gone(&error) => {
184                tracing::debug!(
185                    task = %session.task_id,
186                    pane = %reference.pane_id,
187                    "tern block already closed"
188                );
189                Ok(())
190            }
191            Err(error) => Err(error),
192        }
193    }
194
195    fn rename(&self, _session: &SessionRef, _title: &str) -> Result<()> {
196        // `tern rename BLOCK NAME` renames the block's tab, so one session's
197        // title would land on every pane in the role's tab. Tern offers no
198        // block title of its own — the listing's `title` is the pane's
199        // working directory or its program — so this is unsupported, and the
200        // capability says so.
201        Err(unsupported(
202            self.name(),
203            "rename",
204            "tern renames a block's tab, so renaming one session's block would rename the \
205             whole role tab",
206        ))
207    }
208
209    fn focus(&self, session: &SessionRef) -> Result<()> {
210        let reference = TernRef::from_session(session)?;
211        let listing = self.listing()?;
212        if let Some(site) = self.focus_here(&listing) {
213            if site.pane_id == reference.pane_id
214                && site.tab_id == reference.tab_id
215                && site.session_id == reference.session_id
216            {
217                return Ok(());
218            }
219        }
220        self.focus_block(&reference.pane_id)?;
221        // Confirm the block took focus, naming the one that holds it. The
222        // listing can drift when an operator closes or moves blocks, and a
223        // focus that lands elsewhere sends the operator's keyboard to another
224        // session — a wrong block holding focus is reported, not passed off as
225        // success.
226        let after = self.listing()?;
227        match self.focus_here(&after) {
228            Some(site) if site.pane_id == reference.pane_id => Ok(()),
229            Some(site) => Err(anyhow::anyhow!(
230                "tern left block {} unfocused (focused block {})",
231                reference.pane_id,
232                site.pane_id
233            )),
234            None => Err(anyhow::anyhow!(
235                "tern left block {} unfocused (no block holds focus)",
236                reference.pane_id
237            )),
238        }
239    }
240}