Add GUI session persistence and full pipeline config

This commit is contained in:
2026-06-09 23:18:15 +08:00
parent 882b7f391f
commit d489bc0831
4 changed files with 923 additions and 466 deletions

View File

@@ -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) {

View 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);
}
}

View File

@@ -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