diff options
Diffstat (limited to 'src/persist')
| -rw-r--r-- | src/persist/plugin_registry.rs | 237 | ||||
| -rw-r--r-- | src/persist/restore.rs | 24 |
2 files changed, 257 insertions, 4 deletions
diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs new file mode 100644 index 00000000..19db842a --- /dev/null +++ b/src/persist/plugin_registry.rs @@ -0,0 +1,237 @@ +use std::path::{Path, PathBuf}; + +use tracing::warn; + +use crate::api::schema::InstalledPluginInfo; + +pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: "; + +fn registry_path() -> PathBuf { + crate::session::data_dir().join("plugins.json") +} + +fn save_json_to_path<T: serde::Serialize + ?Sized>(path: &Path, value: &T) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(value)?; + let tmp_path = path.with_extension("json.tmp"); + std::fs::write(&tmp_path, &json)?; + if let Err(err) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err); + } + Ok(()) +} + +/// Atomically write `plugins.json` next to `session.json`. +pub fn save(plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + let path = registry_path(); + save_to_path(&path, plugins) +} + +pub fn save_to_path(path: &Path, plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + save_json_to_path(path, plugins) +} + +/// Load `plugins.json`. Returns an empty vec on any failure so a corrupt or +/// missing file never blocks server startup. +pub fn load() -> Vec<InstalledPluginInfo> { + load_from_path(®istry_path()) +} + +pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> { + if !path.exists() { + return Vec::new(); + } + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to read plugin registry"); + return Vec::new(); + } + }; + match serde_json::from_str::<Vec<InstalledPluginInfo>>(&content) { + Ok(entries) => entries, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry"); + Vec::new() + } + } +} + +/// Re-read each entry's manifest from disk using the provided reload function. +/// +/// If the manifest parses successfully, replace cached fields but keep the +/// stored `enabled` flag. If the file is gone or unparseable, keep the stored +/// entry and append a warning so `plugin.list` surfaces it. +pub fn reload_manifests( + mut entries: Vec<InstalledPluginInfo>, + reload_fn: impl Fn(&str, bool) -> Result<InstalledPluginInfo, String>, +) -> Vec<InstalledPluginInfo> { + for entry in &mut entries { + entry.warnings.clear(); + match reload_fn(&entry.manifest_path, entry.enabled) { + Ok(mut fresh) => { + fresh.enabled = entry.enabled; + fresh.source = entry.source.clone(); + *entry = fresh; + } + Err(warn_msg) => { + entry + .warnings + .push(format!("{MANIFEST_UNAVAILABLE_WARNING_PREFIX}{warn_msg}")); + } + } + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_registry_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir() + .join(format!( + "herdr-registry-{name}-{}-{nanos}", + std::process::id() + )) + .join("plugins.json") + } + + fn sample_plugin(id: &str) -> InstalledPluginInfo { + InstalledPluginInfo { + plugin_id: id.to_string(), + name: "Test Plugin".to_string(), + version: "0.1.0".to_string(), + description: None, + manifest_path: format!("/tmp/{id}/herdr-plugin.toml"), + plugin_root: format!("/tmp/{id}"), + enabled: true, + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + } + } + + #[test] + fn save_and_load_roundtrip() { + let path = temp_registry_path("roundtrip"); + let plugins = vec![sample_plugin("example.a"), sample_plugin("example.b")]; + + save_to_path(&path, &plugins).unwrap(); + + let loaded = load_from_path(&path); + assert_eq!(loaded.len(), 2); + let ids: Vec<_> = loaded.iter().map(|p| p.plugin_id.as_str()).collect(); + assert!(ids.contains(&"example.a")); + assert!(ids.contains(&"example.b")); + } + + #[test] + fn missing_file_returns_empty() { + let path = temp_registry_path("missing"); + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn corrupt_file_returns_empty_without_panic() { + let path = temp_registry_path("corrupt"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, b"this is not valid json {{{{").unwrap(); + + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn reload_manifests_keeps_entry_with_warning_on_missing_manifest() { + let entry = sample_plugin("example.missing"); + let entries = vec![entry]; + + let result = reload_manifests(entries, |path, _enabled| { + Err(format!("manifest not found at {path}")) + }); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].plugin_id, "example.missing"); + assert!(!result[0].warnings.is_empty()); + assert!(result[0].warnings[0].contains("manifest not found")); + } + + #[test] + fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() { + let mut entry = sample_plugin("example.reload"); + entry.enabled = false; + entry.source = crate::api::schema::PluginSourceInfo { + kind: crate::api::schema::PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: Some("main".into()), + resolved_commit: Some("abc123".into()), + managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()), + installed_unix_ms: Some(42), + }; + + let result = reload_manifests(vec![entry], |_path, _enabled| { + Ok(InstalledPluginInfo { + plugin_id: "example.reload".to_string(), + name: "Fresh Name".to_string(), + version: "0.2.0".to_string(), + description: Some("refreshed".to_string()), + manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(), + plugin_root: "/tmp/example.reload".to_string(), + enabled: true, // caller would pass stored enabled; fresh parse returns true + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + }) + }); + + assert_eq!(result[0].name, "Fresh Name"); + assert_eq!(result[0].version, "0.2.0"); + // enabled preserved from stored entry + assert!(!result[0].enabled); + assert_eq!( + result[0].source.kind, + crate::api::schema::PluginSourceKind::Github + ); + assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik")); + assert!(result[0].warnings.is_empty()); + } + + #[test] + fn atomic_write_temp_file_is_cleaned_up_on_rename_failure() { + // Write to a path whose parent does not yet exist, then verify the + // tmp file is removed when the write fails mid-way. Here we just + // confirm a successful write leaves no .tmp file behind. + let path = temp_registry_path("cleanup"); + save_to_path(&path, &[sample_plugin("example.cleanup")]).unwrap(); + + let tmp = path.with_extension("json.tmp"); + assert!( + !tmp.exists(), + "tmp file should be cleaned up after successful rename" + ); + assert!(path.exists()); + } +} diff --git a/src/persist/restore.rs b/src/persist/restore.rs index ca1748e0..229d565d 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -10,7 +10,7 @@ use tracing::{error, warn}; use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; -use crate::pane::PaneState; +use crate::pane::{PaneLaunchEnv, PaneState}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; @@ -354,10 +354,12 @@ fn restore_workspace( let mut failed_imports = 0; for (idx, tab_snap) in snap.tabs.iter().enumerate() { + let tab_number = snap.public_tab_numbers.get(idx).copied().unwrap_or(idx + 1); let (restored_tab, tab_failed_imports) = restore_tab( tab_snap, history.and_then(|history| history.tabs.get(idx)), - idx + 1, + tab_number, + &workspace_id, rows, cols, runtime_context, @@ -438,6 +440,7 @@ fn restore_tab( snap: &TabSnapshot, history: Option<&TabHistorySnapshot>, number: usize, + workspace_id: &str, rows: u16, cols: u16, runtime_context: &RestoreRuntimeContext<'_>, @@ -502,6 +505,19 @@ fn restore_tab( let public_pane_id = old_pane_id .and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id)) .map(String::as_str); + let launch_env = public_pane_id + .map(|pane_id| { + PaneLaunchEnv::from_extra(Vec::new()).with_identity( + workspace_id.to_string(), + format!( + "{}:t{}", + workspace_id, + crate::workspace::encode_public_number(number) + ), + pane_id.to_string(), + ) + }) + .unwrap_or_default(); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); let pending_native_agent_restore = if was_imported { @@ -570,11 +586,11 @@ fn restore_tab( runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), runtime_context.shell_config, + &launch_env, startup.initial_history_ansi, runtime_context.events.clone(), runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), - public_pane_id, ) } @@ -588,11 +604,11 @@ fn restore_tab( runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), runtime_context.shell_config, + &launch_env, startup.initial_history_ansi, runtime_context.events.clone(), runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), - public_pane_id, ) } }; |
