diff --git a/README.md b/README.md index ccc4c57..6f62498 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ forwards extra arguments to `cargo install`, so flags like `--locked` or | `Enter` | Select | | `Esc` | Back/close | | `r`, `Ctrl+R` | Refresh current view | +| `Ctrl+,` | Open settings | | `?` | Toggle help | In pane-based views such as Git Status, Workspace, and Workers, `Tab` and diff --git a/STATUS.md b/STATUS.md index 570ef5e..cfe6326 100644 --- a/STATUS.md +++ b/STATUS.md @@ -169,20 +169,18 @@ Key crates used: ## Stats -- **84 Rust source files** -- **~20,000 lines of code** +- **111 Rust source files** +- **~61,500 lines of code** - **5 active plugins** -- **112 state manager tests** -- **3 AI adapters implemented** +- **1,500+ tests passing** (state manager unit tests plus plugin, adapter, search, and integration coverage) +- **8 AI adapters implemented** (Claude Code, Cursor, Codex, Gemini, Warp, Amp, Kiro, OpenCode) - **4 built-in themes** ## Next Steps (Future Enhancements) -1. **More AI Adapters**: Gemini, Warp, Amp, Kiro, OpenCode -2. **Enhanced UI**: More visual polish, animations -3. **Plugin Configuration**: Per-plugin settings in config file -4. **Search**: Global search across all content -5. **Integration Tests**: Comprehensive test coverage +1. **Enhanced UI**: More visual polish, animations +2. **Plugin Configuration**: Per-plugin settings in config file +3. **More AI Adapters**: Additional sources beyond the eight already supported ## Credits diff --git a/src/adapters/claudecode.rs b/src/adapters/claudecode.rs index ef54eae..427857e 100644 --- a/src/adapters/claudecode.rs +++ b/src/adapters/claudecode.rs @@ -179,7 +179,7 @@ impl Adapter for ClaudeCodeAdapter { } // Sort by updated_at, newest first - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); Ok(sessions) } diff --git a/src/adapters/codex.rs b/src/adapters/codex.rs index 7b40d24..1df292e 100644 --- a/src/adapters/codex.rs +++ b/src/adapters/codex.rs @@ -222,7 +222,7 @@ impl Adapter for CodexAdapter { } // Sort by updated_at, newest first - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); Ok(sessions) } diff --git a/src/adapters/gemini.rs b/src/adapters/gemini.rs index 69d4706..ec43099 100644 --- a/src/adapters/gemini.rs +++ b/src/adapters/gemini.rs @@ -114,7 +114,7 @@ impl Adapter for GeminiAdapter { } // Sort by updated_at, newest first - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); Ok(sessions) } diff --git a/src/adapters/opencode.rs b/src/adapters/opencode.rs index 6571f36..18d5b4b 100644 --- a/src/adapters/opencode.rs +++ b/src/adapters/opencode.rs @@ -314,7 +314,7 @@ impl Adapter for OpenCodeAdapter { } // Sort by updated_at, newest first - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); Ok(sessions) } diff --git a/src/lib.rs b/src/lib.rs index 0060c4b..91d297c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod shell; pub mod keymap; pub mod modal; pub mod palette; +pub mod settings; pub mod ui; // Search system diff --git a/src/main.rs b/src/main.rs index 196ae09..7ac2acb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,8 @@ use rightclick::palette::fuzzy::fuzzy_match_simple; use rightclick::{ adapters::create_default_registry, config, - core::models::Theme, + core::models::{Config, Theme}, + event::{Dispatcher, Event, Topic}, plugin::{ Plugin, PluginCommandError, PluginCommandExecution, PluginContext, PluginSearchEntry, }, @@ -32,6 +33,7 @@ use rightclick::{ search::{ SearchOverlayAction, SearchOverlayState, SearchScope, render_search_overlay, search_files, }, + settings::SettingsModal, state, theme::{self, resolve_theme}, ui::{Footer, Header, NotificationManager, compact_global_search_hint}, @@ -139,6 +141,9 @@ async fn main() -> Result<()> { .unwrap_or_default(); info!("Detected {} adapters", adapters.len()); + // Shared event bus for both plugin context and the App shell. + let event_bus = std::sync::Arc::new(rightclick::event::Dispatcher::new()); + // Create plugin context let plugin_ctx = PluginContext { work_dir: work_dir.clone(), @@ -146,7 +151,7 @@ async fn main() -> Result<()> { config_dir: config::config_dir().unwrap_or_else(|_| PathBuf::from("~/.config/rightclick")), config: config.clone(), adapters: adapters.into(), - event_bus: std::sync::Arc::new(rightclick::event::Dispatcher::new()), + event_bus: event_bus.clone(), logger: tracing::info_span!("plugin"), }; @@ -244,7 +249,7 @@ async fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; // Create app - let mut app = App::new(plugins, theme, work_dir.clone()); + let mut app = App::new(plugins, theme, work_dir.clone(), config, event_bus); // Run main loop let result = run_app(&mut terminal, &mut app).await; @@ -276,10 +281,26 @@ struct App { notifications: NotificationManager, search: SearchOverlayState, work_dir: PathBuf, + /// Authoritative application config. Edited via the settings modal and + /// persisted back to `config.json` on save. + config: Config, + /// Shared event bus. Used to broadcast `Event::ConfigChanged` after a + /// settings save so plugins can live-reload. + event_bus: std::sync::Arc, + /// Whether the settings modal is currently open. + settings_open: bool, + /// The settings modal state, present when open. + settings: Option, } impl App { - fn new(plugins: Vec>, theme: Theme, work_dir: PathBuf) -> Self { + fn new( + plugins: Vec>, + theme: Theme, + work_dir: PathBuf, + config: Config, + event_bus: std::sync::Arc, + ) -> Self { Self { plugins, theme, @@ -289,9 +310,61 @@ impl App { notifications: NotificationManager::new(), search: SearchOverlayState::new(), work_dir, + config, + event_bus, + settings_open: false, + settings: None, + } + } + + /// Toggle the settings modal open/closed. + fn toggle_settings(&mut self) { + if self.settings_open { + self.close_settings(); + } else { + self.settings = Some(SettingsModal::from_config(&self.config)); + self.settings_open = true; } } + /// Close the settings modal, discarding any unapplied edits. + fn close_settings(&mut self) { + self.settings = None; + self.settings_open = false; + } + + /// Persist the edited settings: build the new config from the modal, + /// save it to disk, apply it to live plugins, and broadcast the change. + fn save_settings(&mut self) { + let modal = match self.settings.take() { + Some(modal) => modal, + None => { + self.settings_open = false; + return; + } + }; + let new_config = modal.into_config(&self.config); + // Persist to config.json. + if let Err(e) = config::save(&new_config) { + warn!("Failed to save config: {}", e); + self.notifications + .error(format!("Failed to save settings: {e}")); + self.settings_open = false; + return; + } + // Apply to live plugins. + self.config = new_config.clone(); + for plugin in &mut self.plugins { + plugin.apply_config(&new_config); + } + // Broadcast so any other listeners (and future event-driven plugins) + // can react. + self.event_bus + .publish(Topic::ConfigChange, Event::ConfigChanged); + self.settings_open = false; + self.notifications.success("Settings saved"); + } + async fn handle_event(&mut self, event: crossterm::event::Event) -> Result<()> { use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind}; @@ -309,6 +382,29 @@ impl App { return Ok(()); } + // Ctrl+, toggles the settings modal. + if is_ctrl_comma_key(&key) { + self.toggle_settings(); + return Ok(()); + } + + // While the settings modal is open it captures all input. + if self.settings_open { + if let Some(modal) = self.settings.as_mut() { + match modal.handle_key(key) { + rightclick::settings::SettingsAction::Save => { + self.save_settings(); + } + rightclick::settings::SettingsAction::Cancel => { + self.close_settings(); + } + rightclick::settings::SettingsAction::Handled + | rightclick::settings::SettingsAction::Ignored => {} + } + } + return Ok(()); + } + // If search overlay is visible, route all input to it if self.search.visible { let key_code = key_code_to_string(key.code); @@ -518,7 +614,7 @@ impl App { } // Sort all results by score descending - all_results.sort_by(|a, b| b.score.cmp(&a.score)); + all_results.sort_by_key(|r| std::cmp::Reverse(r.score)); all_results.truncate(50); self.search.set_results(all_results); } @@ -639,7 +735,31 @@ impl App { } } - results.sort_by(|a, b| b.score.cmp(&a.score)); + // App-level system commands exposed via the palette (app.*). These are + // routed by the shell, not by a plugin, so they are added separately. + for entry in rightclick::palette::standard_entries() + .into_iter() + .filter(|e| e.command_id.starts_with("app.")) + { + let name_score = fuzzy_match_simple(&entry.name, query).unwrap_or(0); + let desc_score = fuzzy_match_simple(&entry.description, query).unwrap_or(0); + let id_score = fuzzy_match_simple(&entry.command_id, query).unwrap_or(0); + let key_score = fuzzy_match_simple(&entry.key, query).unwrap_or(0); + let score = name_score.max(desc_score).max(id_score).max(key_score); + if score == 0 { + continue; + } + results.push(SearchResult { + kind: SearchResultKind::Command { + id: entry.command_id.clone(), + }, + title: entry.name, + preview: entry.description, + score, + }); + } + + results.sort_by_key(|r| std::cmp::Reverse(r.score)); results.truncate(max_results); results } @@ -648,6 +768,19 @@ impl App { &mut self, id: &str, ) -> Result { + // App-level commands (app.*) are handled by the shell directly. + if let Some(app_cmd) = id.strip_prefix("app.") { + return self + .execute_app_command(app_cmd) + .map(|_| PluginCommandExecution { + plugin_id: "app".to_string(), + command_id: id.to_string(), + command_name: id.to_string(), + emitted_commands: Vec::new(), + }) + .map_err(SearchCommandError::PluginCommand); + } + let Some((plugin_id, command_id)) = id.split_once(':') else { return Err(SearchCommandError::InvalidRoute(id.to_string())); }; @@ -665,6 +798,33 @@ impl App { .map_err(SearchCommandError::PluginCommand) } + /// Execute an app-level command (`app.`). + /// + /// Handles the small set of system commands that the palette exposes with + /// the `app.` prefix: `quit`, `refresh`, and `settings`. + fn execute_app_command(&mut self, name: &str) -> Result<(), PluginCommandError> { + match name { + "quit" => { + self.should_quit = true; + Ok(()) + } + "refresh" => { + if let Some(plugin) = self.plugins.get_mut(self.active_plugin) { + plugin.handle_event(rightclick::event::Event::RefreshNeeded); + } + Ok(()) + } + "settings" => { + self.toggle_settings(); + Ok(()) + } + other => Err(PluginCommandError::UnknownCommand { + plugin_id: "app".to_string(), + command_id: other.to_string(), + }), + } + } + fn render(&mut self, terminal: &mut Terminal>) -> Result<()> { terminal.draw(|f| { let size = f.area(); @@ -700,6 +860,13 @@ impl App { self.render_help_overlay(size, f.buffer_mut()); } + // Settings modal overlay + if self.settings_open { + if let Some(modal) = &self.settings { + modal.render(size, f.buffer_mut(), &self.theme); + } + } + // Notification toasts (overlay on top of everything) self.notifications.render(size, f.buffer_mut(), &self.theme); })?; @@ -895,7 +1062,7 @@ fn search_plugin_entries( }) .collect(); - results.sort_by(|a, b| b.score.cmp(&a.score)); + results.sort_by_key(|r| std::cmp::Reverse(r.score)); results.truncate(max_results); results } @@ -1058,7 +1225,7 @@ fn build_footer_hints( let mut prioritized_commands: Vec<&rightclick::plugin::PluginCommand> = commands.iter().collect(); - prioritized_commands.sort_by(|left, right| right.priority.cmp(&left.priority)); + prioritized_commands.sort_by_key(|c| std::cmp::Reverse(c.priority)); for command in prioritized_commands { let key = command.key.to_string(); @@ -1176,6 +1343,12 @@ fn is_ctrl_r_refresh_key(key: &crossterm::event::KeyEvent) -> bool { && key.modifiers.contains(KeyModifiers::CONTROL) } +fn is_ctrl_comma_key(key: &crossterm::event::KeyEvent) -> bool { + use crossterm::event::{KeyCode, KeyModifiers}; + + matches!(key.code, KeyCode::Char(',')) && key.modifiers.contains(KeyModifiers::CONTROL) +} + fn plugin_shortcut_index(c: char) -> Option { c.to_digit(10) .and_then(|digit| digit.checked_sub(1)) @@ -1506,6 +1679,8 @@ mod tests { Vec::new(), Theme::default(), PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), ); let area = Rect::new(0, 0, 26, 3); let mut buf = ratatui::buffer::Buffer::empty(area); @@ -1527,6 +1702,8 @@ mod tests { Vec::new(), Theme::default(), PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), ); let area = Rect::new(u16::MAX - 80, u16::MAX - 40, 80, 40); let mut buf = ratatui::buffer::Buffer::empty(area); @@ -1789,7 +1966,13 @@ mod tests { Box::new(workers::WorkersPlugin::new()), Box::new(workspace::WorkspacePlugin::new()), ]; - let mut app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let mut app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); app.handle_event(CEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))) .await @@ -1813,7 +1996,13 @@ mod tests { Box::new(workers::WorkersPlugin::new()), Box::new(workspace::WorkspacePlugin::new()), ]; - let mut app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let mut app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); app.handle_event(CEvent::Key(KeyEvent::new( KeyCode::Tab, @@ -1902,7 +2091,13 @@ mod tests { events: events.clone(), focused: false, })]; - let mut app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let mut app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); app.handle_event(CEvent::Key(KeyEvent::new( KeyCode::Char('r'), @@ -2011,7 +2206,13 @@ mod tests { events, focused: false, })]; - let app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); let results = app.search_plugin_commands("target refresh", 10); @@ -2040,7 +2241,13 @@ mod tests { events, focused: false, })]; - let app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); let results = app.search_plugin_commands("z", 10); @@ -2071,7 +2278,13 @@ mod tests { events, focused: false, })]; - let app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); let results = app.search_plugin_commands("git", 10); @@ -2100,7 +2313,13 @@ mod tests { events, focused: false, })]; - let app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); let results = app.search_plugin_commands("repo_tools:open-pr", 10); @@ -2142,7 +2361,13 @@ mod tests { focused: false, }), ]; - let mut app = App::new(plugins, Theme::default(), PathBuf::from("/tmp/rightclick")); + let mut app = App::new( + plugins, + Theme::default(), + PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), + ); let search_result = rightclick::search::SearchResult { kind: rightclick::search::SearchResultKind::Command { @@ -2179,6 +2404,8 @@ mod tests { Vec::new(), Theme::default(), PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), ); app.search .set_results(vec![rightclick::search::SearchResult { @@ -2208,6 +2435,8 @@ mod tests { Vec::new(), Theme::default(), PathBuf::from("/tmp/rightclick"), + Config::default(), + std::sync::Arc::new(Dispatcher::new()), ); app.search .set_results(vec![rightclick::search::SearchResult { diff --git a/src/palette/fuzzy.rs b/src/palette/fuzzy.rs index 37b3d77..d0c7c51 100644 --- a/src/palette/fuzzy.rs +++ b/src/palette/fuzzy.rs @@ -89,7 +89,7 @@ impl FuzzyMatcher { .collect(); // Sort by score (highest first) - results.sort_by(|a, b| b.score.cmp(&a.score)); + results.sort_by_key(|r| std::cmp::Reverse(r.score)); results } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 12c52ef..3690c15 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -903,6 +903,16 @@ pub trait Plugin: Send + Sync + std::fmt::Debug { vec![] } + /// Re-apply configuration after the user edited settings at runtime. + /// + /// Called by the shell whenever the authoritative [`Config`] changes (for + /// example after the settings modal saves). Plugins should re-extract their + /// slice of the config and update any affected state. The default + /// implementation is a no-op for plugins that do not read config. + /// + /// [`Config`]: crate::core::models::Config + fn apply_config(&mut self, _config: &crate::core::models::Config) {} + /// Update the plugin state asynchronously. /// /// This method is called regularly by the application loop to allow diff --git a/src/plugins/conversations/plugin.rs b/src/plugins/conversations/plugin.rs index 1773452..a24ee36 100644 --- a/src/plugins/conversations/plugin.rs +++ b/src/plugins/conversations/plugin.rs @@ -228,7 +228,7 @@ impl ConversationsPlugin { } // Sort sessions by updated_at (newest first) - all_sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at)); + all_sessions.sort_by_key(|s| std::cmp::Reverse(s.session.updated_at)); self.state.set_sessions(all_sessions); self.state.set_loading(false); @@ -737,17 +737,14 @@ impl Plugin for ConversationsPlugin { } } } - "c" => { - // Exit search mode - if self.state.view == ConversationView::Search { - self.state.clear_search(); - self.state - .list_nav - .set_total_items(self.state.sessions.len()); - if self.state.list_nav.selected < self.state.sessions.len() { - self.state.selected_session = - Some(self.state.list_nav.selected); - } + // Exit search mode + "c" if self.state.view == ConversationView::Search => { + self.state.clear_search(); + self.state + .list_nav + .set_total_items(self.state.sessions.len()); + if self.state.list_nav.selected < self.state.sessions.len() { + self.state.selected_session = Some(self.state.list_nav.selected); } } _ => {} diff --git a/src/plugins/filebrowser/plugin.rs b/src/plugins/filebrowser/plugin.rs index 5ca1c4c..6e7d59c 100644 --- a/src/plugins/filebrowser/plugin.rs +++ b/src/plugins/filebrowser/plugin.rs @@ -125,6 +125,14 @@ impl Plugin for FileBrowserPlugin { Ok(()) } + fn apply_config(&mut self, config: &crate::core::models::Config) { + let show_hidden = config.plugins.file_browser.show_hidden; + if self.state.show_hidden != show_hidden { + self.state.show_hidden = show_hidden; + self.refresh(); + } + } + fn handle_event(&mut self, event: Event) -> Vec { let commands = Vec::new(); diff --git a/src/plugins/gitstatus/plugin.rs b/src/plugins/gitstatus/plugin.rs index 18b4ca7..798118e 100644 --- a/src/plugins/gitstatus/plugin.rs +++ b/src/plugins/gitstatus/plugin.rs @@ -1114,6 +1114,10 @@ impl Plugin for GitStatusPlugin { Ok(()) } + fn apply_config(&mut self, config: &Config) { + self.config = Some(config.clone()); + } + fn handle_event(&mut self, event: Event) -> Vec { let commands = self.handle_event_internal(event); for cmd in commands { diff --git a/src/plugins/workers/plugin.rs b/src/plugins/workers/plugin.rs index 5f531f2..41ce37a 100644 --- a/src/plugins/workers/plugin.rs +++ b/src/plugins/workers/plugin.rs @@ -1058,6 +1058,20 @@ impl Plugin for WorkersPlugin { Ok(()) } + fn apply_config(&mut self, config: &crate::core::models::Config) { + let new_config = config.plugins.workers.clone(); + // Reflect path changes in state if they changed. + let intents_changed = self.config.intents_dir != new_config.intents_dir; + let logs_changed = self.config.logs_dir != new_config.logs_dir; + self.config = new_config; + if intents_changed { + self.state.intents_dir = PathBuf::from(&self.config.intents_dir); + } + if logs_changed { + self.state.logs_dir = PathBuf::from(&self.config.logs_dir); + } + } + fn handle_event(&mut self, event: crate::event::Event) -> Vec { let commands = self.handle_event_internal(event); for command in commands { diff --git a/src/plugins/workspace/plugin.rs b/src/plugins/workspace/plugin.rs index f0aa438..5e7ed69 100644 --- a/src/plugins/workspace/plugin.rs +++ b/src/plugins/workspace/plugin.rs @@ -753,10 +753,8 @@ impl WorkspacePlugin { } } } - ModalState::LinkTask => { - if c.is_alphanumeric() || c == '-' || c == '_' { - self.state.task_id_buffer.push(c); - } + ModalState::LinkTask if c.is_alphanumeric() || c == '-' || c == '_' => { + self.state.task_id_buffer.push(c); } _ => {} } diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..8398d93 --- /dev/null +++ b/src/settings/mod.rs @@ -0,0 +1,353 @@ +//! Plugin and application settings modal. +//! +//! Builds a [`Modal`](crate::modal::Modal) for editing the boolean fields of +//! [`Config`](crate::core::models::Config) at runtime. The modal mirrors the +//! checkbox state in [`SettingsState`] because the generic modal framework +//! reports a toggle without identifying which checkbox flipped; we track the +//! focused id before each key press to map a toggle back to its field. +//! +//! The shell (`App`) owns a `SettingsModal` while open. On `Save` it writes the +//! resulting `Config` back to disk and applies it to the live plugins. + +use crossterm::event::KeyEvent; +use ratatui::{buffer::Buffer, layout::Rect}; + +use crate::core::models::{Config, Theme}; +use crate::keymap::Action; +use crate::modal::{Button, Modal, ModalVariant, section, section::ButtonAction}; + +/// Stable checkbox ids. Kept in sync with the ids passed to [`section::checkbox`]. +mod ids { + pub const UI_SHOW_CLOCK: &str = "ui.show_clock"; + pub const UI_COMPACT_MODE: &str = "ui.compact_mode"; + pub const UI_NERD_FONTS: &str = "ui.nerd_fonts"; + pub const GIT_ENABLED: &str = "git.enabled"; + pub const GIT_SHOW_UNTRACKED: &str = "git.show_untracked"; + pub const CONVERSATIONS_ENABLED: &str = "conversations.enabled"; + pub const CONVERSATIONS_SAVE_CONTEXT: &str = "conversations.save_context"; + pub const FILE_BROWSER_ENABLED: &str = "file_browser.enabled"; + pub const FILE_BROWSER_SHOW_HIDDEN: &str = "file_browser.show_hidden"; + pub const WORKSPACE_ENABLED: &str = "workspace.enabled"; + pub const WORKERS_ENABLED: &str = "workers.enabled"; + pub const SAVE: &str = "settings.save"; + pub const CANCEL: &str = "settings.cancel"; +} + +/// Outcome of a key press while the settings modal is open. +#[derive(Debug, PartialEq)] +pub enum SettingsAction { + /// User confirmed; the shell should call [`SettingsModal::into_config`], + /// persist the result, and apply it to live plugins. + Save, + /// User cancelled or dismissed the modal. + Cancel, + /// Key was consumed by the modal (toggle, focus move); no shell action. + Handled, + /// Key was not consumed; the shell may forward it. + Ignored, +} + +/// Mirror of every editable boolean in the settings modal. +/// +/// Field-for-field with the checkbox ids in [`ids`]. Updating this from a +/// toggle event keeps the modal state and the produced [`Config`] consistent +/// without reaching into the generic `CheckboxSection` internals. +#[derive(Debug, Clone, PartialEq)] +struct SettingsState { + ui_show_clock: bool, + ui_compact_mode: bool, + ui_nerd_fonts: bool, + git_enabled: bool, + git_show_untracked: bool, + conversations_enabled: bool, + conversations_save_context: bool, + file_browser_enabled: bool, + file_browser_show_hidden: bool, + workspace_enabled: bool, + workers_enabled: bool, +} + +impl SettingsState { + /// Snapshot the editable fields from a [`Config`]. + fn from_config(config: &Config) -> Self { + Self { + ui_show_clock: config.ui.show_clock, + ui_compact_mode: config.ui.compact_mode, + ui_nerd_fonts: config.ui.nerd_fonts_enabled, + git_enabled: config.plugins.git_status.enabled, + git_show_untracked: config.plugins.git_status.show_untracked, + conversations_enabled: config.plugins.conversations.enabled, + conversations_save_context: config.plugins.conversations.save_context, + file_browser_enabled: config.plugins.file_browser.enabled, + file_browser_show_hidden: config.plugins.file_browser.show_hidden, + workspace_enabled: config.plugins.workspace.enabled, + workers_enabled: config.plugins.workers.enabled, + } + } + + /// Apply the mirrored state back onto a [`Config`] (cloned first by caller). + fn apply_to(&self, config: &mut Config) { + config.ui.show_clock = self.ui_show_clock; + config.ui.compact_mode = self.ui_compact_mode; + config.ui.nerd_fonts_enabled = self.ui_nerd_fonts; + config.plugins.git_status.enabled = self.git_enabled; + config.plugins.git_status.show_untracked = self.git_show_untracked; + config.plugins.conversations.enabled = self.conversations_enabled; + config.plugins.conversations.save_context = self.conversations_save_context; + config.plugins.file_browser.enabled = self.file_browser_enabled; + config.plugins.file_browser.show_hidden = self.file_browser_show_hidden; + config.plugins.workspace.enabled = self.workspace_enabled; + config.plugins.workers.enabled = self.workers_enabled; + } + + /// Flip the boolean identified by `checkbox_id`. Returns `true` if the id + /// matched an editable field. + fn toggle(&mut self, checkbox_id: &str) -> bool { + match checkbox_id { + ids::UI_SHOW_CLOCK => self.ui_show_clock = !self.ui_show_clock, + ids::UI_COMPACT_MODE => self.ui_compact_mode = !self.ui_compact_mode, + ids::UI_NERD_FONTS => self.ui_nerd_fonts = !self.ui_nerd_fonts, + ids::GIT_ENABLED => self.git_enabled = !self.git_enabled, + ids::GIT_SHOW_UNTRACKED => self.git_show_untracked = !self.git_show_untracked, + ids::CONVERSATIONS_ENABLED => self.conversations_enabled = !self.conversations_enabled, + ids::CONVERSATIONS_SAVE_CONTEXT => { + self.conversations_save_context = !self.conversations_save_context + } + ids::FILE_BROWSER_ENABLED => self.file_browser_enabled = !self.file_browser_enabled, + ids::FILE_BROWSER_SHOW_HIDDEN => { + self.file_browser_show_hidden = !self.file_browser_show_hidden + } + ids::WORKSPACE_ENABLED => self.workspace_enabled = !self.workspace_enabled, + ids::WORKERS_ENABLED => self.workers_enabled = !self.workers_enabled, + _ => return false, + } + true + } +} + +/// The settings modal: a thin wrapper over [`Modal`] plus mirrored state. +pub struct SettingsModal { + modal: Modal, + state: SettingsState, +} + +impl SettingsModal { + /// Build the modal seeded from `config`. + pub fn from_config(config: &Config) -> Self { + let state = SettingsState::from_config(config); + let mut modal = Modal::new("Settings") + .with_variant(ModalVariant::Info) + .with_width(64) + .with_primary_action("Save") + .with_close_on_backdrop(true); + + // Appearance section + modal.add_section(section::text("Appearance")); + modal.add_section(section::checkbox( + ids::UI_SHOW_CLOCK, + "Show clock in header", + state.ui_show_clock, + )); + modal.add_section(section::checkbox( + ids::UI_COMPACT_MODE, + "Compact mode (reduces padding)", + state.ui_compact_mode, + )); + modal.add_section(section::checkbox( + ids::UI_NERD_FONTS, + "Nerd font icons", + state.ui_nerd_fonts, + )); + modal.add_section(section::spacer()); + + // Git Status section + modal.add_section(section::text("Git Status")); + modal.add_section(section::checkbox( + ids::GIT_ENABLED, + "Enabled", + state.git_enabled, + )); + modal.add_section(section::checkbox( + ids::GIT_SHOW_UNTRACKED, + "Show untracked files", + state.git_show_untracked, + )); + modal.add_section(section::spacer()); + + // Conversations section + modal.add_section(section::text("Conversations")); + modal.add_section(section::checkbox( + ids::CONVERSATIONS_ENABLED, + "Enabled", + state.conversations_enabled, + )); + modal.add_section(section::checkbox( + ids::CONVERSATIONS_SAVE_CONTEXT, + "Save conversation context", + state.conversations_save_context, + )); + modal.add_section(section::spacer()); + + // File Browser section + modal.add_section(section::text("File Browser")); + modal.add_section(section::checkbox( + ids::FILE_BROWSER_ENABLED, + "Enabled", + state.file_browser_enabled, + )); + modal.add_section(section::checkbox( + ids::FILE_BROWSER_SHOW_HIDDEN, + "Show hidden files", + state.file_browser_show_hidden, + )); + modal.add_section(section::spacer()); + + // Workspace + Workers (single-line each) + modal.add_section(section::text("Workspace")); + modal.add_section(section::checkbox( + ids::WORKSPACE_ENABLED, + "Enabled", + state.workspace_enabled, + )); + modal.add_section(section::spacer()); + + modal.add_section(section::text("Workers")); + modal.add_section(section::checkbox( + ids::WORKERS_ENABLED, + "Enabled", + state.workers_enabled, + )); + modal.add_section(section::spacer()); + + modal.add_section(section::buttons(vec![ + Button::primary(ids::SAVE, "Save"), + Button::secondary(ids::CANCEL, "Cancel"), + ])); + + Self { modal, state } + } + + /// Handle a key event and translate it into a [`SettingsAction`]. + pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction { + // Capture focus before the key is handled so a Toggle can be attributed + // to the right checkbox (focus does not move during a toggle). + let focused_before = self.modal.focused_id().map(str::to_owned); + + match self.modal.handle_key(key) { + Some(Action::Back) => SettingsAction::Cancel, + Some(Action::Toggle) => { + if let Some(id) = focused_before.as_deref() { + self.state.toggle(id); + } + SettingsAction::Handled + } + Some(Action::Custom(action_any)) => { + if let Some(button) = action_any.downcast_ref::() { + match button.button_id.as_str() { + ids::SAVE => SettingsAction::Save, + ids::CANCEL => SettingsAction::Cancel, + _ => SettingsAction::Handled, + } + } else { + SettingsAction::Handled + } + } + Some(_) => SettingsAction::Handled, + None => SettingsAction::Ignored, + } + } + + /// Produce the final [`Config`] by applying the edited state onto `base` + /// (the live config), preserving every non-editable field. + pub fn into_config(self, base: &Config) -> Config { + let mut config = base.clone(); + self.state.apply_to(&mut config); + config + } + + /// Render the modal. + pub fn render(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { + self.modal.render(area, buf, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_state_round_trips_through_config() { + let mut config = Config::default(); + config.ui.show_clock = false; + config.plugins.git_status.show_untracked = false; + config.plugins.file_browser.show_hidden = true; + + let state = SettingsState::from_config(&config); + let mut applied = Config::default(); + state.apply_to(&mut applied); + + assert!(!applied.ui.show_clock); + assert!(!applied.plugins.git_status.show_untracked); + assert!(applied.plugins.file_browser.show_hidden); + // Untouched fields keep the default. + assert!(applied.plugins.git_status.enabled); + } + + #[test] + fn toggle_flips_named_field_only() { + let mut state = SettingsState::from_config(&Config::default()); + assert!(state.git_show_untracked); + assert!(state.toggle(ids::GIT_SHOW_UNTRACKED)); + assert!(!state.git_show_untracked); + + // Unknown id is a no-op. + assert!(!state.toggle("nope")); + assert!(!state.git_show_untracked); + } + + #[test] + fn into_config_preserves_non_editable_fields() { + let mut base = Config::default(); + base.plugins.workspace.default_branch = "develop".to_string(); + base.ui.theme = "nord".to_string(); + + let modal = SettingsModal::from_config(&base); + let result = modal.into_config(&base); + + // Edited field reflects the snapshot (default true here). + assert!(result.plugins.workspace.enabled); + // Non-editable fields are preserved from the live config. + assert_eq!(result.plugins.workspace.default_branch, "develop"); + assert_eq!(result.ui.theme, "nord"); + } + + #[test] + fn handle_key_toggles_focused_checkbox() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + // The first focusable id is the first checkbox: ui.show_clock. + let config = Config::default(); + assert!(config.ui.show_clock); + + let mut modal = SettingsModal::from_config(&config); + // Sanity: focus starts on the first checkbox. + assert_eq!(modal.modal.focused_id(), Some(super::ids::UI_SHOW_CLOCK)); + + let action = modal.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(action, SettingsAction::Handled); + + // Cancel-style close is not invoked; verify the toggle landed by + // producing the final config. + let result = modal.into_config(&config); + assert!(!result.ui.show_clock); + } + + #[test] + fn handle_key_esc_cancels() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut modal = SettingsModal::from_config(&Config::default()); + let action = modal.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(action, SettingsAction::Cancel); + } +} diff --git a/tests/git_workspace_workflows.rs b/tests/git_workspace_workflows.rs index 3e77906..f97d0af 100644 --- a/tests/git_workspace_workflows.rs +++ b/tests/git_workspace_workflows.rs @@ -29,6 +29,9 @@ fn create_repo() -> (tempfile::TempDir, PathBuf) { run_git(&repo, &["init"]); run_git(&repo, &["config", "user.email", "rightclick@example.com"]); run_git(&repo, &["config", "user.name", "RightClick Test"]); + // Neutralize host-level signing settings so commits succeed without gpg. + run_git(&repo, &["config", "commit.gpgsign", "false"]); + run_git(&repo, &["config", "tag.gpgsign", "false"]); std::fs::write(repo.join("README.md"), "initial\n").expect("seed file"); run_git(&repo, &["add", "README.md"]); diff --git a/tests/settings_round_trip.rs b/tests/settings_round_trip.rs new file mode 100644 index 0000000..622dfca --- /dev/null +++ b/tests/settings_round_trip.rs @@ -0,0 +1,49 @@ +//! Integration test: editing settings via the modal persists through config. +//! +//! Exercises the full shell-side flow without the terminal: build a modal from +//! a live config, simulate toggling a checkbox, produce the new config, save it +//! to disk, and reload it to confirm the change survived a round-trip. + +use rightclick::config::{load_from, save_to}; +use rightclick::core::models::Config; +use rightclick::settings::{SettingsAction, SettingsModal}; + +#[test] +fn settings_modal_edit_persists_through_config_round_trip() { + // Simulate the live config held by the App. + let mut live = Config::default(); + live.plugins.file_browser.show_hidden = false; + let base = live.clone(); + + // Open the settings modal seeded from the live config. + let mut modal = SettingsModal::from_config(&base); + // Focus starts on the first checkbox (ui.show_clock); tab down to the + // file browser "show hidden" checkbox and flip it. The checkbox ids are + // stable, so we navigate by counting Tab presses to land on it. + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for _ in 0..8 { + modal.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + } + // Toggle the now-focused file browser show_hidden checkbox. + assert_eq!( + modal.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + SettingsAction::Handled + ); + + // The shell would call into_config on Save; do it directly here. + let edited = modal.into_config(&base); + assert!(edited.plugins.file_browser.show_hidden); + + // Persist and reload: the edited value must survive. + let dir = tempfile::TempDir::new().expect("temp dir"); + let path = dir.path().join("config.json"); + save_to(&edited, &path).expect("save edited config"); + let reloaded = load_from(&path).expect("reload config"); + + assert!(reloaded.plugins.file_browser.show_hidden); + // Non-edited fields are unchanged. + assert_eq!( + reloaded.plugins.git_status.enabled, + base.plugins.git_status.enabled + ); +}