diff options
| author | zhugehyuk <icedac@users.noreply.github.com> | 2026-05-25 03:28:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-24 21:28:33 +0300 |
| commit | 43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (patch) | |
| tree | 73be12f81129dab324ea08901c733a6a56ec0e61 /src/app | |
| parent | 5ce738cf5397d0d8a50ed1aa6335297073cc0f0f (diff) | |
feat: persist pane history across restarts (#248)
refs #217
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/config_io.rs | 8 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 126 | ||||
| -rw-r--r-- | src/app/mod.rs | 39 | ||||
| -rw-r--r-- | src/app/session.rs | 5 | ||||
| -rw-r--r-- | src/app/state.rs | 9 |
6 files changed, 185 insertions, 5 deletions
diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 36f44b9a..fa2765e9 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -86,6 +86,14 @@ impl App { } } + pub(super) fn save_pane_history_persistence(&mut self, enabled: bool) { + if self.update_config_file("pane screen history", |content| { + crate::config::upsert_section_bool(content, "experimental", "pane_history", enabled) + }) { + self.apply_config_from_disk(false); + } + } + pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) { let value = match scope { crate::app::state::AgentPanelScope::CurrentWorkspace => { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 44491631..0f2f575d 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -204,6 +204,9 @@ impl App { SettingsAction::SaveAgentBorderLabels(enabled) => { self.save_agent_border_labels(enabled) } + SettingsAction::SavePaneHistory(enabled) => { + self.save_pane_history_persistence(enabled) + } SettingsAction::InstallRecommendedIntegrations => { self.install_recommended_integrations() } diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 6caccb0e..986f59e2 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -17,6 +17,7 @@ pub(super) enum SettingsAction { SaveSound(bool), SaveToastDelivery(ToastDelivery), SaveAgentBorderLabels(bool), + SavePaneHistory(bool), InstallRecommendedIntegrations, } @@ -31,6 +32,9 @@ impl App { SettingsAction::SaveAgentBorderLabels(enabled) => { self.save_agent_border_labels(enabled) } + SettingsAction::SavePaneHistory(enabled) => { + self.save_pane_history_persistence(enabled) + } SettingsAction::InstallRecommendedIntegrations => { self.install_recommended_integrations() } @@ -142,6 +146,10 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti state.settings.section = SettingsSection::Sound; state.settings.list.selected = usize::from(!state.sound_enabled()); } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Experiments; + state.settings.list.selected = 0; + } _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { Some(super::modal::ModalAction::Apply) => return apply_settings(state), Some(super::modal::ModalAction::Close) => cancel_settings(state), @@ -219,6 +227,28 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } } }, + SettingsSection::Experiments => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + return Some(SettingsAction::SavePaneHistory( + !state.pane_history_persistence_enabled(), + )); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Integrations; + state.settings.list.selected = 0; + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => { + if let Some(super::modal::ModalAction::Close) = + super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) + { + cancel_settings(state); + } + } + }, SettingsSection::Integrations => match key.code { KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { return Some(SettingsAction::InstallRecommendedIntegrations); @@ -228,8 +258,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); + state.settings.section = SettingsSection::Experiments; + state.settings.list.selected = 0; } _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { Some(super::modal::ModalAction::Apply) => return apply_settings(state), @@ -255,6 +285,7 @@ pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { SettingsSection::Sound => usize::from(!state.sound_enabled()), SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), + SettingsSection::Experiments => 0, SettingsSection::Integrations => 0, }; state.mode = Mode::Settings; @@ -344,6 +375,10 @@ impl AppState { None } } + SettingsSection::Experiments => { + let list_y = area.y + 3; + (row == list_y).then_some(0) + } SettingsSection::Integrations => None, } } @@ -360,6 +395,7 @@ impl AppState { SettingsSection::PaneLabels => { usize::from(!self.agent_border_labels_enabled()) } + SettingsSection::Experiments => 0, SettingsSection::Integrations => 0, }); return None; @@ -383,6 +419,9 @@ impl AppState { let enabled = idx == 0; Some(SettingsAction::SaveAgentBorderLabels(enabled)) } + SettingsSection::Experiments => Some(SettingsAction::SavePaneHistory( + !self.pane_history_persistence_enabled(), + )), SettingsSection::Integrations => None, }; } @@ -470,6 +509,57 @@ mod tests { } #[test] + fn settings_experiments_toggles_pane_history() { + let mut state = state_with_workspaces(&["test"]); + state.pane_history_persistence = false; + open_settings_at(&mut state, SettingsSection::Experiments); + + let action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(action, Some(SettingsAction::SavePaneHistory(true))); + assert_eq!(state.mode, Mode::Settings); + } + + #[test] + fn settings_tab_cycle_places_experiments_last() { + let mut state = state_with_workspaces(&["test"]); + open_settings_at(&mut state, SettingsSection::PaneLabels); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + ); + assert_eq!(state.settings.section, SettingsSection::Integrations); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + ); + assert_eq!(state.settings.section, SettingsSection::Experiments); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + ); + assert_eq!(state.settings.section, SettingsSection::Theme); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), + ); + assert_eq!(state.settings.section, SettingsSection::Experiments); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), + ); + assert_eq!(state.settings.section, SettingsSection::Integrations); + } + + #[test] fn integrations_enter_does_nothing_when_nothing_needs_install() { let mut state = state_with_workspaces(&["test"]); open_settings_at(&mut state, SettingsSection::Integrations); @@ -500,6 +590,23 @@ mod tests { } #[test] + fn settings_mouse_click_toggles_pane_history() { + let mut app = app_for_mouse_test(); + app.state.pane_history_persistence = false; + open_settings_at(&mut app.state, SettingsSection::Experiments); + + let area = app.state.settings_content_rect(); + let action = app.state.handle_settings_mouse(mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + area.x + 2, + area.y + 3, + )); + + assert_eq!(action, Some(SettingsAction::SavePaneHistory(true))); + assert_eq!(app.state.settings.list.selected, 0); + } + + #[test] fn integration_update_badge_only_tracks_outdated_recommendations() { let mut state = state_with_workspaces(&["test"]); state.integration_recommendations = vec![integration_recommendation( @@ -538,10 +645,21 @@ mod tests { let inner = state.settings_inner_rect(); let tab_y = inner.y + 1; + let integrations_idx = SettingsSection::ALL + .iter() + .position(|section| *section == SettingsSection::Integrations) + .expect("integrations section should be present"); let integrations_x = inner.x - + SettingsSection::ALL[..SettingsSection::ALL.len() - 1] + + SettingsSection::ALL[..integrations_idx] .iter() - .map(|section| section.label().len() as u16 + 3) + .map(|section| { + let badge_width = if state.settings_section_has_badge(*section) { + 2 + } else { + 0 + }; + section.label().len() as u16 + 3 + badge_width + }) .sum::<u16>(); let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4; diff --git a/src/app/mod.rs b/src/app/mod.rs index bacbf0ce..1679364b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -82,6 +82,7 @@ pub struct App { pub(crate) next_auto_update_check: Option<Instant>, pub(crate) selection_autoscroll_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, + pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option<Instant>, pub(crate) suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, @@ -252,8 +253,14 @@ impl App { std::collections::HashSet::new(), ) } else if let Some(snap) = crate::persist::load() { + let history = config + .experimental + .pane_history + .then(crate::persist::load_history) + .flatten(); let (ws, terminals, terminal_runtimes) = crate::persist::restore( &snap, + history.as_ref(), 24, 80, config.advanced.scrollback_limit_bytes, @@ -455,6 +462,7 @@ impl App { confirm_close: config.ui.confirm_close, prompt_new_tab_name: config.ui.prompt_new_tab_name, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, + pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), @@ -529,6 +537,7 @@ impl App { .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), session_save_deadline: None, selection_autoscroll_deadline: None, + persist_pane_history: config.experimental.pane_history, last_render_at: None, suppressed_repeat_keys: HashSet::new(), api_rx, @@ -1013,6 +1022,11 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); + self.persist_pane_history = config.experimental.pane_history; + self.state.pane_history_persistence = config.experimental.pane_history; + if !self.persist_pane_history { + crate::persist::clear_history(); + } } if !invalid_section("advanced") { @@ -1848,6 +1862,31 @@ mod tests { } #[test] + fn settings_save_pane_history_persists_then_applies_live_config() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("settings-save-pane-history"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "onboarding = false\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert!(!app.persist_pane_history); + assert!(!app.state.pane_history_persistence); + + app.save_pane_history_persistence(true); + + assert!(app.persist_pane_history); + assert!(app.state.pane_history_persistence); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("[experimental]")); + assert!(content.contains("pane_history = true")); + assert!(app.state.config_diagnostic.is_none()); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_keeps_current_state_on_invalid_toml() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-invalid-toml"); diff --git a/src/app/session.rs b/src/app/session.rs index 2adff1b8..f9b38e79 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -36,7 +36,10 @@ impl App { self.state.sidebar_section_split, self.state.collapsed_space_keys.clone(), ); - crate::persist::save(&snap); + let history = self.persist_pane_history.then(|| { + crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) + }); + crate::persist::save(&snap, history.as_ref()); } self.session_save_deadline = None; diff --git a/src/app/state.rs b/src/app/state.rs index 43d6e00f..422664ff 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -721,6 +721,7 @@ pub enum SettingsSection { Sound, Toast, PaneLabels, + Experiments, Integrations, } @@ -731,6 +732,7 @@ impl SettingsSection { Self::Toast, Self::PaneLabels, Self::Integrations, + Self::Experiments, ]; pub fn label(self) -> &'static str { @@ -739,6 +741,7 @@ impl SettingsSection { Self::Sound => "sound", Self::Toast => "toasts", Self::PaneLabels => "pane labels", + Self::Experiments => "experiments", Self::Integrations => "integrations", } } @@ -1116,6 +1119,7 @@ pub struct AppState { pub confirm_close: bool, pub prompt_new_tab_name: bool, pub show_agent_labels_on_pane_borders: bool, + pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. pub reveal_hidden_cursor_for_cjk_ime: bool, @@ -1175,6 +1179,10 @@ impl AppState { self.show_agent_labels_on_pane_borders } + pub fn pane_history_persistence_enabled(&self) -> bool { + self.pane_history_persistence + } + pub(crate) fn integration_updates_available(&self) -> bool { self.integration_recommendations .iter() @@ -1410,6 +1418,7 @@ impl AppState { confirm_close: true, prompt_new_tab_name: true, show_agent_labels_on_pane_borders: false, + pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, cjk_ime_agents: Vec::new(), |
