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::time::Duration;
|
||||
|
||||
@@ -113,15 +114,16 @@ impl XserialApp {
|
||||
let font_candidates = ui_fonts::discover_font_candidates();
|
||||
let font_settings = ui_fonts::load_font_settings();
|
||||
ui_fonts::apply_font_settings(&ctx, &font_settings, &font_candidates);
|
||||
let saved_state = app_state::load_gui_state();
|
||||
|
||||
Self {
|
||||
let mut app = Self {
|
||||
manager,
|
||||
tabs: Vec::new(),
|
||||
active: 0,
|
||||
display: DisplayOptions {
|
||||
show_timestamp: true,
|
||||
show_direction: true,
|
||||
show_pipeline: true,
|
||||
show_timestamp: saved_state.show_timestamp,
|
||||
show_direction: saved_state.show_direction,
|
||||
show_pipeline: saved_state.show_pipeline,
|
||||
},
|
||||
font_settings_open: false,
|
||||
font_candidates,
|
||||
@@ -137,7 +139,9 @@ impl XserialApp {
|
||||
config_form: config::ConfigForm::default(),
|
||||
event_rx,
|
||||
pending: Vec::new(),
|
||||
}
|
||||
};
|
||||
app.restore_saved_sessions(saved_state);
|
||||
app
|
||||
}
|
||||
|
||||
fn open_create_config(&mut self) {
|
||||
@@ -146,6 +150,54 @@ impl XserialApp {
|
||||
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) {
|
||||
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
||||
self.config_target = Some(id);
|
||||
@@ -166,6 +218,7 @@ impl XserialApp {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tab.send_status = Some(String::from("Session reconfigured"));
|
||||
}
|
||||
self.persist_gui_state();
|
||||
|
||||
if let Some(handle) = handle {
|
||||
tokio::spawn(async move {
|
||||
@@ -246,25 +299,9 @@ impl XserialApp {
|
||||
if let Some(id) = self.config_target {
|
||||
self.apply_session_config(id, session_config);
|
||||
} else {
|
||||
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,
|
||||
});
|
||||
self.add_session_tab(session_config);
|
||||
self.active = self.tabs.len() - 1;
|
||||
self.persist_gui_state();
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
@@ -277,6 +314,7 @@ impl XserialApp {
|
||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
let mut on_new = false;
|
||||
let mut on_delete = None;
|
||||
let previous_active = self.active;
|
||||
|
||||
Panel::left("sidebar")
|
||||
.resizable(false)
|
||||
@@ -305,6 +343,9 @@ impl XserialApp {
|
||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||
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 display = &mut self.display;
|
||||
let mut edit_session = None;
|
||||
let mut persist_state = false;
|
||||
|
||||
{
|
||||
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);
|
||||
}
|
||||
persist_state |= auto_reconnect_changed;
|
||||
|
||||
let mut display_changed = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show:");
|
||||
ui.checkbox(&mut display.show_timestamp, "Timestamp");
|
||||
ui.checkbox(&mut display.show_direction, "Direction");
|
||||
ui.checkbox(&mut display.show_pipeline, "Pipe");
|
||||
display_changed |= ui
|
||||
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||
.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 {
|
||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||
@@ -398,6 +449,9 @@ impl XserialApp {
|
||||
if let Some(id) = edit_session {
|
||||
self.open_edit_config(id);
|
||||
}
|
||||
if persist_state {
|
||||
self.persist_gui_state();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -648,8 +702,9 @@ fn render_session_controls(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
tab: &mut SessionTab,
|
||||
) -> bool {
|
||||
) -> (bool, bool) {
|
||||
let mut configure_clicked = false;
|
||||
let mut auto_reconnect_changed = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Connect").clicked() {
|
||||
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");
|
||||
if response.changed() {
|
||||
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||
auto_reconnect_changed = true;
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let enabled = tab.auto_reconnect;
|
||||
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) {
|
||||
|
||||
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_state;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
mod ui_fonts;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user