aboutsummaryrefslogtreecommitdiff
path: root/src/persist
diff options
context:
space:
mode:
Diffstat (limited to 'src/persist')
-rw-r--r--src/persist/plugin_registry.rs237
-rw-r--r--src/persist/restore.rs24
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(&registry_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,
)
}
};