konoma 0.18.6

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), git suite, and an agent-watch mode that follows your AI's edits (macOS and Linux)
//! Per-project tab-session persistence for `[ui] restore_tabs` (restore the previous tab set).
//!
//! Storage (config base = `$HOME/.config/konoma/`): `<base>/sessions/<start dir percent-encoded>.toml`,
//! one file per start dir — the same layout as local bookmarks (the original path is recorded inside
//! as `dir = "..."`). Saved paths are absolute. A missing or unparsable file simply means "no saved
//! session" (design principle #3: a broken file must never crash or block startup).

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::bookmarks::{default_base, encode_path};

/// `true` unless the value is the field default (`false`) — used to omit `false` bools from the TOML.
fn is_false(b: &bool) -> bool {
    !*b
}

/// One saved tab: its tree root, the entry under the cursor, and — when the tab was left in
/// Preview — the file that was being previewed.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedTab {
    pub root: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preview: Option<String>,
    /// The preview above was a full-screen git diff (Agent Watch follow / `d`), not a plain content
    /// view. Restored by reopening the diff (falling back to a plain preview when there is no diff).
    #[serde(default, skip_serializing_if = "is_false")]
    pub preview_diff: bool,
    /// Hidden (dotfile) visibility was toggled on for this tab. Restored before the tree is rebuilt
    /// so a saved dotfile cursor is revealable.
    #[serde(default, skip_serializing_if = "is_false")]
    pub show_hidden: bool,
    /// Per-tab start dir for @-references (usually the launch dir, but a tab opened at a descended
    /// root keeps that root). Persisted so restore reproduces the exact @-ref base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub open_dir: Option<String>,
}

/// The tab set that was open when konoma last exited in a start dir.
/// Declaration order matters for TOML: scalars (`dir`, `active`) first, the table array (`tabs`) last.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedSession {
    /// Original start dir (human-readable record; the file name is the percent-encoded form).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub dir: String,
    /// Index of the tab that was active (clamped on restore).
    #[serde(default)]
    pub active: usize,
    #[serde(default)]
    pub tabs: Vec<SavedTab>,
}

/// Reads/writes the session file of one start dir.
pub struct SessionStore {
    path: PathBuf,
    dir: String,
}

impl SessionStore {
    /// Store for the default config base (`$HOME/.config/konoma`).
    pub fn load(start_dir: &Path) -> Self {
        Self::with_base(default_base(), start_dir)
    }

    /// Store with an explicit base directory (so tests don't pollute the real `~/.config`).
    pub fn with_base(base: PathBuf, start_dir: &Path) -> Self {
        Self {
            path: base
                .join("sessions")
                .join(format!("{}.toml", encode_path(start_dir))),
            dir: start_dir.to_string_lossy().to_string(),
        }
    }

    /// Read the saved session. `None` = no file **or** unparsable — both mean a fresh start.
    pub fn read(&self) -> Option<SavedSession> {
        let text = std::fs::read_to_string(&self.path).ok()?;
        toml::from_str(&text).ok()
    }

