From 1990fb5da9ecba66f85aba7c2b1672b9980d05ef Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 18:53:00 +0100 Subject: [PATCH] feat: restore tabs and splits at launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab layout (tab order and names, pins, the active tab, split trees with their ratios) is saved whenever it changes — checked once a second, written only on change, so it survives a crash — and reopened at the next launch: saved profiles reconnect and local shells start in the directory they were in (on Linux the live /proc cwd, so it isn't one command behind). Quick Connect tabs and placeholder panes are skipped. A General setting turns it off; it never runs when launched with --connect, --run-local or --open, and screenshot runs never save. Verified: a local tab left in /usr/share came back there with a 'Restored 1 tab(s)' toast. --- docs/overview.md | 1 + docs/user-manual.md | 2 +- rust/app/src/app.rs | 13 +++ rust/app/src/main.rs | 1 + rust/app/src/restore.rs | 184 ++++++++++++++++++++++++++++++++ rust/app/src/settings.rs | 3 + rust/core/src/clients/local.rs | 6 ++ rust/core/src/models.rs | 3 + rust/core/src/service.rs | 5 + rust/core/src/sessions/local.rs | 20 ++++ rust/core/src/storage.rs | 10 ++ 11 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 rust/app/src/restore.rs diff --git a/docs/overview.md b/docs/overview.md index 7290f3a..c120e5c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -73,6 +73,7 @@ Use Nexus Terminal when you need to: - Shift+PageUp/PageDown/Home/End and the mouse wheel scroll the scrollback; on the alternate screen the wheel sends arrow keys, so `tmux`, `screen` and `byobu` scroll naturally. - Context menu with Copy, Paste, Select All, Find, Send Snippet, Port Forwards, Split Right, Split Down, Export Output, Clear and Reset. - Export the whole scrollback as plain text. +- Tabs and split panes are restored at the next launch: connections reconnect and local shells reopen in their last directory. - Inline images in the iTerm2 format (`imgcat`, `wezterm imgcat`, `chafa -f iterm`…): PNG, JPEG and GIF, sized in cells, pixels or percent, scrolling with the text. - Shell integration (OSC 133): jump between prompts, copy the last command's output, and see each command's exit status and duration beside its prompt. - Status-bar latency for the active SSH session. diff --git a/docs/user-manual.md b/docs/user-manual.md index d05d7df..cc6078b 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -434,7 +434,7 @@ Connections, disconnections, profile changes, credential access, imports and exp Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux), the sidebar footer, or **File → Settings**. Click **Save** to apply changes. -- **General**: app theme (Pro, Midnight or Light), default protocol, confirm on close, confirm on disconnect, remote clipboard writes (OSC 52 and VNC; a toast shows what was copied), multi-line paste confirmation, the launch-time update check, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one). +- **General**: app theme (Pro, Midnight or Light), default protocol, restore tabs at launch (reopens the last session's tabs and split panes: saved connections reconnect, local shells start in the directory they were in; Quick Connect tabs aren't restored), confirm on close, confirm on disconnect, remote clipboard writes (OSC 52 and VNC; a toast shows what was copied), multi-line paste confirmation, the launch-time update check, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one). - **Terminal**: colour scheme, font family, font size, scrollback lines and cursor style. These are the defaults for new profiles; profiles can override them. - **Security**: keychain and vault status; initialise, unlock or lock the secret store. - **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 82a167a..91fe67b 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -121,6 +121,9 @@ pub enum InputThen { pub struct App { pub s: Shared, + /// The tab layout as last saved for restoring (see restore.rs), and when it was last checked. + pub saved_layout: Option, + layout_checked: f64, rx: mpsc::Receiver, pub theme: ThemeKind, pub editor: profile_editor::ProfileEditor, @@ -195,7 +198,13 @@ impl App { answers: opts.answers, run_local: opts.run_local.map(|c| (c, None)), resize_sidebar: false, + saved_layout: None, + layout_checked: 0.0, }; + let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions; + if restore { + crate::restore::restore(&mut app); + } for c in &opts.connect { match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) { Some(id) => app.connect_profile(&id, true), @@ -1003,6 +1012,10 @@ impl eframe::App for App { chrome::palette(self, &ctx); chrome::toasts(self, &ctx); self.screenshot(&ctx); + if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 { + self.layout_checked = self.s.now; + crate::restore::save_if_changed(self); + } chrome::resize_edges(&ctx); // Requests raised while drawing are handled on the next frame. if !self.s.requests.is_empty() { diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 4f4e88c..0c78384 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -13,6 +13,7 @@ mod profile_editor; mod quick_connect; mod raw_view; mod recordings; +mod restore; mod settings; mod sftp; mod sidebar; diff --git a/rust/app/src/restore.rs b/rust/app/src/restore.rs new file mode 100644 index 0000000..9d50ce3 --- /dev/null +++ b/rust/app/src/restore.rs @@ -0,0 +1,184 @@ +//! Reopens the previous run's tabs and splits at launch: saved profiles reconnect, local shells +//! start again in the directory they were in. The layout is saved whenever it changes (not only on +//! exit), so it survives a crash. Quick Connect tabs aren't restored (they have no saved profile). + +use crate::app::App; +use crate::splits::{Dir, Split}; +use crate::state::Tab; +use nexus_core::models::Protocol; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const KEY: &str = "session_layout"; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Default)] +pub struct Saved { + tabs: Vec, + active: usize, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct SavedTab { + name: String, + pinned: bool, + layout: Layout, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum Layout { + Profile { + id: String, + }, + Local { + shell: String, + cwd: Option, + }, + /// A split pane still showing the connection picker, or a session that can't be restored. + Empty, + Split { + down: bool, + ratio: f32, + a: Box, + b: Box, + }, +} + +fn session_layout(app: &App, id: &str) -> Layout { + let Some(pane) = app.s.pane(id) else { return Layout::Empty }; + if pane.info.protocol == Protocol::Local { + let (shell, cwd) = app.s.svc.sessions.local_state(id).unwrap_or_default(); + return Layout::Local { shell, cwd }; + } + match app.s.profile(&pane.info.profile_id) { + Some(p) => Layout::Profile { id: p.id.clone() }, + None => Layout::Empty, + } +} + +fn split_layout(app: &App, sp: &Split) -> Layout { + match sp { + Split::Leaf(Some(id)) => session_layout(app, id), + Split::Leaf(None) => Layout::Empty, + Split::Node { dir, ratio, a, b } => { + Layout::Split { down: *dir == Dir::Down, ratio: *ratio, a: Box::new(split_layout(app, a)), b: Box::new(split_layout(app, b)) } + } + } +} + +pub fn capture(app: &App) -> Saved { + let tabs: Vec = app + .s + .tabs + .iter() + .map(|t| { + let info = app.s.pane(&t.id).map(|p| (p.info.tab_name.clone(), p.info.pinned)).unwrap_or_default(); + let layout = match &t.split { + Some(sp) => split_layout(app, sp), + None => session_layout(app, &t.id), + }; + SavedTab { name: info.0, pinned: info.1, layout } + }) + .filter(|t| t.layout != Layout::Empty) + .collect(); + let active = app.s.tabs.iter().position(|t| Some(&t.id) == app.s.active.as_ref()).unwrap_or(0); + Saved { active: active.min(tabs.len().saturating_sub(1)), tabs } +} + +/// Saves the layout if it changed since the last save (called each frame; cheap for a few tabs). +pub fn save_if_changed(app: &mut App) { + let Ok(json) = serde_json::to_string(&capture(app)) else { return }; + if app.saved_layout.as_deref() != Some(&json) { + let _ = app.s.svc.store.set_setting(KEY, &json); + app.saved_layout = Some(json); + } +} + +/// Opens one session for a layout leaf; returns its id. +fn open(app: &mut App, l: &Layout) -> Option { + let id = match l { + Layout::Profile { id } => app.s.svc.connect_profile(id).ok()?, + Layout::Local { shell, cwd } => app.s.svc.connect_local_in(shell, cwd.clone()), + _ => return None, + }; + app.add_session(&id, false); + Some(id) +} + +fn build(app: &mut App, l: &Layout) -> Split { + match l { + Layout::Split { down, ratio, a, b } => Split::Node { + dir: if *down { Dir::Down } else { Dir::Right }, + ratio: ratio.clamp(0.1, 0.9), + a: Box::new(build(app, a)), + b: Box::new(build(app, b)), + }, + leaf => Split::Leaf(open(app, leaf)), + } +} + +pub fn restore(app: &mut App) { + let Some(saved) = app.s.svc.store.setting(KEY).and_then(|j| serde_json::from_str::(&j).ok()) else { return }; + let mut ids = vec![]; + for t in &saved.tabs { + let (id, split) = match &t.layout { + Layout::Split { .. } => { + let sp = build(app, &t.layout); + let Some(first) = sp.sessions().first().cloned() else { continue }; + (first, Some(sp)) + } + leaf => match open(app, leaf) { + Some(id) => (id, None), + None => continue, + }, + }; + if !t.name.is_empty() { + let _ = app.s.svc.sessions.rename(&id, &t.name); + if let Some(p) = app.s.pane_mut(&id) { + p.info.tab_name = t.name.clone(); + } + } + if t.pinned { + let _ = app.s.svc.sessions.pin(&id, true); + if let Some(p) = app.s.pane_mut(&id) { + p.info.pinned = true; + } + } + app.s.tabs.push(Tab { id: id.clone(), activity: false, split }); + ids.push(id); + } + if let Some(id) = ids.get(saved.active).or(ids.first()) { + app.s.active = Some(id.clone()); + app.s.focused = Some(id.clone()); + } + if !ids.is_empty() { + app.s.info(format!("Restored {} tab(s) from last time", ids.len())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layouts_round_trip() { + let saved = Saved { + active: 1, + tabs: vec![ + SavedTab { name: "web".into(), pinned: true, layout: Layout::Profile { id: "p1".into() } }, + SavedTab { + name: String::new(), + pinned: false, + layout: Layout::Split { + down: true, + ratio: 0.3, + a: Box::new(Layout::Local { shell: "/bin/zsh".into(), cwd: Some("/tmp".into()) }), + b: Box::new(Layout::Empty), + }, + }, + ], + }; + let json = serde_json::to_string(&saved).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), saved); + } +} diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index d32b801..5dc54b1 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -249,6 +249,9 @@ impl Settings { field(ui, "Confirm on disconnect"); ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions"); ui.end_row(); + field(ui, "Restore tabs"); + ui.checkbox(&mut p.restore_sessions, "Reopen last session's tabs and splits at launch"); + ui.end_row(); field(ui, "Remote clipboard"); ui.checkbox(&mut p.allow_remote_clipboard, "Let remote hosts set the clipboard (OSC 52, VNC)"); ui.end_row(); diff --git a/rust/core/src/clients/local.rs b/rust/core/src/clients/local.rs index 55c4d0c..7350194 100644 --- a/rust/core/src/clients/local.rs +++ b/rust/core/src/clients/local.rs @@ -147,6 +147,12 @@ impl LocalShell { /// The last directory the shell was seen in (used to restart in the same place). pub fn working_directory(&self) -> PathBuf { + // On Linux the live directory is a cheap readlink; elsewhere the last capture. + if cfg!(target_os = "linux") + && let Some(dir) = self.state.lock().0.as_ref().and_then(|r| r.pid).and_then(process_cwd).filter(|d| d.is_dir()) + { + return dir; + } self.state.lock().1.clone() } diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index 37a64fc..f344777 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -621,6 +621,8 @@ pub struct AppPreferences { pub allow_remote_clipboard: bool, /// Ask before pasting multi-line text into a terminal that isn't in bracketed-paste mode. pub confirm_multiline_paste: bool, + /// Reopen the previous session's tabs and splits at launch. + pub restore_sessions: bool, } impl Default for AppPreferences { @@ -642,6 +644,7 @@ impl Default for AppPreferences { check_for_updates: true, allow_remote_clipboard: true, confirm_multiline_paste: true, + restore_sessions: true, } } } diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 42214d1..e5a65f7 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -127,6 +127,11 @@ impl Service { self.sessions.connect_local(&shell) } + /// A local shell with an explicit shell and start directory (restored tabs). + pub fn connect_local_in(&self, shell: &str, cwd: Option) -> String { + self.sessions.connect_local_in(shell, cwd) + } + pub fn reconnect(&self, id: &str) -> Result { self.sessions.reconnect(id) } diff --git a/rust/core/src/sessions/local.rs b/rust/core/src/sessions/local.rs index 07a7ddb..c39bd74 100644 --- a/rust/core/src/sessions/local.rs +++ b/rust/core/src/sessions/local.rs @@ -6,6 +6,11 @@ impl Manager { // --- local shell -------------------------------------------------------------------- pub fn connect_local(self: &Arc, shell: &str) -> String { + self.connect_local_in(shell, None) + } + + /// A local shell starting in `cwd` (when it still exists), e.g. a restored tab. + pub fn connect_local_in(self: &Arc, shell: &str, cwd: Option) -> String { let id = new_id(); let info = SessionInfo { id: id.clone(), @@ -21,6 +26,9 @@ impl Manager { error: String::new(), }; let epoch = self.new_session(info, None, shell.into(), SessionSecrets::default()); + if let Some(dir) = cwd.filter(|d| d.is_dir()) { + let _ = self.with(&id, |s| s.local_cwd = Some(dir)); + } self.emit_state(&id, SessionState::Connecting, ""); let me = self.clone(); let id2 = id.clone(); @@ -28,6 +36,18 @@ impl Manager { id } + /// A local session's shell and its current directory. + pub fn local_state(&self, id: &str) -> Option<(String, Option)> { + self.with(id, |s| { + let cwd = match &s.client { + Client::Local(c) => Some(c.working_directory()), + _ => s.local_cwd.clone(), + }; + (s.local_shell.clone(), cwd) + }) + .ok() + } + /// Starts a fresh shell in an exited local tab, keeping its scrollback and last directory. pub fn restart_local(self: &Arc, id: &str, cols: u16, rows: u16) -> Result<()> { let epoch = self.with(id, |s| { diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index cfad031..37d937f 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -395,6 +395,16 @@ impl Store { Ok(()) } + /// A value saved under `key` (app state such as the restorable tab layout). + pub fn setting(&self, key: &str) -> Option { + self.db.lock().query_row("SELECT value FROM preferences WHERE key = ?", [key], |r| r.get(0)).optional().ok().flatten() + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { + self.db.lock().execute("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, value])?; + Ok(()) + } + pub fn list_snippets(&self) -> Result> { let db = self.db.lock(); let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;