Add GUI session persistence and full pipeline config
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::app_state::{self, PersistedGuiState};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -113,15 +114,16 @@ impl XserialApp {
|
|||||||
let font_candidates = ui_fonts::discover_font_candidates();
|
let font_candidates = ui_fonts::discover_font_candidates();
|
||||||
let font_settings = ui_fonts::load_font_settings();
|
let font_settings = ui_fonts::load_font_settings();
|
||||||
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
||||||
|
let saved_state = app_state::load_gui_state();
|
||||||
|
|
||||||
Self {
|
let mut app = Self {
|
||||||
manager,
|
manager,
|
||||||
tabs: Vec::new(),
|
tabs: Vec::new(),
|
||||||
active: 0,
|
active: 0,
|
||||||
display: DisplayOptions {
|
display: DisplayOptions {
|
||||||
show_timestamp: true,
|
show_timestamp: saved_state.show_timestamp,
|
||||||
show_direction: true,
|
show_direction: saved_state.show_direction,
|
||||||
show_pipeline: true,
|
show_pipeline: saved_state.show_pipeline,
|
||||||
},
|
},
|
||||||
font_settings_open: false,
|
font_settings_open: false,
|
||||||
font_candidates,
|
font_candidates,
|
||||||
@@ -137,7 +139,9 @@ impl XserialApp {
|
|||||||
config_form: config::ConfigForm::default(),
|
config_form: config::ConfigForm::default(),
|
||||||
event_rx,
|
event_rx,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
}
|
};
|
||||||
|
app.restore_saved_sessions(saved_state);
|
||||||
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_create_config(&mut self) {
|
fn open_create_config(&mut self) {
|
||||||
@@ -146,6 +150,54 @@ impl XserialApp {
|
|||||||
self.config_open = true;
|
self.config_open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
||||||
|
for session_config in saved_state.sessions {
|
||||||
|
self.add_session_tab(session_config);
|
||||||
|
}
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
self.active = saved_state.active.min(self.tabs.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_session_tab(&mut self, session_config: SessionConfig) {
|
||||||
|
let history_limit = session_config.history_limit;
|
||||||
|
let auto_reconnect = session_config.auto_reconnect;
|
||||||
|
let handle = self.manager.create(session_config.clone());
|
||||||
|
self.tabs.push(SessionTab {
|
||||||
|
id: handle.id(),
|
||||||
|
session_config,
|
||||||
|
status: ConnectionStatus::Connecting,
|
||||||
|
console: TextBuffer::new(history_limit),
|
||||||
|
hex: HexBuffer::new(history_limit),
|
||||||
|
plot: PlotBuffer::new(history_limit),
|
||||||
|
plot_view: plot_view::PlotViewState::default(),
|
||||||
|
view: View::Text,
|
||||||
|
auto_reconnect,
|
||||||
|
send_input: String::new(),
|
||||||
|
send_mode: SendMode::Text,
|
||||||
|
append_newline: true,
|
||||||
|
send_status: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_gui_state(&self) {
|
||||||
|
app_state::save_gui_state(&PersistedGuiState {
|
||||||
|
sessions: self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| tab.session_config.clone())
|
||||||
|
.collect(),
|
||||||
|
active: if self.tabs.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.active.min(self.tabs.len() - 1)
|
||||||
|
},
|
||||||
|
show_timestamp: self.display.show_timestamp,
|
||||||
|
show_direction: self.display.show_direction,
|
||||||
|
show_pipeline: self.display.show_pipeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn open_edit_config(&mut self, id: u64) {
|
fn open_edit_config(&mut self, id: u64) {
|
||||||
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
||||||
self.config_target = Some(id);
|
self.config_target = Some(id);
|
||||||
@@ -166,6 +218,7 @@ impl XserialApp {
|
|||||||
tab.status = ConnectionStatus::Connecting;
|
tab.status = ConnectionStatus::Connecting;
|
||||||
tab.send_status = Some(String::from("Session reconfigured"));
|
tab.send_status = Some(String::from("Session reconfigured"));
|
||||||
}
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
|
||||||
if let Some(handle) = handle {
|
if let Some(handle) = handle {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -246,25 +299,9 @@ impl XserialApp {
|
|||||||
if let Some(id) = self.config_target {
|
if let Some(id) = self.config_target {
|
||||||
self.apply_session_config(id, session_config);
|
self.apply_session_config(id, session_config);
|
||||||
} else {
|
} else {
|
||||||
let history_limit = session_config.history_limit;
|
self.add_session_tab(session_config);
|
||||||
let auto_reconnect = session_config.auto_reconnect;
|
|
||||||
let handle = self.manager.create(session_config.clone());
|
|
||||||
self.tabs.push(SessionTab {
|
|
||||||
id: handle.id(),
|
|
||||||
session_config,
|
|
||||||
status: ConnectionStatus::Connecting,
|
|
||||||
console: TextBuffer::new(history_limit),
|
|
||||||
hex: HexBuffer::new(history_limit),
|
|
||||||
plot: PlotBuffer::new(history_limit),
|
|
||||||
plot_view: plot_view::PlotViewState::default(),
|
|
||||||
view: View::Text,
|
|
||||||
auto_reconnect,
|
|
||||||
send_input: String::new(),
|
|
||||||
send_mode: SendMode::Text,
|
|
||||||
append_newline: true,
|
|
||||||
send_status: None,
|
|
||||||
});
|
|
||||||
self.active = self.tabs.len() - 1;
|
self.active = self.tabs.len() - 1;
|
||||||
|
self.persist_gui_state();
|
||||||
}
|
}
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
@@ -277,6 +314,7 @@ impl XserialApp {
|
|||||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
let mut on_new = false;
|
let mut on_new = false;
|
||||||
let mut on_delete = None;
|
let mut on_delete = None;
|
||||||
|
let previous_active = self.active;
|
||||||
|
|
||||||
Panel::left("sidebar")
|
Panel::left("sidebar")
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
@@ -305,6 +343,9 @@ impl XserialApp {
|
|||||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||||
self.active = self.tabs.len() - 1;
|
self.active = self.tabs.len() - 1;
|
||||||
}
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
} else if self.active != previous_active {
|
||||||
|
self.persist_gui_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +363,7 @@ impl XserialApp {
|
|||||||
let manager = self.manager.clone();
|
let manager = self.manager.clone();
|
||||||
let display = &mut self.display;
|
let display = &mut self.display;
|
||||||
let mut edit_session = None;
|
let mut edit_session = None;
|
||||||
|
let mut persist_state = false;
|
||||||
|
|
||||||
{
|
{
|
||||||
let tab = &mut self.tabs[self.active];
|
let tab = &mut self.tabs[self.active];
|
||||||
@@ -350,16 +392,25 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if render_session_controls(ui, &manager, tab) {
|
let (configure_clicked, auto_reconnect_changed) =
|
||||||
|
render_session_controls(ui, &manager, tab);
|
||||||
|
if configure_clicked {
|
||||||
edit_session = Some(tab.id);
|
edit_session = Some(tab.id);
|
||||||
}
|
}
|
||||||
|
persist_state |= auto_reconnect_changed;
|
||||||
|
|
||||||
|
let mut display_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label("Show:");
|
ui.label("Show:");
|
||||||
ui.checkbox(&mut display.show_timestamp, "Timestamp");
|
display_changed |= ui
|
||||||
ui.checkbox(&mut display.show_direction, "Direction");
|
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||||
ui.checkbox(&mut display.show_pipeline, "Pipe");
|
.changed();
|
||||||
|
display_changed |= ui
|
||||||
|
.checkbox(&mut display.show_direction, "Direction")
|
||||||
|
.changed();
|
||||||
|
display_changed |= ui.checkbox(&mut display.show_pipeline, "Pipe").changed();
|
||||||
});
|
});
|
||||||
|
persist_state |= display_changed;
|
||||||
|
|
||||||
if let ConnectionStatus::Error(message) = &tab.status {
|
if let ConnectionStatus::Error(message) = &tab.status {
|
||||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||||
@@ -398,6 +449,9 @@ impl XserialApp {
|
|||||||
if let Some(id) = edit_session {
|
if let Some(id) = edit_session {
|
||||||
self.open_edit_config(id);
|
self.open_edit_config(id);
|
||||||
}
|
}
|
||||||
|
if persist_state {
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,8 +702,9 @@ fn render_session_controls(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
manager: &SessionManager,
|
manager: &SessionManager,
|
||||||
tab: &mut SessionTab,
|
tab: &mut SessionTab,
|
||||||
) -> bool {
|
) -> (bool, bool) {
|
||||||
let mut configure_clicked = false;
|
let mut configure_clicked = false;
|
||||||
|
let mut auto_reconnect_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if ui.button("Connect").clicked() {
|
if ui.button("Connect").clicked() {
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
@@ -697,6 +752,8 @@ fn render_session_controls(
|
|||||||
|
|
||||||
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||||
if response.changed() {
|
if response.changed() {
|
||||||
|
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||||
|
auto_reconnect_changed = true;
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
let enabled = tab.auto_reconnect;
|
let enabled = tab.auto_reconnect;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -707,7 +764,7 @@ fn render_session_controls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
configure_clicked
|
(configure_clicked, auto_reconnect_changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||||
|
|||||||
156
crates/xserial-gui/src/app_state.rs
Normal file
156
crates/xserial-gui/src/app_state.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
use xserial_client::config::SessionConfig;
|
||||||
|
|
||||||
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedGuiState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_direction: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_pipeline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_gui_state() -> PersistedGuiState {
|
||||||
|
match load_gui_state_from_path(&gui_state_path()) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedGuiState::default(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "Failed to load persisted GUI state");
|
||||||
|
PersistedGuiState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_gui_state(state: &PersistedGuiState) {
|
||||||
|
if let Err(err) = save_gui_state_to_path(state, &gui_state_path()) {
|
||||||
|
warn!(error = %err, "Failed to persist GUI state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gui_state_path() -> PathBuf {
|
||||||
|
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
|
match os {
|
||||||
|
"windows" => {
|
||||||
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"macos" => {
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(GUI_STATE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_gui_state_from_path(path: &Path) -> io::Result<PersistedGuiState> {
|
||||||
|
let text = fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&text).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_gui_state_to_path(state: &PersistedGuiState, path: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
|
||||||
|
fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use xserial_core::transport::TransportConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_gui_state_path_uses_appdata() {
|
||||||
|
let path = gui_state_path_for_os_and_env("windows", |key| match key {
|
||||||
|
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
path,
|
||||||
|
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||||
|
.join("xserial")
|
||||||
|
.join(GUI_STATE_FILE_NAME)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gui_state_roundtrip() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
|
||||||
|
let path = dir.join(GUI_STATE_FILE_NAME);
|
||||||
|
let state = PersistedGuiState {
|
||||||
|
sessions: vec![SessionConfig {
|
||||||
|
transport: TransportConfig::Tcp {
|
||||||
|
addr: String::from("127.0.0.1:9000"),
|
||||||
|
},
|
||||||
|
..SessionConfig::default()
|
||||||
|
}],
|
||||||
|
active: 0,
|
||||||
|
show_timestamp: false,
|
||||||
|
show_direction: true,
|
||||||
|
show_pipeline: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
save_gui_state_to_path(&state, &path).unwrap();
|
||||||
|
let loaded = load_gui_state_from_path(&path).unwrap();
|
||||||
|
assert_eq!(loaded.sessions.len(), 1);
|
||||||
|
assert!(!loaded.show_timestamp);
|
||||||
|
assert!(loaded.show_direction);
|
||||||
|
assert!(!loaded.show_pipeline);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod ui_fonts;
|
mod ui_fonts;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user