From a1d0b53c819c1614d6c1d2994122e31bd39c5ca4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 28 May 2026 20:48:14 -0300 Subject: [PATCH] feat(plugin): add session restore lifecycle support Add editor session snapshot APIs, plugin storage, and lifecycle hooks so plugins can persist and restore workspace state across editor runs. Include a built-in `session_restore` plugin that saves file-backed buffers, cursor positions, viewport state, and window splits on exit. --- default_config.toml | 1 + docs/PLUGIN_SYSTEM.md | 26 +++- plugins/session_restore.js | 53 ++++++++ src/config.rs | 2 + src/editor.rs | 262 ++++++++++++++++++++++++++++++++++++- src/main.rs | 3 +- src/plugin/registry.rs | 43 +++++- src/plugin/runtime.js | 67 ++++++++-- src/plugin/runtime.rs | 103 ++++++++++++++- src/window.rs | 132 +++++++++++++++++++ types/red.d.ts | 43 +++++- 11 files changed, 716 insertions(+), 19 deletions(-) create mode 100644 plugins/session_restore.js diff --git a/default_config.toml b/default_config.toml index eb96ad0..03c6af3 100644 --- a/default_config.toml +++ b/default_config.toml @@ -168,3 +168,4 @@ Esc = { EnterMode = "Normal" } [plugins] buffer_picker = "buffer_picker.js" neotree = "neotree.js" +session_restore = "session_restore.js" diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md index 83e3e81..3c57786 100644 --- a/docs/PLUGIN_SYSTEM.md +++ b/docs/PLUGIN_SYSTEM.md @@ -62,8 +62,11 @@ View loaded plugins with the `dp` keybinding or `ListPlugins` command. 1. Create a JavaScript or TypeScript file that exports an `activate` function: **Plugin Lifecycle:** -- `activate(red)` - Called when the plugin is loaded +- `activate(red)` - Called when the plugin is loaded. Async activation is + supported, but startup does not wait for it to finish. - `deactivate(red)` - Optional, called when the plugin is unloaded +- `beforeExit(red, state)` - Optional, awaited after quit succeeds and before + plugin deactivation. `state` is the current editor session snapshot. ```javascript export async function activate(red) { @@ -128,6 +131,7 @@ Subscribes to editor events. Available events include: - `cursor:moved` - Cursor position changes (may fire frequently) - `file:opened` - File opened in a buffer - `file:saved` - File saved from a buffer +- `editor:ready` - Plugins have loaded and startup work can begin #### Editor Information ```javascript @@ -139,6 +143,26 @@ Returns an object containing: - `size` - Editor dimensions (rows, cols) - `theme` - Current theme information +#### Session State +```javascript +const state = await red.getEditorState() +const result = await red.restoreEditorState(state) +``` + +The snapshot includes file-backed buffers, cursor and viewport positions, the +active buffer, cwd, and window split layout. Restore skips missing files and +returns `{ restored, openedFiles, skippedFiles, warnings }`. + +#### Plugin Storage +```javascript +await red.storage.set("latest", state) +const state = await red.storage.get("latest") +await red.storage.delete("latest") +``` + +Storage is JSON, namespaced by plugin, and written under Red's config state +directory. + #### UI Interaction ```javascript // Show a picker dialog diff --git a/plugins/session_restore.js b/plugins/session_restore.js new file mode 100644 index 0000000..bf70571 --- /dev/null +++ b/plugins/session_restore.js @@ -0,0 +1,53 @@ +const STORAGE_KEY = "latest"; + +let redApi = null; + +export async function activate(red) { + redApi = red; + + red.on("editor:ready", async () => { + try { + const startupFileCount = await red.getConfig("startup_file_count"); + if (startupFileCount > 0) { + return; + } + + const snapshot = await red.storage.get(STORAGE_KEY); + if (!snapshot || snapshot.version !== 1) { + return; + } + + const cwd = await red.getConfig("cwd"); + if (snapshot.cwd && cwd && snapshot.cwd !== cwd) { + red.logInfo("Session restore skipped: saved cwd differs from current cwd"); + return; + } + + const result = await red.restoreEditorState(snapshot); + if (!result.restored) { + red.logWarn("Session restore did not restore files", result.warnings); + } + for (const skipped of result.skippedFiles || []) { + red.logWarn("Session restore skipped file", skipped.path, skipped.reason); + } + } catch (error) { + red.logError("Session restore failed", error?.message || error); + } + }); +} + +export async function beforeExit(red, state) { + const api = red || redApi; + if (!api || !state) return; + + const cleanState = { + ...state, + buffers: (state.buffers || []).filter((buffer) => buffer.path && !buffer.dirty), + }; + + await api.storage.set(STORAGE_KEY, cleanState); +} + +export function deactivate() { + redApi = null; +} diff --git a/src/config.rs b/src/config.rs index b850995..0d07f98 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,8 @@ pub struct Config { pub show_diagnostics: bool, #[serde(default = "default_false")] pub window_borders_ascii: bool, + #[serde(default, skip_serializing)] + pub startup_file_count: usize, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/editor.rs b/src/editor.rs index e80ef85..8e9cba7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -6,7 +6,7 @@ use std::{ collections::{HashMap, VecDeque}, io::stdout, path::PathBuf, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use crate::unicode_utils::{ @@ -60,7 +60,7 @@ use crate::{ ui::{CompletionUI, Component, FilePicker, Info, Picker}, undo::{CursorSnapshot, TextPosition, TextRange}, utils::get_workspace_uri, - window::WindowManager, + window::{WindowManager, WindowManagerSnapshot}, }; pub static ACTION_DISPATCHER: Lazy> = @@ -106,6 +106,13 @@ pub enum PluginRequest { GetConfig { key: Option, }, + GetEditorState { + request_id: i32, + }, + RestoreEditorState { + request_id: i32, + snapshot: EditorStateSnapshot, + }, GetTextDisplayWidth { text: String, }, @@ -1231,6 +1238,9 @@ impl Editor { .add(name, path.to_string_lossy().as_ref()); } self.plugin_registry.initialize(&mut runtime).await?; + self.plugin_registry + .notify(&mut runtime, "editor:ready", json!({})) + .await?; let mut buffer = RenderBuffer::new( self.size.0 as usize, @@ -1444,6 +1454,8 @@ impl Editor { "log_file" => json!(self.config.log_file), "mouse_scroll_lines" => json!(self.config.mouse_scroll_lines), "show_diagnostics" => json!(self.config.show_diagnostics), + "startup_file_count" => json!(self.config.startup_file_count), + "cwd" => json!(std::env::current_dir().ok().map(|path| path.to_string_lossy().to_string())), "keys" => json!(self.config.keys), _ => json!(null), } @@ -1455,6 +1467,8 @@ impl Editor { "log_file": self.config.log_file, "mouse_scroll_lines": self.config.mouse_scroll_lines, "show_diagnostics": self.config.show_diagnostics, + "startup_file_count": self.config.startup_file_count, + "cwd": std::env::current_dir().ok().map(|path| path.to_string_lossy().to_string()), "keys": self.config.keys, }) }; @@ -1462,6 +1476,38 @@ impl Editor { .notify(&mut runtime, "config:value", json!({ "value": config_value })) .await?; } + PluginRequest::GetEditorState { request_id } => { + let snapshot = self.editor_state_snapshot(); + self.plugin_registry + .notify( + &mut runtime, + &format!("editor:state:{request_id}"), + serde_json::to_value(snapshot)?, + ) + .await?; + } + PluginRequest::RestoreEditorState { request_id, snapshot } => { + let result = self + .restore_editor_state(snapshot, &mut buffer) + .await; + let payload = match result { + Ok(result) => serde_json::to_value(result)?, + Err(err) => json!({ + "restored": false, + "openedFiles": [], + "skippedFiles": [], + "warnings": [err.to_string()], + }), + }; + self.plugin_registry + .notify( + &mut runtime, + &format!("editor:restore:{request_id}"), + payload, + ) + .await?; + self.render(&mut buffer)?; + } PluginRequest::GetTextDisplayWidth { text } => { let width = crate::unicode_utils::display_width(&text); self.plugin_registry @@ -1608,6 +1654,18 @@ impl Editor { } } + let snapshot = self.editor_state_snapshot(); + if let Err(err) = self + .plugin_registry + .before_exit(&mut runtime, snapshot) + .await + { + log!("Plugin beforeExit failed: {}", err); + } + if let Err(err) = self.plugin_registry.deactivate_all(&mut runtime).await { + log!("Plugin deactivate failed: {}", err); + } + Ok(()) } @@ -4370,6 +4428,162 @@ impl Editor { .collect() } + fn editor_state_snapshot(&mut self) -> EditorStateSnapshot { + self.sync_to_window(); + let cwd = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_default(); + let saved_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + + let mut visible_buffer_positions = HashMap::new(); + for window in self.window_manager.windows() { + visible_buffer_positions.insert( + window.buffer_index, + (window.cx, window.vtop + window.cy, window.vtop), + ); + } + if let Some(window) = self.window_manager.active_window() { + visible_buffer_positions.insert( + window.buffer_index, + (window.cx, window.vtop + window.cy, window.vtop), + ); + } + + let buffers = self + .buffers + .iter() + .enumerate() + .filter_map(|(index, buffer)| { + let path = buffer.file.clone()?; + let (x, y, viewport_top) = visible_buffer_positions + .get(&index) + .copied() + .unwrap_or((buffer.pos.0, buffer.vtop + buffer.pos.1, buffer.vtop)); + Some(BufferStateSnapshot { + index, + path, + dirty: buffer.dirty, + cursor: CursorStateSnapshot { x, y }, + viewport_top, + }) + }) + .collect(); + + EditorStateSnapshot { + version: 1, + cwd, + saved_at, + buffers, + current_buffer_index: self.current_buffer_index, + window_layout: self.window_manager.snapshot(), + } + } + + async fn restore_editor_state( + &mut self, + snapshot: EditorStateSnapshot, + render_buffer: &mut RenderBuffer, + ) -> anyhow::Result { + if snapshot.version != 1 { + return Ok(RestoreResult { + restored: false, + opened_files: Vec::new(), + skipped_files: Vec::new(), + warnings: vec![format!( + "Unsupported editor state version {}", + snapshot.version + )], + }); + } + + let mut opened_files = Vec::new(); + let mut skipped_files = Vec::new(); + let mut buffer_map = HashMap::new(); + let mut restored_buffers = Vec::new(); + + for saved_buffer in &snapshot.buffers { + if !std::path::Path::new(&saved_buffer.path).exists() { + skipped_files.push(SkippedFile { + path: saved_buffer.path.clone(), + reason: "file does not exist".to_string(), + }); + continue; + } + + match Buffer::load_or_create(&mut self.lsp, Some(saved_buffer.path.clone())).await { + Ok(mut buffer) => { + let viewport_top = saved_buffer.viewport_top.min(buffer.last_navigable_line()); + let cursor_y = saved_buffer.cursor.y.min(buffer.last_navigable_line()); + let cursor_x = buffer + .get(cursor_y) + .map(|line| { + saved_buffer + .cursor + .x + .min(line.trim_end_matches('\n').chars().count()) + }) + .unwrap_or(0); + buffer.vtop = viewport_top; + buffer.pos = (cursor_x, cursor_y.saturating_sub(viewport_top)); + + buffer_map.insert(saved_buffer.index, restored_buffers.len()); + opened_files.push(saved_buffer.path.clone()); + restored_buffers.push(buffer); + } + Err(err) => skipped_files.push(SkippedFile { + path: saved_buffer.path.clone(), + reason: err.to_string(), + }), + } + } + + if restored_buffers.is_empty() { + return Ok(RestoreResult { + restored: false, + opened_files, + skipped_files, + warnings: vec!["No saved files could be restored".to_string()], + }); + } + + self.buffers = restored_buffers; + self.current_buffer_index = buffer_map + .get(&snapshot.current_buffer_index) + .copied() + .unwrap_or(0); + + self.window_manager = WindowManager::from_snapshot( + &snapshot.window_layout, + (self.size.0 as usize, self.size.1 as usize), + &buffer_map, + ) + .unwrap_or_else(|| { + WindowManager::new( + self.current_buffer_index, + (self.size.0 as usize, self.size.1 as usize), + ) + }); + + if let Some(active_window) = self.window_manager.active_window() { + self.current_buffer_index = active_window.buffer_index; + } + self.sync_with_window(); + self.check_bounds(); + self.request_diagnostics().await?; + self.render(render_buffer)?; + + Ok(RestoreResult { + restored: true, + opened_files, + skipped_files, + warnings: Vec::new(), + }) + } + fn info(&self) -> EditorInfo { self.into() } @@ -4496,6 +4710,48 @@ impl Editor { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorStateSnapshot { + pub version: u32, + pub cwd: String, + pub saved_at: u64, + pub buffers: Vec, + pub current_buffer_index: usize, + pub window_layout: WindowManagerSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BufferStateSnapshot { + pub index: usize, + pub path: String, + pub dirty: bool, + pub cursor: CursorStateSnapshot, + pub viewport_top: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CursorStateSnapshot { + pub x: usize, + pub y: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreResult { + pub restored: bool, + pub opened_files: Vec, + pub skipped_files: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkippedFile { + pub path: String, + pub reason: String, +} + #[derive(Debug, Clone, Serialize)] pub struct EditorInfo { buffers: Vec, @@ -4511,6 +4767,7 @@ pub struct EditorInfo { #[derive(Debug, Clone, Serialize)] pub struct BufferInfo { name: String, + path: Option, dirty: bool, } @@ -4535,6 +4792,7 @@ impl From<&Buffer> for BufferInfo { fn from(buffer: &Buffer) -> Self { Self { name: buffer.name().to_string(), + path: buffer.file.clone(), dirty: buffer.is_dirty(), } } diff --git a/src/main.rs b/src/main.rs index 5328d8e..dd8c665 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ async fn main() -> anyhow::Result<()> { } let toml = fs::read_to_string(config_file)?; - let config: Config = toml::from_str(&toml)?; + let mut config: Config = toml::from_str(&toml)?; if let Some(log_file) = &config.log_file { LOGGER.get_or_init(|| Some(Logger::new(log_file))); @@ -30,6 +30,7 @@ async fn main() -> anyhow::Result<()> { } let args = Args::parse(); + config.startup_file_count = args.files.len(); if let Some(root) = args.root { // change to root directory diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 975adaf..7053468 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -2,6 +2,8 @@ use serde_json::json; use std::collections::HashMap; use std::path::Path; +use crate::editor::EditorStateSnapshot; + use super::{PluginMetadata, Runtime}; pub struct PluginRegistry { @@ -75,6 +77,7 @@ impl PluginRegistry { import * as plugin_{i} from '{plugin}'; const activate_{i} = plugin_{i}.activate; const deactivate_{i} = plugin_{i}.deactivate || null; + const before_exit_{i} = plugin_{i}.beforeExit || null; globalThis.plugins['{name}'] = activate_{i}; @@ -82,11 +85,16 @@ impl PluginRegistry { globalThis.pluginInstances['{name}'] = {{ activate: activate_{i}, deactivate: deactivate_{i}, + beforeExit: before_exit_{i}, context: null }}; // Activate the plugin - globalThis.pluginInstances['{name}'].context = activate_{i}(globalThis.context); + globalThis.pluginInstances['{name}'].context = globalThis.createPluginContext('{name}'); + if (activate_{i}) {{ + Promise.resolve(activate_{i}(globalThis.pluginInstances['{name}'].context)) + .catch((error) => globalThis.log(`Error activating plugin {name}:`, error)); + }} "#, ); } @@ -130,6 +138,37 @@ impl PluginRegistry { Ok(()) } + pub async fn before_exit( + &self, + runtime: &mut Runtime, + snapshot: EditorStateSnapshot, + ) -> anyhow::Result<()> { + if !self.initialized { + return Ok(()); + } + + let code = format!( + r#" + (async () => {{ + const state = {}; + for (const [name, plugin] of Object.entries(globalThis.pluginInstances)) {{ + if (plugin.beforeExit) {{ + try {{ + await plugin.beforeExit(plugin.context, state); + globalThis.log(`Plugin ${{name}} beforeExit completed`); + }} catch (error) {{ + globalThis.log(`Error in beforeExit for plugin ${{name}}:`, error); + }} + }} + }} + }})(); + "#, + json!(snapshot) + ); + + runtime.run(&code).await + } + /// Deactivate all plugins (call their deactivate functions if available) pub async fn deactivate_all(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { if !self.initialized { @@ -141,7 +180,7 @@ impl PluginRegistry { for (const [name, plugin] of Object.entries(globalThis.pluginInstances)) { if (plugin.deactivate) { try { - await plugin.deactivate(); + await plugin.deactivate(plugin.context); globalThis.log(`Plugin ${name} deactivated`); } catch (error) { globalThis.log(`Error deactivating plugin ${name}:`, error); diff --git a/src/plugin/runtime.js b/src/plugin/runtime.js index a9ea0a5..f7d3259 100644 --- a/src/plugin/runtime.js +++ b/src/plugin/runtime.js @@ -28,34 +28,56 @@ const logError = (...message) => { let nextReqId = 0; class RedContext { - constructor() { - this.commands = {}; - this.eventSubscriptions = {}; + constructor(pluginName = null, root = null) { + this.pluginName = pluginName; + this.root = root || this; + if (!root) { + this.commands = {}; + this.commandOwners = {}; + this.eventSubscriptions = {}; + this.eventOwners = {}; + } + this.storage = { + get: async (key) => ops.op_plugin_storage_get(this.requirePluginName(), key), + set: async (key, value) => ops.op_plugin_storage_set(this.requirePluginName(), key, value), + delete: async (key) => ops.op_plugin_storage_delete(this.requirePluginName(), key), + }; + } + + requirePluginName() { + if (!this.pluginName) { + throw new Error("Plugin storage requires a plugin-specific context"); + } + return this.pluginName; } addCommand(name, command) { log("Adding command", name, "with function: ", command); - this.commands[name] = command; + this.root.commands[name] = command; + this.root.commandOwners[name] = this.pluginName; } getCommandList() { // Return command names as an array - return Object.keys(this.commands); + return Object.keys(this.root.commands); } getCommandsWithCallbacks() { - return this.commands; + return this.root.commands; } on(event, callback) { log("Subscribing to", event, "with callback: ", callback); - const subs = this.eventSubscriptions[event] || []; + const subs = this.root.eventSubscriptions[event] || []; subs.push(callback); - this.eventSubscriptions[event] = subs; + this.root.eventSubscriptions[event] = subs; + const owners = this.root.eventOwners[event] || []; + owners.push({ callback, pluginName: this.pluginName }); + this.root.eventOwners[event] = owners; } notify(event, args) { - const subs = this.eventSubscriptions[event] || []; + const subs = this.root.eventSubscriptions[event] || []; if (subs.length > 0) { log("Notifying event", event); } @@ -196,8 +218,10 @@ class RedContext { // Method to remove event listeners off(event, callback) { - const subs = this.eventSubscriptions[event] || []; - this.eventSubscriptions[event] = subs.filter(sub => sub !== callback); + const subs = this.root.eventSubscriptions[event] || []; + this.root.eventSubscriptions[event] = subs.filter(sub => sub !== callback); + const owners = this.root.eventOwners[event] || []; + this.root.eventOwners[event] = owners.filter(owner => owner.callback !== callback); } // Get list of available commands @@ -218,6 +242,26 @@ class RedContext { }); } + getEditorState() { + return new Promise((resolve, _reject) => { + const reqId = nextReqId++; + this.once(`editor:state:${reqId}`, (state) => { + resolve(state); + }); + ops.op_get_editor_state(reqId); + }); + } + + restoreEditorState(snapshot) { + return new Promise((resolve, _reject) => { + const reqId = nextReqId++; + this.once(`editor:restore:${reqId}`, (result) => { + resolve(result); + }); + ops.op_restore_editor_state(reqId, snapshot); + }); + } + // Logging with levels log(...messages) { log(...messages); @@ -342,6 +386,7 @@ async function execute(command, args) { globalThis.log = log; globalThis.print = print; globalThis.context = new RedContext(); +globalThis.createPluginContext = (pluginName) => new RedContext(pluginName, globalThis.context); globalThis.execute = execute; // Timer functions diff --git a/src/plugin/runtime.rs b/src/plugin/runtime.rs index e358c94..23582b9 100644 --- a/src/plugin/runtime.rs +++ b/src/plugin/runtime.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, - env, + env, fs, + path::PathBuf, rc::Rc, sync::{mpsc, Mutex}, thread, @@ -14,6 +15,7 @@ use tokio::sync::oneshot; use uuid::Uuid; use crate::{ + config::Config, editor::{PluginRequest, ACTION_DISPATCHER}, log, }; @@ -526,6 +528,56 @@ fn op_get_config(#[string] key: Option) -> Result<(), AnyError> { Ok(()) } +#[op2(fast)] +fn op_get_editor_state(request_id: i32) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::GetEditorState { request_id }); + Ok(()) +} + +#[op2] +fn op_restore_editor_state( + request_id: i32, + #[serde] snapshot: serde_json::Value, +) -> Result<(), AnyError> { + let snapshot = serde_json::from_value(snapshot)?; + ACTION_DISPATCHER.send_request(PluginRequest::RestoreEditorState { + request_id, + snapshot, + }); + Ok(()) +} + +#[op2] +#[serde] +fn op_plugin_storage_get( + #[string] plugin_name: String, + #[string] key: String, +) -> Result { + let values = read_plugin_storage(&plugin_name)?; + Ok(values.get(&key).cloned().unwrap_or(serde_json::Value::Null)) +} + +#[op2] +fn op_plugin_storage_set( + #[string] plugin_name: String, + #[string] key: String, + #[serde] value: serde_json::Value, +) -> Result<(), AnyError> { + let mut values = read_plugin_storage(&plugin_name)?; + values.insert(key, value); + write_plugin_storage(&plugin_name, &values) +} + +#[op2(fast)] +fn op_plugin_storage_delete( + #[string] plugin_name: String, + #[string] key: String, +) -> Result<(), AnyError> { + let mut values = read_plugin_storage(&plugin_name)?; + values.remove(&key); + write_plugin_storage(&plugin_name, &values) +} + #[op2] fn op_create_overlay( #[string] id: String, @@ -668,6 +720,50 @@ fn op_unwatch_directory(watch_id: i32) -> Result<(), AnyError> { Ok(()) } +fn plugin_storage_path(plugin_name: &str) -> anyhow::Result { + let safe_name: String = plugin_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if safe_name.is_empty() { + return Err(anyhow::anyhow!("plugin name cannot be empty")); + } + Ok(Config::path("state") + .join("plugins") + .join(format!("{safe_name}.json"))) +} + +fn read_plugin_storage(plugin_name: &str) -> anyhow::Result> { + let path = plugin_storage_path(plugin_name)?; + if !path.exists() { + return Ok(serde_json::Map::new()); + } + let contents = fs::read_to_string(path)?; + if contents.trim().is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = serde_json::from_str(&contents)?; + Ok(value.as_object().cloned().unwrap_or_default()) +} + +fn write_plugin_storage( + plugin_name: &str, + values: &serde_json::Map, +) -> Result<(), AnyError> { + let path = plugin_storage_path(plugin_name)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(values)?)?; + Ok(()) +} + extension!( js_runtime, ops = [ @@ -687,6 +783,11 @@ extension!( op_set_cursor_position, op_get_buffer_text, op_get_config, + op_get_editor_state, + op_restore_editor_state, + op_plugin_storage_get, + op_plugin_storage_set, + op_plugin_storage_delete, op_create_overlay, op_update_overlay, op_remove_overlay, diff --git a/src/window.rs b/src/window.rs index d38c994..6cbbf15 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,6 @@ use crate::editor::Point; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, Copy)] pub enum Direction { @@ -129,6 +131,22 @@ mod tests { assert_eq!(manager.windows().len(), 1); assert_eq!(manager.active_window_id(), 0); } + + #[test] + fn snapshot_round_trips_split_layout() { + let mut manager = WindowManager::new(0, (80, 26)); + manager.split_vertical(1).unwrap(); + manager.active_window_mut().unwrap().vtop = 12; + + let snapshot = manager.snapshot(); + let buffer_map = HashMap::from([(0, 3), (1, 4)]); + let restored = WindowManager::from_snapshot(&snapshot, (100, 30), &buffer_map).unwrap(); + + assert_eq!(restored.windows().len(), 2); + assert_eq!(restored.active_window_id(), manager.active_window_id()); + assert_eq!(restored.active_window().unwrap().buffer_index, 4); + assert_eq!(restored.active_window().unwrap().vtop, 12); + } } /// Represents a split in the window layout @@ -154,6 +172,35 @@ pub enum Split { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SplitSnapshot { + Window { + buffer_index: usize, + vtop: usize, + vleft: usize, + cx: usize, + cy: usize, + vx: usize, + }, + Horizontal { + ratio: f32, + top: Box, + bottom: Box, + }, + Vertical { + ratio: f32, + left: Box, + right: Box, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WindowManagerSnapshot { + pub active_window_id: usize, + pub root: SplitSnapshot, +} + impl Split { /// Creates a new window split pub fn new_window(buffer_index: usize, position: Point, size: (usize, usize)) -> Self { @@ -227,6 +274,61 @@ impl Split { } } } + + fn snapshot(&self) -> SplitSnapshot { + match self { + Split::Window(window) => SplitSnapshot::Window { + buffer_index: window.buffer_index, + vtop: window.vtop, + vleft: window.vleft, + cx: window.cx, + cy: window.cy, + vx: window.vx, + }, + Split::Horizontal { top, bottom, ratio } => SplitSnapshot::Horizontal { + ratio: *ratio, + top: Box::new(top.snapshot()), + bottom: Box::new(bottom.snapshot()), + }, + Split::Vertical { left, right, ratio } => SplitSnapshot::Vertical { + ratio: *ratio, + left: Box::new(left.snapshot()), + right: Box::new(right.snapshot()), + }, + } + } + + fn from_snapshot(snapshot: &SplitSnapshot, buffer_map: &HashMap) -> Option { + match snapshot { + SplitSnapshot::Window { + buffer_index, + vtop, + vleft, + cx, + cy, + vx, + } => { + let mapped_buffer = *buffer_map.get(buffer_index)?; + let mut window = Window::new(mapped_buffer, Point::new(0, 0), (0, 0)); + window.vtop = *vtop; + window.vleft = *vleft; + window.cx = *cx; + window.cy = *cy; + window.vx = *vx; + Some(Split::Window(window)) + } + SplitSnapshot::Horizontal { top, bottom, ratio } => Some(Split::Horizontal { + ratio: *ratio, + top: Box::new(Self::from_snapshot(top, buffer_map)?), + bottom: Box::new(Self::from_snapshot(bottom, buffer_map)?), + }), + SplitSnapshot::Vertical { left, right, ratio } => Some(Split::Vertical { + ratio: *ratio, + left: Box::new(Self::from_snapshot(left, buffer_map)?), + right: Box::new(Self::from_snapshot(right, buffer_map)?), + }), + } + } } /// Manages windows and their layout @@ -258,6 +360,36 @@ impl WindowManager { } } + pub fn snapshot(&self) -> WindowManagerSnapshot { + WindowManagerSnapshot { + active_window_id: self.active_window_id, + root: self.root.snapshot(), + } + } + + pub fn from_snapshot( + snapshot: &WindowManagerSnapshot, + terminal_size: (usize, usize), + buffer_map: &HashMap, + ) -> Option { + let mut root = Split::from_snapshot(&snapshot.root, buffer_map)?; + root.layout( + Point::new(0, 0), + (terminal_size.0, terminal_size.1.saturating_sub(2)), + ); + + let mut manager = Self { + root, + active_window_id: 0, + }; + let window_count = manager.root.windows().len(); + if window_count == 0 { + return None; + } + manager.set_active(snapshot.active_window_id.min(window_count - 1)); + Some(manager) + } + /// Returns the currently active window pub fn active_window(&self) -> Option<&Window> { self.root.windows().get(self.active_window_id).copied() diff --git a/types/red.d.ts b/types/red.d.ts index 2ad8560..b7dba67 100644 --- a/types/red.d.ts +++ b/types/red.d.ts @@ -54,6 +54,36 @@ declare namespace Red { }; } + interface EditorStateSnapshot { + version: number; + cwd: string; + savedAt: number; + buffers: BufferStateSnapshot[]; + currentBufferIndex: number; + windowLayout: any; + } + + interface BufferStateSnapshot { + index: number; + path: string; + dirty: boolean; + cursor: CursorPosition; + viewportTop: number; + } + + interface RestoreResult { + restored: boolean; + openedFiles: string[]; + skippedFiles: Array<{ path: string; reason: string }>; + warnings: string[]; + } + + interface PluginStorage { + get(key: string): Promise; + set(key: string, value: any): Promise; + delete(key: string): Promise; + } + /** * Cursor position */ @@ -158,6 +188,7 @@ declare namespace Red { * The main Red editor API object passed to plugins */ interface RedAPI { + storage: PluginStorage; /** * Register a new command * @param name Command name @@ -328,9 +359,14 @@ declare namespace Red { getConfig(key: "log_file"): Promise; getConfig(key: "mouse_scroll_lines"): Promise; getConfig(key: "show_diagnostics"): Promise; + getConfig(key: "startup_file_count"): Promise; + getConfig(key: "cwd"): Promise; getConfig(key: "keys"): Promise; getConfig(key: string): Promise; + getEditorState(): Promise; + restoreEditorState(snapshot: EditorStateSnapshot): Promise; + /** * Log messages to the debug log (info level) * @param messages Messages to log @@ -406,4 +442,9 @@ export function activate(red: Red.RedAPI): void | Promise; * Plugin deactivation function (optional) * @param red The Red editor API object */ -export function deactivate?(red: Red.RedAPI): void | Promise; \ No newline at end of file +export function deactivate?(red: Red.RedAPI): void | Promise; + +export function beforeExit?( + red: Red.RedAPI, + state: Red.EditorStateSnapshot, +): void | Promise;