diff --git a/client/config.toml b/client/config.toml index 121e747..a02804d 100644 --- a/client/config.toml +++ b/client/config.toml @@ -17,6 +17,7 @@ toggle_buffer_list = ["ctrl+b"] next_field = ["Tab"] prev_field = ["Shift+Tab"] exit_table_scroll = ["esc"] +open_search = ["ctrl+f"] [keybindings.common] save = ["ctrl+s"] diff --git a/client/src/components/common.rs b/client/src/components/common.rs index 57a242a..c3b3cc0 100644 --- a/client/src/components/common.rs +++ b/client/src/components/common.rs @@ -5,6 +5,7 @@ pub mod text_editor; pub mod background; pub mod dialog; pub mod autocomplete; +pub mod search_palette; pub mod find_file_palette; pub use command_line::*; @@ -13,4 +14,5 @@ pub use text_editor::*; pub use background::*; pub use dialog::*; pub use autocomplete::*; +pub use search_palette::*; pub use find_file_palette::*; diff --git a/client/src/components/common/search_palette.rs b/client/src/components/common/search_palette.rs new file mode 100644 index 0000000..f0810ff --- /dev/null +++ b/client/src/components/common/search_palette.rs @@ -0,0 +1,121 @@ +// src/components/common/search_palette.rs + +use crate::config::colors::themes::Theme; +use crate::state::app::search::SearchState; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + Frame, +}; + +/// Renders the search palette dialog over the main UI. +pub fn render_search_palette( + f: &mut Frame, + area: Rect, + theme: &Theme, + state: &SearchState, +) { + // --- Dialog Area Calculation --- + let height = (area.height as f32 * 0.7).min(30.0) as u16; + let width = (area.width as f32 * 0.6).min(100.0) as u16; + let dialog_area = Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 4, + width, + height, + }; + + f.render_widget(Clear, dialog_area); // Clear background + + let block = Block::default() + .title(format!(" Search in '{}' ", state.table_name)) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent)); + f.render_widget(block.clone(), dialog_area); + + // --- Inner Layout (Input + Results) --- + let inner_chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // For input box + Constraint::Min(0), // For results list + ]) + .split(dialog_area); + + // --- Render Input Box --- + let input_block = Block::default() + .title("Query") + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)); + let input_text = Paragraph::new(state.input.as_str()) + .block(input_block) + .style(Style::default().fg(theme.fg)); + f.render_widget(input_text, inner_chunks[0]); + // Set cursor position + f.set_cursor( + inner_chunks[0].x + state.cursor_position as u16 + 1, + inner_chunks[0].y + 1, + ); + + // --- Render Results List --- + if state.is_loading { + let loading_p = Paragraph::new("Searching...") + .style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC)); + f.render_widget(loading_p, inner_chunks[1]); + } else { + let list_items: Vec = state + .results + .iter() + .map(|hit| { + // Parse the JSON string to make it readable + let content_summary = match serde_json::from_str::< + serde_json::Value, + >(&hit.content_json) + { + Ok(json) => { + if let Some(obj) = json.as_object() { + // Create a summary from the first few non-null string values + obj.values() + .filter_map(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .take(3) + .collect::>() + .join(" | ") + } else { + "Non-object JSON".to_string() + } + } + Err(_) => "Invalid JSON content".to_string(), + }; + + let line = Line::from(vec![ + Span::styled( + format!("{:<4.2} ", hit.score), + Style::default().fg(theme.accent), + ), + Span::raw(content_summary), + ]); + ListItem::new(line) + }) + .collect(); + + let results_list = List::new(list_items) + .block(Block::default().title("Results")) + .highlight_style( + Style::default() + .bg(theme.highlight) + .fg(theme.bg) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + // We need a mutable ListState to render the selection + let mut list_state = + ratatui::widgets::ListState::default().with_selected(Some(state.selected_index)); + + f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state); + } +} diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs index 15712ef..f6c5fdf 100644 --- a/client/src/modes/handlers/event.rs +++ b/client/src/modes/handlers/event.rs @@ -21,6 +21,7 @@ use crate::state::{ app::{ buffer::{AppView, BufferState}, highlight::HighlightState, + search::SearchState, // Correctly imported state::AppState, }, pages::{ @@ -41,10 +42,12 @@ use crate::tui::{ use crate::ui::handlers::context::UiContext; use crate::ui::handlers::rat_state::UiStateHandler; use anyhow::Result; +use common::proto::multieko2::search::search_response::Hit; // Correctly imported use crossterm::cursor::SetCursorStyle; use crossterm::event::KeyCode; use crossterm::event::{Event, KeyEvent}; use tokio::sync::mpsc; +use tokio::sync::mpsc::unbounded_channel; #[derive(Debug, Clone, PartialEq, Eq)] pub enum EventOutcome { @@ -79,6 +82,8 @@ pub struct EventHandler { pub save_table_result_sender: SaveTableResultSender, pub save_logic_result_sender: SaveLogicResultSender, pub navigation_state: NavigationState, + pub search_result_sender: mpsc::UnboundedSender>, + pub search_result_receiver: mpsc::UnboundedReceiver>, } impl EventHandler { @@ -88,6 +93,7 @@ impl EventHandler { save_table_result_sender: SaveTableResultSender, save_logic_result_sender: SaveLogicResultSender, ) -> Result { + let (search_tx, search_rx) = unbounded_channel(); Ok(EventHandler { command_mode: false, command_input: String::new(), @@ -103,6 +109,8 @@ impl EventHandler { save_table_result_sender, save_logic_result_sender, navigation_state: NavigationState::new(), + search_result_sender: search_tx, + search_result_receiver: search_rx, }) } @@ -114,6 +122,90 @@ impl EventHandler { self.navigation_state.activate_find_file(options); } + // REFACTORED: This function now safely handles state changes. + async fn handle_search_palette_event( + &mut self, + key_event: KeyEvent, + grpc_client: &mut GrpcClient, + form_state: &mut FormState, + app_state: &mut AppState, + ) -> Result { + let mut should_close = false; + let mut outcome_message = String::new(); + let mut trigger_search = false; + + if let Some(search_state) = app_state.search_state.as_mut() { + match key_event.code { + KeyCode::Esc => { + should_close = true; + outcome_message = "Search cancelled".to_string(); + } + KeyCode::Enter => { + if let Some(selected_hit) = search_state.results.get(search_state.selected_index) { + if let Ok(data) = serde_json::from_str::>(&selected_hit.content_json) { + let detached_pos = form_state.total_count + 2; + form_state.update_from_response(&data, detached_pos); + } + should_close = true; + outcome_message = format!("Loaded record ID {}", selected_hit.id); + } + } + KeyCode::Up => search_state.previous_result(), + KeyCode::Down => search_state.next_result(), + KeyCode::Char(c) => { + search_state.input.insert(search_state.cursor_position, c); + search_state.cursor_position += 1; + trigger_search = true; + } + KeyCode::Backspace => { + if search_state.cursor_position > 0 { + search_state.cursor_position -= 1; + search_state.input.remove(search_state.cursor_position); + trigger_search = true; + } + } + KeyCode::Left => { + search_state.cursor_position = search_state.cursor_position.saturating_sub(1); + } + KeyCode::Right => { + if search_state.cursor_position < search_state.input.len() { + search_state.cursor_position += 1; + } + } + _ => {} + } + + if trigger_search { + search_state.is_loading = true; + search_state.results.clear(); + search_state.selected_index = 0; + + let query = search_state.input.clone(); + let table_name = search_state.table_name.clone(); + let mut client = grpc_client.clone(); + let sender = self.search_result_sender.clone(); + + tokio::spawn(async move { + if let Ok(response) = client.search_table(table_name, query).await { + let _ = sender.send(response.hits); + } else { + let _ = sender.send(vec![]); + } + }); + } + } + + // The borrow on `app_state.search_state` ends here. + // Now we can safely modify the Option itself. + if should_close { + app_state.search_state = None; + app_state.ui.show_search_palette = false; + app_state.ui.focus_outside_canvas = false; + } + + Ok(EventOutcome::Ok(outcome_message)) + } + #[allow(clippy::too_many_arguments)] pub async fn handle_event( &mut self, @@ -131,6 +223,20 @@ impl EventHandler { buffer_state: &mut BufferState, app_state: &mut AppState, ) -> Result { + if let Ok(hits) = self.search_result_receiver.try_recv() { + if let Some(search_state) = app_state.search_state.as_mut() { + search_state.results = hits; + search_state.is_loading = false; + } + } + + if app_state.ui.show_search_palette { + if let Event::Key(key_event) = event { + return self.handle_search_palette_event(key_event, grpc_client, form_state, app_state).await; + } + return Ok(EventOutcome::Ok(String::new())); + } + let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state); if current_mode == AppMode::General && self.navigation_state.active { @@ -212,6 +318,19 @@ impl EventHandler { _ => {} } } + + if let Some(action) = config.get_general_action(key_code, modifiers) { + if action == "open_search" { + if app_state.ui.show_form { + if let Some(table_name) = app_state.current_view_table_name.clone() { + app_state.ui.show_search_palette = true; + app_state.search_state = Some(SearchState::new(table_name)); + app_state.ui.focus_outside_canvas = true; + return Ok(EventOutcome::Ok("Search palette opened".to_string())); + } + } + } + } } match current_mode { @@ -348,7 +467,6 @@ impl EventHandler { &mut admin_state.add_table_state, &mut admin_state.add_logic_state, &mut self.key_sequence_tracker, - // No more current_position or total_count arguments grpc_client, &mut self.command_message, &mut self.edit_mode_cooldown, @@ -477,7 +595,6 @@ impl EventHandler { if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") { if app_state.ui.show_form || app_state.ui.show_intro { - // --- START FIX --- let mut all_table_paths: Vec = app_state .profile_tree .profiles @@ -491,7 +608,6 @@ impl EventHandler { all_table_paths.sort(); self.navigation_state.activate_find_file(all_table_paths); - // --- END FIX --- self.command_mode = false; self.command_input.clear(); diff --git a/client/src/services/grpc_client.rs b/client/src/services/grpc_client.rs index 84c8f83..704adf2 100644 --- a/client/src/services/grpc_client.rs +++ b/client/src/services/grpc_client.rs @@ -20,6 +20,9 @@ use common::proto::multieko2::tables_data::{ PostTableDataRequest, PostTableDataResponse, PutTableDataRequest, PutTableDataResponse, }; +use common::proto::multieko2::search::{ + searcher_client::SearcherClient, SearchRequest, SearchResponse, +}; use anyhow::{Context, Result}; // Added Context use std::collections::HashMap; // NEW @@ -28,36 +31,32 @@ pub struct GrpcClient { table_structure_client: TableStructureServiceClient, table_definition_client: TableDefinitionClient, table_script_client: TableScriptClient, - tables_data_client: TablesDataClient, // NEW + tables_data_client: TablesDataClient, + search_client: SearcherClient, } impl GrpcClient { pub async fn new() -> Result { - let table_structure_client = TableStructureServiceClient::connect( - "http://[::1]:50051", - ) - .await - .context("Failed to connect to TableStructureService")?; - let table_definition_client = TableDefinitionClient::connect( - "http://[::1]:50051", - ) - .await - .context("Failed to connect to TableDefinitionService")?; - let table_script_client = - TableScriptClient::connect("http://[::1]:50051") - .await - .context("Failed to connect to TableScriptService")?; - let tables_data_client = - TablesDataClient::connect("http://[::1]:50051") - .await - .context("Failed to connect to TablesDataService")?; // NEW + let channel = Channel::from_static("http://[::1]:50051") + .connect() + .await + .context("Failed to create gRPC channel")?; + + let table_structure_client = + TableStructureServiceClient::new(channel.clone()); + let table_definition_client = + TableDefinitionClient::new(channel.clone()); + let table_script_client = TableScriptClient::new(channel.clone()); + let tables_data_client = TablesDataClient::new(channel.clone()); + // NEW: Instantiate the search client + let search_client = SearcherClient::new(channel.clone()); Ok(Self { - // adresar_client, // REMOVE table_structure_client, table_definition_client, table_script_client, - tables_data_client, // NEW + tables_data_client, + search_client, // NEW }) } @@ -197,4 +196,18 @@ impl GrpcClient { .context("gRPC PutTableData call failed")?; Ok(response.into_inner()) } + + pub async fn search_table( + &mut self, + table_name: String, + query: String, + ) -> Result { + let request = tonic::Request::new(SearchRequest { table_name, query }); + let response = self + .search_client + .search_table(request) + .await + .context("gRPC SearchTable call failed")?; + Ok(response.into_inner()) + } } diff --git a/client/src/state/app.rs b/client/src/state/app.rs index caf30f1..8f3ce93 100644 --- a/client/src/state/app.rs +++ b/client/src/state/app.rs @@ -2,4 +2,5 @@ pub mod state; pub mod buffer; +pub mod search; pub mod highlight; diff --git a/client/src/state/app/search.rs b/client/src/state/app/search.rs new file mode 100644 index 0000000..c206b7f --- /dev/null +++ b/client/src/state/app/search.rs @@ -0,0 +1,56 @@ +// src/state/app/search.rs + +use common::proto::multieko2::search::search_response::Hit; + +/// Holds the complete state for the search palette. +pub struct SearchState { + /// The name of the table being searched. + pub table_name: String, + /// The current text entered by the user. + pub input: String, + /// The position of the cursor within the input text. + pub cursor_position: usize, + /// The search results returned from the server. + pub results: Vec, + /// The index of the currently selected search result. + pub selected_index: usize, + /// A flag to indicate if a search is currently in progress. + pub is_loading: bool, +} + +impl SearchState { + /// Creates a new SearchState for a given table. + pub fn new(table_name: String) -> Self { + Self { + table_name, + input: String::new(), + cursor_position: 0, + results: Vec::new(), + selected_index: 0, + is_loading: false, + } + } + + /// Moves the selection to the next item, wrapping around if at the end. + pub fn next_result(&mut self) { + if !self.results.is_empty() { + let next = self.selected_index + 1; + self.selected_index = if next >= self.results.len() { + 0 // Wrap to the start + } else { + next + }; + } + } + + /// Moves the selection to the previous item, wrapping around if at the beginning. + pub fn previous_result(&mut self) { + if !self.results.is_empty() { + self.selected_index = if self.selected_index == 0 { + self.results.len() - 1 // Wrap to the end + } else { + self.selected_index - 1 + }; + } + } +} diff --git a/client/src/state/app/state.rs b/client/src/state/app/state.rs index 582c135..f3cb8a8 100644 --- a/client/src/state/app/state.rs +++ b/client/src/state/app/state.rs @@ -1,11 +1,13 @@ -// src/state/state.rs +// src/state/app/state.rs use std::env; use common::proto::multieko2::table_definition::ProfileTreeResponse; use crate::modes::handlers::mode_manager::AppMode; use crate::ui::handlers::context::DialogPurpose; +use crate::state::app::search::SearchState; // ADDED use anyhow::Result; +// --- YOUR EXISTING DIALOGSTATE IS UNTOUCHED --- pub struct DialogState { pub dialog_show: bool, pub dialog_title: String, @@ -26,6 +28,7 @@ pub struct UiState { pub show_form: bool, pub show_login: bool, pub show_register: bool, + pub show_search_palette: bool, // ADDED pub focus_outside_canvas: bool, pub dialog: DialogState, } @@ -42,6 +45,9 @@ pub struct AppState { pub focused_button_index: usize, pub pending_table_structure_fetch: Option<(String, String)>, + // ADDED: State for the search palette + pub search_state: Option, + // UI preferences pub ui: UiState, @@ -63,6 +69,7 @@ impl AppState { current_mode: AppMode::General, focused_button_index: 0, pending_table_structure_fetch: None, + search_state: None, // ADDED ui: UiState::default(), #[cfg(feature = "ui-debug")] @@ -70,18 +77,17 @@ impl AppState { }) } + // --- ALL YOUR EXISTING METHODS ARE UNTOUCHED --- + pub fn update_mode(&mut self, mode: AppMode) { self.current_mode = mode; } - + pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) { self.current_view_profile_name = Some(profile_name); self.current_view_table_name = Some(table_name); } - // Add dialog helper methods - /// Shows a dialog with the given title, message, and buttons. - /// The first button (index 0) is active by default. pub fn show_dialog( &mut self, title: &str, @@ -99,19 +105,17 @@ impl AppState { self.ui.focus_outside_canvas = true; } - /// Shows a dialog specifically for loading states. pub fn show_loading_dialog(&mut self, title: &str, message: &str) { self.ui.dialog.dialog_title = title.to_string(); self.ui.dialog.dialog_message = message.to_string(); - self.ui.dialog.dialog_buttons.clear(); // No buttons during loading + self.ui.dialog.dialog_buttons.clear(); self.ui.dialog.dialog_active_button_index = 0; - self.ui.dialog.purpose = None; // Purpose is set when loading finishes + self.ui.dialog.purpose = None; self.ui.dialog.is_loading = true; self.ui.dialog.dialog_show = true; - self.ui.focus_outside_canvas = true; // Keep focus management consistent + self.ui.focus_outside_canvas = true; } - /// Updates the content of an existing dialog, typically after loading. pub fn update_dialog_content( &mut self, message: &str, @@ -121,16 +125,12 @@ impl AppState { if self.ui.dialog.dialog_show { self.ui.dialog.dialog_message = message.to_string(); self.ui.dialog.dialog_buttons = buttons; - self.ui.dialog.dialog_active_button_index = 0; // Reset focus + self.ui.dialog.dialog_active_button_index = 0; self.ui.dialog.purpose = Some(purpose); - self.ui.dialog.is_loading = false; // Loading finished - // Keep dialog_show = true - // Keep focus_outside_canvas = true + self.ui.dialog.is_loading = false; } } - - /// Hides the dialog and clears its content. pub fn hide_dialog(&mut self) { self.ui.dialog.dialog_show = false; self.ui.dialog.dialog_title.clear(); @@ -142,30 +142,27 @@ impl AppState { self.ui.dialog.is_loading = false; } - /// Sets the active button index, wrapping around if necessary. pub fn next_dialog_button(&mut self) { if !self.ui.dialog.dialog_buttons.is_empty() { let next_index = (self.ui.dialog.dialog_active_button_index + 1) % self.ui.dialog.dialog_buttons.len(); - self.ui.dialog.dialog_active_button_index = next_index; // Use new name + self.ui.dialog.dialog_active_button_index = next_index; } } - /// Sets the active button index, wrapping around if necessary. pub fn previous_dialog_button(&mut self) { if !self.ui.dialog.dialog_buttons.is_empty() { let len = self.ui.dialog.dialog_buttons.len(); let prev_index = (self.ui.dialog.dialog_active_button_index + len - 1) % len; - self.ui.dialog.dialog_active_button_index = prev_index; // Use new name + self.ui.dialog.dialog_active_button_index = prev_index; } } - /// Gets the label of the currently active button, if any. pub fn get_active_dialog_button_label(&self) -> Option<&str> { self.ui.dialog - .dialog_buttons // Use new name - .get(self.ui.dialog.dialog_active_button_index) // Use new name + .dialog_buttons + .get(self.ui.dialog.dialog_active_button_index) .map(|s| s.as_str()) } } @@ -182,13 +179,13 @@ impl Default for UiState { show_login: false, show_register: false, show_buffer_list: true, + show_search_palette: false, // ADDED focus_outside_canvas: false, dialog: DialogState::default(), } } } -// Update the Default implementation for DialogState itself impl Default for DialogState { fn default() -> Self { Self { diff --git a/client/src/state/pages/form.rs b/client/src/state/pages/form.rs index 75fd303..acf7777 100644 --- a/client/src/state/pages/form.rs +++ b/client/src/state/pages/form.rs @@ -6,6 +6,8 @@ use ratatui::layout::Rect; use ratatui::Frame; use crate::state::app::highlight::HighlightState; use crate::state::pages::canvas_state::CanvasState; +use crate::state::app::state::AppState; +use crate::components::common::search_palette::render_search_palette; pub struct FormState { pub id: i64, @@ -54,6 +56,7 @@ impl FormState { theme: &Theme, is_edit_mode: bool, highlight_state: &HighlightState, + app_state: &AppState, ) { let fields_str_slice: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect(); @@ -73,6 +76,12 @@ impl FormState { self.total_count, self.current_position, ); + + if app_state.ui.show_search_palette { + if let Some(search_state) = &app_state.search_state { + render_search_palette(f, f.size(), theme, search_state); + } + } } /// Resets the form to a state for creating a new entry. diff --git a/client/src/ui/handlers/render.rs b/client/src/ui/handlers/render.rs index 7f84db9..dcc94b4 100644 --- a/client/src/ui/handlers/render.rs +++ b/client/src/ui/handlers/render.rs @@ -12,6 +12,7 @@ use crate::components::{ admin::add_logic::render_add_logic, auth::{login::render_login, register::render_register}, common::find_file_palette, + common::search_palette::render_search_palette }; use crate::config::colors::themes::Theme; use ratatui::{