feat: restore tabs and splits at launch
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.
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Status-bar latency for the active SSH session.
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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.
|
- **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.
|
- **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**.
|
- **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**.
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ pub enum InputThen {
|
|||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub s: Shared,
|
pub s: Shared,
|
||||||
|
/// The tab layout as last saved for restoring (see restore.rs), and when it was last checked.
|
||||||
|
pub saved_layout: Option<String>,
|
||||||
|
layout_checked: f64,
|
||||||
rx: mpsc::Receiver<Event>,
|
rx: mpsc::Receiver<Event>,
|
||||||
pub theme: ThemeKind,
|
pub theme: ThemeKind,
|
||||||
pub editor: profile_editor::ProfileEditor,
|
pub editor: profile_editor::ProfileEditor,
|
||||||
@@ -195,7 +198,13 @@ impl App {
|
|||||||
answers: opts.answers,
|
answers: opts.answers,
|
||||||
run_local: opts.run_local.map(|c| (c, None)),
|
run_local: opts.run_local.map(|c| (c, None)),
|
||||||
resize_sidebar: false,
|
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 {
|
for c in &opts.connect {
|
||||||
match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) {
|
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),
|
Some(id) => app.connect_profile(&id, true),
|
||||||
@@ -1003,6 +1012,10 @@ impl eframe::App for App {
|
|||||||
chrome::palette(self, &ctx);
|
chrome::palette(self, &ctx);
|
||||||
chrome::toasts(self, &ctx);
|
chrome::toasts(self, &ctx);
|
||||||
self.screenshot(&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);
|
chrome::resize_edges(&ctx);
|
||||||
// Requests raised while drawing are handled on the next frame.
|
// Requests raised while drawing are handled on the next frame.
|
||||||
if !self.s.requests.is_empty() {
|
if !self.s.requests.is_empty() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod profile_editor;
|
|||||||
mod quick_connect;
|
mod quick_connect;
|
||||||
mod raw_view;
|
mod raw_view;
|
||||||
mod recordings;
|
mod recordings;
|
||||||
|
mod restore;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sftp;
|
mod sftp;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
|
|||||||
@@ -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<SavedTab>,
|
||||||
|
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<PathBuf>,
|
||||||
|
},
|
||||||
|
/// A split pane still showing the connection picker, or a session that can't be restored.
|
||||||
|
Empty,
|
||||||
|
Split {
|
||||||
|
down: bool,
|
||||||
|
ratio: f32,
|
||||||
|
a: Box<Layout>,
|
||||||
|
b: Box<Layout>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SavedTab> = 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<String> {
|
||||||
|
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::<Saved>(&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::<Saved>(&json).unwrap(), saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,9 @@ impl Settings {
|
|||||||
field(ui, "Confirm on disconnect");
|
field(ui, "Confirm on disconnect");
|
||||||
ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions");
|
ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions");
|
||||||
ui.end_row();
|
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");
|
field(ui, "Remote clipboard");
|
||||||
ui.checkbox(&mut p.allow_remote_clipboard, "Let remote hosts set the clipboard (OSC 52, VNC)");
|
ui.checkbox(&mut p.allow_remote_clipboard, "Let remote hosts set the clipboard (OSC 52, VNC)");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ impl LocalShell {
|
|||||||
|
|
||||||
/// The last directory the shell was seen in (used to restart in the same place).
|
/// The last directory the shell was seen in (used to restart in the same place).
|
||||||
pub fn working_directory(&self) -> PathBuf {
|
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()
|
self.state.lock().1.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -621,6 +621,8 @@ pub struct AppPreferences {
|
|||||||
pub allow_remote_clipboard: bool,
|
pub allow_remote_clipboard: bool,
|
||||||
/// Ask before pasting multi-line text into a terminal that isn't in bracketed-paste mode.
|
/// Ask before pasting multi-line text into a terminal that isn't in bracketed-paste mode.
|
||||||
pub confirm_multiline_paste: bool,
|
pub confirm_multiline_paste: bool,
|
||||||
|
/// Reopen the previous session's tabs and splits at launch.
|
||||||
|
pub restore_sessions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppPreferences {
|
impl Default for AppPreferences {
|
||||||
@@ -642,6 +644,7 @@ impl Default for AppPreferences {
|
|||||||
check_for_updates: true,
|
check_for_updates: true,
|
||||||
allow_remote_clipboard: true,
|
allow_remote_clipboard: true,
|
||||||
confirm_multiline_paste: true,
|
confirm_multiline_paste: true,
|
||||||
|
restore_sessions: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ impl Service {
|
|||||||
self.sessions.connect_local(&shell)
|
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<PathBuf>) -> String {
|
||||||
|
self.sessions.connect_local_in(shell, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reconnect(&self, id: &str) -> Result<String> {
|
pub fn reconnect(&self, id: &str) -> Result<String> {
|
||||||
self.sessions.reconnect(id)
|
self.sessions.reconnect(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ impl Manager {
|
|||||||
// --- local shell --------------------------------------------------------------------
|
// --- local shell --------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn connect_local(self: &Arc<Self>, shell: &str) -> String {
|
pub fn connect_local(self: &Arc<Self>, 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<Self>, shell: &str, cwd: Option<PathBuf>) -> String {
|
||||||
let id = new_id();
|
let id = new_id();
|
||||||
let info = SessionInfo {
|
let info = SessionInfo {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -21,6 +26,9 @@ impl Manager {
|
|||||||
error: String::new(),
|
error: String::new(),
|
||||||
};
|
};
|
||||||
let epoch = self.new_session(info, None, shell.into(), SessionSecrets::default());
|
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, "");
|
self.emit_state(&id, SessionState::Connecting, "");
|
||||||
let me = self.clone();
|
let me = self.clone();
|
||||||
let id2 = id.clone();
|
let id2 = id.clone();
|
||||||
@@ -28,6 +36,18 @@ impl Manager {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A local session's shell and its current directory.
|
||||||
|
pub fn local_state(&self, id: &str) -> Option<(String, Option<PathBuf>)> {
|
||||||
|
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.
|
/// Starts a fresh shell in an exited local tab, keeping its scrollback and last directory.
|
||||||
pub fn restart_local(self: &Arc<Self>, id: &str, cols: u16, rows: u16) -> Result<()> {
|
pub fn restart_local(self: &Arc<Self>, id: &str, cols: u16, rows: u16) -> Result<()> {
|
||||||
let epoch = self.with(id, |s| {
|
let epoch = self.with(id, |s| {
|
||||||
|
|||||||
@@ -395,6 +395,16 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A value saved under `key` (app state such as the restorable tab layout).
|
||||||
|
pub fn setting(&self, key: &str) -> Option<String> {
|
||||||
|
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<Vec<Snippet>> {
|
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
|
||||||
let db = self.db.lock();
|
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")?;
|
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
|
||||||
|
|||||||
Reference in New Issue
Block a user