search
This commit is contained in:
107
client/src/search/event.rs
Normal file
107
client/src/search/event.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
// src/search/event.rs
|
||||
use crate::search::state::SearchState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::KeyCode;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
use std::collections::HashMap;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_search_palette_event(
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
app_state: &mut AppState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut should_close = false;
|
||||
let mut outcome_message = None;
|
||||
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 = Some("Search cancelled".to_string());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Step 1: Extract the data we need while holding the borrow
|
||||
let maybe_data = search_state
|
||||
.results
|
||||
.get(search_state.selected_index)
|
||||
.map(|hit| (hit.id, hit.content_json.clone()));
|
||||
|
||||
// Step 2: Process outside the borrow
|
||||
if let Some((id, content_json)) = maybe_data {
|
||||
if let Ok(data) = serde_json::from_str::<HashMap<String, String>>(&content_json) {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
let detached_pos = fs.total_count + 2;
|
||||
fs.update_from_response(&data, detached_pos);
|
||||
}
|
||||
should_close = true;
|
||||
outcome_message = Some(format!("Loaded record ID {}", 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 {
|
||||
if let Some(search_state) = app_state.search_state.as_mut() {
|
||||
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 sender = search_result_sender.clone();
|
||||
let mut grpc_client = grpc_client.clone();
|
||||
|
||||
info!("Spawning search task for query: '{}'", query);
|
||||
tokio::spawn(async move {
|
||||
match grpc_client.search_table(table_name, query).await {
|
||||
Ok(response) => {
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Search failed: {:?}", e);
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if should_close {
|
||||
app_state.search_state = None;
|
||||
app_state.ui.show_search_palette = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
}
|
||||
|
||||
Ok(outcome_message)
|
||||
}
|
||||
7
client/src/search/mod.rs
Normal file
7
client/src/search/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/search/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod event;
|
||||
|
||||
pub use ui::*;
|
||||
56
client/src/search/state.rs
Normal file
56
client/src/search/state.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/search/state.rs
|
||||
|
||||
use common::proto::komp_ac::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<Hit>,
|
||||
/// 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
121
client/src/search/ui.rs
Normal file
121
client/src/search/ui.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/search/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::search::state::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_position((
|
||||
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<ListItem> = 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::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user