aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs20
-rw-r--r--src/app/input.rs33
-rw-r--r--src/app/mod.rs114
-rw-r--r--src/app/state.rs7
4 files changed, 136 insertions, 38 deletions
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<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();
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,
}
}
}