1use super::*;
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14const SESSION_PREFIX: &str = "onlyne-";
17
18const SESSION_ID_CHARS: usize = 12;
30
31const CONTRACT_DIR: &str = "contract_version_1";
34
35#[cfg(target_os = "macos")]
40const SOCK_PATH_LIMIT: usize = 104;
41#[cfg(not(target_os = "macos"))]
42const SOCK_PATH_LIMIT: usize = 108;
43
44fn short_session_name(task_id: &str) -> String {
52 let id: String = task_id
53 .chars()
54 .filter(|c| *c != '-')
55 .take(SESSION_ID_CHARS)
56 .collect();
57 format!("{SESSION_PREFIX}{id}")
58}
59
60fn session_name(task_id: &str) -> Result<String> {
63 let name = short_session_name(task_id);
64 check_socket_budget(&socket_dir(), &name)?;
65 Ok(name)
66}
67
68fn check_socket_budget(dir: &Path, name: &str) -> Result<()> {
77 let socket = dir.join(name);
78 let path_len = socket.as_os_str().len();
79 if path_len >= SOCK_PATH_LIMIT {
80 anyhow::bail!(
81 "zellij session {name} needs a {path_len}-byte socket path ({}), over the \
82 {SOCK_PATH_LIMIT}-byte unix socket limit; set ZELLIJ_SOCKET_DIR to a shorter directory",
83 socket.display()
84 );
85 }
86 Ok(())
87}
88
89fn socket_dir() -> PathBuf {
95 let base = std::env::var("ZELLIJ_SOCKET_DIR").map_or_else(
96 |_| {
97 runtime_dir()
98 .unwrap_or_else(|| std::env::temp_dir().join(format!("zellij-{}", temp_dir_uid())))
99 },
100 PathBuf::from,
101 );
102 base.join(CONTRACT_DIR)
103}
104
105#[cfg(target_os = "linux")]
109fn runtime_dir() -> Option<PathBuf> {
110 std::env::var_os("XDG_RUNTIME_DIR")
111 .filter(|dir| !dir.is_empty())
112 .map(|dir| PathBuf::from(dir).join("zellij"))
113}
114
115#[cfg(not(target_os = "linux"))]
116fn runtime_dir() -> Option<PathBuf> {
117 None
118}
119
120#[cfg(unix)]
128fn temp_dir_uid() -> u32 {
129 use std::os::unix::fs::MetadataExt;
130
131 std::fs::metadata(std::env::temp_dir()).map_or(0, |meta| meta.uid())
132}
133
134#[cfg(not(unix))]
135fn temp_dir_uid() -> u32 {
136 0
137}
138
139enum SessionListing {
140 Missing,
141 Exited,
142 Live,
143}
144
145fn classify_session_listing(listing: &str, name: &str) -> SessionListing {
146 let Some(line) = listing.lines().find(|line| listing_line_names(line, name)) else {
147 return SessionListing::Missing;
148 };
149 if line.contains("EXITED") {
150 SessionListing::Exited
151 } else {
152 SessionListing::Live
153 }
154}
155
156fn listing_line_names(line: &str, name: &str) -> bool {
157 let trimmed = line.trim();
158 trimmed == name
159 || trimmed.starts_with(&format!("{name} "))
160 || trimmed.starts_with(&format!("{name}\t"))
161 || trimmed.split_whitespace().any(|tok| tok == name)
162}
163
164fn parse_pane_token(pane: &str) -> Option<(u32, bool)> {
165 let pane = pane.trim();
166 if let Some(rest) = pane.strip_prefix("terminal_") {
167 rest.parse().ok().map(|id| (id, false))
168 } else if let Some(rest) = pane.strip_prefix("plugin_") {
169 rest.parse().ok().map(|id| (id, true))
170 } else {
171 pane.parse().ok().map(|id| (id, false))
172 }
173}
174
175fn collect_panes<'a>(value: &'a Value, out: &mut Vec<&'a Value>) {
176 match value {
177 Value::Array(items) => {
178 for item in items {
179 collect_panes(item, out);
180 }
181 }
182 Value::Object(map) => {
183 if map.contains_key("id") {
184 out.push(value);
185 }
186 for nested in map.values() {
187 collect_panes(nested, out);
188 }
189 }
190 _ => {}
191 }
192}
193
194fn probe_pane(rows: &Value, pane_ref: &str) -> ResourceProbe {
195 let Some((want_id, want_plugin)) = parse_pane_token(pane_ref) else {
196 return ResourceProbe {
197 alive: false,
198 attached: false,
199 detail: Some(serde_json::json!({"reason": "pane_missing"})),
200 };
201 };
202 let mut panes = Vec::new();
203 collect_panes(rows, &mut panes);
204 let Some(row) = panes.iter().copied().find(|row| {
205 let id = row
206 .get("id")
207 .and_then(Value::as_u64)
208 .map(|id| id as u32)
209 .or_else(|| {
210 row.get("id")
211 .and_then(Value::as_str)
212 .and_then(|id| id.parse().ok())
213 });
214 let plugin = row
215 .get("is_plugin")
216 .and_then(Value::as_bool)
217 .unwrap_or(false);
218 id == Some(want_id) && plugin == want_plugin
219 }) else {
220 return ResourceProbe {
221 alive: false,
222 attached: false,
223 detail: Some(serde_json::json!({"reason": "pane_missing"})),
224 };
225 };
226 let exited = row.get("exited").and_then(Value::as_bool).unwrap_or(false);
227 let held = row.get("is_held").and_then(Value::as_bool).unwrap_or(false);
228 if exited || held {
229 let exit = row.get("exit_status").cloned().unwrap_or(Value::Null);
230 return ResourceProbe {
231 alive: false,
232 attached: false,
233 detail: Some(serde_json::json!({"exit": exit, "exited": true})),
234 };
235 }
236 ResourceProbe {
237 alive: true,
238 attached: true,
239 detail: None,
240 }
241}
242
243pub struct ZellijBackend {
244 runner: Arc<dyn Runner>,
245 command: String,
246}
247impl ZellijBackend {
248 pub fn new(runner: Arc<dyn Runner>) -> Self {
249 Self {
250 runner,
251 command: std::env::var("ZELLIJ_COMMAND").unwrap_or_else(|_| "zellij".into()),
252 }
253 }
254
255 fn ensure_session(&self, name: &str) -> Result<bool> {
266 if self.session_listed(name)? {
267 return Ok(false);
268 }
269 run_checked(
270 self.runner.as_ref(),
271 &self.command,
272 &["attach".into(), "--create-background".into(), name.into()],
273 None,
274 &BTreeMap::new(),
275 )
276 .map_err(|error| anyhow::anyhow!("zellij attach --create-background {name}: {error}"))?;
277 Ok(true)
278 }
279
280 fn session_listed(&self, name: &str) -> Result<bool> {
284 let out = self.runner.run(
285 &self.command,
286 &["list-sessions".into(), "--short".into()],
287 None,
288 &BTreeMap::new(),
289 )?;
290 Ok(out.status == 0
291 && String::from_utf8_lossy(&out.stdout)
292 .lines()
293 .any(|line| line.trim() == name))
294 }
295
296 fn session_listing(&self, name: &str) -> Result<SessionListing> {
298 let out = self.runner.run(
299 &self.command,
300 &["list-sessions".into(), "--no-formatting".into()],
301 None,
302 &BTreeMap::new(),
303 )?;
304 if out.status != 0 {
305 return Ok(SessionListing::Missing);
306 }
307 Ok(classify_session_listing(
308 &String::from_utf8_lossy(&out.stdout),
309 name,
310 ))
311 }
312
313 fn list_panes(&self, name: &str) -> Result<Value> {
314 let out = self.runner.run(
315 &self.command,
316 &[
317 "--session".into(),
318 name.into(),
319 "action".into(),
320 "list-panes".into(),
321 "--json".into(),
322 "--state".into(),
323 "--command".into(),
324 ],
325 None,
326 &BTreeMap::new(),
327 )?;
328 if out.status != 0 {
329 anyhow::bail!(
330 "zellij action list-panes {name} failed (status {})",
331 out.status
332 );
333 }
334 serde_json::from_slice(&out.stdout)
335 .map_err(|error| anyhow::anyhow!("zellij list-panes json: {error}"))
336 }
337
338 fn kill_session(&self, name: &str) -> Result<()> {
340 run_checked(
341 self.runner.as_ref(),
342 &self.command,
343 &["kill-session".into(), name.into()],
344 None,
345 &BTreeMap::new(),
346 )
347 .map(|_| ())
348 }
349
350 fn reclaim_created(&self, created: bool, name: &str) {
359 if !created {
360 return;
361 }
362 if let Err(error) = self.kill_session(name) {
363 tracing::warn!(
364 session = %name,
365 %error,
366 "zellij could not reclaim the session of a failed spawn"
367 );
368 }
369 }
370}
371impl SessionBackend for ZellijBackend {
372 fn name(&self) -> &'static str {
373 "zellij"
374 }
375 fn capabilities(&self) -> Capabilities {
376 Capabilities {
377 spawn: true,
378 attach: true,
379 probe: true,
380 close: true,
381 focus: false,
382 rename: false,
383 }
384 }
385 fn available(&self) -> Result<bool> {
386 Ok(self
387 .runner
388 .run(
389 &self.command,
390 &["list-sessions".into(), "--short".into()],
391 None,
392 &BTreeMap::new(),
393 )
394 .map(|o| o.status == 0)
395 .unwrap_or(false))
396 }
397
398 fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
405 let session = session_name(&spec.task_id)?;
406 let created = self.ensure_session(&session)?;
407 let mut args = vec![
408 "--session".into(),
409 session.clone(),
410 "run".into(),
411 "--cwd".into(),
412 spec.cwd.to_string_lossy().into_owned(),
413 "--no-focus".into(),
414 "--".into(),
415 ];
416 args.extend(spec.command);
417 let pane = match run_checked(self.runner.as_ref(), &self.command, &args, None, &spec.env) {
418 Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_owned(),
419 Err(error) => {
420 self.reclaim_created(created, &session);
421 return Err(error);
422 }
423 };
424 if pane.is_empty() {
425 self.reclaim_created(created, &session);
426 return Err(anyhow::anyhow!("zellij run returned no pane id"));
427 }
428 Ok(SessionRef {
429 task_id: spec.task_id,
430 backend: self.name().into(),
431 backend_ref: serde_json::json!({"session": session, "pane": pane}),
432 generation: 1,
433 })
434 }
435 fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
436 let name = session_name(&session.task_id)?;
437 if !self.session_listed(&name)? {
438 return Err(anyhow::anyhow!("zellij session not found: {name}"));
439 }
440 Ok(session.clone())
441 }
442 fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
443 let name = session_name(&session.task_id)?;
444 match self.session_listing(&name)? {
445 SessionListing::Missing => Ok(ResourceProbe {
446 alive: false,
447 attached: false,
448 detail: Some(serde_json::json!({"reason": "session_missing"})),
449 }),
450 SessionListing::Exited => Ok(ResourceProbe {
451 alive: false,
452 attached: false,
453 detail: Some(serde_json::json!({"reason": "session_exited"})),
454 }),
455 SessionListing::Live => {
456 let pane = session
457 .backend_ref
458 .get("pane")
459 .and_then(Value::as_str)
460 .unwrap_or("");
461 match self.list_panes(&name) {
462 Ok(rows) => Ok(probe_pane(&rows, pane)),
463 Err(_) => Ok(ResourceProbe {
464 alive: false,
465 attached: false,
466 detail: Some(serde_json::json!({"reason": "pane_missing"})),
467 }),
468 }
469 }
470 }
471 }
472 fn close(&self, session: &SessionRef, _reason: CloseReason, _force: bool) -> Result<()> {
473 self.kill_session(&session_name(&session.task_id)?)
474 }
475}