use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use super::{VERSION, ViewSnapshot, timestamp_now};
use crate::api::ids::OrganizationId;
pub const STATE_DIR_ENV: &str = "LINEAR_TUI_STATE_DIR";
const DEBOUNCE: Duration = Duration::from_millis(500);
const KEEP: usize = 4;
pub fn state_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(STATE_DIR_ENV).filter(|d| !d.is_empty()) {
return Ok(PathBuf::from(dir));
}
let dirs =
ProjectDirs::from("", "", "linear-tui").context("Failed to determine state directory")?;
Ok(dirs
.state_dir()
.unwrap_or_else(|| dirs.data_local_dir())
.to_path_buf())
}
pub fn workspace_of(cwd: &Path) -> PathBuf {
let common = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| String::from_utf8(out.stdout).ok())
.map(|text| PathBuf::from(text.trim_end()));
let dir = match common {
Some(dir) if dir.file_name().is_some_and(|n| n == ".git") => {
dir.parent().map_or(dir.clone(), Path::to_path_buf)
}
Some(dir) => dir,
None => cwd.to_path_buf(),
};
fs::canonicalize(&dir).unwrap_or(dir)
}
pub fn is_running(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(all(unix, not(target_os = "linux")))]
{
Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
#[derive(Debug, Clone)]
pub struct Shelf {
dir: PathBuf,
}
impl Shelf {
pub fn new(state_dir: &Path, workspace: &Path) -> Self {
let name: String = workspace
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(40)
.collect();
let hash = fnv1a(workspace.as_os_str().as_encoded_bytes());
Self {
dir: state_dir
.join("workspaces")
.join(format!("{name}-{hash:016x}")),
}
}
pub fn path_for(&self, pid: u32) -> PathBuf {
self.dir.join(format!("{pid}.json"))
}
pub fn read_all(&self) -> Vec<(PathBuf, ViewSnapshot)> {
let Ok(entries) = fs::read_dir(&self.dir) else {
return Vec::new();
};
let mut snapshots: Vec<_> = entries
.filter_map(|entry| {
let path = entry.ok()?.path();
if path.extension()? != "json" {
return None;
}
let snapshot: ViewSnapshot = serde_json::from_slice(&fs::read(&path).ok()?)
.inspect_err(
|e| tracing::warn!(path = %path.display(), %e, "unreadable snapshot"),
)
.ok()?;
(snapshot.version <= VERSION).then_some((path, snapshot))
})
.collect();
snapshots.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at));
snapshots
}
pub fn for_restore(
&self,
own_pid: u32,
organization: Option<&OrganizationId>,
) -> Option<ViewSnapshot> {
let candidates: Vec<ViewSnapshot> = self
.read_all()
.into_iter()
.map(|(_, s)| s)
.filter(|s| s.pid != own_pid || s.closed_at.is_some())
.filter(|s| match (&s.organization, organization) {
(Some(theirs), Some(ours)) => theirs.id == *ours,
_ => true,
})
.collect();
let closed = candidates
.iter()
.filter(|s| s.closed_at.is_some())
.max_by(|a, b| a.closed_at.cmp(&b.closed_at));
closed.or(candidates.first()).cloned()
}
pub fn for_context(&self) -> Option<(ViewSnapshot, bool)> {
let all = self.read_all();
let live = all
.iter()
.find(|(_, s)| s.closed_at.is_none() && is_running(s.pid));
match live {
Some((_, s)) => Some((s.clone(), true)),
None => all.into_iter().next().map(|(_, s)| (s, false)),
}
}
pub fn prune(&self) {
let gone = self
.read_all()
.into_iter()
.filter(|(_, s)| s.closed_at.is_some() || !is_running(s.pid));
for (path, _) in gone.skip(KEEP) {
let _ = fs::remove_file(path);
}
}
}
#[derive(Debug)]
pub struct Recorder {
path: PathBuf,
last: Option<ViewSnapshot>,
due: Option<Instant>,
}
impl Recorder {
pub fn new(shelf: &Shelf, pid: u32) -> Self {
Self {
path: shelf.path_for(pid),
last: None,
due: None,
}
}
pub fn touch(&mut self, now: Instant) {
self.due.get_or_insert(now + DEBOUNCE);
}
pub fn is_due(&self, now: Instant) -> bool {
self.due.is_some_and(|due| now >= due)
}
pub fn record(&mut self, snapshot: ViewSnapshot) {
self.due = None;
if self
.last
.as_ref()
.is_some_and(|last| last.same_view(&snapshot))
{
return;
}
self.write(snapshot);
}
pub fn close(&mut self, mut snapshot: ViewSnapshot) {
snapshot.closed_at = Some(timestamp_now());
self.write(snapshot);
}
fn write(&mut self, snapshot: ViewSnapshot) {
let result = serde_json::to_vec_pretty(&snapshot)
.map_err(anyhow::Error::from)
.and_then(|bytes| crate::private_file::write(&self.path, &bytes));
match result {
Ok(()) => self.last = Some(snapshot),
Err(e) => {
tracing::warn!(path = %self.path.display(), "failed to write snapshot: {e:#}")
}
}
}
}
fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
})
}