    /// Persist `sess` (the store fills in `dir`). Creates the parent directory as needed. The write
    /// is **atomic**: it goes to a sibling temp file and is then renamed into place, so a crash or
    /// kill mid-write can never truncate an existing valid session (a half-written file parses as
    /// `None` = the *whole* saved tab set would be silently lost, not just the in-flight change).
    pub fn write(&self, mut sess: SavedSession) -> Result<()> {
        sess.dir = self.dir.clone();
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("セッション保存先の作成: {}", parent.display()))?;
        }
        let text = toml::to_string(&sess).context("セッションの TOML 整形")?;
        let mut tmp = self.path.clone().into_os_string();
        tmp.push(".tmp");
        let tmp = PathBuf::from(tmp);
        std::fs::write(&tmp, text)
            .with_context(|| format!("セッション一時保存: {}", tmp.display()))?;
        // rename は同一ディレクトリ=同一 FS なのでアトミック(既存ファイルを瞬時に置換)。
        std::fs::rename(&tmp, &self.path)
            .with_context(|| format!("セッション保存: {}", self.path.display()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn write_read_round_trip_records_dir_and_tabs() {
        let base = std::env::temp_dir().join("konoma_session_store_test_base");
        let _ = std::fs::remove_dir_all(&base);
        let proj = std::env::temp_dir().join("konoma_session_store_test_proj");
        std::fs::create_dir_all(&proj).unwrap();

        let store = SessionStore::with_base(base.clone(), &proj);
        let sess = SavedSession {
            dir: String::new(), // write が埋める
            active: 1,
            tabs: vec![
                SavedTab {
                    root: "/tmp/a".into(),
                    cursor: Some("/tmp/a/x.txt".into()),
                    preview: None,
                    ..Default::default()
                },
                SavedTab {
                    root: "/tmp/b".into(),
                    cursor: None,
                    preview: Some("/tmp/b/y.md".into()),
                    ..Default::default()
                },
            ],
        };
        store.write(sess.clone()).unwrap();

        let got = SessionStore::with_base(base.clone(), &proj).read().unwrap();
        assert_eq!(got.dir, proj.to_string_lossy(), "起動 dir を記録する");
        assert_eq!(got.active, 1);
        assert_eq!(got.tabs, sess.tabs);

        // 別の起動 dir のストアからは見えない(ディレクトリ毎に1ファイル)。
        let proj2 = std::env::temp_dir().join("konoma_session_store_test_proj2");
        std::fs::create_dir_all(&proj2).unwrap();
        assert!(SessionStore::with_base(base.clone(), &proj2)
            .read()
            .is_none());

        std::fs::remove_dir_all(&base).ok();
        std::fs::remove_dir_all(&proj).ok();
        std::fs::remove_dir_all(&proj2).ok();
    }

    #[test]
    fn missing_or_corrupt_file_reads_none() {
        let base = std::env::temp_dir().join("konoma_session_corrupt_test_base");
        let _ = std::fs::remove_dir_all(&base);
        let proj = std::env::temp_dir().join("konoma_session_corrupt_test_proj");
        std::fs::create_dir_all(&proj).unwrap();

        let store = SessionStore::with_base(base.clone(), &proj);
        assert!(store.read().is_none(), "ファイル無し = セッション無し");

        // 壊れた TOML は None(= まっさら起動)に安全降格する。
        std::fs::create_dir_all(store.path.parent().unwrap()).unwrap();
        std::fs::write(&store.path, "this is [not toml").unwrap();
        assert!(store.read().is_none(), "壊れたファイルはクラッシュせず無視");

        std::fs::remove_dir_all(&base).ok();
        std::fs::remove_dir_all(&proj).ok();
    }

    #[test]
    fn write_replaces_in_place_and_leaves_no_temp() {
        let base = std::env::temp_dir().join("konoma_session_atomic_test_base");
        let _ = std::fs::remove_dir_all(&base);
        let proj = std::env::temp_dir().join("konoma_session_atomic_test_proj");
        std::fs::create_dir_all(&proj).unwrap();
        let store = SessionStore::with_base(base.clone(), &proj);

        let mk = |root: &str| SavedSession {
            active: 0,
            tabs: vec![SavedTab {
                root: root.into(),
                ..Default::default()
            }],
            ..Default::default()
        };
        store.write(mk("/x")).unwrap();
        // 2回目: temp 書き→rename で既存を瞬時に置換する。
        store.write(mk("/y")).unwrap();
        assert_eq!(
            store.read().unwrap().tabs[0].root,
            "/y",
            "2回目の書込みが反映"
        );

        // アトミック書込みの temp ファイルを残さない。
        let leftovers = std::fs::read_dir(base.join("sessions"))
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
            .count();
        assert_eq!(leftovers, 0, "temp ファイルを残さない");

        std::fs::remove_dir_all(&base).ok();
        std::fs::remove_dir_all(&proj).ok();
    }
}