feat: add debounced session autosave
This commit is contained in:
parent
3689515f6a
commit
c62599de00
|
|
@ -106,6 +106,7 @@ impl AppState {
|
|||
if idx < self.workspaces.len() {
|
||||
self.active = Some(idx);
|
||||
self.selected = idx;
|
||||
self.mark_session_dirty();
|
||||
if matches!(
|
||||
self.agent_panel_scope,
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
|
|
@ -152,6 +153,7 @@ impl AppState {
|
|||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.switch_tab(idx);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +182,8 @@ impl AppState {
|
|||
return;
|
||||
}
|
||||
|
||||
self.mark_session_dirty();
|
||||
|
||||
let active_id = self.active.map(|idx| self.workspaces[idx].id.clone());
|
||||
let selected_id = self
|
||||
.workspaces
|
||||
|
|
@ -203,23 +207,23 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
|
||||
if !ws.tabs.is_empty() {
|
||||
let next = (ws.active_tab + 1) % ws.tabs.len();
|
||||
ws.switch_tab(next);
|
||||
self.switch_tab(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_tab(&mut self) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
|
||||
if !ws.tabs.is_empty() {
|
||||
let prev = if ws.active_tab == 0 {
|
||||
ws.tabs.len() - 1
|
||||
} else {
|
||||
ws.active_tab - 1
|
||||
};
|
||||
ws.switch_tab(prev);
|
||||
self.switch_tab(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +232,7 @@ impl AppState {
|
|||
if self.workspaces.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.mark_session_dirty();
|
||||
let name = self.workspaces[self.selected].display_name();
|
||||
info!(workspace = %name, "workspace closed");
|
||||
self.workspaces.remove(self.selected);
|
||||
|
|
@ -263,6 +268,7 @@ impl AppState {
|
|||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
tab.layout.focus_pane(target);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,6 +287,7 @@ impl AppState {
|
|||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
tab.layout.resize_focused(direction, 0.05, area);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -296,6 +303,7 @@ impl AppState {
|
|||
} else {
|
||||
tab.layout.focus_next();
|
||||
}
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,11 +315,13 @@ impl AppState {
|
|||
{
|
||||
if tab.layout.pane_count() > 1 {
|
||||
tab.zoomed = !tab.zoomed;
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_pane(&mut self) {
|
||||
self.mark_session_dirty();
|
||||
let should_close_workspace = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
|
|
@ -322,6 +332,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn close_tab(&mut self) {
|
||||
self.mark_session_dirty();
|
||||
let should_close_workspace = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get(i))
|
||||
|
|
@ -530,6 +541,7 @@ impl AppState {
|
|||
let ws = &mut self.workspaces[ws_idx];
|
||||
ws.remove_pane(pane_id)
|
||||
};
|
||||
self.mark_session_dirty();
|
||||
|
||||
if should_close_workspace {
|
||||
self.workspaces.remove(ws_idx);
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ impl App {
|
|||
if is_double_click {
|
||||
self.state.sidebar_width = self.state.default_sidebar_width;
|
||||
self.state.sidebar_width_auto = false;
|
||||
self.state.mark_session_dirty();
|
||||
self.state.drag = None;
|
||||
return;
|
||||
}
|
||||
|
|
@ -1034,6 +1035,7 @@ fn apply_rename_action(state: &mut AppState, action: ModalAction) {
|
|||
Mode::RenameWorkspace if !state.workspaces.is_empty() => {
|
||||
if !new_name.is_empty() {
|
||||
state.workspaces[state.selected].set_custom_name(new_name);
|
||||
state.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
Mode::RenameTab if state.creating_new_tab => {
|
||||
|
|
@ -1049,6 +1051,7 @@ fn apply_rename_action(state: &mut AppState, action: ModalAction) {
|
|||
if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
|
||||
if let Some(tab) = ws.active_tab_mut() {
|
||||
tab.set_custom_name(new_name);
|
||||
state.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2059,10 +2062,8 @@ impl AppState {
|
|||
self.collapsed_agent_detail_target_at(mouse.row)
|
||||
{
|
||||
self.switch_workspace(ws_idx);
|
||||
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
|
||||
ws.switch_tab(tab_idx);
|
||||
ws.layout.focus_pane(pane_id);
|
||||
}
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
return None;
|
||||
|
|
@ -2109,6 +2110,7 @@ impl AppState {
|
|||
AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
|
||||
};
|
||||
self.agent_panel_scroll = 0;
|
||||
self.mark_session_dirty();
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -2131,10 +2133,8 @@ impl AppState {
|
|||
if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
|
||||
{
|
||||
self.switch_workspace(ws_idx);
|
||||
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
|
||||
ws.switch_tab(tab_idx);
|
||||
ws.layout.focus_pane(pane_id);
|
||||
}
|
||||
self.switch_tab(tab_idx);
|
||||
self.focus_pane(pane_id);
|
||||
self.mode = Mode::Terminal;
|
||||
return None;
|
||||
}
|
||||
|
|
@ -2150,11 +2150,7 @@ impl AppState {
|
|||
);
|
||||
self.selection = Some(Selection::anchor(info.id, row, col, info.inner_rect));
|
||||
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.focused() != info.id {
|
||||
ws.layout.focus_pane(info.id);
|
||||
}
|
||||
}
|
||||
self.focus_pane(info.id);
|
||||
if self.mode != Mode::Terminal {
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
|
|
@ -2165,11 +2161,7 @@ impl AppState {
|
|||
&& mouse.row < p.rect.y + p.rect.height
|
||||
}) {
|
||||
let id = info.id;
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.focused() != id {
|
||||
ws.layout.focus_pane(id);
|
||||
}
|
||||
}
|
||||
self.focus_pane(id);
|
||||
if self.mode != Mode::Terminal {
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
|
|
@ -2241,6 +2233,7 @@ impl AppState {
|
|||
let path = path.clone();
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.layout.set_ratio_at(&path, ratio);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
DragTarget::PaneScrollbar {
|
||||
|
|
@ -2456,6 +2449,7 @@ impl AppState {
|
|||
let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
|
||||
self.sidebar_width =
|
||||
width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
|
||||
fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool {
|
||||
|
|
@ -2482,6 +2476,7 @@ impl AppState {
|
|||
let relative_y = row.saturating_sub(sidebar.y);
|
||||
let ratio = (relative_y as f32) / (content_height as f32);
|
||||
self.sidebar_section_split = ratio.clamp(0.1, 0.9);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
|
||||
/// Find which workspace index a sidebar row belongs to (two-section layout).
|
||||
|
|
@ -2771,6 +2766,7 @@ impl AppState {
|
|||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.focused() != pane_id {
|
||||
ws.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2987,6 +2983,7 @@ impl AppState {
|
|||
self.host_terminal_theme,
|
||||
) {
|
||||
ws.layout.focus_pane(new_id);
|
||||
self.mark_session_dirty();
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/app/mod.rs
114
src/app/mod.rs
|
|
@ -20,6 +20,7 @@ const ANIMATION_INTERVAL: Duration = Duration::from_millis(16);
|
|||
const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
|
||||
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
|
||||
|
||||
use crossterm::terminal;
|
||||
|
|
@ -52,6 +53,7 @@ pub struct App {
|
|||
next_resize_poll: Instant,
|
||||
next_animation_tick: Option<Instant>,
|
||||
next_auto_update_check: Option<Instant>,
|
||||
session_save_deadline: Option<Instant>,
|
||||
last_render_at: Option<Instant>,
|
||||
suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
|
||||
render_notify: Arc<Notify>,
|
||||
|
|
@ -300,6 +302,7 @@ impl App {
|
|||
},
|
||||
global_menu: state::MenuListState::new(0),
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
|
||||
session_dirty: false,
|
||||
};
|
||||
|
||||
for ws in &mut state.workspaces {
|
||||
|
|
@ -335,6 +338,7 @@ impl App {
|
|||
next_animation_tick: None,
|
||||
next_auto_update_check: (!no_session)
|
||||
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
|
||||
session_save_deadline: None,
|
||||
last_render_at: None,
|
||||
suppressed_repeat_keys: HashSet::new(),
|
||||
api_rx,
|
||||
|
|
@ -348,6 +352,42 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn schedule_session_save(&mut self) {
|
||||
if !self.no_session {
|
||||
self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_session_save_schedule(&mut self) {
|
||||
if self.state.session_dirty {
|
||||
self.state.session_dirty = false;
|
||||
self.schedule_session_save();
|
||||
}
|
||||
}
|
||||
|
||||
fn save_session_now(&mut self) {
|
||||
if self.no_session {
|
||||
self.session_save_deadline = None;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state.workspaces.is_empty() {
|
||||
crate::persist::clear();
|
||||
} else {
|
||||
let snap = crate::persist::capture(
|
||||
&self.state.workspaces,
|
||||
self.state.active,
|
||||
self.state.selected,
|
||||
self.state.agent_panel_scope,
|
||||
self.state.sidebar_width,
|
||||
self.state.sidebar_section_split,
|
||||
);
|
||||
crate::persist::save(&snap);
|
||||
}
|
||||
|
||||
self.session_save_deadline = None;
|
||||
}
|
||||
|
||||
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
if self.input_rx.is_none() {
|
||||
self.input_rx = Some(crate::raw_input::spawn_input_reader());
|
||||
|
|
@ -370,6 +410,7 @@ impl App {
|
|||
}
|
||||
|
||||
self.sync_focus_events();
|
||||
self.sync_session_save_schedule();
|
||||
|
||||
let now = Instant::now();
|
||||
if self.handle_scheduled_tasks(now) {
|
||||
|
|
@ -457,16 +498,8 @@ impl App {
|
|||
}
|
||||
|
||||
// Save session on exit (skip in --no-session mode)
|
||||
if !self.no_session && !self.state.workspaces.is_empty() {
|
||||
let snap = crate::persist::capture(
|
||||
&self.state.workspaces,
|
||||
self.state.active,
|
||||
self.state.selected,
|
||||
self.state.agent_panel_scope,
|
||||
self.state.sidebar_width,
|
||||
self.state.sidebar_section_split,
|
||||
);
|
||||
crate::persist::save(&snap);
|
||||
if !self.no_session {
|
||||
self.save_session_now();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -643,6 +676,13 @@ impl App {
|
|||
self.run_auto_update_check();
|
||||
}
|
||||
|
||||
if self
|
||||
.session_save_deadline
|
||||
.is_some_and(|deadline| now >= deadline)
|
||||
{
|
||||
self.save_session_now();
|
||||
}
|
||||
|
||||
self.sync_animation_timer(now);
|
||||
changed
|
||||
}
|
||||
|
|
@ -714,6 +754,7 @@ impl App {
|
|||
self.next_animation_tick,
|
||||
self.git_refresh_deadline(),
|
||||
self.next_auto_update_check,
|
||||
self.session_save_deadline,
|
||||
render_deadline,
|
||||
]
|
||||
.into_iter()
|
||||
|
|
@ -1108,6 +1149,7 @@ impl App {
|
|||
.unwrap();
|
||||
};
|
||||
ws.set_custom_name(params.label.clone());
|
||||
self.schedule_session_save();
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::WorkspaceRenamed,
|
||||
data: crate::api::schema::EventData::WorkspaceRenamed {
|
||||
|
|
@ -1279,11 +1321,10 @@ impl App {
|
|||
Ok(tab_idx) => {
|
||||
if params.focus {
|
||||
self.state.switch_workspace(ws_idx);
|
||||
if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
|
||||
ws.switch_tab(tab_idx);
|
||||
}
|
||||
self.state.switch_tab(tab_idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.schedule_session_save();
|
||||
let tab = self.tab_info(ws_idx, tab_idx).unwrap();
|
||||
if let Some(pane_id) = self.state.workspaces[ws_idx].tabs[tab_idx]
|
||||
.layout
|
||||
|
|
@ -1333,9 +1374,7 @@ impl App {
|
|||
.unwrap();
|
||||
};
|
||||
self.state.switch_workspace(ws_idx);
|
||||
if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
|
||||
ws.switch_tab(tab_idx);
|
||||
}
|
||||
self.state.switch_tab(tab_idx);
|
||||
let tab = self.tab_info(ws_idx, tab_idx).unwrap();
|
||||
SuccessResponse {
|
||||
id: request.id,
|
||||
|
|
@ -1369,6 +1408,7 @@ impl App {
|
|||
.unwrap();
|
||||
};
|
||||
tab.set_custom_name(params.label.clone());
|
||||
self.schedule_session_save();
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::TabRenamed,
|
||||
data: crate::api::schema::EventData::TabRenamed {
|
||||
|
|
@ -1424,6 +1464,7 @@ impl App {
|
|||
})
|
||||
.unwrap();
|
||||
}
|
||||
self.schedule_session_save();
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::TabClosed,
|
||||
data: crate::api::schema::EventData::TabClosed {
|
||||
|
|
@ -1491,6 +1532,7 @@ impl App {
|
|||
if !params.focus {
|
||||
ws.layout.focus_pane(target_pane_id);
|
||||
}
|
||||
self.schedule_session_save();
|
||||
let pane = self.pane_info(ws_idx, new_pane_id).unwrap();
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneCreated,
|
||||
|
|
@ -1822,6 +1864,7 @@ impl App {
|
|||
});
|
||||
} else {
|
||||
ws.close_pane(pane_id);
|
||||
self.schedule_session_save();
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneClosed,
|
||||
data: crate::api::schema::EventData::PaneClosed {
|
||||
|
|
@ -2053,6 +2096,7 @@ impl App {
|
|||
if let Some(tab) = ws.tabs.get_mut(tab_idx) {
|
||||
tab.set_custom_name(name);
|
||||
}
|
||||
self.schedule_session_save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2083,6 +2127,7 @@ impl App {
|
|||
ws.switch_tab(idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.schedule_session_save();
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
|
|
@ -2109,6 +2154,7 @@ impl App {
|
|||
self.state.switch_workspace(idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
self.schedule_session_save();
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
|
|
@ -2522,6 +2568,42 @@ mod tests {
|
|||
assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_dirty_flag_schedules_debounced_save() {
|
||||
let mut app = test_app();
|
||||
app.no_session = false;
|
||||
app.state.session_dirty = true;
|
||||
|
||||
app.sync_session_save_schedule();
|
||||
|
||||
assert!(!app.state.session_dirty);
|
||||
assert!(app.session_save_deadline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_loop_deadline_includes_session_save_deadline() {
|
||||
let mut app = test_app();
|
||||
let now = Instant::now();
|
||||
app.session_save_deadline = Some(now + Duration::from_secs(2));
|
||||
app.next_resize_poll = now + Duration::from_secs(5);
|
||||
app.next_auto_update_check = Some(now + Duration::from_secs(6));
|
||||
|
||||
assert_eq!(
|
||||
app.next_loop_deadline(now, false),
|
||||
app.session_save_deadline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn due_session_save_deadline_is_cleared() {
|
||||
let mut app = test_app();
|
||||
app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1));
|
||||
|
||||
app.handle_scheduled_tasks(Instant::now());
|
||||
|
||||
assert!(app.session_save_deadline.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() {
|
||||
let mut app = test_app();
|
||||
|
|
|
|||
|
|
@ -626,9 +626,15 @@ pub struct AppState {
|
|||
pub global_menu: MenuListState,
|
||||
/// Resolved host terminal default colors for theming embedded panes.
|
||||
pub host_terminal_theme: TerminalTheme,
|
||||
/// Set when a persisted session snapshot would change.
|
||||
pub session_dirty: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub(crate) fn mark_session_dirty(&mut self) {
|
||||
self.session_dirty = true;
|
||||
}
|
||||
|
||||
pub fn sound_enabled(&self) -> bool {
|
||||
self.sound.enabled
|
||||
}
|
||||
|
|
@ -780,6 +786,7 @@ impl AppState {
|
|||
},
|
||||
global_menu: MenuListState::new(0),
|
||||
host_terminal_theme: TerminalTheme::default(),
|
||||
session_dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
src/persist.rs
100
src/persist.rs
|
|
@ -487,36 +487,46 @@ fn session_path() -> PathBuf {
|
|||
crate::config::config_dir().join("session.json")
|
||||
}
|
||||
|
||||
fn save_to_path(path: &std::path::Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(snapshot)?;
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp_path, &json)?;
|
||||
if let Err(err) = std::fs::rename(&tmp_path, path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_path(path: &std::path::Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(snapshot: &SessionSnapshot) {
|
||||
let path = session_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
error!(err = %e, "failed to create session directory");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let json = match serde_json::to_string_pretty(snapshot) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
error!(err = %e, "failed to serialize session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Atomic write: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp_path, &json) {
|
||||
error!(err = %e, "failed to write session temp file");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp_path, &path) {
|
||||
error!(err = %e, "failed to rename session file");
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
if let Err(err) = save_to_path(&path, snapshot) {
|
||||
error!(err = %err, path = %path.display(), "failed to save session");
|
||||
return;
|
||||
}
|
||||
info!(workspaces = snapshot.workspaces.len(), "session saved");
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
let path = session_path();
|
||||
if let Err(err) = clear_path(&path) {
|
||||
error!(err = %err, path = %path.display(), "failed to clear session");
|
||||
return;
|
||||
}
|
||||
info!(path = %path.display(), "session cleared");
|
||||
}
|
||||
|
||||
fn parse_snapshot(content: &str) -> Result<SessionSnapshot, String> {
|
||||
let raw = serde_json::from_str::<RawSessionSnapshot>(content).map_err(|e| e.to_string())?;
|
||||
if raw.version > SNAPSHOT_VERSION {
|
||||
|
|
@ -583,6 +593,19 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn temp_session_path(name: &str) -> PathBuf {
|
||||
let unique = format!(
|
||||
"herdr-session-tests-{}-{}-{}",
|
||||
name,
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
std::env::temp_dir().join(unique).join("session.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_empty_session() {
|
||||
let snap = SessionSnapshot {
|
||||
|
|
@ -602,6 +625,37 @@ mod tests {
|
|||
assert_eq!(restored.sidebar_section_split, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_path_removes_existing_session_file() {
|
||||
let path = temp_session_path("clear-existing");
|
||||
save_to_path(
|
||||
&path,
|
||||
&SessionSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
workspaces: vec![],
|
||||
active: None,
|
||||
selected: 0,
|
||||
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
|
||||
sidebar_width: Some(26),
|
||||
sidebar_section_split: Some(0.5),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
clear_path(&path).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_path_ignores_missing_session_file() {
|
||||
let path = temp_session_path("clear-missing");
|
||||
|
||||
clear_path(&path).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_layout_snapshot() {
|
||||
let layout = LayoutSnapshot::Split {
|
||||
|
|
|
|||
Loading…
Reference in New Issue