feat: add debounced session autosave

This commit is contained in:
Ogulcan Celik 2026-04-08 18:27:16 +03:00
parent 3689515f6a
commit c62599de00
5 changed files with 213 additions and 61 deletions

View File

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

View File

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

View File

@ -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();

View File

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

View File

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