diff --git a/src/app/actions.rs b/src/app/actions.rs index fff092d3..3622787e 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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); diff --git a/src/app/input.rs b/src/app/input.rs index b9ddc890..c7874bbc 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -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; } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 74a873e0..22e4eef6 100644 --- a/src/app/mod.rs +++ b/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, next_auto_update_check: Option, + session_save_deadline: Option, last_render_at: Option, suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, render_notify: Arc, @@ -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(); diff --git a/src/app/state.rs b/src/app/state.rs index 2904b206..ea388daa 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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, } } } diff --git a/src/persist.rs b/src/persist.rs index ccb44ace..6704dd17 100644 --- a/src/persist.rs +++ b/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 { let raw = serde_json::from_str::(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 {