From 7ae2e8ab02c8a76e080e8fba8bb4981b719fc723 Mon Sep 17 00:00:00 2001 From: "ian.hersom" Date: Sun, 1 Feb 2026 23:12:13 -0700 Subject: [PATCH 1/9] initial feature commit --- lua/async-remote-write/tree_browser.lua | 105 ++++ lua/remote-session/commands.lua | 173 +++++++ lua/remote-session/config.lua | 128 +++++ lua/remote-session/init.lua | 208 ++++++++ lua/remote-session/naming.lua | 149 ++++++ lua/remote-session/persistence.lua | 243 +++++++++ lua/remote-session/picker.lua | 527 ++++++++++++++++++++ lua/remote-session/session.lua | 610 +++++++++++++++++++++++ lua/remote-session/session_manager.lua | 404 +++++++++++++++ lua/remote-session/statusline.lua | 148 ++++++ lua/remote-session/window_layout.lua | 231 +++++++++ lua/remote-ssh.lua | 2 + lua/remote-terminal/picker.lua | 36 +- lua/remote-terminal/terminal_manager.lua | 62 +++ 14 files changed, 3023 insertions(+), 3 deletions(-) create mode 100644 lua/remote-session/commands.lua create mode 100644 lua/remote-session/config.lua create mode 100644 lua/remote-session/init.lua create mode 100644 lua/remote-session/naming.lua create mode 100644 lua/remote-session/persistence.lua create mode 100644 lua/remote-session/picker.lua create mode 100644 lua/remote-session/session.lua create mode 100644 lua/remote-session/session_manager.lua create mode 100644 lua/remote-session/statusline.lua create mode 100644 lua/remote-session/window_layout.lua diff --git a/lua/async-remote-write/tree_browser.lua b/lua/async-remote-write/tree_browser.lua index a794e3f..a4bd50d 100644 --- a/lua/async-remote-write/tree_browser.lua +++ b/lua/async-remote-write/tree_browser.lua @@ -1964,6 +1964,111 @@ function M.restore_state(state) end end +-- Open tree with restored state (for remote-session integration) +-- This opens the tree and restores expanded directories +function M.open_tree_with_state(url, state) + if not url then + return + end + + -- Store state to restore after tree loads + local expanded_to_restore = state and state.expanded_dirs or {} + local scroll_position = state and state.scroll_position or nil + local cursor_line = state and state.cursor_line or nil + + -- Open the tree first + M.open_tree(url) + + -- Restore expanded state after initial load completes + if vim.tbl_count(expanded_to_restore) > 0 then + vim.defer_fn(function() + if not TreeBrowser.bufnr or not vim.api.nvim_buf_is_valid(TreeBrowser.bufnr) then + return + end + + -- Restore expanded directories + TreeBrowser.expanded_dirs = vim.deepcopy(expanded_to_restore) + + -- Re-expand all directories that were previously expanded + local function restore_expansions(tree_items, depth) + depth = depth or 0 + if depth > 10 then + return + end -- Safety limit + + for _, item in ipairs(tree_items) do + if item.is_dir and expanded_to_restore[item.url] then + -- Load children if not already loaded + if not item.children then + local cached_files = get_cached_directory(item.url) + if cached_files then + item.children = {} + for _, file_info in ipairs(cached_files) do + table.insert(item.children, create_tree_item(file_info, item.depth + 1, item.url)) + end + -- Recursively restore expansions for children + restore_expansions(item.children, depth + 1) + else + -- Load directory async + load_directory(item.url, function(files) + if files then + item.children = {} + for _, file_info in ipairs(files) do + table.insert(item.children, create_tree_item(file_info, item.depth + 1, item.url)) + end + restore_expansions(item.children, depth + 1) + refresh_display() + end + end) + end + else + restore_expansions(item.children, depth + 1) + end + end + end + end + + restore_expansions(TreeBrowser.tree_data) + refresh_display() + + -- Restore cursor position if provided + if cursor_line and TreeBrowser.win_id and vim.api.nvim_win_is_valid(TreeBrowser.win_id) then + pcall(vim.api.nvim_win_set_cursor, TreeBrowser.win_id, { cursor_line, 0 }) + end + + utils.log( + "Restored tree state with " .. vim.tbl_count(expanded_to_restore) .. " expanded directories", + vim.log.levels.DEBUG, + false, + config.config + ) + end, 300) -- Wait for initial tree load + end +end + +-- Get expanded directories state (for session persistence) +function M.get_expanded_dirs() + return vim.deepcopy(TreeBrowser.expanded_dirs) +end + +-- Get current cursor line in tree browser +function M.get_cursor_line() + if TreeBrowser.win_id and vim.api.nvim_win_is_valid(TreeBrowser.win_id) then + return vim.api.nvim_win_get_cursor(TreeBrowser.win_id)[1] + end + return nil +end + +-- Get the current base URL +function M.get_base_url() + return TreeBrowser.base_url +end + +-- Get window ID (for layout capture) +function M.get_window_id() + return TreeBrowser.win_id +end + -- Configuration API functions -- Configure custom icons diff --git a/lua/remote-session/commands.lua b/lua/remote-session/commands.lua new file mode 100644 index 0000000..f0c3bf3 --- /dev/null +++ b/lua/remote-session/commands.lua @@ -0,0 +1,173 @@ +-- User command registration for remote-session +local M = {} + +--- Register all user commands +function M.register() + -- :RemoteSession [url] - Open session by URL or show picker + vim.api.nvim_create_user_command("RemoteSession", function(opts) + local session = require("remote-session.session") + + local url = opts.args ~= "" and opts.args or nil + session.open(url) + end, { + nargs = "?", + desc = "Open a remote session by URL or show session picker", + complete = function(arglead, cmdline, cursorpos) + -- Provide completion from session history + local session_manager = require("remote-session.session_manager") + local sessions = session_manager.get_all_for_picker() + + local completions = {} + for _, s in ipairs(sessions) do + -- Strip rsync:// for cleaner completion + local url = s.url:gsub("^rsync://", "") + if url:find(arglead, 1, true) then + table.insert(completions, url) + end + end + + return completions + end, + }) + + -- :RemoteSessionClose [id] - Close session + vim.api.nvim_create_user_command("RemoteSessionClose", function(opts) + local session = require("remote-session.session") + local session_manager = require("remote-session.session_manager") + + local session_id = opts.args ~= "" and opts.args or session_manager.get_active_session_id() + + if not session_id then + vim.notify("[remote-session] No active session to close", vim.log.levels.WARN) + return + end + + session.close(session_id) + end, { + nargs = "?", + desc = "Close the current or specified remote session", + complete = function(arglead, cmdline, cursorpos) + local session_manager = require("remote-session.session_manager") + local sessions = session_manager.get_all_for_picker() + + local completions = {} + for _, s in ipairs(sessions) do + if s.id:find(arglead, 1, true) or s.name:find(arglead, 1, true) then + table.insert(completions, s.id) + end + end + + return completions + end, + }) + + -- :RemoteSessionMinimize - Minimize current session + vim.api.nvim_create_user_command("RemoteSessionMinimize", function(opts) + local session = require("remote-session.session") + local session_manager = require("remote-session.session_manager") + + local active_id = session_manager.get_active_session_id() + if not active_id then + vim.notify("[remote-session] No active session to minimize", vim.log.levels.WARN) + return + end + + session.minimize(active_id) + end, { + desc = "Minimize the current remote session", + }) + + -- :RemoteSessionRename [name] - Rename current session + vim.api.nvim_create_user_command("RemoteSessionRename", function(opts) + local session = require("remote-session.session") + + local new_name = opts.args ~= "" and opts.args or nil + session.rename(nil, new_name) + end, { + nargs = "?", + desc = "Rename the current remote session", + }) + + -- :RemoteSessionList - List all sessions (for debugging) + vim.api.nvim_create_user_command("RemoteSessionList", function(opts) + local session_manager = require("remote-session.session_manager") + local sessions = session_manager.get_all_for_picker() + + if #sessions == 0 then + vim.notify("[remote-session] No sessions", vim.log.levels.INFO) + return + end + + local active_id = session_manager.get_active_session_id() + local lines = { "Remote Sessions:" } + + for _, s in ipairs(sessions) do + local marker = s.id == active_id and " * " or " " + local state_str = s.state or "persisted" + table.insert(lines, string.format("%s[%s] %s (%s)", marker, state_str:upper(), s.name, s.host)) + end + + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) + end, { + desc = "List all remote sessions", + }) + + -- :RemoteSessionPicker - Show session picker explicitly + vim.api.nvim_create_user_command("RemoteSessionPicker", function(opts) + local picker = require("remote-session.picker") + picker.show() + end, { + desc = "Show the remote session picker", + }) + + -- :RemoteSessionRestore [id] - Restore a specific session + vim.api.nvim_create_user_command("RemoteSessionRestore", function(opts) + local session = require("remote-session.session") + local session_manager = require("remote-session.session_manager") + + if opts.args == "" then + -- Show picker + local picker = require("remote-session.picker") + picker.show() + return + end + + -- Try to find session by ID or name + local sessions = session_manager.get_all_for_picker() + local target = nil + + for _, s in ipairs(sessions) do + if s.id == opts.args or s.name == opts.args then + target = s + break + end + end + + if not target then + vim.notify("[remote-session] Session not found: " .. opts.args, vim.log.levels.ERROR) + return + end + + session.restore(target.id) + end, { + nargs = "?", + desc = "Restore a minimized or persisted session", + complete = function(arglead, cmdline, cursorpos) + local session_manager = require("remote-session.session_manager") + local sessions = session_manager.get_all_for_picker() + + local completions = {} + for _, s in ipairs(sessions) do + if s.state ~= "active" then + if s.name:find(arglead, 1, true) then + table.insert(completions, s.name) + end + end + end + + return completions + end, + }) +end + +return M diff --git a/lua/remote-session/config.lua b/lua/remote-session/config.lua new file mode 100644 index 0000000..767761e --- /dev/null +++ b/lua/remote-session/config.lua @@ -0,0 +1,128 @@ +-- Configuration module for remote-session +local M = {} + +-- Default configuration +local defaults = { + -- Auto-minimize active session when opening a new one + auto_minimize = true, + + -- Confirm before closing sessions with unsaved buffers + confirm_close = true, + + -- Confirm before closing sessions with running terminal processes + confirm_terminal_close = true, + + -- Default window layout ratios (proportional 0.0-1.0) + default_layout = { + tree_browser_width_ratio = 0.2, -- 20% of editor width + terminal_height_ratio = 0.3, -- 30% of editor height + }, + + -- Statusline component configuration + statusline = { + -- Show minimized session count + show_minimized_count = true, + -- Format string for active session display + -- Available: {name}, {host}, {path}, {minimized_count} + format = "SSH: {name}", + -- Format when showing minimized count + format_with_minimized = "SSH: {name} [+{minimized_count} minimized]", + -- Text shown when no active session + no_session_text = "", + }, + + -- Picker UI configuration + picker = { + -- Width of the picker window (percentage or absolute) + width = 0.6, + -- Maximum height of picker window + max_height = 20, + -- Show help hints in picker header + show_hints = true, + }, + + -- Persistence configuration + persistence = { + -- Enable persistence across Neovim restarts + enabled = true, + -- Maximum number of persisted sessions to keep + max_persisted = 50, + -- Save expanded directories in tree browser + save_expanded_dirs = true, + }, + + -- Terminal configuration + terminal = { + -- Auto-create terminal when opening a new session + auto_create = true, + -- Start directory for new terminals (relative to session path) + start_in_session_path = true, + }, +} + +-- Current configuration (merged with defaults) +M.config = vim.deepcopy(defaults) + +--- Setup configuration with user options +---@param opts table|nil User configuration options +function M.setup(opts) + if opts then + M.config = vim.tbl_deep_extend("force", defaults, opts) + else + M.config = vim.deepcopy(defaults) + end +end + +--- Get a configuration value by path +--- Example: M.get("statusline", "format") returns config.statusline.format +---@vararg string Configuration path segments +---@return any value +function M.get(...) + local path = { ... } + local value = M.config + + for _, key in ipairs(path) do + if type(value) ~= "table" then + return nil + end + value = value[key] + end + + return value +end + +--- Set a configuration value by path +---@vararg any Path segments followed by value +function M.set(...) + local args = { ... } + if #args < 2 then + return + end + + local value = args[#args] + local path = { unpack(args, 1, #args - 1) } + + local current = M.config + for i = 1, #path - 1 do + local key = path[i] + if type(current[key]) ~= "table" then + current[key] = {} + end + current = current[key] + end + + current[path[#path]] = value +end + +--- Reset configuration to defaults +function M.reset() + M.config = vim.deepcopy(defaults) +end + +--- Get all defaults +---@return table defaults +function M.get_defaults() + return vim.deepcopy(defaults) +end + +return M diff --git a/lua/remote-session/init.lua b/lua/remote-session/init.lua new file mode 100644 index 0000000..4a6339f --- /dev/null +++ b/lua/remote-session/init.lua @@ -0,0 +1,208 @@ +-- Remote Session - Unified session management for remote-ssh.nvim +-- Combines file browser + terminal into cohesive, persistable sessions +local M = {} + +local config = require("remote-session.config") +local session_manager = require("remote-session.session_manager") +local session = require("remote-session.session") +local picker = require("remote-session.picker") +local statusline = require("remote-session.statusline") +local commands = require("remote-session.commands") + +--- Setup remote-session with configuration +---@param opts table|nil Configuration options +function M.setup(opts) + -- Apply configuration + config.setup(opts) + + -- Initialize session manager (loads persistence) + session_manager.init() + + -- Register user commands + commands.register() +end + +-- ============================================================================= +-- Public API +-- ============================================================================= + +--- Open a session by URL or show picker if no URL provided +---@param url string|nil Remote URL (e.g., "user@host//path" or "rsync://user@host//path") +---@param opts table|nil Options {name, open_terminal} +function M.open(url, opts) + session.open(url, opts) +end + +--- Create a new session +---@param url string Remote URL +---@param opts table|nil Options {name, open_terminal} +---@return table|nil session Created session +function M.create(url, opts) + return session.create(url, opts) +end + +--- Minimize the current active session +---@return boolean success +function M.minimize() + local active_id = session_manager.get_active_session_id() + if not active_id then + vim.notify("[remote-session] No active session to minimize", vim.log.levels.WARN) + return false + end + return session.minimize(active_id) +end + +--- Restore a minimized or persisted session +---@param session_id string Session ID +---@return boolean success +function M.restore(session_id) + return session.restore(session_id) +end + +--- Close a session +---@param session_id string|nil Session ID (uses active if nil) +---@param opts table|nil Options {force} +---@return boolean success +function M.close(session_id, opts) + session_id = session_id or session_manager.get_active_session_id() + if not session_id then + vim.notify("[remote-session] No session to close", vim.log.levels.WARN) + return false + end + return session.close(session_id, opts) +end + +--- Rename a session +---@param session_id string|nil Session ID (uses active if nil) +---@param new_name string|nil New name (prompts if nil) +function M.rename(session_id, new_name) + session.rename(session_id, new_name) +end + +--- Show the session picker +function M.show_picker() + picker.show() +end + +-- ============================================================================= +-- Query API +-- ============================================================================= + +--- Get the currently active session +---@return table|nil session +function M.get_active_session() + return session_manager.get_active_session() +end + +--- Get all minimized sessions +---@return table[] sessions +function M.get_minimized_sessions() + return session_manager.get_minimized_sessions() +end + +--- Get count of minimized sessions +---@return number count +function M.get_minimized_count() + return session_manager.get_minimized_count() +end + +--- Get all sessions (for picker display) +---@return table[] sessions +function M.get_all_sessions() + return session_manager.get_all_for_picker() +end + +--- Find a session by URL +---@param url string Remote URL +---@return table|nil session +function M.find_by_url(url) + return session_manager.find_by_url(session.normalize_url(url)) +end + +--- Check if a session is active +---@param session_id string +---@return boolean +function M.is_active(session_id) + return session_manager.is_active(session_id) +end + +--- Check if a session is minimized +---@param session_id string +---@return boolean +function M.is_minimized(session_id) + return session_manager.is_minimized(session_id) +end + +-- ============================================================================= +-- Statusline API +-- ============================================================================= + +--- Get statusline component string +--- Returns formatted string showing active session and minimized count +--- Example: "SSH: signal-lemon-rs @ bizon-rf [+2 minimized]" +---@return string component +function M.statusline_component() + return statusline.component() +end + +--- Get minimal statusline component (just session name) +---@return string component +function M.statusline_minimal() + return statusline.minimal() +end + +--- Get icon-based statusline component +---@return string component +function M.statusline_icon() + return statusline.icon() +end + +--- Get detailed statusline component +---@return string component +function M.statusline_detailed() + return statusline.detailed() +end + +--- Get lualine-compatible component +---@return table component +function M.lualine() + return statusline.lualine() +end + +--- Check if there's an active session (for statusline conditions) +---@return boolean +function M.has_active_session() + return statusline.has_active_session() +end + +--- Check if there are any sessions (for statusline conditions) +---@return boolean +function M.has_any_session() + return statusline.has_any_session() +end + +-- ============================================================================= +-- Utility API +-- ============================================================================= + +--- Normalize a URL to full rsync:// format +---@param input string +---@return string normalized_url +function M.normalize_url(input) + return session.normalize_url(input) +end + +--- Get configuration value +---@vararg string Path segments +---@return any +function M.get_config(...) + return config.get(...) +end + +--- Set configuration value +---@vararg any Path segments followed by value +function M.set_config(...) + config.set(...) +end + +return M diff --git a/lua/remote-session/naming.lua b/lua/remote-session/naming.lua new file mode 100644 index 0000000..1c8b60b --- /dev/null +++ b/lua/remote-session/naming.lua @@ -0,0 +1,149 @@ +-- Naming module for remote-session +-- Handles auto-name generation for sessions +local M = {} + +local session_manager = require("remote-session.session_manager") + +--- Extract the last directory component from a path +---@param path string +---@return string +local function get_last_dir(path) + -- Remove trailing slash if present + path = path:gsub("/$", "") + + -- Handle root path + if path == "" or path == "/" then + return "root" + end + + -- Extract last component + local last = path:match("([^/]+)$") + return last or "root" +end + +--- Extract a short host identifier +---@param host string Full host string (may include user@) +---@return string Short host identifier +local function get_short_host(host) + -- Remove user@ prefix if present + host = host:gsub("^[^@]+@", "") + + -- Remove domain suffix for common patterns + -- e.g., "myserver.example.com" -> "myserver" + local short = host:match("^([^%.]+)") + + return short or host +end + +--- Generate an auto-name for a session +--- Format: "{last_dir} @ {short_host}" +---@param host string Full host string +---@param path string Remote directory path +---@return string name Auto-generated name +function M.generate_name(host, path) + local dir_name = get_last_dir(path) + local short_host = get_short_host(host) + + return dir_name .. " @ " .. short_host +end + +--- Make a name unique by appending a suffix if needed +---@param base_name string The base name to make unique +---@param exclude_session_id string|nil Session ID to exclude from collision check +---@return string unique_name +function M.make_unique(base_name, exclude_session_id) + local all_sessions = session_manager.get_all_for_picker() + + -- Collect existing names (excluding the session we're renaming) + local existing_names = {} + for _, session in ipairs(all_sessions) do + if session.id ~= exclude_session_id then + existing_names[session.name] = true + end + end + + -- If base name doesn't exist, use it + if not existing_names[base_name] then + return base_name + end + + -- Try adding numeric suffix + local suffix = 2 + while true do + local candidate = base_name .. " (" .. suffix .. ")" + if not existing_names[candidate] then + return candidate + end + suffix = suffix + 1 + + -- Safety limit + if suffix > 100 then + return base_name .. " (" .. os.time() .. ")" + end + end +end + +--- Generate a unique auto-name for a new session +---@param host string Full host string +---@param path string Remote directory path +---@return string name Unique auto-generated name +function M.generate_unique_name(host, path) + local base_name = M.generate_name(host, path) + return M.make_unique(base_name) +end + +--- Validate a user-provided session name +---@param name string +---@return boolean is_valid +---@return string|nil error_message +function M.validate_name(name) + if not name or name == "" then + return false, "Name cannot be empty" + end + + if #name > 100 then + return false, "Name cannot exceed 100 characters" + end + + -- Check for invalid characters + if name:match("[%c]") then + return false, "Name cannot contain control characters" + end + + return true, nil +end + +--- Parse a session name to extract components (if possible) +--- This is the inverse of generate_name, useful for display +---@param name string +---@return table|nil components {dir_name, host} +function M.parse_name(name) + local dir_name, host = name:match("^(.+) @ (.+)$") + if dir_name and host then + return { + dir_name = dir_name, + host = host, + } + end + return nil +end + +--- Suggest a name based on URL +---@param url string Remote URL (rsync:// or similar) +---@return string|nil suggested_name +function M.suggest_from_url(url) + -- Try to parse the URL + local ok, utils = pcall(require, "async-remote-write.utils") + if not ok then + return nil + end + + local remote_info = utils.parse_remote_path(url) + if not remote_info then + return nil + end + + return M.generate_unique_name(remote_info.host, remote_info.path) +end + +return M diff --git a/lua/remote-session/persistence.lua b/lua/remote-session/persistence.lua new file mode 100644 index 0000000..7360858 --- /dev/null +++ b/lua/remote-session/persistence.lua @@ -0,0 +1,243 @@ +-- Persistence module for remote-session +-- Handles JSON file I/O for saving/loading sessions +local M = {} + +local config = require("remote-session.config") + +-- Storage path +local data_dir = vim.fn.stdpath("data") .. "/remote-ssh" +local data_path = data_dir .. "/sessions.json" + +-- Cached session data +local session_data = { + sessions = {}, -- Map of session_id -> session_entry + version = 1, -- Schema version for future migrations + last_updated = nil, +} + +--- Ensure the data directory exists +local function ensure_data_dir() + if vim.fn.isdirectory(data_dir) == 0 then + vim.fn.mkdir(data_dir, "p") + end +end + +--- Load session data from disk +---@return boolean success +function M.load() + if not config.get("persistence", "enabled") then + return false + end + + local file = io.open(data_path, "r") + if not file then + -- No existing data, start fresh + return true + end + + local content = file:read("*all") + file:close() + + if not content or content == "" then + return true + end + + local ok, data = pcall(vim.json.decode, content) + if not ok or not data then + vim.notify("[remote-session] Failed to parse session data, starting fresh", vim.log.levels.WARN) + return false + end + + -- Validate and migrate if needed + if data.version and data.version > session_data.version then + vim.notify("[remote-session] Session data from newer version, some features may not work", vim.log.levels.WARN) + end + + session_data.sessions = data.sessions or {} + session_data.version = data.version or 1 + session_data.last_updated = data.last_updated + + return true +end + +--- Save session data to disk +---@return boolean success +function M.save() + if not config.get("persistence", "enabled") then + return false + end + + ensure_data_dir() + + session_data.last_updated = os.time() + + -- Trim to max persisted if needed + local max_persisted = config.get("persistence", "max_persisted") or 50 + local sessions_list = {} + for id, session in pairs(session_data.sessions) do + table.insert(sessions_list, { id = id, session = session }) + end + + -- Sort by last_accessed_at (most recent first) + table.sort(sessions_list, function(a, b) + return (a.session.last_accessed_at or 0) > (b.session.last_accessed_at or 0) + end) + + -- Keep only max_persisted sessions + if #sessions_list > max_persisted then + local trimmed = {} + for i = 1, max_persisted do + trimmed[sessions_list[i].id] = sessions_list[i].session + end + session_data.sessions = trimmed + end + + local ok, content = pcall(vim.json.encode, session_data) + if not ok then + vim.notify("[remote-session] Failed to encode session data", vim.log.levels.ERROR) + return false + end + + local file = io.open(data_path, "w") + if not file then + vim.notify("[remote-session] Failed to write session data to " .. data_path, vim.log.levels.ERROR) + return false + end + + file:write(content) + file:close() + + return true +end + +--- Get a session by ID +---@param session_id string +---@return table|nil session +function M.get_session(session_id) + return session_data.sessions[session_id] +end + +--- Get all persisted sessions +---@return table sessions Map of session_id -> session_entry +function M.get_all_sessions() + return vim.deepcopy(session_data.sessions) +end + +--- Get sessions sorted by last access time (most recent first) +---@return table[] sessions +function M.get_sessions_sorted() + local list = {} + for id, session in pairs(session_data.sessions) do + session.id = id + table.insert(list, session) + end + + table.sort(list, function(a, b) + return (a.last_accessed_at or 0) > (b.last_accessed_at or 0) + end) + + return list +end + +--- Save or update a session +---@param session table Session data to save +---@return boolean success +function M.save_session(session) + if not session or not session.id then + return false + end + + -- Clone to avoid references + local session_copy = vim.deepcopy(session) + + -- Remove runtime-only fields that shouldn't be persisted + session_copy.terminal_ids = nil -- Terminals are recreated + + -- Set state to persisted if not already set + if session_copy.state ~= "minimized" then + session_copy.state = "persisted" + end + + session_data.sessions[session.id] = session_copy + + return M.save() +end + +--- Delete a session +---@param session_id string +---@return boolean success +function M.delete_session(session_id) + if not session_data.sessions[session_id] then + return false + end + + session_data.sessions[session_id] = nil + return M.save() +end + +--- Check if a session with the given URL exists +---@param url string +---@return table|nil session +function M.find_session_by_url(url) + for id, session in pairs(session_data.sessions) do + if session.url == url then + session.id = id + return session + end + end + return nil +end + +--- Clear all persisted sessions +---@return boolean success +function M.clear_all() + session_data.sessions = {} + return M.save() +end + +--- Get statistics about persisted sessions +---@return table stats +function M.get_stats() + local count = 0 + local by_state = { active = 0, minimized = 0, persisted = 0 } + local oldest_access = math.huge + local newest_access = 0 + + for _, session in pairs(session_data.sessions) do + count = count + 1 + local state = session.state or "persisted" + by_state[state] = (by_state[state] or 0) + 1 + + local access_time = session.last_accessed_at or session.created_at or 0 + if access_time < oldest_access then + oldest_access = access_time + end + if access_time > newest_access then + newest_access = access_time + end + end + + return { + total_count = count, + by_state = by_state, + oldest_access = oldest_access ~= math.huge and oldest_access or nil, + newest_access = newest_access > 0 and newest_access or nil, + last_updated = session_data.last_updated, + } +end + +--- Initialize persistence module +function M.init() + M.load() + + -- Setup auto-save on Neovim exit + vim.api.nvim_create_autocmd("VimLeavePre", { + callback = function() + M.save() + end, + group = vim.api.nvim_create_augroup("RemoteSessionPersistence", { clear = true }), + desc = "Save remote session data on Neovim exit", + }) +end + +return M diff --git a/lua/remote-session/picker.lua b/lua/remote-session/picker.lua new file mode 100644 index 0000000..8ea606c --- /dev/null +++ b/lua/remote-session/picker.lua @@ -0,0 +1,527 @@ +-- Session picker UI for remote-session +-- Floating window for session selection and management +local M = {} + +local config = require("remote-session.config") +local session_manager = require("remote-session.session_manager") + +-- Picker state +local PickerState = { + bufnr = nil, + win_id = nil, + items = {}, + selected_idx = 1, + filter_text = "", + mode = "normal", -- 'normal' or 'filter' +} + +--- Setup highlight groups for the picker +local function setup_highlight_groups() + local highlights = { + RemoteSessionActive = { fg = "#90caf9", bold = true }, + RemoteSessionMinimized = { fg = "#ffb74d" }, + RemoteSessionPersisted = { fg = "#9e9e9e" }, + RemoteSessionSelected = { bg = "#404040" }, + RemoteSessionHeader = { fg = "#ffffff", bold = true }, + RemoteSessionHelp = { fg = "#888888", italic = true }, + RemoteSessionFilter = { fg = "#4caf50" }, + } + + for hl_name, hl_def in pairs(highlights) do + if vim.fn.hlexists(hl_name) == 0 then + vim.api.nvim_set_hl(0, hl_name, hl_def) + end + end +end + +--- Get state icon and highlight for a session +---@param session table +---@return string icon, string hl_group +local function get_state_display(session) + local state = session.state or "persisted" + + if state == "active" then + return "[ACTIVE] ", "RemoteSessionActive" + elseif state == "minimized" then + return "[MINIMIZED]", "RemoteSessionMinimized" + else + return "[HISTORY] ", "RemoteSessionPersisted" + end +end + +--- Format a session entry for display +---@param session table +---@param is_selected boolean +---@return string line +---@return table[] highlights +local function format_session_entry(session, is_selected) + local state_icon, state_hl = get_state_display(session) + local prefix = is_selected and "▶ " or " " + local line = prefix .. state_icon .. " " .. session.name + + local highlights = {} + + -- Selection highlight + if is_selected then + table.insert(highlights, { + hl_group = "RemoteSessionSelected", + col_start = 0, + col_end = -1, + }) + end + + -- State highlight + table.insert(highlights, { + hl_group = state_hl, + col_start = #prefix, + col_end = #prefix + #state_icon, + }) + + return line, highlights +end + +--- Filter sessions based on filter text +---@return table[] filtered_sessions +local function filter_sessions() + local all_sessions = session_manager.get_all_for_picker() + + if PickerState.filter_text == "" then + return all_sessions + end + + local filter_lower = string.lower(PickerState.filter_text) + local filtered = {} + + for _, session in ipairs(all_sessions) do + local name_lower = string.lower(session.name or "") + local host_lower = string.lower(session.host or "") + local path_lower = string.lower(session.path or "") + + if + name_lower:find(filter_lower, 1, true) + or host_lower:find(filter_lower, 1, true) + or path_lower:find(filter_lower, 1, true) + then + table.insert(filtered, session) + end + end + + return filtered +end + +--- Calculate optimal window size +---@return number width, number height +local function calculate_window_size() + local picker_config = config.get("picker") or {} + local width_config = picker_config.width or 0.6 + local max_height = picker_config.max_height or 20 + + local editor_width = vim.o.columns + local editor_height = vim.o.lines + + -- Calculate width + local width + if type(width_config) == "number" and width_config < 1 then + width = math.floor(editor_width * width_config) + else + width = math.min(width_config, editor_width - 4) + end + width = math.max(width, 50) + + -- Calculate height based on content + local sessions = session_manager.get_all_for_picker() + local content_height = 7 + #sessions -- header + help + filter + sessions + + local height = math.min(content_height, max_height, editor_height - 4) + height = math.max(height, 10) + + return width, height +end + +--- Refresh the picker display +local function refresh_display() + if not PickerState.bufnr or not vim.api.nvim_buf_is_valid(PickerState.bufnr) then + return + end + + PickerState.items = filter_sessions() + + -- Clamp selected index + if PickerState.selected_idx > #PickerState.items then + PickerState.selected_idx = math.max(1, #PickerState.items) + end + + local lines = {} + local all_highlights = {} + + -- Get window width for formatting + local width = 60 + if PickerState.win_id and vim.api.nvim_win_is_valid(PickerState.win_id) then + width = vim.api.nvim_win_get_width(PickerState.win_id) + end + + -- Header + local show_hints = config.get("picker", "show_hints") ~= false + if show_hints then + local title = " Remote Sessions " + local top_line = "╭─" .. title .. string.rep("─", math.max(0, width - #title - 4)) .. "╮" + table.insert(lines, top_line) + + local help = ":Open :Minimize :Delete :Rename :Filter :Quit" + local help_line = "│ " .. help .. string.rep(" ", math.max(0, width - #help - 4)) .. " │" + table.insert(lines, help_line) + table.insert(all_highlights, { line = 1, hl_group = "RemoteSessionHelp", col_start = 2, col_end = #help + 2 }) + + local bottom_line = "╰" .. string.rep("─", width - 2) .. "╯" + table.insert(lines, bottom_line) + table.insert(lines, "") + end + + -- Filter line + local filter_line = "Filter: " .. PickerState.filter_text + if PickerState.mode == "filter" then + filter_line = filter_line .. "█" + table.insert(all_highlights, { + line = #lines, + hl_group = "RemoteSessionFilter", + col_start = 0, + col_end = -1, + }) + end + table.insert(lines, filter_line) + table.insert(lines, "") + + local content_start_line = #lines + + -- Session entries + if #PickerState.items == 0 then + table.insert(lines, " No sessions found") + table.insert(all_highlights, { + line = #lines - 1, + hl_group = "Comment", + col_start = 0, + col_end = -1, + }) + else + for i, session in ipairs(PickerState.items) do + local is_selected = (i == PickerState.selected_idx) + local line, highlights = format_session_entry(session, is_selected) + + table.insert(lines, line) + + local line_idx = #lines - 1 + for _, hl in ipairs(highlights) do + hl.line = line_idx + table.insert(all_highlights, hl) + end + end + end + + -- Update buffer + vim.api.nvim_buf_set_option(PickerState.bufnr, "modifiable", true) + vim.api.nvim_buf_set_lines(PickerState.bufnr, 0, -1, false, lines) + vim.api.nvim_buf_set_option(PickerState.bufnr, "modifiable", false) + + -- Apply highlights + local ns_id = vim.api.nvim_create_namespace("RemoteSessionPicker") + vim.api.nvim_buf_clear_namespace(PickerState.bufnr, ns_id, 0, -1) + + for _, hl in ipairs(all_highlights) do + vim.api.nvim_buf_add_highlight(PickerState.bufnr, ns_id, hl.hl_group, hl.line, hl.col_start, hl.col_end) + end +end + +--- Navigate in the picker +---@param direction number 1 for down, -1 for up +local function navigate(direction) + if #PickerState.items == 0 then + return + end + + PickerState.selected_idx = PickerState.selected_idx + direction + + if PickerState.selected_idx < 1 then + PickerState.selected_idx = #PickerState.items + elseif PickerState.selected_idx > #PickerState.items then + PickerState.selected_idx = 1 + end + + refresh_display() +end + +--- Get currently selected session +---@return table|nil session +local function get_selected_session() + if #PickerState.items == 0 then + return nil + end + return PickerState.items[PickerState.selected_idx] +end + +--- Open/restore selected session +local function open_selected() + local session = get_selected_session() + if not session then + return + end + + M.close() + + local session_module = require("remote-session.session") + + if session.state == "active" then + -- Already active, nothing to do + vim.notify("[remote-session] Session is already active", vim.log.levels.INFO) + else + session_module.restore(session.id) + end +end + +--- Minimize selected session +local function minimize_selected() + local session = get_selected_session() + if not session then + return + end + + if session.state ~= "active" then + vim.notify("[remote-session] Can only minimize active sessions", vim.log.levels.WARN) + return + end + + local session_module = require("remote-session.session") + session_module.minimize(session.id) + + refresh_display() +end + +--- Delete selected session +local function delete_selected() + local session = get_selected_session() + if not session then + return + end + + local choice = vim.fn.confirm("Delete session: " .. session.name .. "?", "&Yes\n&No", 2) + if choice ~= 1 then + return + end + + local session_module = require("remote-session.session") + session_module.close(session.id, { force = true }) + + refresh_display() +end + +--- Rename selected session +local function rename_selected() + local session = get_selected_session() + if not session then + return + end + + M.close() + + local session_module = require("remote-session.session") + session_module.rename(session.id) +end + +--- Handle filter input +---@param char string +local function handle_filter_input(char) + if char == "" then -- Backspace + PickerState.filter_text = string.sub(PickerState.filter_text, 1, -2) + else + PickerState.filter_text = PickerState.filter_text .. char + end + + PickerState.selected_idx = 1 + refresh_display() +end + +--- Setup keymaps for the picker +local function setup_keymaps() + local opts = { noremap = true, silent = true, buffer = PickerState.bufnr } + + -- Navigation + vim.keymap.set("n", "j", function() + if PickerState.mode == "filter" then + handle_filter_input("j") + else + navigate(1) + end + end, opts) + + vim.keymap.set("n", "k", function() + if PickerState.mode == "filter" then + handle_filter_input("k") + else + navigate(-1) + end + end, opts) + + vim.keymap.set("n", "", function() + navigate(1) + end, opts) + vim.keymap.set("n", "", function() + navigate(-1) + end, opts) + + -- Selection + vim.keymap.set("n", "", open_selected, opts) + vim.keymap.set("n", "", open_selected, opts) + + -- Minimize + vim.keymap.set("n", "m", function() + if PickerState.mode == "filter" then + handle_filter_input("m") + else + minimize_selected() + end + end, opts) + + -- Delete + vim.keymap.set("n", "d", function() + if PickerState.mode == "filter" then + handle_filter_input("d") + else + delete_selected() + end + end, opts) + + -- Rename + vim.keymap.set("n", "r", function() + if PickerState.mode == "filter" then + handle_filter_input("r") + else + rename_selected() + end + end, opts) + + -- Filter mode + vim.keymap.set("n", "/", function() + PickerState.mode = "filter" + refresh_display() + end, opts) + + -- Exit filter mode + vim.keymap.set("n", "", function() + if PickerState.mode == "filter" then + PickerState.mode = "normal" + refresh_display() + else + M.close() + end + end, opts) + + -- Clear filter + vim.keymap.set("n", "", function() + PickerState.filter_text = "" + PickerState.selected_idx = 1 + PickerState.mode = "normal" + refresh_display() + end, opts) + + -- Close + vim.keymap.set("n", "q", function() + if PickerState.mode == "filter" then + handle_filter_input("q") + else + M.close() + end + end, opts) + + -- Backspace in filter mode + vim.keymap.set("n", "", function() + if PickerState.mode == "filter" then + handle_filter_input("") + end + end, opts) + + -- Character input for filter mode + local chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_@:/ \\" + for i = 1, #chars do + local char = chars:sub(i, i) + if not vim.tbl_contains({ "j", "k", "m", "d", "r", "q", "/" }, char) then + vim.keymap.set("n", char, function() + if PickerState.mode == "filter" then + handle_filter_input(char) + end + end, opts) + end + end +end + +--- Show the session picker +function M.show() + -- Close existing picker if open + if PickerState.bufnr and vim.api.nvim_buf_is_valid(PickerState.bufnr) then + M.close() + end + + setup_highlight_groups() + + -- Create buffer + PickerState.bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(PickerState.bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(PickerState.bufnr, "swapfile", false) + vim.api.nvim_buf_set_option(PickerState.bufnr, "modifiable", false) + vim.api.nvim_buf_set_option(PickerState.bufnr, "filetype", "remote-session-picker") + vim.api.nvim_buf_set_name(PickerState.bufnr, "Remote Sessions") + + -- Calculate window size + local width, height = calculate_window_size() + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + -- Create floating window + PickerState.win_id = vim.api.nvim_open_win(PickerState.bufnr, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = " Remote Sessions ", + title_pos = "center", + }) + + -- Window options + vim.api.nvim_win_set_option(PickerState.win_id, "wrap", false) + vim.api.nvim_win_set_option(PickerState.win_id, "cursorline", false) + + -- Setup keymaps + setup_keymaps() + + -- Reset state + PickerState.selected_idx = 1 + PickerState.filter_text = "" + PickerState.mode = "normal" + + -- Initial display + refresh_display() +end + +--- Close the session picker +function M.close() + if PickerState.win_id and vim.api.nvim_win_is_valid(PickerState.win_id) then + vim.api.nvim_win_close(PickerState.win_id, false) + end + + if PickerState.bufnr and vim.api.nvim_buf_is_valid(PickerState.bufnr) then + vim.api.nvim_buf_delete(PickerState.bufnr, { force = true }) + end + + PickerState.bufnr = nil + PickerState.win_id = nil + PickerState.items = {} +end + +--- Check if picker is open +---@return boolean +function M.is_open() + return PickerState.bufnr ~= nil and vim.api.nvim_buf_is_valid(PickerState.bufnr) +end + +return M diff --git a/lua/remote-session/session.lua b/lua/remote-session/session.lua new file mode 100644 index 0000000..e3609f7 --- /dev/null +++ b/lua/remote-session/session.lua @@ -0,0 +1,610 @@ +-- Session lifecycle module for remote-session +-- Handles create, open, minimize, restore, close operations +local M = {} + +local config = require("remote-session.config") +local session_manager = require("remote-session.session_manager") +local naming = require("remote-session.naming") +local window_layout = require("remote-session.window_layout") +local persistence = require("remote-session.persistence") + +--- Normalize a URL input to full rsync:// format +---@param input string User input URL +---@return string normalized_url +function M.normalize_url(input) + if not input or input == "" then + return "" + end + + -- If already has protocol, use as-is + if input:match("^rsync://") or input:match("^scp://") then + return input + end + + -- Otherwise, prepend rsync:// + return "rsync://" .. input +end + +--- Parse URL to extract host and path +---@param url string Normalized URL +---@return table|nil parsed {host, path, user, port} +function M.parse_url(url) + local ok, utils = pcall(require, "async-remote-write.utils") + if not ok then + return nil + end + + return utils.parse_remote_path(url) +end + +--- Create a new session +---@param url string Remote URL (will be normalized) +---@param opts table|nil Options {name, open_terminal} +---@return table|nil session The created session +function M.create(url, opts) + opts = opts or {} + + -- Normalize URL + url = M.normalize_url(url) + if url == "" then + vim.notify("[remote-session] Invalid URL", vim.log.levels.ERROR) + return nil + end + + -- Parse URL + local parsed = M.parse_url(url) + if not parsed then + vim.notify("[remote-session] Failed to parse URL: " .. url, vim.log.levels.ERROR) + return nil + end + + -- Check if session for this URL already exists + local existing = session_manager.find_by_url(url) + if existing then + -- Offer to switch to existing session + local choice = vim.fn.confirm( + "Session for this URL already exists: " .. existing.name .. "\nSwitch to existing session?", + "&Yes\n&No, create new", + 1 + ) + if choice == 1 then + M.restore(existing.id) + return existing + end + end + + -- Generate name + local name = opts.name or naming.generate_unique_name(parsed.host, parsed.path) + + -- Create session object + local session = { + name = name, + url = url, + host = parsed.host, + path = parsed.path, + state = "active", + created_at = os.time(), + last_accessed_at = os.time(), + window_layout = window_layout.get_default_layout(), + tree_browser_state = nil, + terminal_ids = {}, + open_buffers = {}, + } + + -- Minimize current active session if configured + local current_active = session_manager.get_active_session() + if current_active and config.get("auto_minimize") then + M.minimize(current_active.id) + end + + -- Register the session + local session_id = session_manager.register_session(session) + session_manager.set_active(session_id) + + -- Open tree browser + local ok_tree, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok_tree then + tree_browser.open_tree(url) + end + + -- Create terminal if configured + local open_terminal = opts.open_terminal + if open_terminal == nil then + open_terminal = config.get("terminal", "auto_create") + end + + if open_terminal then + M.create_terminal_for_session(session_id) + end + + vim.notify("[remote-session] Created session: " .. name, vim.log.levels.INFO) + + return session +end + +--- Create a terminal for a session +---@param session_id string +---@return number|nil terminal_id +function M.create_terminal_for_session(session_id) + local session = session_manager.get_session(session_id) + if not session then + return nil + end + + local ok, terminal_session = pcall(require, "remote-terminal.terminal_session") + if not ok then + return nil + end + + -- Build connection info from session + local user, host = nil, session.host + local user_host = session.host:match("^([^@]+)@(.+)$") + if user_host then + user, host = session.host:match("^([^@]+)@(.+)$") + end + + local connection_info = { + user = user, + host = host, + port = nil, + path = session.path, + } + + -- Create terminal + local terminal = terminal_session.create_session(connection_info, function(new_session) + if new_session then + -- Associate terminal with session + session_manager.add_terminal(session_id, new_session.id) + + -- Also register in terminal_manager + local ok_tm, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok_tm and terminal_manager.associate_terminal_with_session then + terminal_manager.associate_terminal_with_session(new_session.id, session_id) + end + + -- Show the terminal split window + local ok_wm, window_manager = pcall(require, "remote-terminal.window_manager") + if ok_wm then + if not terminal_manager.is_split_visible() or not window_manager.is_layout_valid() then + window_manager.create_split(new_session.bufnr) + -- Initialize picker keymaps + local ok_picker, picker = pcall(require, "remote-terminal.picker") + if ok_picker then + local picker_bufnr = terminal_manager.get_picker_bufnr() + if picker_bufnr then + picker.init_buffer(picker_bufnr) + end + picker.refresh() + end + else + -- Just switch to the new terminal + window_manager.switch_terminal(new_session.id) + end + end + end + end) + + return terminal and terminal.id or nil +end + +--- Minimize a session +---@param session_id string +---@return boolean success +function M.minimize(session_id) + local session = session_manager.get_session(session_id) + if not session then + vim.notify("[remote-session] Session not found", vim.log.levels.ERROR) + return false + end + + -- Capture current window layout + session.window_layout = window_layout.capture_layout() + + -- Capture tree browser state + local ok_tree, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok_tree and tree_browser.is_open() then + local tree_state = tree_browser.get_state() + if tree_state then + session.tree_browser_state = { + expanded_dirs = tree_state.expanded_dirs, + -- We could also save cursor position here + } + end + end + + -- Capture open buffer positions + session.open_buffers = M.capture_buffer_states(session.url) + + -- Check for unsaved buffers + if config.get("confirm_close") then + local unsaved = M.get_unsaved_buffers(session.url) + if #unsaved > 0 then + local choice = vim.fn.confirm( + "Session has unsaved buffers:\n" + .. table.concat( + vim.tbl_map(function(b) + return " " .. vim.api.nvim_buf_get_name(b) + end, unsaved), + "\n" + ) + .. "\n\nMinimize anyway?", + "&Save all\n&Discard\n&Cancel", + 1 + ) + if choice == 1 then + for _, bufnr in ipairs(unsaved) do + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("write") + end) + end + elseif choice == 3 or choice == 0 then + return false + end + end + end + + -- Hide windows + window_layout.hide_session_windows() + + -- Update state + session_manager.set_minimized(session_id) + + vim.notify("[remote-session] Minimized: " .. session.name, vim.log.levels.INFO) + + return true +end + +--- Restore a minimized or persisted session +---@param session_id string +---@return boolean success +function M.restore(session_id) + local session = session_manager.get_session(session_id) + + -- If not in runtime, try to load from persistence + if not session then + session = session_manager.load_from_persistence(session_id) + end + + if not session then + vim.notify("[remote-session] Session not found", vim.log.levels.ERROR) + return false + end + + -- Remember original state before we change it + local original_state = session.state + + -- Minimize current active session if different + local current_active = session_manager.get_active_session() + if current_active and current_active.id ~= session_id and config.get("auto_minimize") then + M.minimize(current_active.id) + end + + -- Set as active + session_manager.set_active(session_id) + + -- Restore tree browser with state + local ok_tree, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok_tree then + if session.tree_browser_state then + -- Open tree with restored state + M.open_tree_with_state(session.url, session.tree_browser_state) + else + tree_browser.open_tree(session.url) + end + end + + -- Determine if we should show/create terminal + local should_show_terminal = true + if session.window_layout and session.window_layout.terminal_visible == false then + should_show_terminal = false + end + + if should_show_terminal then + -- Check if we have runtime terminals that still exist + local valid_terminal_id = nil + if session.terminal_ids and #session.terminal_ids > 0 then + local ok_tm, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok_tm then + for _, term_id in ipairs(session.terminal_ids) do + local term = terminal_manager.get_terminal(term_id) + if term and term.bufnr and vim.api.nvim_buf_is_valid(term.bufnr) then + valid_terminal_id = term_id + break + end + end + end + end + + if valid_terminal_id then + -- Set the active terminal to one from this session + local ok_tm, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok_tm then + terminal_manager.set_active_terminal(valid_terminal_id) + end + + -- Show existing terminals + local ok_wm, window_manager = pcall(require, "remote-terminal.window_manager") + if ok_wm then + window_manager.show_split() + end + else + -- No valid terminals exist, create a new one + -- This handles both persisted sessions and minimized sessions where terminal was closed + M.create_terminal_for_session(session_id) + end + end + + -- Apply window layout + window_layout.apply_layout(session.window_layout, session.url) + + -- Restore buffer cursor positions + if session.open_buffers then + M.restore_buffer_states(session.open_buffers) + end + + vim.notify("[remote-session] Restored: " .. session.name, vim.log.levels.INFO) + + return true +end + +--- Open tree browser with restored state +---@param url string +---@param state table Tree browser state +function M.open_tree_with_state(url, state) + local ok, tree_browser = pcall(require, "async-remote-write.tree_browser") + if not ok then + return + end + + -- First open the tree + tree_browser.open_tree(url) + + -- Then restore expanded directories + if state and state.expanded_dirs then + -- Wait a bit for initial load, then restore state + vim.defer_fn(function() + tree_browser.restore_state({ + base_url = url, + expanded_dirs = state.expanded_dirs, + }) + end, 200) + end +end + +--- Close a session +---@param session_id string +---@param opts table|nil {force: boolean} +---@return boolean success +function M.close(session_id, opts) + opts = opts or {} + local session = session_manager.get_session(session_id) + + if not session then + -- Try to just delete from persistence + persistence.delete_session(session_id) + return true + end + + -- Check for unsaved buffers + if not opts.force and config.get("confirm_close") then + local unsaved = M.get_unsaved_buffers(session.url) + if #unsaved > 0 then + local choice = vim.fn.confirm( + "Session has unsaved buffers. Close anyway?", + "&Save all\n&Discard\n&Cancel", + 1 + ) + if choice == 1 then + for _, bufnr in ipairs(unsaved) do + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("write") + end) + end + elseif choice == 3 or choice == 0 then + return false + end + end + end + + -- Check for running terminals + if not opts.force and config.get("confirm_terminal_close") and session.terminal_ids and #session.terminal_ids > 0 then + local choice = vim.fn.confirm( + "Session has " .. #session.terminal_ids .. " terminal(s). Close session?", + "&Yes\n&No", + 1 + ) + if choice ~= 1 then + return false + end + end + + -- Close tree browser if this is the active session + if session_manager.is_active(session_id) then + local ok_tree, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok_tree then + tree_browser.close_tree() + end + end + + -- Close associated terminals + local ok_ts, terminal_session = pcall(require, "remote-terminal.terminal_session") + if ok_ts and session.terminal_ids then + for _, terminal_id in ipairs(session.terminal_ids) do + terminal_session.close_terminal(terminal_id) + end + end + + -- Close session buffers + M.close_session_buffers(session.url) + + -- Unregister from runtime + session_manager.unregister_session(session_id) + + -- Delete from persistence + persistence.delete_session(session_id) + + vim.notify("[remote-session] Closed: " .. session.name, vim.log.levels.INFO) + + return true +end + +--- Get unsaved buffers for a session URL +---@param url string Session URL +---@return number[] bufnrs +function M.get_unsaved_buffers(url) + local unsaved = {} + local host_pattern = url:match("://([^/]+)") + + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(bufnr) then + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname:match(host_pattern) and vim.bo[bufnr].modified then + table.insert(unsaved, bufnr) + end + end + end + + return unsaved +end + +--- Capture buffer states for a session +---@param url string Session URL +---@return table[] buffer_states +function M.capture_buffer_states(url) + local states = {} + local host_pattern = url:match("://([^/]+)") + + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(bufnr) then + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname:match(host_pattern) then + -- Find window displaying this buffer + local winid = nil + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + winid = win + break + end + end + + local cursor_pos = { 1, 0 } + if winid then + cursor_pos = vim.api.nvim_win_get_cursor(winid) + end + + table.insert(states, { + url = bufname, + cursor_pos = cursor_pos, + winid = winid, + }) + end + end + end + + return states +end + +--- Restore buffer cursor states +---@param states table[] Buffer states +function M.restore_buffer_states(states) + for _, state in ipairs(states) do + -- Find buffer by name + local bufnr = vim.fn.bufnr(state.url) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + -- Find window displaying this buffer + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + pcall(vim.api.nvim_win_set_cursor, win, state.cursor_pos) + break + end + end + end + end +end + +--- Close all buffers for a session +---@param url string Session URL +function M.close_session_buffers(url) + local host_pattern = url:match("://([^/]+)") + + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(bufnr) then + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname:match(host_pattern) then + -- Don't close tree browser buffer + if not bufname:match("^Remote Tree:") then + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + end + end + end + end +end + +--- Open a session by URL (creates new or restores existing) +---@param url string|nil URL to open (shows picker if nil) +---@param opts table|nil Options +function M.open(url, opts) + opts = opts or {} + + if not url or url == "" then + -- Show picker + local picker = require("remote-session.picker") + picker.show() + return + end + + -- Normalize URL + url = M.normalize_url(url) + + -- Check for existing session + local existing = session_manager.find_by_url(url) + if existing then + -- Restore existing session + M.restore(existing.id) + else + -- Create new session + M.create(url, opts) + end +end + +--- Rename the active session or a specific session +---@param session_id string|nil Session ID (uses active if nil) +---@param new_name string|nil New name (prompts if nil) +function M.rename(session_id, new_name) + session_id = session_id or session_manager.get_active_session_id() + if not session_id then + vim.notify("[remote-session] No active session to rename", vim.log.levels.WARN) + return + end + + local session = session_manager.get_session(session_id) + if not session then + vim.notify("[remote-session] Session not found", vim.log.levels.ERROR) + return + end + + if new_name then + -- Validate and apply + local valid, err = naming.validate_name(new_name) + if not valid then + vim.notify("[remote-session] Invalid name: " .. err, vim.log.levels.ERROR) + return + end + + local unique_name = naming.make_unique(new_name, session_id) + session_manager.rename_session(session_id, unique_name) + vim.notify("[remote-session] Renamed to: " .. unique_name, vim.log.levels.INFO) + else + -- Prompt for name + vim.ui.input({ + prompt = "New session name: ", + default = session.name, + }, function(input) + if input and input ~= "" then + M.rename(session_id, input) + end + end) + end +end + +return M diff --git a/lua/remote-session/session_manager.lua b/lua/remote-session/session_manager.lua new file mode 100644 index 0000000..9404cf1 --- /dev/null +++ b/lua/remote-session/session_manager.lua @@ -0,0 +1,404 @@ +-- Session manager for remote-session +-- Core state management for active and minimized sessions +local M = {} + +local config = require("remote-session.config") +local persistence = require("remote-session.persistence") + +---@class RemoteSession +---@field id string Unique ID (timestamp_random) +---@field name string Display name +---@field url string Base rsync:// URL +---@field host string Parsed hostname +---@field path string Remote directory path +---@field state "active"|"minimized"|"persisted" +---@field created_at number Unix timestamp +---@field last_accessed_at number Unix timestamp +---@field window_layout WindowLayout +---@field tree_browser_state TreeBrowserState|nil +---@field terminal_ids number[] Associated terminal IDs +---@field open_buffers BufferState[] + +---@class WindowLayout +---@field tree_browser_width_ratio number 0.0-1.0 of editor width +---@field terminal_height_ratio number 0.0-1.0 of editor height +---@field tree_browser_visible boolean +---@field terminal_visible boolean + +---@class TreeBrowserState +---@field expanded_dirs table URLs of expanded directories +---@field scroll_position number|nil +---@field cursor_line number|nil + +---@class BufferState +---@field url string Remote file URL +---@field cursor_pos number[] {line, col} +---@field winid number|nil + +-- Runtime state (not persisted) +local runtime_state = { + -- Currently active session (only one at a time) + active_session_id = nil, + + -- Minimized sessions (still loaded in memory) + minimized_sessions = {}, -- Map of session_id -> true + + -- All loaded sessions (active + minimized) + sessions = {}, -- Map of session_id -> RemoteSession +} + +--- Generate a unique session ID +---@return string id +local function generate_session_id() + return tostring(os.time()) .. "_" .. tostring(math.random(1000, 9999)) +end + +--- Get the currently active session +---@return RemoteSession|nil +function M.get_active_session() + if not runtime_state.active_session_id then + return nil + end + return runtime_state.sessions[runtime_state.active_session_id] +end + +--- Get the active session ID +---@return string|nil +function M.get_active_session_id() + return runtime_state.active_session_id +end + +--- Get a session by ID +---@param session_id string +---@return RemoteSession|nil +function M.get_session(session_id) + return runtime_state.sessions[session_id] +end + +--- Get all loaded sessions (active + minimized) +---@return table +function M.get_all_sessions() + return runtime_state.sessions +end + +--- Get all minimized sessions +---@return RemoteSession[] +function M.get_minimized_sessions() + local result = {} + for session_id in pairs(runtime_state.minimized_sessions) do + local session = runtime_state.sessions[session_id] + if session then + table.insert(result, session) + end + end + -- Sort by last_accessed_at + table.sort(result, function(a, b) + return (a.last_accessed_at or 0) > (b.last_accessed_at or 0) + end) + return result +end + +--- Get count of minimized sessions +---@return number +function M.get_minimized_count() + local count = 0 + for _ in pairs(runtime_state.minimized_sessions) do + count = count + 1 + end + return count +end + +--- Check if a session is active +---@param session_id string +---@return boolean +function M.is_active(session_id) + return runtime_state.active_session_id == session_id +end + +--- Check if a session is minimized +---@param session_id string +---@return boolean +function M.is_minimized(session_id) + return runtime_state.minimized_sessions[session_id] == true +end + +--- Register a new session +---@param session RemoteSession +---@return string session_id +function M.register_session(session) + if not session.id then + session.id = generate_session_id() + end + + session.created_at = session.created_at or os.time() + session.last_accessed_at = os.time() + session.state = session.state or "active" + session.terminal_ids = session.terminal_ids or {} + session.open_buffers = session.open_buffers or {} + + runtime_state.sessions[session.id] = session + + return session.id +end + +--- Set a session as active +---@param session_id string +---@return boolean success +function M.set_active(session_id) + local session = runtime_state.sessions[session_id] + if not session then + return false + end + + -- Remove from minimized if it was there + runtime_state.minimized_sessions[session_id] = nil + + -- Update state + session.state = "active" + session.last_accessed_at = os.time() + + runtime_state.active_session_id = session_id + + return true +end + +--- Set a session as minimized +---@param session_id string +---@return boolean success +function M.set_minimized(session_id) + local session = runtime_state.sessions[session_id] + if not session then + return false + end + + -- If this was the active session, clear active + if runtime_state.active_session_id == session_id then + runtime_state.active_session_id = nil + end + + -- Add to minimized + runtime_state.minimized_sessions[session_id] = true + session.state = "minimized" + session.last_accessed_at = os.time() + + -- Persist the minimized session + persistence.save_session(session) + + return true +end + +--- Remove a session from runtime state +---@param session_id string +---@return boolean success +function M.unregister_session(session_id) + if not runtime_state.sessions[session_id] then + return false + end + + -- Clear from active if needed + if runtime_state.active_session_id == session_id then + runtime_state.active_session_id = nil + end + + -- Clear from minimized + runtime_state.minimized_sessions[session_id] = nil + + -- Remove from sessions + runtime_state.sessions[session_id] = nil + + return true +end + +--- Update session's window layout +---@param session_id string +---@param layout WindowLayout +function M.update_window_layout(session_id, layout) + local session = runtime_state.sessions[session_id] + if session then + session.window_layout = layout + session.last_accessed_at = os.time() + end +end + +--- Update session's tree browser state +---@param session_id string +---@param state TreeBrowserState +function M.update_tree_browser_state(session_id, state) + local session = runtime_state.sessions[session_id] + if session then + session.tree_browser_state = state + session.last_accessed_at = os.time() + end +end + +--- Add a terminal to session +---@param session_id string +---@param terminal_id number +function M.add_terminal(session_id, terminal_id) + local session = runtime_state.sessions[session_id] + if session then + if not vim.tbl_contains(session.terminal_ids, terminal_id) then + table.insert(session.terminal_ids, terminal_id) + end + end +end + +--- Remove a terminal from session +---@param session_id string +---@param terminal_id number +function M.remove_terminal(session_id, terminal_id) + local session = runtime_state.sessions[session_id] + if session then + session.terminal_ids = vim.tbl_filter(function(id) + return id ~= terminal_id + end, session.terminal_ids) + end +end + +--- Get terminals for a session +---@param session_id string +---@return number[] +function M.get_terminals(session_id) + local session = runtime_state.sessions[session_id] + if session then + return session.terminal_ids or {} + end + return {} +end + +--- Update session's open buffers +---@param session_id string +---@param buffers BufferState[] +function M.update_open_buffers(session_id, buffers) + local session = runtime_state.sessions[session_id] + if session then + session.open_buffers = buffers + session.last_accessed_at = os.time() + end +end + +--- Rename a session +---@param session_id string +---@param new_name string +---@return boolean success +function M.rename_session(session_id, new_name) + local session = runtime_state.sessions[session_id] + if not session then + return false + end + + session.name = new_name + session.last_accessed_at = os.time() + + -- Update persistence + persistence.save_session(session) + + return true +end + +--- Find session by URL +---@param url string +---@return RemoteSession|nil +function M.find_by_url(url) + -- Check runtime sessions first + for _, session in pairs(runtime_state.sessions) do + if session.url == url then + return session + end + end + + -- Check persisted sessions + return persistence.find_session_by_url(url) +end + +--- Load a persisted session into runtime +---@param session_id string +---@return RemoteSession|nil +function M.load_from_persistence(session_id) + local persisted = persistence.get_session(session_id) + if not persisted then + return nil + end + + -- Copy to runtime + persisted.id = session_id + runtime_state.sessions[session_id] = persisted + + return persisted +end + +--- Get all sessions (runtime + persisted) for picker display +---@return RemoteSession[] +function M.get_all_for_picker() + local result = {} + local seen_ids = {} + + -- Add active session first + if runtime_state.active_session_id then + local active = runtime_state.sessions[runtime_state.active_session_id] + if active then + table.insert(result, active) + seen_ids[active.id] = true + end + end + + -- Add minimized sessions + for _, session in ipairs(M.get_minimized_sessions()) do + if not seen_ids[session.id] then + table.insert(result, session) + seen_ids[session.id] = true + end + end + + -- Add persisted sessions not already in runtime + local persisted = persistence.get_sessions_sorted() + for _, session in ipairs(persisted) do + if not seen_ids[session.id] then + session.state = "persisted" + table.insert(result, session) + seen_ids[session.id] = true + end + end + + return result +end + +--- Persist all minimized sessions +function M.persist_all_minimized() + for session_id in pairs(runtime_state.minimized_sessions) do + local session = runtime_state.sessions[session_id] + if session then + persistence.save_session(session) + end + end +end + +--- Initialize the session manager +function M.init() + -- Initialize persistence + persistence.init() + + -- Setup auto-persist on Neovim exit + vim.api.nvim_create_autocmd("VimLeavePre", { + callback = function() + M.persist_all_minimized() + end, + group = vim.api.nvim_create_augroup("RemoteSessionManager", { clear = true }), + desc = "Persist minimized remote sessions on Neovim exit", + }) +end + +--- Get runtime state (for debugging) +---@return table +function M.get_runtime_state() + return vim.deepcopy(runtime_state) +end + +--- Clear all runtime state (for testing) +function M.clear_runtime_state() + runtime_state.active_session_id = nil + runtime_state.minimized_sessions = {} + runtime_state.sessions = {} +end + +return M diff --git a/lua/remote-session/statusline.lua b/lua/remote-session/statusline.lua new file mode 100644 index 0000000..b3d044f --- /dev/null +++ b/lua/remote-session/statusline.lua @@ -0,0 +1,148 @@ +-- Statusline component for remote-session +-- Provides a function to display active session and minimized count +local M = {} + +local config = require("remote-session.config") +local session_manager = require("remote-session.session_manager") + +--- Get the statusline component string +--- Returns formatted string for statusline, or empty string if no active session +---@return string component +function M.component() + local active = session_manager.get_active_session() + local minimized_count = session_manager.get_minimized_count() + + -- If no active session and no minimized sessions, return empty + if not active and minimized_count == 0 then + return config.get("statusline", "no_session_text") or "" + end + + -- If no active session but have minimized sessions + if not active then + local show_minimized = config.get("statusline", "show_minimized_count") + if show_minimized and minimized_count > 0 then + return "SSH: [" .. minimized_count .. " minimized]" + end + return "" + end + + -- We have an active session + local format_str + local show_minimized = config.get("statusline", "show_minimized_count") + + if show_minimized and minimized_count > 0 then + format_str = config.get("statusline", "format_with_minimized") or "SSH: {name} [+{minimized_count} minimized]" + else + format_str = config.get("statusline", "format") or "SSH: {name}" + end + + -- Replace placeholders + local result = format_str + :gsub("{name}", active.name or "unnamed") + :gsub("{host}", active.host or "") + :gsub("{path}", active.path or "") + :gsub("{minimized_count}", tostring(minimized_count)) + + return result +end + +--- Get minimal statusline component (just session name) +---@return string component +function M.minimal() + local active = session_manager.get_active_session() + if not active then + return "" + end + return active.name or "" +end + +--- Get icon-based statusline component +--- Returns icon with session count indicator +---@return string component +function M.icon() + local active = session_manager.get_active_session() + local minimized_count = session_manager.get_minimized_count() + + if not active and minimized_count == 0 then + return "" + end + + local icon = "󰣀" -- SSH/remote icon + + if active then + if minimized_count > 0 then + return icon .. " " .. active.name .. " +" .. minimized_count + else + return icon .. " " .. active.name + end + else + return icon .. " [" .. minimized_count .. "]" + end +end + +--- Get detailed statusline component with state indicators +---@return string component +function M.detailed() + local active = session_manager.get_active_session() + local minimized_count = session_manager.get_minimized_count() + + if not active and minimized_count == 0 then + return "" + end + + local parts = {} + + if active then + table.insert(parts, "● " .. active.name) + end + + if minimized_count > 0 then + table.insert(parts, "○×" .. minimized_count) + end + + return "SSH: " .. table.concat(parts, " ") +end + +--- Get lualine-compatible component table +--- Use in lualine config: require("remote-session.statusline").lualine() +---@return table lualine_component +function M.lualine() + return { + function() + return M.component() + end, + cond = function() + local active = session_manager.get_active_session() + local minimized = session_manager.get_minimized_count() + return active ~= nil or minimized > 0 + end, + } +end + +--- Check if there's an active session +---@return boolean +function M.has_active_session() + return session_manager.get_active_session() ~= nil +end + +--- Check if there are any sessions (active or minimized) +---@return boolean +function M.has_any_session() + return session_manager.get_active_session() ~= nil or session_manager.get_minimized_count() > 0 +end + +--- Get session summary for display +---@return table summary {active_name, minimized_count, total_count} +function M.get_summary() + local active = session_manager.get_active_session() + local minimized_count = session_manager.get_minimized_count() + + return { + active_name = active and active.name or nil, + active_host = active and active.host or nil, + minimized_count = minimized_count, + total_count = (active and 1 or 0) + minimized_count, + } +end + +return M diff --git a/lua/remote-session/window_layout.lua b/lua/remote-session/window_layout.lua new file mode 100644 index 0000000..4d1a269 --- /dev/null +++ b/lua/remote-session/window_layout.lua @@ -0,0 +1,231 @@ +-- Window layout module for remote-session +-- Handles capturing and restoring window layouts with proportional sizing +local M = {} + +local config = require("remote-session.config") + +---@class WindowLayout +---@field tree_browser_width_ratio number 0.0-1.0 of editor width +---@field terminal_height_ratio number 0.0-1.0 of editor height +---@field tree_browser_visible boolean +---@field terminal_visible boolean + +--- Get default window layout from config +---@return WindowLayout +function M.get_default_layout() + local defaults = config.get("default_layout") + return { + tree_browser_width_ratio = defaults.tree_browser_width_ratio or 0.2, + terminal_height_ratio = defaults.terminal_height_ratio or 0.3, + tree_browser_visible = true, + terminal_visible = true, + } +end + +--- Capture the current window layout for a session +---@return WindowLayout +function M.capture_layout() + local layout = M.get_default_layout() + + -- Get editor dimensions + local editor_width = vim.o.columns + local editor_height = vim.o.lines + + -- Try to find tree browser window + local tree_browser_win = M.find_tree_browser_window() + if tree_browser_win and vim.api.nvim_win_is_valid(tree_browser_win) then + local width = vim.api.nvim_win_get_width(tree_browser_win) + layout.tree_browser_width_ratio = width / editor_width + layout.tree_browser_visible = true + else + layout.tree_browser_visible = false + end + + -- Try to find terminal window + local terminal_win = M.find_terminal_window() + if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then + local height = vim.api.nvim_win_get_height(terminal_win) + layout.terminal_height_ratio = height / editor_height + layout.terminal_visible = true + else + layout.terminal_visible = false + end + + return layout +end + +--- Find the tree browser window (if open) +---@return number|nil win_id +function M.find_tree_browser_window() + local ok, tree_browser = pcall(require, "async-remote-write.tree_browser") + if not ok then + return nil + end + + local state = tree_browser.get_state() + if state and state.base_url then + -- Tree browser state exists, but we need to find its window + -- Check all windows for the tree browser buffer + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local bufname = vim.api.nvim_buf_get_name(buf) + if bufname:match("^Remote Tree:") then + return win + end + end + end + + return nil +end + +--- Find the terminal window (if open) +---@return number|nil win_id +function M.find_terminal_window() + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if not ok then + return nil + end + + return terminal_manager.get_terminal_win() +end + +--- Calculate actual pixel dimensions from layout ratios +---@param layout WindowLayout +---@return table dimensions {tree_width, terminal_height} +function M.calculate_dimensions(layout) + local editor_width = vim.o.columns + local editor_height = vim.o.lines + + -- Calculate tree browser width (minimum 30, maximum 80% of screen) + local tree_width = math.floor(editor_width * layout.tree_browser_width_ratio) + tree_width = math.max(30, math.min(tree_width, math.floor(editor_width * 0.8))) + + -- Calculate terminal height (minimum 5 lines, maximum 70% of screen) + local terminal_height = math.floor(editor_height * layout.terminal_height_ratio) + terminal_height = math.max(5, math.min(terminal_height, math.floor(editor_height * 0.7))) + + return { + tree_width = tree_width, + terminal_height = terminal_height, + } +end + +--- Apply a window layout +---@param layout WindowLayout +---@param session_url string|nil URL for tree browser +---@return boolean success +function M.apply_layout(layout, session_url) + local dimensions = M.calculate_dimensions(layout) + + -- Apply tree browser layout + if layout.tree_browser_visible and session_url then + local ok, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok then + -- Open tree browser if not already open + if not tree_browser.is_open() then + tree_browser.open_tree(session_url) + end + + -- Set width + local tree_win = M.find_tree_browser_window() + if tree_win and vim.api.nvim_win_is_valid(tree_win) then + vim.api.nvim_win_set_width(tree_win, dimensions.tree_width) + end + end + end + + -- Apply terminal layout + if layout.terminal_visible then + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok and terminal_manager.is_split_visible() then + local terminal_win = terminal_manager.get_terminal_win() + if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then + vim.api.nvim_win_set_height(terminal_win, dimensions.terminal_height) + end + end + end + + return true +end + +--- Hide all session windows (for minimizing) +---@return boolean success +function M.hide_session_windows() + -- Hide tree browser + local ok_tree, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok_tree then + tree_browser.hide_tree() + end + + -- Hide terminal + local ok_term, window_manager = pcall(require, "remote-terminal.window_manager") + if ok_term then + window_manager.hide_split() + end + + return true +end + +--- Show session windows with layout +---@param layout WindowLayout +---@param session table Session data +---@return boolean success +function M.show_session_windows(layout, session) + -- Show tree browser + if layout.tree_browser_visible and session.url then + local ok, tree_browser = pcall(require, "async-remote-write.tree_browser") + if ok then + if tree_browser.is_open() then + tree_browser.show_tree() + else + tree_browser.open_tree(session.url) + end + end + end + + -- Show terminal if there are associated terminals + if layout.terminal_visible and session.terminal_ids and #session.terminal_ids > 0 then + local ok, window_manager = pcall(require, "remote-terminal.window_manager") + if ok then + window_manager.show_split() + end + end + + -- Apply dimensions + M.apply_layout(layout, session.url) + + return true +end + +--- Check if any session windows are currently visible +---@return boolean +function M.are_session_windows_visible() + local tree_visible = M.find_tree_browser_window() ~= nil + + local terminal_visible = false + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok then + terminal_visible = terminal_manager.is_split_visible() + end + + return tree_visible or terminal_visible +end + +--- Get current visibility state +---@return table state {tree_browser_visible, terminal_visible} +function M.get_visibility_state() + local tree_visible = M.find_tree_browser_window() ~= nil + + local terminal_visible = false + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok then + terminal_visible = terminal_manager.is_split_visible() + end + + return { + tree_browser_visible = tree_visible, + terminal_visible = terminal_visible, + } +end + +return M diff --git a/lua/remote-ssh.lua b/lua/remote-ssh.lua index 2f90f0b..f161912 100644 --- a/lua/remote-ssh.lua +++ b/lua/remote-ssh.lua @@ -8,11 +8,13 @@ function M.setup(opts) local remote_lsp = require("remote-lsp") local remote_tui = require("remote-tui") local remote_terminal = require("remote-terminal") + local remote_session = require("remote-session") remote_lsp.setup(opts) remote_treesitter.setup() remote_tui.setup(opts and opts.remote_tui_opts or {}) remote_terminal.setup(opts and opts.remote_terminal_opts or {}) + remote_session.setup(opts and opts.remote_session_opts or {}) end return M diff --git a/lua/remote-terminal/picker.lua b/lua/remote-terminal/picker.lua index 0bef2f1..672ef26 100644 --- a/lua/remote-terminal/picker.lua +++ b/lua/remote-terminal/picker.lua @@ -14,6 +14,36 @@ function M.get_terminal_at_line(line) return line_to_terminal_id[line] end +--- Get terminals filtered by active remote session (if any) +---@return table[] terminals +local function get_session_filtered_terminals() + local all_terminals = terminal_manager.get_all_terminals() + + -- Try to get active remote session + local ok, session_manager = pcall(require, "remote-session.session_manager") + if not ok then + -- remote-session not available, return all terminals + return all_terminals + end + + local active_session_id = session_manager.get_active_session_id() + if not active_session_id then + -- No active session, return all terminals + return all_terminals + end + + -- Filter terminals to only those associated with the active session + local filtered = {} + for _, term in ipairs(all_terminals) do + local term_session_id = terminal_manager.get_session_for_terminal(term.id) + if term_session_id == active_session_id then + table.insert(filtered, term) + end + end + + return filtered +end + --- Render the picker content ---@return string[] lines ---@return table[] highlights Array of {line, col_start, col_end, hl_group} @@ -27,7 +57,7 @@ local function render_picker_content() table.insert(highlights, { line = 1, col_start = 0, col_end = -1, hl_group = "TerminalPickerHeader" }) table.insert(lines, string.rep("-", config.get("picker", "width") - 2)) - local terminals = terminal_manager.get_all_terminals() + local terminals = get_session_filtered_terminals() local active_id = terminal_manager.get_active_terminal_id() if #terminals == 0 then @@ -204,7 +234,7 @@ function M.navigate_down() local current_line = cursor[1] -- Find next valid line - local terminals = terminal_manager.get_all_terminals() + local terminals = get_session_filtered_terminals() local header_lines = 2 -- Header + separator if #terminals == 0 then @@ -234,7 +264,7 @@ function M.navigate_up() local current_line = cursor[1] -- Find previous valid line - local terminals = terminal_manager.get_all_terminals() + local terminals = get_session_filtered_terminals() local header_lines = 2 if #terminals == 0 then diff --git a/lua/remote-terminal/terminal_manager.lua b/lua/remote-terminal/terminal_manager.lua index 0af9ccf..a063922 100644 --- a/lua/remote-terminal/terminal_manager.lua +++ b/lua/remote-terminal/terminal_manager.lua @@ -10,6 +10,7 @@ local TerminalState = { picker_win_id = nil, -- Picker sidebar window ID (right side) picker_bufnr = nil, -- Picker buffer (reused) split_visible = false, + session_associations = {}, -- terminal_id -> session_id (remote-session associations) } --- Get the current state (for debugging or external access) @@ -271,7 +272,68 @@ function M.close_all() TerminalState.terminal_win_id = nil TerminalState.picker_win_id = nil TerminalState.split_visible = false + TerminalState.session_associations = {} -- Keep picker_bufnr for reuse end +-- ============================================================================= +-- Remote Session Associations +-- ============================================================================= + +--- Associate a terminal with a remote session +---@param terminal_id number Terminal ID +---@param session_id string Remote session ID +function M.associate_terminal_with_session(terminal_id, session_id) + TerminalState.session_associations[terminal_id] = session_id +end + +--- Get the session ID associated with a terminal +---@param terminal_id number Terminal ID +---@return string|nil session_id +function M.get_session_for_terminal(terminal_id) + return TerminalState.session_associations[terminal_id] +end + +--- Get all terminals associated with a session +---@param session_id string Remote session ID +---@return number[] terminal_ids +function M.get_terminals_for_session(session_id) + local terminal_ids = {} + for terminal_id, assoc_session_id in pairs(TerminalState.session_associations) do + if assoc_session_id == session_id then + table.insert(terminal_ids, terminal_id) + end + end + return terminal_ids +end + +--- Remove session association for a terminal +---@param terminal_id number Terminal ID +function M.dissociate_terminal(terminal_id) + TerminalState.session_associations[terminal_id] = nil +end + +--- Remove all terminal associations for a session +---@param session_id string Remote session ID +function M.dissociate_session_terminals(session_id) + for terminal_id, assoc_session_id in pairs(TerminalState.session_associations) do + if assoc_session_id == session_id then + TerminalState.session_associations[terminal_id] = nil + end + end +end + +--- Check if a terminal is associated with any session +---@param terminal_id number Terminal ID +---@return boolean +function M.is_terminal_associated(terminal_id) + return TerminalState.session_associations[terminal_id] ~= nil +end + +--- Get all session associations +---@return table associations (terminal_id -> session_id) +function M.get_all_session_associations() + return vim.deepcopy(TerminalState.session_associations) +end + return M From 4695edee92cf995299ff88117dee524290629d2a Mon Sep 17 00:00:00 2001 From: "ian.hersom" Date: Tue, 3 Feb 2026 17:04:29 -0700 Subject: [PATCH 2/9] Fix buffers opening in terminal/floating windows Prevent files from opening in terminal picker/terminal windows by validating cached window IDs before reuse. Added is_file_window_suitable() helper that checks for valid window type, excludes floating windows, and explicitly excludes terminal infrastructure windows. The cached file_win_id is now revalidated on every use to ensure it remains suitable for opening files, fixing the issue where terminals opened in a previously cached window would incorrectly receive file buffers. Co-Authored-By: Claude Sonnet 4.5 --- lua/async-remote-write/tree_browser.lua | 118 +++++++++++++----------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/lua/async-remote-write/tree_browser.lua b/lua/async-remote-write/tree_browser.lua index a4bd50d..b949732 100644 --- a/lua/async-remote-write/tree_browser.lua +++ b/lua/async-remote-write/tree_browser.lua @@ -804,6 +804,34 @@ local function toggle_directory(item) end end +-- Helper to check if a window is suitable for opening files +local function is_file_window_suitable(win_id, tree_win) + if not vim.api.nvim_win_is_valid(win_id) then + return false + end + if win_id == tree_win then + return false + end + -- Skip floating windows + local win_config = vim.api.nvim_win_get_config(win_id) + if win_config.relative and win_config.relative ~= "" then + return false + end + -- Skip terminal infrastructure windows + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok then + local terminal_win = terminal_manager.get_terminal_win() + local picker_win = terminal_manager.get_picker_win() + if win_id == terminal_win or win_id == picker_win then + return false + end + end + local buf_in_win = vim.api.nvim_win_get_buf(win_id) + local buftype = vim.bo[buf_in_win].buftype + -- Only accept normal files or remote files + return buftype == "" or buftype == "acwrite" +end + -- Open file in new buffer to the right of tree browser local function open_file(item) if not item then @@ -835,22 +863,22 @@ local function open_file(item) -- Find or create target window for file display local target_win = nil - -- First, check if we have a stored file window that's still valid - if TreeBrowser.file_win_id and vim.api.nvim_win_is_valid(TreeBrowser.file_win_id) then + -- First, check if we have a stored file window that's still valid and suitable + if TreeBrowser.file_win_id and is_file_window_suitable(TreeBrowser.file_win_id, tree_win) then target_win = TreeBrowser.file_win_id else + -- Invalidate cached window if it's no longer suitable + if TreeBrowser.file_win_id then + TreeBrowser.file_win_id = nil + end + -- Look for a suitable existing window (not the tree browser) local windows = vim.api.nvim_tabpage_list_wins(0) for _, win_id in ipairs(windows) do - if win_id ~= tree_win then - local buf_in_win = vim.api.nvim_win_get_buf(win_id) - local buftype = vim.bo[buf_in_win].buftype - -- Accept normal files or remote files (more flexible matching) - if buftype == "" or buftype == "acwrite" then - target_win = win_id - TreeBrowser.file_win_id = win_id -- Store for future use - break - end + if is_file_window_suitable(win_id, tree_win) then + target_win = win_id + TreeBrowser.file_win_id = win_id -- Store for future use + break end end end @@ -861,7 +889,11 @@ local function open_file(item) local non_tree_windows = {} for _, win_id in ipairs(all_windows) do if win_id ~= tree_win then - table.insert(non_tree_windows, win_id) + -- Skip floating windows + local win_config = vim.api.nvim_win_get_config(win_id) + if not (win_config.relative and win_config.relative ~= "") then + table.insert(non_tree_windows, win_id) + end end end -- If there's exactly one other window and it's a nofile buffer, use it @@ -881,6 +913,13 @@ local function open_file(item) vim.cmd("rightbelow vsplit") target_win = vim.api.nvim_get_current_win() TreeBrowser.file_win_id = target_win -- Store the new window + + -- Create a temporary empty buffer immediately so the window is suitable + -- for simple_open_remote_file (which rejects windows with nofile buftype) + local temp_buf = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_option(temp_buf, "buftype", "") + vim.api.nvim_buf_set_option(temp_buf, "bufhidden", "wipe") + vim.api.nvim_win_set_buf(target_win, temp_buf) end vim.api.nvim_set_current_win(target_win) @@ -1743,7 +1782,15 @@ function M.hide_tree() end -- Close the window but keep buffer alive - vim.api.nvim_win_close(TreeBrowser.win_id, false) + -- Handle last window case to avoid E444 error + local win_count = #vim.api.nvim_list_wins() + if win_count > 1 then + vim.api.nvim_win_close(TreeBrowser.win_id, false) + else + -- Last window: replace with empty buffer instead of closing + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(TreeBrowser.win_id, empty_buf) + end TreeBrowser.win_id = nil utils.log("Hidden remote tree browser (buffer preserved)", vim.log.levels.DEBUG, false, config.config) @@ -1973,7 +2020,6 @@ function M.open_tree_with_state(url, state) -- Store state to restore after tree loads local expanded_to_restore = state and state.expanded_dirs or {} - local scroll_position = state and state.scroll_position or nil local cursor_line = state and state.cursor_line or nil -- Open the tree first @@ -1986,49 +2032,9 @@ function M.open_tree_with_state(url, state) return end - -- Restore expanded directories + -- Simply restore the expanded_dirs map - the tree will use this + -- when directories are toggled TreeBrowser.expanded_dirs = vim.deepcopy(expanded_to_restore) - - -- Re-expand all directories that were previously expanded - local function restore_expansions(tree_items, depth) - depth = depth or 0 - if depth > 10 then - return - end -- Safety limit - - for _, item in ipairs(tree_items) do - if item.is_dir and expanded_to_restore[item.url] then - -- Load children if not already loaded - if not item.children then - local cached_files = get_cached_directory(item.url) - if cached_files then - item.children = {} - for _, file_info in ipairs(cached_files) do - table.insert(item.children, create_tree_item(file_info, item.depth + 1, item.url)) - end - -- Recursively restore expansions for children - restore_expansions(item.children, depth + 1) - else - -- Load directory async - load_directory(item.url, function(files) - if files then - item.children = {} - for _, file_info in ipairs(files) do - table.insert(item.children, create_tree_item(file_info, item.depth + 1, item.url)) - end - restore_expansions(item.children, depth + 1) - refresh_display() - end - end) - end - else - restore_expansions(item.children, depth + 1) - end - end - end - end - - restore_expansions(TreeBrowser.tree_data) refresh_display() -- Restore cursor position if provided From 0cdf86fa7bf118ee7085abb244296ebd7114ceff Mon Sep 17 00:00:00 2001 From: "ian.hersom" Date: Tue, 3 Feb 2026 17:04:52 -0700 Subject: [PATCH 3/9] updates --- lua/async-remote-write/operations.lua | 68 +++++++- lua/remote-session/persistence.lua | 1 + lua/remote-session/session.lua | 233 ++++++++++++++++++------- lua/remote-session/session_manager.lua | 45 ++++- lua/remote-terminal/window_manager.lua | 24 ++- 5 files changed, 299 insertions(+), 72 deletions(-) diff --git a/lua/async-remote-write/operations.lua b/lua/async-remote-write/operations.lua index 16166a8..41b255b 100644 --- a/lua/async-remote-write/operations.lua +++ b/lua/async-remote-write/operations.lua @@ -1373,6 +1373,22 @@ function M.simple_open_remote_file(url, position, target_win) return false end + -- Exclude floating windows (used by notifications, popups, etc.) + local win_config = vim.api.nvim_win_get_config(win_id) + if win_config.relative and win_config.relative ~= "" then + return false + end + + -- Explicitly exclude terminal infrastructure windows by ID + local ok, terminal_manager = pcall(require, "remote-terminal.terminal_manager") + if ok then + local terminal_win = terminal_manager.get_terminal_win() + local picker_win = terminal_manager.get_picker_win() + if win_id == terminal_win or win_id == picker_win then + return false + end + end + local buf_in_win = vim.api.nvim_win_get_buf(win_id) local buftype = vim.api.nvim_buf_get_option(buf_in_win, "buftype") local bufname = vim.api.nvim_buf_get_name(buf_in_win) @@ -1382,8 +1398,8 @@ function M.simple_open_remote_file(url, position, target_win) return false end - -- Check for special buffer names that indicate tree browser or other special buffers - if bufname:match("TreeBrowser") or bufname:match("NvimTree") or bufname:match("neo%-tree") then + -- Check for special buffer names that indicate tree browser, terminal picker, or other special buffers + if bufname:match("Remote Tree") or bufname:match("Remote Terminals") or bufname:match("TreeBrowser") or bufname:match("NvimTree") or bufname:match("neo%-tree") then return false end @@ -1426,6 +1442,12 @@ function M.simple_open_remote_file(url, position, target_win) local regular_window_count = 0 for _, win_id in ipairs(all_windows) do + -- Skip floating windows + local win_config = vim.api.nvim_win_get_config(win_id) + if win_config.relative and win_config.relative ~= "" then + goto continue + end + local buf_in_win = vim.api.nvim_win_get_buf(win_id) local bt = vim.bo[buf_in_win].buftype if bt == "" or bt == "acwrite" then @@ -1443,6 +1465,7 @@ function M.simple_open_remote_file(url, position, target_win) nofile_win = win_id end end + ::continue:: end -- If no regular windows and we found a nofile window, use it @@ -1483,9 +1506,24 @@ function M.simple_open_remote_file(url, position, target_win) return end + -- Normalize position: handle both Vim format {line, col} and LSP format {line=, character=} + local line, col + if position[1] then + -- Vim format: {line, col} - already 1-based + line = position[1] + col = position[2] or 0 + elseif position.line then + -- LSP format: {line=, character=} - 0-based, convert to 1-based + line = position.line + 1 + col = position.character or 0 + else + -- Invalid format, use defaults + line = 1 + col = 0 + end + -- Validate the position is within buffer boundaries local line_count = vim.api.nvim_buf_line_count(bufnr) - local line = position.line + 1 -- LSP is 0-based, Vim is 1-based -- Ensure line is valid if line <= 0 then @@ -1497,7 +1535,6 @@ function M.simple_open_remote_file(url, position, target_win) -- Get the line content to determine max character position local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or "" local max_col = #line_content - local col = position.character -- Ensure column is valid if col > max_col then @@ -1539,6 +1576,29 @@ function M.simple_open_remote_file(url, position, target_win) -- Register buffer-specific autocommands for saving buffer.register_buffer_autocommands(bufnr) + -- Register buffer with active session + local ok_sm, session_manager = pcall(require, "remote-session.session_manager") + if ok_sm then + local active_session_id = session_manager.get_active_session_id() + if active_session_id then + session_manager.add_buffer(active_session_id, bufnr, url) + end + end + + -- Add cleanup on buffer wipe + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = bufnr, + callback = function() + local ok_sm2, sm = pcall(require, "remote-session.session_manager") + if ok_sm2 then + -- Remove from all sessions (buffer could theoretically be in multiple) + for session_id, _ in pairs(sm.get_all_sessions()) do + sm.remove_buffer(session_id, bufnr) + end + end + end, + }) + -- Start LSP for this buffer vim.schedule(function() if vim.api.nvim_buf_is_valid(bufnr) then diff --git a/lua/remote-session/persistence.lua b/lua/remote-session/persistence.lua index 7360858..deaa555 100644 --- a/lua/remote-session/persistence.lua +++ b/lua/remote-session/persistence.lua @@ -152,6 +152,7 @@ function M.save_session(session) -- Remove runtime-only fields that shouldn't be persisted session_copy.terminal_ids = nil -- Terminals are recreated + session_copy.session_buffers = nil -- Buffer numbers are runtime-only -- Set state to persisted if not already set if session_copy.state ~= "minimized" then diff --git a/lua/remote-session/session.lua b/lua/remote-session/session.lua index e3609f7..4ba3707 100644 --- a/lua/remote-session/session.lua +++ b/lua/remote-session/session.lua @@ -213,11 +213,11 @@ function M.minimize(session_id) end -- Capture open buffer positions - session.open_buffers = M.capture_buffer_states(session.url) + session.open_buffers = M.capture_buffer_states(session_id) -- Check for unsaved buffers if config.get("confirm_close") then - local unsaved = M.get_unsaved_buffers(session.url) + local unsaved = M.get_unsaved_buffers(session_id) if #unsaved > 0 then local choice = vim.fn.confirm( "Session has unsaved buffers:\n" @@ -243,7 +243,10 @@ function M.minimize(session_id) end end - -- Hide windows + -- Hide session buffer windows first (before tree/terminal, so we have more windows to work with) + M.hide_session_buffer_windows(session_id) + + -- Hide tree browser and terminal windows window_layout.hide_session_windows() -- Update state @@ -337,9 +340,9 @@ function M.restore(session_id) -- Apply window layout window_layout.apply_layout(session.window_layout, session.url) - -- Restore buffer cursor positions - if session.open_buffers then - M.restore_buffer_states(session.open_buffers) + -- Restore open buffers (reopen files and restore cursor positions) + if session.open_buffers and #session.open_buffers > 0 then + M.restore_session_buffers(session.open_buffers) end vim.notify("[remote-session] Restored: " .. session.name, vim.log.levels.INFO) @@ -356,18 +359,26 @@ function M.open_tree_with_state(url, state) return end - -- First open the tree - tree_browser.open_tree(url) + -- Use the tree_browser's open_tree_with_state if available + if tree_browser.open_tree_with_state then + tree_browser.open_tree_with_state(url, state) + else + -- Fallback: open tree then restore state + tree_browser.open_tree(url) - -- Then restore expanded directories - if state and state.expanded_dirs then - -- Wait a bit for initial load, then restore state - vim.defer_fn(function() - tree_browser.restore_state({ - base_url = url, - expanded_dirs = state.expanded_dirs, - }) - end, 200) + -- Then restore expanded directories (don't pass base_url to avoid reopening) + if state and state.expanded_dirs then + vim.defer_fn(function() + -- Just set the expanded_dirs directly, don't call restore_state + -- which would reopen the tree + if tree_browser.get_state then + local current_state = tree_browser.get_state() + if current_state then + current_state.expanded_dirs = state.expanded_dirs + end + end + end, 200) + end end end @@ -387,7 +398,7 @@ function M.close(session_id, opts) -- Check for unsaved buffers if not opts.force and config.get("confirm_close") then - local unsaved = M.get_unsaved_buffers(session.url) + local unsaved = M.get_unsaved_buffers(session_id) if #unsaved > 0 then local choice = vim.fn.confirm( "Session has unsaved buffers. Close anyway?", @@ -435,7 +446,7 @@ function M.close(session_id, opts) end -- Close session buffers - M.close_session_buffers(session.url) + M.close_session_buffers(session_id) -- Unregister from runtime session_manager.unregister_session(session_id) @@ -448,17 +459,16 @@ function M.close(session_id, opts) return true end ---- Get unsaved buffers for a session URL ----@param url string Session URL +--- Get unsaved buffers for a session +---@param session_id string Session ID ---@return number[] bufnrs -function M.get_unsaved_buffers(url) +function M.get_unsaved_buffers(session_id) local unsaved = {} - local host_pattern = url:match("://([^/]+)") + local session_buffers = session_manager.get_session_buffers(session_id) - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(bufnr) then - local bufname = vim.api.nvim_buf_get_name(bufnr) - if bufname:match(host_pattern) and vim.bo[bufnr].modified then + for bufnr, _ in pairs(session_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then + if vim.bo[bufnr].modified then table.insert(unsaved, bufnr) end end @@ -468,43 +478,146 @@ function M.get_unsaved_buffers(url) end --- Capture buffer states for a session ----@param url string Session URL +---@param session_id string Session ID ---@return table[] buffer_states -function M.capture_buffer_states(url) +function M.capture_buffer_states(session_id) local states = {} - local host_pattern = url:match("://([^/]+)") + local session_buffers = session_manager.get_session_buffers(session_id) - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(bufnr) then - local bufname = vim.api.nvim_buf_get_name(bufnr) - if bufname:match(host_pattern) then - -- Find window displaying this buffer - local winid = nil - for _, win in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_buf(win) == bufnr then - winid = win - break - end + for bufnr, url in pairs(session_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then + -- Find window displaying this buffer + local winid = nil + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + winid = win + break end + end + + local cursor_pos = { 1, 0 } + if winid then + cursor_pos = vim.api.nvim_win_get_cursor(winid) + end + + table.insert(states, { + url = url, + cursor_pos = cursor_pos, + winid = winid, + bufnr = bufnr, + }) + end + end + + return states +end + +--- Hide windows displaying session buffers (for minimizing) +--- This closes the windows but keeps the buffers loaded +---@param session_id string Session ID +function M.hide_session_buffer_windows(session_id) + local session_buffers = session_manager.get_session_buffers(session_id) + + -- Collect windows to close (avoid modifying list while iterating) + local windows_to_close = {} + + for bufnr, _ in pairs(session_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then + table.insert(windows_to_close, win) + end + end + end + end + + -- Debug: log windows we're closing + if #windows_to_close > 0 then + vim.notify("[remote-session] Closing " .. #windows_to_close .. " buffer window(s)", vim.log.levels.DEBUG) + end - local cursor_pos = { 1, 0 } - if winid then - cursor_pos = vim.api.nvim_win_get_cursor(winid) + -- Close the windows one at a time, checking remaining windows each time + for _, win in ipairs(windows_to_close) do + if vim.api.nvim_win_is_valid(win) then + -- Count current valid windows + local current_wins = #vim.api.nvim_list_wins() + if current_wins > 1 then + local ok, err = pcall(vim.api.nvim_win_close, win, true) + if not ok then + vim.notify("[remote-session] Failed to close window: " .. tostring(err), vim.log.levels.WARN) end + else + -- Last window: replace buffer with empty buffer instead of closing + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(win, empty_buf) + end + end + end +end - table.insert(states, { - url = bufname, - cursor_pos = cursor_pos, - winid = winid, - }) +--- Restore session buffers (for restoring minimized session) +--- This reopens the files that were open before minimizing +---@param states table[] Buffer states from capture_buffer_states +function M.restore_session_buffers(states) + if not states or #states == 0 then + return + end + + local ok, operations = pcall(require, "async-remote-write.operations") + if not ok then + return + end + + -- Find or create a suitable window for opening files + local target_win = nil + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) then + -- Skip floating windows + local win_config = vim.api.nvim_win_get_config(win) + if win_config.relative and win_config.relative ~= "" then + goto continue end + + local buf = vim.api.nvim_win_get_buf(win) + local bufname = vim.api.nvim_buf_get_name(buf) + local buftype = vim.bo[buf].buftype + -- Find a window that's not tree browser, terminal, or special buffer + if not bufname:match("^Remote Tree:") and not bufname:match("Remote Terminals") and buftype ~= "terminal" and buftype ~= "nofile" then + target_win = win + break + end + ::continue:: end end - return states + -- Open each buffer + for i, state in ipairs(states) do + -- Check if buffer is already open in a window + local already_visible = false + local bufnr = vim.fn.bufnr(state.url) + if bufnr ~= -1 then + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + already_visible = true + -- Restore cursor position + pcall(vim.api.nvim_win_set_cursor, win, state.cursor_pos) + break + end + end + end + + if not already_visible then + -- Open the file - first file uses target window, rest create splits + local win_to_use = nil + if i == 1 and target_win then + win_to_use = target_win + end + operations.simple_open_remote_file(state.url, state.cursor_pos, win_to_use) + end + end end ---- Restore buffer cursor states +--- Restore buffer cursor states (legacy - for cursor positions only) ---@param states table[] Buffer states function M.restore_buffer_states(states) for _, state in ipairs(states) do @@ -523,18 +636,16 @@ function M.restore_buffer_states(states) end --- Close all buffers for a session ----@param url string Session URL -function M.close_session_buffers(url) - local host_pattern = url:match("://([^/]+)") +---@param session_id string Session ID +function M.close_session_buffers(session_id) + local session_buffers = session_manager.get_session_buffers(session_id) - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(bufnr) then + for bufnr, _ in pairs(session_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then local bufname = vim.api.nvim_buf_get_name(bufnr) - if bufname:match(host_pattern) then - -- Don't close tree browser buffer - if not bufname:match("^Remote Tree:") then - pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) - end + -- Don't close tree browser buffer + if not bufname:match("^Remote Tree:") then + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) end end end diff --git a/lua/remote-session/session_manager.lua b/lua/remote-session/session_manager.lua index 9404cf1..575b9e2 100644 --- a/lua/remote-session/session_manager.lua +++ b/lua/remote-session/session_manager.lua @@ -135,6 +135,7 @@ function M.register_session(session) session.state = session.state or "active" session.terminal_ids = session.terminal_ids or {} session.open_buffers = session.open_buffers or {} + session.session_buffers = session.session_buffers or {} runtime_state.sessions[session.id] = session @@ -237,6 +238,10 @@ end function M.add_terminal(session_id, terminal_id) local session = runtime_state.sessions[session_id] if session then + -- Ensure terminal_ids exists (may be nil if loaded from persistence) + if not session.terminal_ids then + session.terminal_ids = {} + end if not vim.tbl_contains(session.terminal_ids, terminal_id) then table.insert(session.terminal_ids, terminal_id) end @@ -266,6 +271,42 @@ function M.get_terminals(session_id) return {} end +--- Add a buffer to a session +---@param session_id string +---@param bufnr number Buffer number +---@param url string Buffer URL +function M.add_buffer(session_id, bufnr, url) + local session = runtime_state.sessions[session_id] + if session then + if not session.session_buffers then + session.session_buffers = {} + end + -- Store by bufnr for quick lookup + session.session_buffers[bufnr] = url + end +end + +--- Remove a buffer from a session +---@param session_id string +---@param bufnr number +function M.remove_buffer(session_id, bufnr) + local session = runtime_state.sessions[session_id] + if session and session.session_buffers then + session.session_buffers[bufnr] = nil + end +end + +--- Get all buffers for a session +---@param session_id string +---@return table Map of bufnr -> url +function M.get_session_buffers(session_id) + local session = runtime_state.sessions[session_id] + if session then + return session.session_buffers or {} + end + return {} +end + --- Update session's open buffers ---@param session_id string ---@param buffers BufferState[] @@ -320,8 +361,10 @@ function M.load_from_persistence(session_id) return nil end - -- Copy to runtime + -- Copy to runtime and ensure runtime-only fields are initialized persisted.id = session_id + persisted.terminal_ids = persisted.terminal_ids or {} + persisted.open_buffers = persisted.open_buffers or {} runtime_state.sessions[session_id] = persisted return persisted diff --git a/lua/remote-terminal/window_manager.lua b/lua/remote-terminal/window_manager.lua index 9fc3874..512e5a2 100644 --- a/lua/remote-terminal/window_manager.lua +++ b/lua/remote-terminal/window_manager.lua @@ -107,15 +107,27 @@ function M.hide_split() local terminal_win = terminal_manager.get_terminal_win() local picker_win = terminal_manager.get_picker_win() - -- Close picker window first (it's inside the terminal split) - if picker_win and vim.api.nvim_win_is_valid(picker_win) then - vim.api.nvim_win_hide(picker_win) + -- Helper to safely hide a window, handling last window case + local function safe_win_hide(win) + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + local win_count = #vim.api.nvim_list_wins() + if win_count > 1 then + pcall(vim.api.nvim_win_hide, win) + else + -- Last window: replace with empty buffer instead of closing + local empty_buf = vim.api.nvim_create_buf(false, true) + pcall(vim.api.nvim_win_set_buf, win, empty_buf) + end end + -- Close picker window first (it's inside the terminal split) + safe_win_hide(picker_win) + -- Close terminal window - if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then - vim.api.nvim_win_hide(terminal_win) - end + safe_win_hide(terminal_win) terminal_manager.set_terminal_win(nil) terminal_manager.set_picker_win(nil) From 7cffdc26ba5ca9122ba1440c652f6d026b8e8474 Mon Sep 17 00:00:00 2001 From: "ian.hersom" Date: Thu, 5 Feb 2026 22:31:46 -0700 Subject: [PATCH 4/9] various fixes --- lua/remote-terminal/init.lua | 31 ++++++++++++++++++++ lua/remote-terminal/picker.lua | 2 +- lua/remote-terminal/terminal_manager.lua | 10 ++++++- lua/remote-terminal/terminal_session.lua | 36 ++++++++++-------------- lua/remote-terminal/window_manager.lua | 2 +- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/lua/remote-terminal/init.lua b/lua/remote-terminal/init.lua index 402ece8..28c44d1 100644 --- a/lua/remote-terminal/init.lua +++ b/lua/remote-terminal/init.lua @@ -62,12 +62,43 @@ local function setup_global_keymaps() end end +--- Setup autocommand to detect externally closed terminal/picker windows +local function setup_win_closed_autocmd() + local group = vim.api.nvim_create_augroup("RemoteTerminalWinClosed", { clear = true }) + vim.api.nvim_create_autocmd("WinClosed", { + group = group, + callback = function(args) + local closed_win = tonumber(args.match) + if not closed_win then + return + end + + local terminal_win = terminal_manager.get_terminal_win() + local picker_win = terminal_manager.get_picker_win() + + if closed_win == terminal_win or closed_win == picker_win then + if closed_win == terminal_win then + terminal_manager.set_terminal_win(nil) + end + if closed_win == picker_win then + terminal_manager.set_picker_win(nil) + end + -- If both windows are now gone, mark split as hidden + if not terminal_manager.is_terminal_win_valid() and not terminal_manager.is_picker_win_valid() then + terminal_manager.set_split_visible(false) + end + end + end, + }) +end + --- Setup the remote-terminal module ---@param opts table|nil Configuration options function M.setup(opts) config.setup(opts) commands.register() setup_global_keymaps() + setup_win_closed_autocmd() end -- Export public API diff --git a/lua/remote-terminal/picker.lua b/lua/remote-terminal/picker.lua index 672ef26..cc22445 100644 --- a/lua/remote-terminal/picker.lua +++ b/lua/remote-terminal/picker.lua @@ -122,8 +122,8 @@ function M.refresh() vim.api.nvim_buf_set_lines(picker_bufnr, 0, -1, false, lines) -- Clear existing highlights and apply new ones - vim.api.nvim_buf_clear_namespace(picker_bufnr, -1, 0, -1) local ns_id = vim.api.nvim_create_namespace("remote_terminal_picker") + vim.api.nvim_buf_clear_namespace(picker_bufnr, ns_id, 0, -1) for _, hl in ipairs(highlights) do vim.api.nvim_buf_add_highlight(picker_bufnr, ns_id, hl.hl_group, hl.line - 1, hl.col_start, hl.col_end) diff --git a/lua/remote-terminal/terminal_manager.lua b/lua/remote-terminal/terminal_manager.lua index a063922..ebfff56 100644 --- a/lua/remote-terminal/terminal_manager.lua +++ b/lua/remote-terminal/terminal_manager.lua @@ -262,7 +262,11 @@ end --- Close all terminals and clean up state function M.close_all() + local ids = {} for id, _ in pairs(TerminalState.terminals) do + table.insert(ids, id) + end + for _, id in ipairs(ids) do M.remove_terminal(id) end @@ -316,11 +320,15 @@ end --- Remove all terminal associations for a session ---@param session_id string Remote session ID function M.dissociate_session_terminals(session_id) + local to_remove = {} for terminal_id, assoc_session_id in pairs(TerminalState.session_associations) do if assoc_session_id == session_id then - TerminalState.session_associations[terminal_id] = nil + table.insert(to_remove, terminal_id) end end + for _, terminal_id in ipairs(to_remove) do + TerminalState.session_associations[terminal_id] = nil + end end --- Check if a terminal is associated with any session diff --git a/lua/remote-terminal/terminal_session.lua b/lua/remote-terminal/terminal_session.lua index c7f014b..a82ecd4 100644 --- a/lua/remote-terminal/terminal_session.lua +++ b/lua/remote-terminal/terminal_session.lua @@ -247,27 +247,21 @@ function M.create_session(connection_info, callback) -- Set buffer name vim.api.nvim_buf_set_name(bufnr, "Terminal " .. id .. ": " .. host_string) - -- Open terminal in the buffer - -- Need to switch to the buffer first - local original_buf = vim.api.nvim_get_current_buf() - vim.api.nvim_set_current_buf(bufnr) - - -- Start the terminal - local job_id = vim.fn.termopen(ssh_cmd, { - on_exit = function(job_id, exit_code, event) - -- Handle terminal exit - vim.schedule(function() - M.handle_terminal_exit(id, exit_code) - end) - end, - }) - - -- Restore original buffer if it's still valid - if vim.api.nvim_buf_is_valid(original_buf) then - vim.api.nvim_set_current_buf(original_buf) - end - - if job_id <= 0 then + -- Open terminal in the buffer using nvim_buf_call to avoid hijacking the current window. + -- nvim_buf_call creates a hidden autocommand window when the buffer isn't displayed, + -- so termopen runs without visual artifacts or unwanted BufLeave/BufEnter side effects. + local job_id + vim.api.nvim_buf_call(bufnr, function() + job_id = vim.fn.termopen(ssh_cmd, { + on_exit = function(_, exit_code) + vim.schedule(function() + M.handle_terminal_exit(id, exit_code) + end) + end, + }) + end) + + if not job_id or job_id <= 0 then vim.notify("Failed to start terminal: " .. tostring(job_id), vim.log.levels.ERROR) terminal_manager.remove_terminal(id) return nil diff --git a/lua/remote-terminal/window_manager.lua b/lua/remote-terminal/window_manager.lua index 512e5a2..16e9b63 100644 --- a/lua/remote-terminal/window_manager.lua +++ b/lua/remote-terminal/window_manager.lua @@ -163,7 +163,7 @@ end --- Toggle the terminal split visibility function M.toggle_split() - if terminal_manager.is_split_visible() then + if terminal_manager.is_split_visible() and M.is_layout_valid() then M.hide_split() else if terminal_manager.get_terminal_count() > 0 then From 8a6da022ae207175e931a023410ed33e9db6a567 Mon Sep 17 00:00:00 2001 From: "ian.hersom" Date: Thu, 5 Feb 2026 23:28:35 -0700 Subject: [PATCH 5/9] save --- lua/remote-terminal/terminal_session.lua | 64 +++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/lua/remote-terminal/terminal_session.lua b/lua/remote-terminal/terminal_session.lua index a82ecd4..6338ee4 100644 --- a/lua/remote-terminal/terminal_session.lua +++ b/lua/remote-terminal/terminal_session.lua @@ -269,6 +269,18 @@ function M.create_session(connection_info, callback) session.job_id = job_id + -- Associate with the active remote session (if any) so the picker filter includes this terminal + local ok_sm, session_manager = pcall(require, "remote-session.session_manager") + if ok_sm then + local active_session_id = session_manager.get_active_session_id() + if active_session_id then + terminal_manager.associate_terminal_with_session(id, active_session_id) + if session_manager.add_terminal then + session_manager.add_terminal(active_session_id, id) + end + end + end + if callback then callback(session) end @@ -320,21 +332,25 @@ function M.close_active_terminal() return end - terminal_manager.remove_terminal(active_id) - - -- Update UI local window_manager = require("remote-terminal.window_manager") local picker = require("remote-terminal.picker") - if terminal_manager.get_terminal_count() == 0 then + -- Find the next terminal BEFORE removal so we can switch the window first + local next_terminal = terminal_manager.get_next_terminal() + local has_next = next_terminal and next_terminal.id ~= active_id + + if has_next then + -- Switch window to next terminal's buffer before deleting the old one + -- This prevents layout corruption from force-deleting a displayed buffer + window_manager.switch_terminal(next_terminal.id) + end + + terminal_manager.remove_terminal(active_id) + + if not has_next then -- No more terminals, hide the split window_manager.hide_split() else - -- Switch to next terminal and refresh - local next_terminal = terminal_manager.get_active_terminal() - if next_terminal then - window_manager.switch_terminal(next_terminal.id) - end picker.refresh() end end @@ -346,19 +362,31 @@ function M.close_terminal(id) return end - terminal_manager.remove_terminal(id) - - -- Update UI local window_manager = require("remote-terminal.window_manager") local picker = require("remote-terminal.picker") - if terminal_manager.get_terminal_count() == 0 then - window_manager.hide_split() - else - local active = terminal_manager.get_active_terminal() - if active then - window_manager.switch_terminal(active.id) + -- If deleting the currently displayed terminal, switch window first + local active_id = terminal_manager.get_active_terminal_id() + if active_id == id then + local next_terminal = terminal_manager.get_next_terminal() + local has_next = next_terminal and next_terminal.id ~= id + + if has_next then + -- Switch window to next terminal's buffer before deleting the old one + -- This prevents layout corruption from force-deleting a displayed buffer + window_manager.switch_terminal(next_terminal.id) end + + terminal_manager.remove_terminal(id) + + if not has_next then + window_manager.hide_split() + else + picker.refresh() + end + else + -- Deleting a non-displayed terminal; safe to remove directly + terminal_manager.remove_terminal(id) picker.refresh() end end From 555a68b2c12b71a1ecc22f196da83712e43cfff8 Mon Sep 17 00:00:00 2001 From: Ian Hersom Date: Sun, 22 Feb 2026 22:00:35 -0700 Subject: [PATCH 6/9] format using stylua --- lua/async-remote-write/operations.lua | 8 +++++- lua/remote-session/session.lua | 38 +++++++++++---------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lua/async-remote-write/operations.lua b/lua/async-remote-write/operations.lua index 41b255b..fde9870 100644 --- a/lua/async-remote-write/operations.lua +++ b/lua/async-remote-write/operations.lua @@ -1399,7 +1399,13 @@ function M.simple_open_remote_file(url, position, target_win) end -- Check for special buffer names that indicate tree browser, terminal picker, or other special buffers - if bufname:match("Remote Tree") or bufname:match("Remote Terminals") or bufname:match("TreeBrowser") or bufname:match("NvimTree") or bufname:match("neo%-tree") then + if + bufname:match("Remote Tree") + or bufname:match("Remote Terminals") + or bufname:match("TreeBrowser") + or bufname:match("NvimTree") + or bufname:match("neo%-tree") + then return false end diff --git a/lua/remote-session/session.lua b/lua/remote-session/session.lua index 4ba3707..b15c5b0 100644 --- a/lua/remote-session/session.lua +++ b/lua/remote-session/session.lua @@ -219,18 +219,12 @@ function M.minimize(session_id) if config.get("confirm_close") then local unsaved = M.get_unsaved_buffers(session_id) if #unsaved > 0 then - local choice = vim.fn.confirm( - "Session has unsaved buffers:\n" - .. table.concat( - vim.tbl_map(function(b) - return " " .. vim.api.nvim_buf_get_name(b) - end, unsaved), - "\n" - ) - .. "\n\nMinimize anyway?", - "&Save all\n&Discard\n&Cancel", - 1 - ) + local choice = vim.fn.confirm("Session has unsaved buffers:\n" .. table.concat( + vim.tbl_map(function(b) + return " " .. vim.api.nvim_buf_get_name(b) + end, unsaved), + "\n" + ) .. "\n\nMinimize anyway?", "&Save all\n&Discard\n&Cancel", 1) if choice == 1 then for _, bufnr in ipairs(unsaved) do vim.api.nvim_buf_call(bufnr, function() @@ -400,11 +394,7 @@ function M.close(session_id, opts) if not opts.force and config.get("confirm_close") then local unsaved = M.get_unsaved_buffers(session_id) if #unsaved > 0 then - local choice = vim.fn.confirm( - "Session has unsaved buffers. Close anyway?", - "&Save all\n&Discard\n&Cancel", - 1 - ) + local choice = vim.fn.confirm("Session has unsaved buffers. Close anyway?", "&Save all\n&Discard\n&Cancel", 1) if choice == 1 then for _, bufnr in ipairs(unsaved) do vim.api.nvim_buf_call(bufnr, function() @@ -419,11 +409,8 @@ function M.close(session_id, opts) -- Check for running terminals if not opts.force and config.get("confirm_terminal_close") and session.terminal_ids and #session.terminal_ids > 0 then - local choice = vim.fn.confirm( - "Session has " .. #session.terminal_ids .. " terminal(s). Close session?", - "&Yes\n&No", - 1 - ) + local choice = + vim.fn.confirm("Session has " .. #session.terminal_ids .. " terminal(s). Close session?", "&Yes\n&No", 1) if choice ~= 1 then return false end @@ -582,7 +569,12 @@ function M.restore_session_buffers(states) local bufname = vim.api.nvim_buf_get_name(buf) local buftype = vim.bo[buf].buftype -- Find a window that's not tree browser, terminal, or special buffer - if not bufname:match("^Remote Tree:") and not bufname:match("Remote Terminals") and buftype ~= "terminal" and buftype ~= "nofile" then + if + not bufname:match("^Remote Tree:") + and not bufname:match("Remote Terminals") + and buftype ~= "terminal" + and buftype ~= "nofile" + then target_win = win break end From 0f938f9d1f59a9d7b7bf00b31c833a567ffd07ba Mon Sep 17 00:00:00 2001 From: Ian Hersom Date: Sun, 22 Feb 2026 22:22:22 -0700 Subject: [PATCH 7/9] fix: address PR #67 review feedback Remove unused `original_state` variable, deduplicate regex match for host parsing, remove dead legacy `restore_buffer_states` function, and document `session_buffers` field in RemoteSession class annotation. Co-Authored-By: Claude Opus 4.6 --- lua/remote-session/session.lua | 27 +++----------------------- lua/remote-session/session_manager.lua | 3 ++- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/lua/remote-session/session.lua b/lua/remote-session/session.lua index b15c5b0..f59b89f 100644 --- a/lua/remote-session/session.lua +++ b/lua/remote-session/session.lua @@ -138,9 +138,9 @@ function M.create_terminal_for_session(session_id) -- Build connection info from session local user, host = nil, session.host - local user_host = session.host:match("^([^@]+)@(.+)$") - if user_host then - user, host = session.host:match("^([^@]+)@(.+)$") + local u, h = session.host:match("^([^@]+)@(.+)$") + if u then + user, host = u, h end local connection_info = { @@ -267,9 +267,6 @@ function M.restore(session_id) return false end - -- Remember original state before we change it - local original_state = session.state - -- Minimize current active session if different local current_active = session_manager.get_active_session() if current_active and current_active.id ~= session_id and config.get("auto_minimize") then @@ -609,24 +606,6 @@ function M.restore_session_buffers(states) end end ---- Restore buffer cursor states (legacy - for cursor positions only) ----@param states table[] Buffer states -function M.restore_buffer_states(states) - for _, state in ipairs(states) do - -- Find buffer by name - local bufnr = vim.fn.bufnr(state.url) - if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then - -- Find window displaying this buffer - for _, win in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_buf(win) == bufnr then - pcall(vim.api.nvim_win_set_cursor, win, state.cursor_pos) - break - end - end - end - end -end - --- Close all buffers for a session ---@param session_id string Session ID function M.close_session_buffers(session_id) diff --git a/lua/remote-session/session_manager.lua b/lua/remote-session/session_manager.lua index 575b9e2..db024be 100644 --- a/lua/remote-session/session_manager.lua +++ b/lua/remote-session/session_manager.lua @@ -17,7 +17,8 @@ local persistence = require("remote-session.persistence") ---@field window_layout WindowLayout ---@field tree_browser_state TreeBrowserState|nil ---@field terminal_ids number[] Associated terminal IDs ----@field open_buffers BufferState[] +---@field open_buffers BufferState[] Saved buffer states for persistence/restoration (cursor positions, URLs) +---@field session_buffers table Runtime map of bufnr -> URL for currently loaded buffers ---@class WindowLayout ---@field tree_browser_width_ratio number 0.0-1.0 of editor width From a011bc9e44c537c66db9a3671353fabf5bc33d47 Mon Sep 17 00:00:00 2001 From: Ian Hersom Date: Sun, 22 Feb 2026 22:26:12 -0700 Subject: [PATCH 8/9] update version --- lua/version.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/version.lua b/lua/version.lua index a6a872a..b7a4ccc 100644 --- a/lua/version.lua +++ b/lua/version.lua @@ -1,9 +1,9 @@ local M = {} -M.version = "0.6.0" +M.version = "0.7.0" M.version_info = { major = 0, - minor = 6, + minor = 7, patch = 0, } From b9d7873c4caaa82c991ce6f54660ae46ffc35755 Mon Sep 17 00:00:00 2001 From: Ian Hersom Date: Sun, 22 Feb 2026 22:36:42 -0700 Subject: [PATCH 9/9] docs: add remote-session module documentation to README Document the complete remote-session feature introduced in PR #67: quick start step, features bullet, configuration block, dedicated usage section with picker keybinds and statusline integration, and all 7 commands in the commands table. Co-Authored-By: Claude Opus 4.6 --- README.md | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/README.md b/README.md index 9e833c9..3d7f692 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,13 @@ This gives you zero-latency editing with full LSP features like code completion, ``` Use `` to toggle, picker sidebar for managing multiple terminals. +6. **Manage remote sessions:** + ```vim + :RemoteSession user@host//path/to/project " Open a unified session + :RemoteSessionMinimize " Minimize to background + :RemoteSessionPicker " Visual picker to switch/restore + ``` + That's it! The plugin handles the rest automatically. ![RemoteTreeBrowser With Open Remote Buffers](./images/term.png) @@ -81,6 +88,7 @@ That's it! The plugin handles the rest automatically. - **📊 Interactive Log Viewer** - View and filter plugin logs with rich diagnostic context for troubleshooting - **🖥️ Remote Terminal Management** - VS Code-style integrated terminal with SSH connections and multi-terminal picker - **📥 Rsync to Local** - Download remote files or directories to local folders with progress tracking +- **🔗 Remote Session Management** - Unified workspace sessions with minimize/restore, persistence, and a visual session picker ### 🖥️ Language Server Support Ready-to-use configurations for popular language servers: @@ -485,6 +493,54 @@ require('remote-ssh').setup({ TerminalPickerHeader = { fg = "#61afef", bold = true }, TerminalPickerId = { fg = "#d19a66" }, }, + }, + + -- Remote session management configuration + remote_session_opts = { + -- Auto-minimize active session when opening a new one + auto_minimize = true, + + -- Confirm before closing sessions with unsaved buffers + confirm_close = true, + + -- Confirm before closing sessions with running terminal processes + confirm_terminal_close = true, + + -- Default window layout ratios (proportional 0.0-1.0) + default_layout = { + tree_browser_width_ratio = 0.2, -- 20% of editor width + terminal_height_ratio = 0.3, -- 30% of editor height + }, + + -- Statusline component configuration + statusline = { + show_minimized_count = true, -- Show minimized session count + -- Format string for active session display + -- Available placeholders: {name}, {host}, {path}, {minimized_count} + format = "SSH: {name}", + format_with_minimized = "SSH: {name} [+{minimized_count} minimized]", + no_session_text = "", -- Text shown when no active session + }, + + -- Picker UI configuration + picker = { + width = 0.6, -- Width of the picker window (percentage or absolute) + max_height = 20, -- Maximum height of picker window + show_hints = true, -- Show help hints in picker header + }, + + -- Persistence configuration + persistence = { + enabled = true, -- Enable persistence across Neovim restarts + max_persisted = 50, -- Maximum number of persisted sessions to keep + save_expanded_dirs = true, -- Save expanded directories in tree browser + }, + + -- Terminal configuration + terminal = { + auto_create = true, -- Auto-create terminal when opening a new session + start_in_session_path = true, -- Start directory for new terminals (relative to session path) + }, } }) ``` @@ -927,6 +983,111 @@ The terminal split appears at the bottom of the screen with two panes: **💡 Pro tip**: The terminal split remembers which terminal was active. Toggle it away with `` while working, then toggle back to resume exactly where you left off. +## 🔗 Remote Session Management + +**Benefits**: Combine the tree browser, terminal, and file editing into a single unified session per remote project. Minimize sessions to the background, switch between multiple projects instantly, and persist session state across Neovim restarts. + +The remote session module wraps the existing remote-ssh features into cohesive workspace sessions. Each session tracks a remote host + project path and manages the associated tree browser and terminal windows as a unit. + +### Usage + +**Open a session:** +```vim +:RemoteSession user@host//path/to/project " Open or reuse a session +:RemoteSession rsync://user@host//path/to/project " Explicit rsync:// URL also works +:RemoteSession " No URL → opens session picker +``` + +**Minimize and restore:** +```vim +:RemoteSessionMinimize " Minimize the active session to background +:RemoteSessionRestore my-project " Restore by session name or ID +:RemoteSessionPicker " Visual picker to browse and restore sessions +``` + +**Manage sessions:** +```vim +:RemoteSessionRename my-project " Rename the active session +:RemoteSessionList " List all sessions with their states +:RemoteSessionClose " Close the active session +:RemoteSessionClose " Close a specific session by ID +``` + +### Session Picker Keybinds + +When the picker is open (`:RemoteSessionPicker` or `:RemoteSession` with no args): + +| Key | Action | +|-----|--------| +| `Enter` / `Space` | Open/restore the selected session | +| `m` | Minimize the selected session | +| `d` | Delete the selected session | +| `r` | Rename the selected session | +| `/` | Enter filter mode (type to search) | +| `Esc` | Exit filter mode, or close picker | +| `q` | Close the picker | +| `j` / `k` | Navigate down / up | + +### Statusline Integration + +The session module provides statusline components that show the active session and minimized count. + +**Lualine setup (recommended):** +```lua +require("lualine").setup({ + sections = { + lualine_x = { + require("remote-session").lualine(), + }, + }, +}) +``` + +**Custom statusline functions:** + +All functions are available on `require("remote-session")`: + +| Function | Returns | Description | +|----------|---------|-------------| +| `statusline_component()` | `string` | Formatted string using the `statusline.format` config (e.g. `"SSH: my-project [+2 minimized]"`) | +| `statusline_minimal()` | `string` | Just the session name, or `""` | +| `statusline_icon()` | `string` | Icon-prefixed name with minimized count (e.g. `"󰣀 my-project +2"`) | +| `statusline_detailed()` | `string` | State indicators `● active ○×N minimized` | +| `has_active_session()` | `boolean` | `true` if a session is currently active | +| `has_any_session()` | `boolean` | `true` if any session exists (active or minimized) | +| `lualine()` | `table` | Lualine-compatible component with `cond` that hides when no sessions exist | + +**Example with a custom statusline:** +```lua +-- In your statusline config +local rs = require("remote-session") +if rs.has_any_session() then + statusline_text = rs.statusline_icon() +end +``` + +### Common Workflows + +**Multi-project switching:** +```vim +" Open project A +:RemoteSession user@host//home/user/project-a +" ... work on project A ... + +" Switch to project B (project A auto-minimizes) +:RemoteSession user@host//home/user/project-b +" ... work on project B ... + +" Open picker to jump back to project A +:RemoteSessionPicker +``` + +**Persistence across restarts:** + +Sessions are automatically persisted when `persistence.enabled = true` (the default). When you restart Neovim, previously open sessions appear in the picker and can be restored with `:RemoteSessionRestore` or `:RemoteSessionPicker`. + +**💡 Pro tip**: The `auto_minimize` option (enabled by default) automatically minimizes the current session when you open a new one, so you never have to manually minimize before switching projects. + ## 🤖 Available commands | Primary Commands | What does it do? | @@ -956,6 +1117,16 @@ The terminal split appears at the bottom of the screen with two panes: | `:RemoteTerminalRename [name]` | Rename the active terminal | | `:RemoteTerminalList` | List all remote terminals (debug) | +| Remote Session Commands | What does it do? | +| ------------------------- | --------------------------------------------------------------------------- | +| `:RemoteSession [url]` | Open a remote session by URL, or show session picker if no URL given | +| `:RemoteSessionClose [id]`| Close the current or specified remote session | +| `:RemoteSessionMinimize` | Minimize the current remote session to background | +| `:RemoteSessionRename [name]` | Rename the current remote session | +| `:RemoteSessionList` | List all remote sessions with their states | +| `:RemoteSessionPicker` | Show the remote session picker | +| `:RemoteSessionRestore [id]` | Restore a minimized or persisted session (opens picker if no ID given) | + | File Watcher Commands | What does it do? | | ------------------------- | --------------------------------------------------------------------------- | | `:RemoteWatchStart` | Start file watching for current buffer (monitors remote changes) |