Compare commits

...

16 Commits

Author SHA1 Message Date
filipriec
6b5cbe854b now working with the gen schema in the database 2025-06-02 12:39:23 +02:00
filipriec
59ed52814e compiled, needs other fixes 2025-06-02 12:08:16 +02:00
filipriec
3488ab4f6b hardcoded adresar to general form 2025-06-02 10:32:39 +02:00
filipriec
6e2fc5349b code cleanup 2025-05-31 23:02:09 +02:00
filipriec
ea88c2686d tabbing now adds / if there is nothing to tab to 2025-05-30 23:43:49 +02:00
filipriec
3df4baec92 tabbing now works perfectly well 2025-05-30 23:36:53 +02:00
filipriec
ff74e1aaa1 it works amazingly well now, we can select the table name via command line 2025-05-30 22:46:32 +02:00
filipriec
b0c865ab76 workig suggestion menu 2025-05-29 19:46:58 +02:00
filipriec
3dbc086f10 overriding overflows by using empty spaces as letters 2025-05-29 19:32:48 +02:00
filipriec
e9b4b34fb4 fixed height of the find file 2025-05-29 19:02:02 +02:00
filipriec
668eeee197 navigation in the menu but needs refactoring 2025-05-29 16:11:41 +02:00
filipriec
799d8471c9 open menu in command mode now implemented 2025-05-28 19:09:55 +02:00
filipriec
f77c16dec9 temp fix, before implementing C-x C-f 2025-05-28 15:53:33 +02:00
filipriec
45026cac6a table schema is gen now 2025-05-28 15:40:17 +02:00
filipriec
edf6ab5bca gen schema being created 2025-05-28 13:10:08 +02:00
filipriec
462b1f14e2 generated tables are now in gen schema, breaking change, needs crucial fixes NOW 2025-05-27 22:21:40 +02:00
37 changed files with 2188 additions and 950 deletions

View File

@@ -83,6 +83,7 @@ quit = ["q"]
force_quit = ["q!"] force_quit = ["q!"]
save_and_quit = ["wq"] save_and_quit = ["wq"]
revert = ["r"] revert = ["r"]
find_file_palette_toggle = ["ff"]
[editor] [editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs" keybinding_mode = "vim" # Options: "default", "vim", "emacs"

View File

@@ -5,6 +5,7 @@ pub mod text_editor;
pub mod background; pub mod background;
pub mod dialog; pub mod dialog;
pub mod autocomplete; pub mod autocomplete;
pub mod find_file_palette;
pub use command_line::*; pub use command_line::*;
pub use status_line::*; pub use status_line::*;
@@ -12,3 +13,4 @@ pub use text_editor::*;
pub use background::*; pub use background::*;
pub use dialog::*; pub use dialog::*;
pub use autocomplete::*; pub use autocomplete::*;
pub use find_file_palette::*;

View File

@@ -1,4 +1,5 @@
// src/client/components/command_line.rs // src/components/common/command_line.rs
use ratatui::{ use ratatui::{
widgets::{Block, Paragraph}, widgets::{Block, Paragraph},
style::Style, style::Style,
@@ -6,30 +7,63 @@ use ratatui::{
Frame, Frame,
}; };
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use unicode_width::UnicodeWidthStr; // Import for width calculation
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) { pub fn render_command_line(
let prompt = if active { f: &mut Frame,
":" area: Rect,
} else { input: &str, // This is event_handler.command_input
"" active: bool, // This is event_handler.command_mode
theme: &Theme,
message: &str, // This is event_handler.command_message
) {
// Original logic for determining display_text
let display_text = if !active {
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
// Or if command mode is off and message is empty (render minimally)
if message.is_empty() {
"".to_string() // Render an empty string, background will cover
} else {
message.to_string()
}
} else { // active is true (normal command mode)
let prompt = ":";
if message.is_empty() || message == ":" {
format!("{}{}", prompt, input)
} else {
if input.is_empty() { // If command was just executed, input is cleared, show message
message.to_string()
} else { // Show input and message
format!("{}{} | {}", prompt, input, message)
}
}
}; };
// Combine the prompt, input, and message let content_width = UnicodeWidthStr::width(display_text.as_str());
let display_text = if message.is_empty() { let available_width = area.width as usize;
format!("{}{}", prompt, input) let padding_needed = available_width.saturating_sub(content_width);
let display_text_padded = if padding_needed > 0 {
format!("{}{}", display_text, " ".repeat(padding_needed))
} else { } else {
format!("{}{} | {}", prompt, input, message) // If text is too long, ratatui's Paragraph will handle truncation.
// We could also truncate here if specific behavior is needed:
// display_text.chars().take(available_width).collect::<String>()
display_text
}; };
let style = if active { // Determine style based on active state, but apply to the whole paragraph
let text_style = if active {
Style::default().fg(theme.accent) Style::default().fg(theme.accent)
} else { } else {
// If not active, but there's a message, use default foreground.
// If message is also empty, this style won't matter much for empty text.
Style::default().fg(theme.fg) Style::default().fg(theme.fg)
}; };
let paragraph = Paragraph::new(display_text) let paragraph = Paragraph::new(display_text_padded)
.block(Block::default().style(Style::default().bg(theme.bg))) .block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
.style(style); .style(text_style); // Style for the text itself
f.render_widget(paragraph, area); f.render_widget(paragraph, area);
} }

View File

@@ -0,0 +1,142 @@
// src/components/common/find_file_palette.rs
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState; // Corrected path
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, List, ListItem, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
const PADDING_CHAR: &str = " ";
pub fn render_find_file_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
navigation_state: &NavigationState,
) {
let palette_display_input = navigation_state.get_display_input(); // Use the new method
let num_total_filtered = navigation_state.filtered_options.len();
let current_selected_list_idx = navigation_state.selected_index;
let mut display_start_offset = 0;
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
if let Some(sel_idx) = current_selected_list_idx {
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
} else if sel_idx < display_start_offset {
display_start_offset = sel_idx;
}
display_start_offset = display_start_offset
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
}
}
display_start_offset = display_start_offset.max(0);
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
.min(num_total_filtered);
// navigation_state.filtered_options is Vec<(usize, String)>
// We only need the String part for display.
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
navigation_state.filtered_options
[display_start_offset..display_end_offset]
.iter()
.map(|(_, opt_str)| opt_str)
.collect()
} else {
Vec::new()
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // For palette input line
Constraint::Min(0), // For options list, take remaining space
])
.split(area);
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
let input_area = chunks[0];
// let list_area = chunks[1]; // Use final_list_area
let prompt_prefix = match navigation_state.navigation_type {
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
};
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
let input_area_width = input_area.width as usize;
let input_padding_needed =
input_area_width.saturating_sub(prompt_text_width);
let padded_prompt_text = if input_padding_needed > 0 {
format!(
"{}{}",
base_prompt_text,
PADDING_CHAR.repeat(input_padding_needed)
)
} else {
base_prompt_text
};
let input_paragraph = Paragraph::new(padded_prompt_text)
.style(Style::default().fg(theme.accent).bg(theme.bg));
f.render_widget(input_paragraph, input_area);
let mut display_list_items: Vec<ListItem> =
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
for (idx_in_visible_slice, opt_str) in
visible_options_slice.iter().enumerate()
{
// The selected_index in navigation_state is relative to the full filtered_options list.
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
let is_selected =
current_selected_list_idx == Some(original_filtered_idx);
let style = if is_selected {
Style::default().fg(theme.bg).bg(theme.accent)
} else {
Style::default().fg(theme.fg).bg(theme.bg)
};
let opt_width = opt_str.width() as u16;
let list_item_width = final_list_area.width;
let padding_amount = list_item_width.saturating_sub(opt_width);
let padded_opt_str = format!(
"{}{}",
opt_str,
PADDING_CHAR.repeat(padding_amount as usize)
);
display_list_items.push(ListItem::new(padded_opt_str).style(style));
}
// Fill remaining lines in the list area to maintain fixed height appearance
let num_rendered_options = display_list_items.len();
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
for _ in num_rendered_options..(final_list_area.height as usize) {
let empty_padded_str =
PADDING_CHAR.repeat(final_list_area.width as usize);
display_list_items.push(
ListItem::new(empty_padded_str)
.style(Style::default().fg(theme.bg).bg(theme.bg)),
);
}
}
let options_list_widget = List::new(display_list_items)
.block(Block::default().style(Style::default().bg(theme.bg)));
f.render_widget(options_list_widget, final_list_area);
}

View File

@@ -1,3 +1,4 @@
// src/components/common/status_line.rs
use ratatui::{ use ratatui::{
style::Style, style::Style,
layout::Rect, layout::Rect,
@@ -35,43 +36,62 @@ pub fn render_status_line(
let separator = " | "; let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator); let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width + let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width; program_info_width + separator_width + fps_width;
let show_fps = fixed_width_with_fps < available_width; let show_fps = fixed_width_with_fps <= available_width; // Use <= to show if it fits exactly
let remaining_width_for_dir = available_width.saturating_sub( let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width + separator_width + program_info_width + mode_width + separator_width + // after mode
if show_fps { separator_width + fps_width } else { 0 } separator_width + program_info_width + // after program_info
if show_fps { separator_width + fps_width } else { 0 } // after fps
); );
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir { // Original directory display logic
display_dir let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
display_dir // display_dir is already a String here
} else { } else {
let dir_name = Path::new(current_dir) let dir_name = Path::new(current_dir) // Use original current_dir for path logic
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or(current_dir); .unwrap_or(current_dir); // Fallback to current_dir if no filename
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir { if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string() dir_name.to_string()
} else { } else {
dir_name.chars().take(remaining_width_for_dir).collect() dir_name.chars().take(remaining_width_for_dir).collect::<String>()
} }
}; };
let mut spans = vec![ // Calculate current content width based on what will be displayed
let mut current_content_width = mode_width + separator_width +
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
separator_width + program_info_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)), Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)), Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(dir_display_text, Style::default().fg(theme.fg)), Span::styled(dir_display_text_str.as_str(), Style::default().fg(theme.fg)),
Span::styled(" | ", Style::default().fg(theme.border)), Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(program_info, Style::default().fg(theme.secondary)), Span::styled(program_info.as_str(), Style::default().fg(theme.secondary)),
]; ];
if show_fps { if show_fps {
spans.push(Span::styled(" | ", Style::default().fg(theme.border))); line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary))); line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
} }
let paragraph = Paragraph::new(Line::from(spans)) // Calculate padding
let padding_needed = available_width.saturating_sub(current_content_width);
if padding_needed > 0 {
line_spans.push(Span::styled(
" ".repeat(padding_needed),
Style::default().bg(theme.bg), // Ensure padding uses background color
));
}
let paragraph = Paragraph::new(Line::from(line_spans))
.style(Style::default().bg(theme.bg)); .style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area); f.render_widget(paragraph, area);

View File

@@ -13,9 +13,9 @@ use crate::components::handlers::canvas::render_canvas;
pub fn render_form( pub fn render_form(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
form_state: &impl CanvasState, form_state_param: &impl CanvasState,
fields: &[&str], fields: &[&str],
current_field: &usize, current_field_idx: &usize,
inputs: &[&String], inputs: &[&String],
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
@@ -48,7 +48,16 @@ pub fn render_form(
.split(inner_area); .split(inner_area);
// Render count/position // Render count/position
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position); let count_position_text = if total_count == 0 && current_position == 1 {
"Total: 0 | New Entry".to_string()
} else if current_position > total_count && total_count > 0 {
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 { // Should not happen if logic is correct
format!("Total: 0 | New Entry ({})", current_position)
}
else {
format!("Total: {} | Position: {}/{}", total_count, current_position, total_count)
};
let count_para = Paragraph::new(count_position_text) let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg)) .style(Style::default().fg(theme.fg))
.alignment(Alignment::Left); .alignment(Alignment::Left);
@@ -58,9 +67,9 @@ pub fn render_form(
render_canvas( render_canvas(
f, f,
main_layout[1], main_layout[1],
form_state, form_state_param,
fields, fields,
current_field, current_field_idx,
inputs, inputs,
theme, theme,
is_edit_mode, is_edit_mode,

View File

@@ -29,8 +29,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let outcome = save( let outcome = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
) )
.await?; .await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now let message = format!("Save successful: {:?}", outcome); // Simple message for now
@@ -40,8 +38,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
revert( revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
) )
.await .await
} }

View File

@@ -14,8 +14,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
action: &str, action: &str,
state: &mut S, state: &mut S,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome> { ) -> Result<EventOutcome> {
match action { match action {
"save" | "revert" => { "save" | "revert" => {
@@ -30,8 +28,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let save_result = save( let save_result = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await; ).await;
match save_result { match save_result {
@@ -50,8 +46,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let revert_result = revert( let revert_result = revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await; ).await;
match revert_result { match revert_result {

View File

@@ -24,8 +24,6 @@ pub async fn handle_core_action(
auth_client: &mut AuthClient, auth_client: &mut AuthClient,
terminal: &mut TerminalCore, terminal: &mut TerminalCore,
app_state: &mut AppState, app_state: &mut AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome> { ) -> Result<EventOutcome> {
match action { match action {
"save" => { "save" => {
@@ -36,8 +34,6 @@ pub async fn handle_core_action(
let save_outcome = form_save( let save_outcome = form_save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await.context("Register save action failed")?; ).await.context("Register save action failed")?;
let message = match save_outcome { let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(), SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -58,8 +54,6 @@ pub async fn handle_core_action(
let save_outcome = form_save( let save_outcome = form_save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
match save_outcome { match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(), SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -81,8 +75,6 @@ pub async fn handle_core_action(
let message = form_revert( let message = form_revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await.context("Form revert x action failed")?; ).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message)) Ok(EventOutcome::Ok(message))
} }

View File

@@ -66,7 +66,7 @@ pub async fn handle_edit_event(
// TODO: Implement common actions for AddLogic if needed // TODO: Implement common actions for AddLogic if needed
format!("Action '{}' not implemented for Add Logic in edit mode.", action) format!("Action '{}' not implemented for Add Logic in edit mode.", action)
} else { // Assuming Form view } else { // Assuming Form view
let outcome = form_e::execute_common_action(action, form_state, grpc_client, current_position, total_count).await?; let outcome = form_e::execute_common_action(action, form_state, grpc_client).await?;
match outcome { match outcome {
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg, EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
_ => format!("Unexpected outcome from common action: {:?}", outcome), _ => format!("Unexpected outcome from common action: {:?}", outcome),

View File

@@ -119,8 +119,6 @@ async fn process_command(
let outcome = save( let outcome = save(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
let message = match outcome { let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(), SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
@@ -134,8 +132,6 @@ async fn process_command(
let message = revert( let message = revert(
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
).await?; ).await?;
command_input.clear(); command_input.clear();
Ok(EventOutcome::Ok(message)) Ok(EventOutcome::Ok(message))

View File

@@ -1,3 +1,4 @@
// src/client/modes/general.rs // src/client/modes/general.rs
pub mod navigation; pub mod navigation;
pub mod dialog; pub mod dialog;
pub mod command_navigation;

View File

@@ -0,0 +1,448 @@
// src/modes/general/command_navigation.rs
use crate::config::binds::config::Config;
use crate::modes::handlers::event::EventOutcome;
use anyhow::Result;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crossterm::event::{KeyCode, KeyEvent};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub enum NavigationType {
FindFile,
TableTree,
}
#[derive(Debug, Clone)]
pub struct TableDependencyGraph {
all_tables: HashSet<String>,
dependents_map: HashMap<String, Vec<String>>,
root_tables: Vec<String>,
}
impl TableDependencyGraph {
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
let mut all_tables_set: HashSet<String> = HashSet::new();
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
for profile in &profile_tree.profiles {
for table in &profile.tables {
all_tables_set.insert(table.name.clone());
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
for dependency_name in &table.depends_on {
dependents_map
.entry(dependency_name.clone())
.or_default()
.push(table.name.clone());
}
}
}
let root_tables: Vec<String> = all_tables_set
.iter()
.filter(|name| {
table_dependencies
.get(*name)
.map_or(true, |deps| deps.is_empty())
})
.cloned()
.collect();
let mut sorted_root_tables = root_tables;
sorted_root_tables.sort();
for dependents_list in dependents_map.values_mut() {
dependents_list.sort();
}
Self {
all_tables: all_tables_set,
dependents_map,
root_tables: sorted_root_tables,
}
}
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
if path.is_empty() {
return self.root_tables.clone();
}
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if let Some(last_segment_name) = path_segments.last() {
if self.all_tables.contains(*last_segment_name) {
return self
.dependents_map
.get(*last_segment_name)
.cloned()
.unwrap_or_default();
}
}
Vec::new()
}
}
pub struct NavigationState {
pub active: bool,
pub input: String,
pub selected_index: Option<usize>,
pub filtered_options: Vec<(usize, String)>,
pub navigation_type: NavigationType,
pub current_path: String,
pub graph: Option<TableDependencyGraph>,
pub all_options: Vec<String>,
}
impl NavigationState {
pub fn new() -> Self {
Self {
active: false,
input: String::new(),
selected_index: None,
filtered_options: Vec::new(),
navigation_type: NavigationType::FindFile,
current_path: String::new(),
graph: None,
all_options: Vec::new(),
}
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.active = true;
self.navigation_type = NavigationType::FindFile;
self.all_options = options;
self.input.clear();
self.current_path.clear();
self.graph = None;
self.update_filtered_options(); // Initial filter with empty input
}
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
self.active = true;
self.navigation_type = NavigationType::TableTree;
self.graph = Some(graph);
self.input.clear();
self.current_path.clear();
self.update_options_for_path(); // Initial options are root tables
}
pub fn deactivate(&mut self) {
self.active = false;
self.input.clear();
self.all_options.clear();
self.filtered_options.clear();
self.selected_index = None;
self.current_path.clear();
self.graph = None;
}
pub fn add_char(&mut self, c: char) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.push(c);
self.update_filtered_options();
}
NavigationType::TableTree => {
if c == '/' {
if !self.input.is_empty() {
// Append current input to path
if self.current_path.is_empty() {
self.current_path = self.input.clone();
} else {
self.current_path.push('/');
self.current_path.push_str(&self.input);
}
self.input.clear();
self.update_options_for_path();
}
// If input is empty and char is '/', do nothing or define behavior
} else {
self.input.push(c);
self.update_filtered_options(); // Filter current level options based on input
}
}
}
}
pub fn remove_char(&mut self) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.pop();
self.update_filtered_options();
}
NavigationType::TableTree => {
if self.input.is_empty() {
// If input is empty, try to go up in path
if !self.current_path.is_empty() {
if let Some(last_slash_idx) =
self.current_path.rfind('/')
{
// Set input to the segment being removed from path
self.input = self.current_path
[last_slash_idx + 1..]
.to_string();
self.current_path =
self.current_path[..last_slash_idx].to_string();
} else {
// Path was a single segment
self.input = self.current_path.clone();
self.current_path.clear();
}
self.update_options_for_path();
// After path change, current input might match some options, so filter
self.update_filtered_options();
}
} else {
self.input.pop();
self.update_filtered_options();
}
}
}
}
pub fn move_up(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(0) => Some(self.filtered_options.len() - 1),
Some(current) => Some(current - 1),
None => Some(self.filtered_options.len() - 1),
};
}
pub fn move_down(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(current) if current >= self.filtered_options.len() - 1 => {
Some(0)
}
Some(current) => Some(current + 1),
None => Some(0),
};
}
pub fn get_selected_option_str(&self) -> Option<&str> {
self.selected_index
.and_then(|idx| self.filtered_options.get(idx))
.map(|(_, option_str)| option_str.as_str())
}
pub fn autocomplete_selected(&mut self) {
if let Some(selected_option_str) = self.get_selected_option_str() {
// The current `self.input` is the text being typed for the current segment/filter.
// We replace it with the full string of the selected option.
self.input = selected_option_str.to_string();
// After updating the input, we need to re-filter the options.
// This will typically result in the filtered_options containing only the
// autocompleted item (or items that start with it, if any).
self.update_filtered_options();
}
}
// Returns the string to display in the input line of the palette
pub fn get_display_input(&self) -> String {
match self.navigation_type {
NavigationType::FindFile => self.input.clone(),
NavigationType::TableTree => {
if self.current_path.is_empty() {
self.input.clone()
} else {
format!("{}/{}", self.current_path, self.input)
}
}
}
}
// Gets the full path of the currently selected item for TableTree, or input for FindFile
pub fn get_selected_value(&self) -> Option<String> {
match self.navigation_type {
NavigationType::FindFile => {
if self.input.is_empty() { None } else { Some(self.input.clone()) }
}
NavigationType::TableTree => {
self.get_selected_option_str().map(|selected_name| {
if self.current_path.is_empty() {
selected_name.to_string()
} else {
format!("{}/{}", self.current_path, selected_name)
}
})
}
}
}
// Update self.all_options based on current_path (for TableTree)
fn update_options_for_path(&mut self) {
if let NavigationType::TableTree = self.navigation_type {
if let Some(graph) = &self.graph {
self.all_options =
graph.get_dependent_children(&self.current_path);
} else {
self.all_options.clear();
}
}
// For FindFile, all_options is set once at activation.
self.update_filtered_options();
}
// Update self.filtered_options based on self.all_options and self.input
fn update_filtered_options(&mut self) {
let filter_text = match self.navigation_type {
NavigationType::FindFile => &self.input,
NavigationType::TableTree => &self.input, // For TableTree, input is the current segment being typed
}
.to_lowercase();
if filter_text.is_empty() {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.map(|(i, opt)| (i, opt.clone()))
.collect();
} else {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
.map(|(i, opt)| (i, opt.clone()))
.collect();
}
if self.filtered_options.is_empty() {
self.selected_index = None;
} else {
self.selected_index = Some(0); // Default to selecting the first item
}
}
}
pub async fn handle_command_navigation_event(
navigation_state: &mut NavigationState,
key: KeyEvent,
config: &Config,
) -> Result<EventOutcome> {
if !navigation_state.active {
return Ok(EventOutcome::Ok(String::new()));
}
match key.code {
KeyCode::Esc => {
navigation_state.deactivate();
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
}
KeyCode::Enter => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let message = match navigation_state.navigation_type {
NavigationType::FindFile => format!("Selected file: {}", selected_value),
NavigationType::TableTree => format!("Selected table: {}", selected_value),
};
navigation_state.deactivate();
Ok(EventOutcome::Ok(message))
} else {
// Enhanced Enter behavior for TableTree: if input is a valid partial path, try to navigate
if navigation_state.navigation_type == NavigationType::TableTree && !navigation_state.input.is_empty() {
// Check if current input is a prefix of any option or a full option name
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
if navigation_state.input == selected_opt_str {
// Input exactly matches the selected option, try to navigate
let input_before_slash = navigation_state.input.clone();
navigation_state.add_char('/');
if navigation_state.input.is_empty() {
return Ok(EventOutcome::Ok(format!("Navigated to: {}/", input_before_slash)));
} else {
return Ok(EventOutcome::Ok(format!("Selected leaf: {}", input_before_slash)));
}
}
}
}
Ok(EventOutcome::Ok("No valid selection to confirm or navigate".to_string()))
}
}
KeyCode::Tab => {
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
// Scenario 1: Input already exactly matches the selected option
if navigation_state.input == selected_opt_str {
// Only attempt to navigate deeper for TableTree mode
if navigation_state.navigation_type == NavigationType::TableTree {
let path_before_nav = navigation_state.current_path.clone();
let input_before_nav = navigation_state.input.clone();
navigation_state.add_char('/');
if navigation_state.input.is_empty() &&
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty()) {
// Navigation successful
} else {
// Revert if navigation didn't happen
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
navigation_state.input = input_before_nav;
if navigation_state.current_path != path_before_nav {
navigation_state.current_path = path_before_nav;
}
navigation_state.update_options_for_path();
}
}
}
} else {
// Scenario 2: Input is a partial match - autocomplete
navigation_state.autocomplete_selected();
}
}
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Up => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Down => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Backspace => {
navigation_state.remove_char();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Char(c) => {
navigation_state.add_char(c);
Ok(EventOutcome::Ok(String::new()))
}
_ => {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
"move_down" => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
"select" => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let message = match navigation_state.navigation_type {
NavigationType::FindFile => format!("Selected file: {}", selected_value),
NavigationType::TableTree => format!("Selected table: {}", selected_value),
};
navigation_state.deactivate();
Ok(EventOutcome::Ok(message))
} else {
Ok(EventOutcome::Ok("No selection".to_string()))
}
}
_ => Ok(EventOutcome::Ok(String::new())),
}
} else {
Ok(EventOutcome::Ok(String::new()))
}
}
}
}

View File

@@ -11,6 +11,7 @@ use crate::state::pages::admin::AdminState;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::UiContext; use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
use anyhow::Result; use anyhow::Result;
pub async fn handle_navigation_event( pub async fn handle_navigation_event(
@@ -25,7 +26,13 @@ pub async fn handle_navigation_event(
command_mode: &mut bool, command_mode: &mut bool,
command_input: &mut String, command_input: &mut String,
command_message: &mut String, command_message: &mut String,
navigation_state: &mut NavigationState,
) -> Result<EventOutcome> { ) -> Result<EventOutcome> {
// Handle command navigation first if active
if navigation_state.active {
return handle_command_navigation_event(navigation_state, key, config).await;
}
if let Some(action) = config.get_general_action(key.code, key.modifiers) { if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action { match action {
"move_up" => { "move_up" => {

View File

@@ -1,48 +1,50 @@
// src/modes/handlers/event.rs // src/modes/handlers/event.rs
use crossterm::event::Event;
use crossterm::cursor::SetCursorStyle;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::ui::handlers::rat_state::UiStateHandler; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::ui::handlers::context::UiContext;
use crate::functions::common::buffer; use crate::functions::common::buffer;
use anyhow::Result; use crate::functions::modes::navigation::add_logic_nav;
use crate::tui::{ use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
terminal::core::TerminalCore, use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
functions::{ use crate::functions::modes::navigation::{add_table_nav, admin_nav};
common::{form::SaveOutcome, login, register}, use crate::modes::general::command_navigation::{
}, handle_command_navigation_event, NavigationState, TableDependencyGraph,
{intro, admin},
}; };
use crate::modes::{
canvas::{common_mode, edit, read_only},
common::{command_mode, commands::CommandHandler},
general::{dialog, navigation},
handlers::mode_manager::{AppMode, ModeManager},
};
use crate::services::auth::AuthClient;
use crate::services::grpc_client::GrpcClient;
use crate::state::{ use crate::state::{
app::{ app::{
buffer::{AppView, BufferState},
highlight::HighlightState, highlight::HighlightState,
state::AppState, state::AppState,
buffer::{AppView, BufferState},
}, },
pages::{ pages::{
auth::{AuthState, LoginState, RegisterState},
admin::AdminState, admin::AdminState,
auth::{AuthState, LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
form::FormState, form::FormState,
intro::IntroState, intro::IntroState,
}, },
}; };
use crate::modes::{
common::{command_mode, commands::CommandHandler},
handlers::mode_manager::{ModeManager, AppMode},
canvas::{edit, read_only, common_mode},
general::{navigation, dialog},
};
use crate::functions::modes::navigation::{admin_nav, add_table_nav};
use crate::config::binds::key_sequences::KeySequenceTracker;
use tokio::sync::mpsc;
use crate::tui::functions::common::login::LoginResult; use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult; use crate::tui::functions::common::register::RegisterResult;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender; use crate::tui::{
use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender; functions::common::{form::SaveOutcome, login, register},
use crate::functions::modes::navigation::add_logic_nav; terminal::core::TerminalCore,
{admin, intro},
};
use crate::ui::handlers::context::UiContext;
use crate::ui::handlers::rat_state::UiStateHandler;
use anyhow::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::{Event, KeyEvent};
use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventOutcome { pub enum EventOutcome {
@@ -52,6 +54,15 @@ pub enum EventOutcome {
ButtonSelected { context: UiContext, index: usize }, ButtonSelected { context: UiContext, index: usize },
} }
impl EventOutcome {
pub fn get_message_if_ok(&self) -> String {
match self {
EventOutcome::Ok(msg) => msg.clone(),
_ => String::new(),
}
}
}
pub struct EventHandler { pub struct EventHandler {
pub command_mode: bool, pub command_mode: bool,
pub command_input: String, pub command_input: String,
@@ -66,6 +77,7 @@ pub struct EventHandler {
pub register_result_sender: mpsc::Sender<RegisterResult>, pub register_result_sender: mpsc::Sender<RegisterResult>,
pub save_table_result_sender: SaveTableResultSender, pub save_table_result_sender: SaveTableResultSender,
pub save_logic_result_sender: SaveLogicResultSender, pub save_logic_result_sender: SaveLogicResultSender,
pub navigation_state: NavigationState,
} }
impl EventHandler { impl EventHandler {
@@ -83,15 +95,25 @@ impl EventHandler {
highlight_state: HighlightState::Off, highlight_state: HighlightState::Off,
edit_mode_cooldown: false, edit_mode_cooldown: false,
ideal_cursor_column: 0, ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(800), key_sequence_tracker: KeySequenceTracker::new(400),
auth_client: AuthClient::new().await?, auth_client: AuthClient::new().await?,
login_result_sender, login_result_sender,
register_result_sender, register_result_sender,
save_table_result_sender, save_table_result_sender,
save_logic_result_sender, save_logic_result_sender,
navigation_state: NavigationState::new(),
}) })
} }
pub fn is_navigation_active(&self) -> bool {
self.navigation_state.active
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.navigation_state.activate_find_file(options);
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_event( pub async fn handle_event(
&mut self, &mut self,
event: Event, event: Event,
@@ -107,61 +129,104 @@ impl EventHandler {
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
app_state: &mut AppState, app_state: &mut AppState,
total_count: u64,
current_position: &mut u64,
) -> Result<EventOutcome> { ) -> Result<EventOutcome> {
let current_mode = ModeManager::derive_mode(app_state, self, admin_state); let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
// Handle active command navigation first
if current_mode == AppMode::General && self.navigation_state.active {
if let Event::Key(key_event) = event {
let outcome =
handle_command_navigation_event(&mut self.navigation_state, key_event, config)
.await?;
if !self.navigation_state.active {
self.command_message = outcome.get_message_if_ok();
current_mode = ModeManager::derive_mode(app_state, self, admin_state);
}
app_state.update_mode(current_mode);
return Ok(outcome);
}
app_state.update_mode(current_mode);
return Ok(EventOutcome::Ok(String::new()));
}
app_state.update_mode(current_mode); app_state.update_mode(current_mode);
let current_view = { let current_view = {
let ui = &app_state.ui; let ui = &app_state.ui;
if ui.show_intro { AppView::Intro } if ui.show_intro {
else if ui.show_login { AppView::Login } AppView::Intro
else if ui.show_register { AppView::Register } } else if ui.show_login {
else if ui.show_admin { AppView::Admin } AppView::Login
else if ui.show_add_logic { AppView::AddLogic } } else if ui.show_register {
else if ui.show_add_table { AppView::AddTable } AppView::Register
else if ui.show_form { AppView::Form } // Remove the dynamic name part } else if ui.show_admin {
else { AppView::Scratch } AppView::Admin
} else if ui.show_add_logic {
AppView::AddLogic
} else if ui.show_add_table {
AppView::AddTable
} else if ui.show_form {
AppView::Form
} else {
AppView::Scratch
}
}; };
buffer_state.update_history(current_view); buffer_state.update_history(current_view);
if app_state.ui.dialog.dialog_show { if app_state.ui.dialog.dialog_show {
if let Some(dialog_result) = dialog::handle_dialog_event( if let Event::Key(key_event) = event {
&event, if let Some(dialog_result) = dialog::handle_dialog_event(
config, &Event::Key(key_event),
app_state, config,
login_state, app_state,
register_state, login_state,
buffer_state, register_state,
admin_state, buffer_state,
).await { admin_state,
return dialog_result; )
.await
{
return dialog_result;
}
} else if let Event::Resize(_, _) = event {
// Handle resize if needed
} }
return Ok(EventOutcome::Ok(String::new())); return Ok(EventOutcome::Ok(String::new()));
} }
if let Event::Key(key) = event { if let Event::Key(key_event) = event {
let key_code = key.code; let key_code = key_event.code;
let modifiers = key.modifiers; let modifiers = key_event.modifiers;
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) { if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
let message = format!("Sidebar {}", let message = format!(
if app_state.ui.show_sidebar { "shown" } else { "hidden" } "Sidebar {}",
if app_state.ui.show_sidebar {
"shown"
} else {
"hidden"
}
); );
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) { if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
let message = format!("Buffer {}", let message = format!(
if app_state.ui.show_buffer_list { "shown" } else { "hidden" } "Buffer {}",
if app_state.ui.show_buffer_list {
"shown"
} else {
"hidden"
}
); );
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
if !matches!(current_mode, AppMode::Edit | AppMode::Command) { if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode( if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global, key_code, modifiers &config.keybindings.global,
key_code,
modifiers,
) { ) {
match action { match action {
"next_buffer" => { "next_buffer" => {
@@ -171,17 +236,15 @@ impl EventHandler {
} }
"previous_buffer" => { "previous_buffer" => {
if buffer::switch_buffer(buffer_state, false) { if buffer::switch_buffer(buffer_state, false) {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string())); return Ok(EventOutcome::Ok(
"Switched to previous buffer".to_string(),
));
} }
} }
//"close_buffer" => {
// let message = buffer_state.close_buffer_with_intro_fallback();
// return Ok(EventOutcome::Ok(message));
//}
"close_buffer" => { "close_buffer" => {
// TODO: Replace with actual table name from server response let current_table_name = Some("2025_customer");
let current_table_name = Some("2025_customer"); // Your hardcoded table name let message =
let message = buffer_state.close_buffer_with_intro_fallback(current_table_name); buffer_state.close_buffer_with_intro_fallback(current_table_name);
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
_ => {} _ => {}
@@ -191,11 +254,9 @@ impl EventHandler {
match current_mode { match current_mode {
AppMode::General => { AppMode::General => {
// Prioritize Admin Panel navigation if it's visible if app_state.ui.show_admin && auth_state.role.as_deref() == Some("admin") {
if app_state.ui.show_admin
&& auth_state.role.as_deref() == Some("admin") {
if admin_nav::handle_admin_navigation( if admin_nav::handle_admin_navigation(
key, key_event,
config, config,
app_state, app_state,
admin_state, admin_state,
@@ -205,13 +266,13 @@ impl EventHandler {
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
// --- Add Logic Page Navigation ---
if app_state.ui.show_add_logic { if app_state.ui.show_add_logic {
let client_clone = grpc_client.clone(); let client_clone = grpc_client.clone();
let sender_clone = self.save_logic_result_sender.clone(); let sender_clone = self.save_logic_result_sender.clone();
if add_logic_nav::handle_add_logic_navigation( if add_logic_nav::handle_add_logic_navigation(
key, key_event,
config, config,
app_state, app_state,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
@@ -225,27 +286,25 @@ impl EventHandler {
} }
} }
// --- Add Table Page Navigation ---
if app_state.ui.show_add_table { if app_state.ui.show_add_table {
let client_clone = grpc_client.clone(); let client_clone = grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone(); let sender_clone = self.save_table_result_sender.clone();
if add_table_nav::handle_add_table_navigation( if add_table_nav::handle_add_table_navigation(
key, key_event,
config, config,
app_state, app_state,
&mut admin_state.add_table_state, &mut admin_state.add_table_state,
client_clone, client_clone,
sender_clone, sender_clone,
&mut self.command_message, &mut self.command_message,
) { ) {
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
let nav_outcome = navigation::handle_navigation_event( let nav_outcome = navigation::handle_navigation_event(
key, key_event,
config, config,
form_state, form_state,
app_state, app_state,
@@ -256,7 +315,10 @@ impl EventHandler {
&mut self.command_mode, &mut self.command_mode,
&mut self.command_input, &mut self.command_input,
&mut self.command_message, &mut self.command_message,
).await; &mut self.navigation_state,
)
.await;
match nav_outcome { match nav_outcome {
Ok(EventOutcome::ButtonSelected { context, index }) => { Ok(EventOutcome::ButtonSelected { context, index }) => {
let message = match context { let message = match context {
@@ -269,26 +331,36 @@ impl EventHandler {
} }
format!("Intro Option {} selected", index) format!("Intro Option {} selected", index)
} }
UiContext::Login => { UiContext::Login => match index {
let login_action_message = match index { 0 => login::initiate_login(
0 => { login_state,
login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()) app_state,
}, self.auth_client.clone(),
1 => login::back_to_main(login_state, app_state, buffer_state).await, self.login_result_sender.clone(),
_ => "Invalid Login Option".to_string(), ),
}; 1 => {
login_action_message login::back_to_main(login_state, app_state, buffer_state)
} .await
UiContext::Register => { }
let register_action_message = match index { _ => "Invalid Login Option".to_string(),
0 => { },
register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone()) UiContext::Register => match index {
}, 0 => register::initiate_registration(
1 => register::back_to_login(register_state, app_state, buffer_state).await, register_state,
_ => "Invalid Login Option".to_string(), app_state,
}; self.auth_client.clone(),
register_action_message self.register_result_sender.clone(),
} ),
1 => {
register::back_to_login(
register_state,
app_state,
buffer_state,
)
.await
}
_ => "Invalid Login Option".to_string(),
},
UiContext::Admin => { UiContext::Admin => {
admin::handle_admin_selection(app_state, admin_state); admin::handle_admin_selection(app_state, admin_state);
format!("Admin Option {} selected", index) format!("Admin Option {} selected", index)
@@ -296,65 +368,84 @@ impl EventHandler {
UiContext::Dialog => { UiContext::Dialog => {
"Internal error: Unexpected dialog state".to_string() "Internal error: Unexpected dialog state".to_string()
} }
}; // Semicolon added here };
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
other => return other, other => return other,
} }
}, }
AppMode::ReadOnly => { AppMode::ReadOnly => {
// Check for Linewise highlight first if config.get_read_only_action_for_key(key_code, modifiers)
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") == Some("enter_highlight_mode_linewise")
&& ModeManager::can_enter_highlight_mode(current_mode) && ModeManager::can_enter_highlight_mode(current_mode)
{ {
let current_field_index = if app_state.ui.show_login { login_state.current_field() } let current_field_index = if app_state.ui.show_login {
else if app_state.ui.show_register { register_state.current_field() } login_state.current_field()
else { form_state.current_field() }; } else if app_state.ui.show_register {
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index }; register_state.current_field()
} else {
form_state.current_field()
};
self.highlight_state = HighlightState::Linewise {
anchor_line: current_field_index,
};
self.command_message = "-- LINE HIGHLIGHT --".to_string(); self.command_message = "-- LINE HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers)
// Check for Character-wise highlight == Some("enter_highlight_mode")
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
&& ModeManager::can_enter_highlight_mode(current_mode) && ModeManager::can_enter_highlight_mode(current_mode)
{ {
let current_field_index = if app_state.ui.show_login { login_state.current_field() } let current_field_index = if app_state.ui.show_login {
else if app_state.ui.show_register { register_state.current_field() } login_state.current_field()
else { form_state.current_field() }; } else if app_state.ui.show_register {
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } register_state.current_field()
else if app_state.ui.show_register { register_state.current_cursor_pos() } } else {
else { form_state.current_cursor_pos() }; form_state.current_field()
};
let current_cursor_pos = if app_state.ui.show_login {
login_state.current_cursor_pos()
} else if app_state.ui.show_register {
register_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
};
let anchor = (current_field_index, current_cursor_pos); let anchor = (current_field_index, current_cursor_pos);
self.highlight_state = HighlightState::Characterwise { anchor }; self.highlight_state = HighlightState::Characterwise { anchor };
self.command_message = "-- HIGHLIGHT --".to_string(); self.command_message = "-- HIGHLIGHT --".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config
// Check for entering edit mode (before cursor) .get_read_only_action_for_key(key_code, modifiers)
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") .as_deref()
&& ModeManager::can_enter_edit_mode(current_mode) { == Some("enter_edit_mode_before")
&& ModeManager::can_enter_edit_mode(current_mode)
{
self.is_edit_mode = true; self.is_edit_mode = true;
self.edit_mode_cooldown = true; self.edit_mode_cooldown = true;
self.command_message = "Edit mode".to_string(); self.command_message = "Edit mode".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?; terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config
// Check for entering edit mode (after cursor) .get_read_only_action_for_key(key_code, modifiers)
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") .as_deref()
&& ModeManager::can_enter_edit_mode(current_mode) { == Some("enter_edit_mode_after")
let current_input = if app_state.ui.show_login || app_state.ui.show_register{ && ModeManager::can_enter_edit_mode(current_mode)
{
let current_input = if app_state.ui.show_login || app_state.ui.show_register
{
login_state.get_current_input() login_state.get_current_input()
} else { } else {
form_state.get_current_input() form_state.get_current_input()
}; };
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register{ let current_cursor_pos =
login_state.current_cursor_pos() if app_state.ui.show_login || app_state.ui.show_register {
} else { login_state.current_cursor_pos()
form_state.current_cursor_pos() } else {
}; form_state.current_cursor_pos()
};
if !current_input.is_empty() && current_cursor_pos < current_input.len() { if !current_input.is_empty() && current_cursor_pos < current_input.len() {
if app_state.ui.show_login || app_state.ui.show_register{ if app_state.ui.show_login || app_state.ui.show_register {
login_state.set_current_cursor_pos(current_cursor_pos + 1); login_state.set_current_cursor_pos(current_cursor_pos + 1);
self.ideal_cursor_column = login_state.current_cursor_pos(); self.ideal_cursor_column = login_state.current_cursor_pos();
} else { } else {
@@ -368,17 +459,16 @@ impl EventHandler {
self.command_message = "Edit mode (after cursor)".to_string(); self.command_message = "Edit mode (after cursor)".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?; terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_read_only_action_for_key(key_code, modifiers)
// Check for entering command mode == Some("enter_command_mode")
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode)
&& ModeManager::can_enter_command_mode(current_mode) { {
self.command_mode = true; self.command_mode = true;
self.command_input.clear(); self.command_input.clear();
self.command_message.clear(); self.command_message.clear();
return Ok(EventOutcome::Ok(String::new())); return Ok(EventOutcome::Ok(String::new()));
} }
// Check for common actions (save, quit, etc.) only if no mode change happened
if let Some(action) = config.get_common_action(key_code, modifiers) { if let Some(action) = config.get_common_action(key_code, modifiers) {
match action { match action {
"save" | "force_quit" | "save_and_quit" | "revert" => { "save" | "force_quit" | "save_and_quit" | "revert" => {
@@ -392,18 +482,20 @@ impl EventHandler {
&mut self.auth_client, &mut self.auth_client,
terminal, terminal,
app_state, app_state,
current_position, )
total_count, .await;
).await; }
},
_ => {} _ => {}
} }
} }
// If no mode change or specific common action handled, delegate to read_only handler // Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let (_should_exit, message) = read_only::handle_read_only_event( let (_should_exit, message) = read_only::handle_read_only_event(
app_state, app_state,
key, key_event,
config, config,
form_state, form_state,
login_state, login_state,
@@ -411,47 +503,56 @@ impl EventHandler {
&mut admin_state.add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
&mut self.key_sequence_tracker, &mut self.key_sequence_tracker,
current_position, &mut current_position,
total_count, total_count,
grpc_client, grpc_client,
&mut self.command_message, &mut self.command_message,
&mut self.edit_mode_cooldown, &mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column, &mut self.ideal_cursor_column,
).await?; )
// Note: handle_read_only_event should ignore mode entry keys internally now .await?;
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
}, // End AppMode::ReadOnly }
AppMode::Highlight => { AppMode::Highlight => {
// --- Handle Highlight Mode Specific Keys --- if config.get_highlight_action_for_key(key_code, modifiers)
// 1. Check for Exit first == Some("exit_highlight_mode")
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") { {
self.highlight_state = HighlightState::Off; self.highlight_state = HighlightState::Off;
self.command_message = "Exited highlight mode".to_string(); self.command_message = "Exited highlight mode".to_string();
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} } else if config.get_highlight_action_for_key(key_code, modifiers)
// 2. Check for Switch to Linewise == Some("enter_highlight_mode_linewise")
else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") { {
// Only switch if currently characterwise if let HighlightState::Characterwise { anchor } = self.highlight_state {
if let HighlightState::Characterwise { anchor } = self.highlight_state { self.highlight_state = HighlightState::Linewise {
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 }; anchor_line: anchor.0,
self.command_message = "-- LINE HIGHLIGHT --".to_string(); };
return Ok(EventOutcome::Ok(self.command_message.clone())); self.command_message = "-- LINE HIGHLIGHT --".to_string();
} return Ok(EventOutcome::Ok(self.command_message.clone()));
}
return Ok(EventOutcome::Ok("".to_string())); return Ok(EventOutcome::Ok("".to_string()));
} }
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let (_should_exit, message) = read_only::handle_read_only_event( let (_should_exit, message) = read_only::handle_read_only_event(
app_state, key, config, form_state, login_state, app_state,
register_state, key_event,
&mut admin_state.add_table_state, config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
&mut self.key_sequence_tracker, &mut self.key_sequence_tracker,
current_position, &mut current_position,
total_count, total_count,
grpc_client, grpc_client,
&mut self.command_message, &mut self.command_message,
&mut self.edit_mode_cooldown, &mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column, &mut self.ideal_cursor_column,
) )
@@ -460,14 +561,9 @@ impl EventHandler {
} }
AppMode::Edit => { AppMode::Edit => {
// First, check for common actions (save, revert, etc.) that apply in Edit mode
// These might take precedence or have different behavior than the edit handler
if let Some(action) = config.get_common_action(key_code, modifiers) { if let Some(action) = config.get_common_action(key_code, modifiers) {
// Handle common actions like save, revert, force_quit, save_and_quit
// Ensure these actions return EventOutcome directly if they might exit the app
match action { match action {
"save" | "force_quit" | "save_and_quit" | "revert" => { "save" | "force_quit" | "save_and_quit" | "revert" => {
// This call likely returns EventOutcome, handle it directly
return common_mode::handle_core_action( return common_mode::handle_core_action(
action, action,
form_state, form_state,
@@ -478,106 +574,207 @@ impl EventHandler {
&mut self.auth_client, &mut self.auth_client,
terminal, terminal,
app_state, app_state,
current_position, )
total_count, .await;
).await; }
},
// Handle other common actions if necessary
_ => {} _ => {}
} }
// If a common action was handled but didn't return/exit,
// we might want to stop further processing for this key event.
// Depending on the action, you might return Ok(EventOutcome::Ok(...)) here.
// For now, assume common actions either exit or don't prevent further processing.
} }
// If no common action took precedence, delegate to the edit-specific handler // Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let edit_result = edit::handle_edit_event( let edit_result = edit::handle_edit_event(
key, key_event,
config, config,
form_state, form_state,
login_state, login_state,
register_state, register_state,
admin_state, admin_state,
&mut self.ideal_cursor_column, &mut self.ideal_cursor_column,
current_position, &mut current_position,
total_count, total_count,
grpc_client, grpc_client,
app_state, app_state,
).await; )
.await;
match edit_result { match edit_result {
Ok(edit::EditEventOutcome::ExitEditMode) => { Ok(edit::EditEventOutcome::ExitEditMode) => {
// The edit handler signaled to exit the mode
self.is_edit_mode = false; self.is_edit_mode = false;
self.edit_mode_cooldown = true; self.edit_mode_cooldown = true;
let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() } let has_changes = if app_state.ui.show_login {
else if app_state.ui.show_register { register_state.has_unsaved_changes() } login_state.has_unsaved_changes()
else { form_state.has_unsaved_changes() }; } else if app_state.ui.show_register {
register_state.has_unsaved_changes()
} else {
form_state.has_unsaved_changes()
};
self.command_message = if has_changes { self.command_message = if has_changes {
"Exited edit mode (unsaved changes remain)".to_string() "Exited edit mode (unsaved changes remain)".to_string()
} else { } else {
"Read-only mode".to_string() "Read-only mode".to_string()
}; };
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
// Adjust cursor position if needed let current_input = if app_state.ui.show_login {
let current_input = if app_state.ui.show_login { login_state.get_current_input() } login_state.get_current_input()
else if app_state.ui.show_register { register_state.get_current_input() } } else if app_state.ui.show_register {
else { form_state.get_current_input() }; register_state.get_current_input()
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } } else {
else if app_state.ui.show_register { register_state.current_cursor_pos() } form_state.get_current_input()
else { form_state.current_cursor_pos() }; };
if !current_input.is_empty() && current_cursor_pos >= current_input.len() { let current_cursor_pos = if app_state.ui.show_login {
login_state.current_cursor_pos()
} else if app_state.ui.show_register {
register_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
};
if !current_input.is_empty()
&& current_cursor_pos >= current_input.len()
{
let new_pos = current_input.len() - 1; let new_pos = current_input.len() - 1;
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state }; let target_state: &mut dyn CanvasState = if app_state.ui.show_login
{
login_state
} else if app_state.ui.show_register {
register_state
} else {
form_state
};
target_state.set_current_cursor_pos(new_pos); target_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = new_pos; self.ideal_cursor_column = new_pos;
} }
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
Ok(edit::EditEventOutcome::Message(msg)) => { Ok(edit::EditEventOutcome::Message(msg)) => {
// Stay in edit mode, update message if not empty
if !msg.is_empty() { if !msg.is_empty() {
self.command_message = msg; self.command_message = msg;
} }
self.key_sequence_tracker.reset(); // Reset sequence tracker on successful edit action self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
Err(e) => { Err(e) => {
// Handle error from the edit handler
return Err(e.into()); return Err(e.into());
} }
} }
}, // End AppMode::Edit }
AppMode::Command => { AppMode::Command => {
let outcome = command_mode::handle_command_event( if config.is_exit_command_mode(key_code, modifiers) {
key, self.command_input.clear();
config, self.command_message.clear();
app_state, self.command_mode = false;
login_state, self.key_sequence_tracker.reset();
register_state, return Ok(EventOutcome::Ok("Exited command mode".to_string()));
form_state,
&mut self.command_input,
&mut self.command_message,
grpc_client,
command_handler,
terminal,
current_position,
total_count,
).await?;
if let EventOutcome::Ok(msg) = &outcome {
if msg == "Exited command mode" {
self.command_mode = false;
}
} }
return Ok(outcome);
if config.is_command_execute(key_code, modifiers) {
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let outcome = command_mode::handle_command_event(
key_event,
config,
app_state,
login_state,
register_state,
form_state,
&mut self.command_input,
&mut self.command_message,
grpc_client,
command_handler,
terminal,
&mut current_position,
total_count,
)
.await?;
// Update form_state with potentially changed position
form_state.current_position = current_position;
self.command_mode = false;
self.key_sequence_tracker.reset();
let new_mode = ModeManager::derive_mode(app_state, self, admin_state);
app_state.update_mode(new_mode);
return Ok(outcome);
}
if key_code == KeyCode::Backspace {
self.command_input.pop();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
}
if let KeyCode::Char(c) = key_code {
if c == 'f' {
// Assuming 'f' is part of the sequence, e.g. ":f" or " f"
self.key_sequence_tracker.add_key(key_code);
let sequence = self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(&sequence)
== Some("find_file_palette_toggle")
{
if app_state.ui.show_form || app_state.ui.show_intro {
// Build table graph from profile data
let graph = TableDependencyGraph::from_profile_tree(
&app_state.profile_tree,
);
// Activate navigation with graph
self.navigation_state.activate_table_tree(graph);
self.command_mode = false; // Exit command mode
self.command_input.clear();
// Message is set by render_find_file_palette's prompt_prefix
self.command_message.clear(); // Clear old command message
self.key_sequence_tracker.reset();
// ModeManager will derive AppMode::General due to navigation_state.active
// app_state.update_mode(AppMode::General); // This will be handled by ModeManager
return Ok(EventOutcome::Ok(
"Table tree palette activated".to_string(),
));
} else {
self.key_sequence_tracker.reset();
self.command_input.push('f');
if sequence.len() > 1 && sequence[0] == KeyCode::Char('f') {
self.command_input.push('f');
}
self.command_message =
"Find File not available in this view.".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
if config.is_key_sequence_prefix(&sequence) {
return Ok(EventOutcome::Ok(String::new()));
}
}
if c != 'f' && !self.key_sequence_tracker.current_sequence.is_empty() {
self.key_sequence_tracker.reset();
}
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
} }
} }
} else if let Event::Resize(_, _) = event {
return Ok(EventOutcome::Ok("Resized".to_string()));
} }
self.edit_mode_cooldown = false; self.edit_mode_cooldown = false;
Ok(EventOutcome::Ok(self.command_message.clone())) Ok(EventOutcome::Ok(self.command_message.clone()))
} }
fn is_processed_command(&self, command: &str) -> bool {
matches!(command, "w" | "q" | "q!" | "wq" | "r")
}
} }

View File

@@ -23,6 +23,10 @@ impl ModeManager {
event_handler: &EventHandler, event_handler: &EventHandler,
admin_state: &AdminState, admin_state: &AdminState,
) -> AppMode { ) -> AppMode {
if event_handler.navigation_state.active {
return AppMode::General;
}
if event_handler.command_mode { if event_handler.command_mode {
return AppMode::Command; return AppMode::Command;
} }
@@ -78,14 +82,14 @@ impl ModeManager {
} }
// Mode transition rules // Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool { pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode !matches!(current_mode, AppMode::Edit)
} }
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool { pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly matches!(current_mode, AppMode::ReadOnly)
} }
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool { pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight) matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
} }

View File

@@ -1,101 +1,200 @@
// src/services/grpc_client.rs // src/services/grpc_client.rs
use tonic::transport::Channel; use tonic::transport::Channel;
use common::proto::multieko2::adresar::adresar_client::AdresarClient; use common::proto::multieko2::common::{CountResponse, Empty};
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient; use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
// Import the new request type for table structure use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
use common::proto::multieko2::table_structure::{TableStructureResponse, GetTableStructureRequest};
use common::proto::multieko2::table_definition::{ use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient, table_definition_client::TableDefinitionClient,
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse, PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
}; };
use common::proto::multieko2::table_script::{ use common::proto::multieko2::table_script::{
table_script_client::TableScriptClient, table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse, PostTableScriptRequest, TableScriptResponse,
}; };
use anyhow::Result; use common::proto::multieko2::tables_data::{
tables_data_client::TablesDataClient,
GetTableDataByPositionRequest,
GetTableDataResponse,
GetTableDataCountRequest,
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use anyhow::{Context, Result}; // Added Context
use std::collections::HashMap; // NEW
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcClient { pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>, table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>, table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>, table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>, // NEW
} }
impl GrpcClient { impl GrpcClient {
pub async fn new() -> Result<Self> { pub async fn new() -> Result<Self> {
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?; let table_structure_client = TableStructureServiceClient::connect(
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?; "http://[::1]:50051",
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?; )
let table_script_client = TableScriptClient::connect("http://[::1]:50051").await?; .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
Ok(Self { Ok(Self {
adresar_client, // adresar_client, // REMOVE
table_structure_client, table_structure_client,
table_definition_client, table_definition_client,
table_script_client, table_script_client,
tables_data_client, // NEW
}) })
} }
pub async fn get_adresar_count(&mut self) -> Result<u64> {
let request = tonic::Request::new(Empty::default());
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
Ok(response.count as u64)
}
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse> {
let request = tonic::Request::new(PositionRequest { position: position as i64 });
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
Ok(response)
}
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.post_adresar(request).await?;
Ok(response)
}
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.put_adresar(request).await?;
Ok(response)
}
// Updated get_table_structure method
pub async fn get_table_structure( pub async fn get_table_structure(
&mut self, &mut self,
profile_name: String, profile_name: String,
table_name: String, table_name: String,
) -> Result<TableStructureResponse> { ) -> Result<TableStructureResponse> {
// Create the new request type
let grpc_request = GetTableStructureRequest { let grpc_request = GetTableStructureRequest {
profile_name, profile_name,
table_name, table_name,
}; };
let request = tonic::Request::new(grpc_request); let request = tonic::Request::new(grpc_request);
// Call the new gRPC method let response = self
let response = self.table_structure_client.get_table_structure(request).await?; .table_structure_client
.get_table_structure(request)
.await
.context("gRPC GetTableStructure call failed")?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse> { pub async fn get_profile_tree(
&mut self,
) -> Result<ProfileTreeResponse> {
let request = tonic::Request::new(Empty::default()); let request = tonic::Request::new(Empty::default());
let response = self.table_definition_client.get_profile_tree(request).await?; let response = self
.table_definition_client
.get_profile_tree(request)
.await
.context("gRPC GetProfileTree call failed")?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
pub async fn post_table_definition(&mut self, request: PostTableDefinitionRequest) -> Result<TableDefinitionResponse> { pub async fn post_table_definition(
&mut self,
request: PostTableDefinitionRequest,
) -> Result<TableDefinitionResponse> {
let tonic_request = tonic::Request::new(request); let tonic_request = tonic::Request::new(request);
let response = self.table_definition_client.post_table_definition(tonic_request).await?; let response = self
.table_definition_client
.post_table_definition(tonic_request)
.await
.context("gRPC PostTableDefinition call failed")?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
pub async fn post_table_script(&mut self, request: PostTableScriptRequest) -> Result<TableScriptResponse> { pub async fn post_table_script(
&mut self,
request: PostTableScriptRequest,
) -> Result<TableScriptResponse> {
let tonic_request = tonic::Request::new(request); let tonic_request = tonic::Request::new(request);
let response = self.table_script_client.post_table_script(tonic_request).await?; let response = self
.table_script_client
.post_table_script(tonic_request)
.await
.context("gRPC PostTableScript call failed")?;
Ok(response.into_inner())
}
// NEW Methods for TablesData service
pub async fn get_table_data_count(
&mut self,
profile_name: String,
table_name: String,
) -> Result<u64> {
let grpc_request = GetTableDataCountRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_count(request)
.await
.context("gRPC GetTableDataCount call failed")?;
Ok(response.into_inner().count as u64)
}
pub async fn get_table_data_by_position(
&mut self,
profile_name: String,
table_name: String,
position: i32,
) -> Result<GetTableDataResponse> {
let grpc_request = GetTableDataByPositionRequest {
profile_name,
table_name,
position,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_by_position(request)
.await
.context("gRPC GetTableDataByPosition call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_data(
&mut self,
profile_name: String,
table_name: String,
data: HashMap<String, String>,
) -> Result<PostTableDataResponse> {
let grpc_request = PostTableDataRequest {
profile_name,
table_name,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.post_table_data(request)
.await
.context("gRPC PostTableData call failed")?;
Ok(response.into_inner())
}
pub async fn put_table_data(
&mut self,
profile_name: String,
table_name: String,
id: i64,
data: HashMap<String, String>,
) -> Result<PutTableDataResponse> {
let grpc_request = PutTableDataRequest {
profile_name,
table_name,
id,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.put_table_data(request)
.await
.context("gRPC PutTableData call failed")?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
} }

View File

@@ -90,110 +90,173 @@ impl UiService {
} }
} }
} }
pub async fn initialize_app_state( // MODIFIED: To set initial view table in AppState and return initial column names
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &mut AppState, app_state: &mut AppState,
) -> Result<Vec<String>> { // Returns (initial_profile, initial_table, initial_columns)
// Fetch profile tree ) -> Result<(String, String, Vec<String>)> {
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?; let profile_tree = grpc_client
.get_profile_tree()
.await
.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree; app_state.profile_tree = profile_tree;
// TODO for general tables and not hardcoded // Determine initial table to load (e.g., first table of first profile, or a default)
let default_profile_name = "default".to_string(); // For now, let's hardcode a default for simplicity, but this should be more dynamic
let default_table_name = "2025_customer".to_string(); let initial_profile_name = app_state
.profile_tree
.profiles
.first()
.map(|p| p.name.clone())
.unwrap_or_else(|| "default".to_string());
let initial_table_name = app_state
.profile_tree
.profiles
.first()
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
.unwrap_or_else(|| "2025_company_data1".to_string()); // Fallback if no tables
app_state.set_current_view_table(
initial_profile_name.clone(),
initial_table_name.clone(),
);
// Fetch table structure for the default table
let table_structure = grpc_client let table_structure = grpc_client
.get_table_structure(default_profile_name, default_table_name) .get_table_structure(
initial_profile_name.clone(),
initial_table_name.clone(),
)
.await .await
.context("Failed to get initial table structure")?; .context(format!(
"Failed to get initial table structure for {}.{}",
initial_profile_name, initial_table_name
))?;
// Extract the column names from the response
let column_names: Vec<String> = table_structure let column_names: Vec<String> = table_structure
.columns .columns
.iter() .iter()
.map(|col| col.name.clone()) .map(|col| col.name.clone())
.collect(); .collect();
Ok(column_names) Ok((initial_profile_name, initial_table_name, column_names))
} }
pub async fn initialize_adresar_count( // NEW: Fetches and sets count for the current table in FormState
pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar count")?;
app_state.update_total_count(total_count);
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
Ok(())
}
pub async fn update_adresar_count(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar by position")?;
app_state.update_total_count(total_count);
Ok(())
}
pub async fn load_adresar_by_position(
grpc_client: &mut GrpcClient,
_app_state: &mut AppState,
form_state: &mut FormState, form_state: &mut FormState,
position: u64, ) -> Result<()> {
let total_count = grpc_client
.get_table_data_count(
form_state.profile_name.clone(),
form_state.table_name.clone(),
)
.await
.context(format!(
"Failed to get count for table {}.{}",
form_state.profile_name, form_state.table_name
))?;
form_state.total_count = total_count;
// Set initial position: if table has items, point to first, else point to new entry
if total_count > 0 {
form_state.current_position = 1;
} else {
form_state.current_position = 1; // For a new entry in an empty table
}
Ok(())
}
// MODIFIED: Generic table data loading
pub async fn load_table_data_by_position(
grpc_client: &mut GrpcClient,
form_state: &mut FormState, // Takes &mut FormState to update it
// position is now read from form_state.current_position
) -> Result<String> { ) -> Result<String> {
match grpc_client.get_adresar_by_position(position).await { // Ensure current_position is valid before fetching
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
// This indicates a "new entry" state, no data to load from server.
// The caller should handle this by calling form_state.reset_to_empty()
// or ensuring this function isn't called for a new entry position.
// For now, let's assume reset_to_empty was called if needed.
form_state.reset_to_empty(); // Ensure fields are clear for new entry
return Ok(format!(
"New entry mode for table {}.{}",
form_state.profile_name, form_state.table_name
));
}
if form_state.total_count == 0 && form_state.current_position == 1 {
// Table is empty, this is the position for a new entry
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for empty table {}.{}",
form_state.profile_name, form_state.table_name
));
}
match grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.current_position as i32,
)
.await
{
Ok(response) => { Ok(response) => {
// Set the ID properly form_state.update_from_response(&response.data);
form_state.id = response.id; // ID, values, current_field, current_cursor_pos, has_unsaved_changes are set by update_from_response
Ok(format!(
// Update form values dynamically "Loaded entry {}/{} for table {}.{}",
form_state.values = vec![ form_state.current_position,
response.firma, form_state.total_count,
response.kz, form_state.profile_name,
response.drc, form_state.table_name
response.ulica, ))
response.psc,
response.mesto,
response.stat,
response.banka,
response.ucet,
response.skladm,
response.ico,
response.kontakt,
response.telefon,
response.skladu,
response.fax,
];
form_state.has_unsaved_changes = false;
Ok(format!("Loaded entry {}", position))
} }
Err(e) => { Err(e) => {
Ok(format!("Error loading entry: {}", e)) // If loading fails (e.g., record deleted, network error), what should happen?
// Maybe reset to a new entry state or show an error and keep current data.
// For now, log error and return error message.
tracing::error!(
"Error loading entry {} for table {}.{}: {}",
form_state.current_position,
form_state.profile_name,
form_state.table_name,
e
);
// Potentially clear form or revert to a safe state
// form_state.reset_to_empty();
Err(anyhow::anyhow!(
"Error loading entry {}: {}",
form_state.current_position,
e
))
} }
} }
} }
/// Handles the consequences of a save operation, like updating counts. // MODIFIED: To work with FormState's count and position
pub async fn handle_save_outcome( pub async fn handle_save_outcome(
save_outcome: SaveOutcome, save_outcome: SaveOutcome,
grpc_client: &mut GrpcClient, _grpc_client: &mut GrpcClient, // May not be needed if count is fetched separately
app_state: &mut AppState, _app_state: &mut AppState, // May not be needed directly
form_state: &mut FormState, form_state: &mut FormState,
) -> Result<()> { ) -> Result<()> {
match save_outcome { match save_outcome {
SaveOutcome::CreatedNew(new_id) => { SaveOutcome::CreatedNew(new_id) => {
// A new record was created, update the count! // form_state.total_count and form_state.current_position should have been updated
UiService::update_adresar_count(grpc_client, app_state).await?; // by the `save` function itself.
// Navigate to the new record (now that count is updated) // Ensure form_state.id is set.
app_state.update_current_position(app_state.total_count); form_state.id = new_id;
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it) // Potentially, re-fetch count to be absolutely sure, but save should be authoritative.
// UiService::fetch_and_set_table_count(grpc_client, form_state).await?;
} }
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => { SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No count update needed for these outcomes // No changes to total_count or current_position needed from here.
} }
} }
Ok(()) Ok(())

View File

@@ -33,11 +33,12 @@ pub struct UiState {
pub struct AppState { pub struct AppState {
// Core editor state // Core editor state
pub current_dir: String, pub current_dir: String,
pub total_count: u64,
pub current_position: u64,
pub profile_tree: ProfileTreeResponse, pub profile_tree: ProfileTreeResponse,
pub selected_profile: Option<String>, pub selected_profile: Option<String>,
pub current_mode: AppMode, pub current_mode: AppMode,
pub current_view_profile_name: Option<String>,
pub current_view_table_name: Option<String>,
pub focused_button_index: usize, pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>, pub pending_table_structure_fetch: Option<(String, String)>,
@@ -52,10 +53,10 @@ impl AppState {
.to_string(); .to_string();
Ok(AppState { Ok(AppState {
current_dir, current_dir,
total_count: 0,
current_position: 0,
profile_tree: ProfileTreeResponse::default(), profile_tree: ProfileTreeResponse::default(),
selected_profile: None, selected_profile: None,
current_view_profile_name: None,
current_view_table_name: None,
current_mode: AppMode::General, current_mode: AppMode::General,
focused_button_index: 0, focused_button_index: 0,
pending_table_structure_fetch: None, pending_table_structure_fetch: None,
@@ -63,18 +64,14 @@ impl AppState {
}) })
} }
// Existing methods remain unchanged
pub fn update_total_count(&mut self, total_count: u64) {
self.total_count = total_count;
}
pub fn update_current_position(&mut self, current_position: u64) {
self.current_position = current_position;
}
pub fn update_mode(&mut self, mode: AppMode) { pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode; 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 // Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons. /// Shows a dialog with the given title, message, and buttons.

View File

@@ -1,4 +1,6 @@
// src/state/pages/form.rs // src/state/pages/form.rs
use std::collections::HashMap; // NEW
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::Frame; use ratatui::Frame;
@@ -7,7 +9,13 @@ use crate::state::pages::canvas_state::CanvasState;
pub struct FormState { pub struct FormState {
pub id: i64, pub id: i64,
pub fields: Vec<String>, // NEW fields for dynamic table context
pub profile_name: String,
pub table_name: String,
pub total_count: u64,
pub current_position: u64, // 1-based index, 0 or total_count + 1 for new entry
pub fields: Vec<String>, // Already dynamic, which is good
pub values: Vec<String>, pub values: Vec<String>,
pub current_field: usize, pub current_field: usize,
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
@@ -15,11 +23,19 @@ pub struct FormState {
} }
impl FormState { impl FormState {
/// Create a new FormState with dynamic fields. // MODIFIED constructor
pub fn new(fields: Vec<String>) -> Self { pub fn new(
let values = vec![String::new(); fields.len()]; // Initialize values for each field profile_name: String,
table_name: String,
fields: Vec<String>,
) -> Self {
let values = vec![String::new(); fields.len()];
FormState { FormState {
id: 0, id: 0, // Default to 0, indicating a new or unloaded record
profile_name,
table_name,
total_count: 0, // Will be fetched after initialization
current_position: 0, // Will be set after count is fetched (e.g., 1 or total_count + 1)
fields, fields,
values, values,
current_field: 0, current_field: 0,
@@ -35,31 +51,42 @@ impl FormState {
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, // total_count and current_position are now part of self
current_position: u64,
) { ) {
let fields: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect(); let fields_str_slice: Vec<&str> =
let values: Vec<&String> = self.values.iter().collect(); self.fields.iter().map(|s| s.as_str()).collect();
let values_str_slice: Vec<&String> = self.values.iter().collect();
crate::components::form::form::render_form( crate::components::form::form::render_form(
f, f,
area, area,
self, self, // Pass self as CanvasState
&fields, &fields_str_slice,
&self.current_field, &self.current_field,
&values, &values_str_slice,
theme, theme,
is_edit_mode, is_edit_mode,
highlight_state, highlight_state,
total_count, self.total_count, // MODIFIED: Use self.total_count
current_position, self.current_position, // MODIFIED: Use self.current_position
); );
} }
// MODIFIED: Reset now also considers table context for counts
pub fn reset_to_empty(&mut self) { pub fn reset_to_empty(&mut self) {
self.id = 0; // Reset ID to 0 for new entries self.id = 0;
self.values.iter_mut().for_each(|v| v.clear()); // Clear all values self.values.iter_mut().for_each(|v| v.clear());
self.current_field = 0;
self.current_cursor_pos = 0;
self.has_unsaved_changes = false; self.has_unsaved_changes = false;
// current_position should be set to total_count + 1 for a new entry
// This might be better handled by the logic that calls reset_to_empty
// For now, let's ensure it's consistent with a "new" state.
if self.total_count > 0 {
self.current_position = self.total_count + 1;
} else {
self.current_position = 1; // If table is empty, new record is at position 1
}
} }
pub fn get_current_input(&self) -> &str { pub fn get_current_input(&self) -> &str {
@@ -75,15 +102,43 @@ impl FormState {
.expect("Invalid current_field index") .expect("Invalid current_field index")
} }
pub fn update_from_response(&mut self, response: common::proto::multieko2::adresar::AdresarResponse) { // MODIFIED: Update from a generic HashMap response
self.id = response.id; pub fn update_from_response(
self.values = vec![ &mut self,
response.firma, response.kz, response.drc, response_data: &HashMap<String, String>,
response.ulica, response.psc, response.mesto, ) {
response.stat, response.banka, response.ucet, self.values = self.fields
response.skladm, response.ico, response.kontakt, .iter()
response.telefon, response.skladu, response.fax, .map(|field_name| {
]; response_data.get(field_name).cloned().unwrap_or_default()
})
.collect();
if let Some(id_str) = response_data.get("id") {
match id_str.parse::<i64>() {
Ok(parsed_id) => self.id = parsed_id,
Err(e) => {
tracing::error!(
"Failed to parse 'id' field '{}' for table {}.{}: {}",
id_str,
self.profile_name,
self.table_name,
e
);
self.id = 0; // Default to 0 if parsing fails
}
}
} else {
// If no ID is present, it might be a new record structure or an error
// For now, assume it means the record doesn't have an ID from the server yet
self.id = 0;
}
self.has_unsaved_changes = false;
// current_field and current_cursor_pos might need resetting or adjusting
// depending on the desired behavior after loading data.
// For now, let's reset current_field to 0.
self.current_field = 0;
self.current_cursor_pos = 0;
} }
} }
@@ -105,31 +160,26 @@ impl CanvasState for FormState {
} }
fn get_current_input(&self) -> &str { fn get_current_input(&self) -> &str {
self.values // Re-use the struct's own method
.get(self.current_field) FormState::get_current_input(self)
.map(|s| s.as_str())
.unwrap_or("")
} }
fn get_current_input_mut(&mut self) -> &mut String { fn get_current_input_mut(&mut self) -> &mut String {
self.values // Re-use the struct's own method
.get_mut(self.current_field) FormState::get_current_input_mut(self)
.expect("Invalid current_field index")
} }
fn fields(&self) -> Vec<&str> { fn fields(&self) -> Vec<&str> {
self.fields.iter().map(|s| s.as_str()).collect() self.fields.iter().map(|s| s.as_str()).collect()
} }
// --- Implement the setter methods ---
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() { // Basic bounds check if index < self.fields.len() {
self.current_field = index; self.current_field = index;
} }
} }
fn set_current_cursor_pos(&mut self, pos: usize) { fn set_current_cursor_pos(&mut self, pos: usize) {
// Optional: Add validation based on current input length if needed
self.current_cursor_pos = pos; self.current_cursor_pos = pos;
} }
@@ -137,12 +187,11 @@ impl CanvasState for FormState {
self.has_unsaved_changes = changed; self.has_unsaved_changes = changed;
} }
// --- Autocomplete Support (Not Used for FormState) ---
fn get_suggestions(&self) -> Option<&[String]> { fn get_suggestions(&self) -> Option<&[String]> {
None // FormState doesn't provide suggestions None
} }
fn get_selected_suggestion_index(&self) -> Option<usize> { fn get_selected_suggestion_index(&self) -> Option<usize> {
None // FormState doesn't have selected suggestions None
} }
} }

View File

@@ -2,114 +2,130 @@
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest}; use anyhow::{Context, Result}; // Added Context
use anyhow::Result; use std::collections::HashMap; // NEW
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome { pub enum SaveOutcome {
NoChange, // Nothing needed saving NoChange,
UpdatedExisting, // An existing record was updated UpdatedExisting,
CreatedNew(i64), // A new record was created (include its new ID) CreatedNew(i64), // Keep the ID
} }
/// Shared logic for saving the current form state // MODIFIED save function
pub async fn save( pub async fn save(
form_state: &mut FormState, form_state: &mut FormState,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64, ) -> Result<SaveOutcome> {
total_count: u64,
) -> Result<SaveOutcome> { // <-- Return SaveOutcome
if !form_state.has_unsaved_changes { if !form_state.has_unsaved_changes {
return Ok(SaveOutcome::NoChange); // Early exit if no changes return Ok(SaveOutcome::NoChange);
} }
let is_new = *current_position == total_count + 1;
let outcome = if is_new { let data_map: HashMap<String, String> = form_state
let post_request = PostAdresarRequest { .fields
firma: form_state.values[0].clone(), .iter()
kz: form_state.values[1].clone(), .zip(form_state.values.iter())
drc: form_state.values[2].clone(), .map(|(field, value)| (field.clone(), value.clone()))
ulica: form_state.values[3].clone(), .collect();
psc: form_state.values[4].clone(),
mesto: form_state.values[5].clone(), let outcome: SaveOutcome;
stat: form_state.values[6].clone(),
banka: form_state.values[7].clone(), let is_new_entry = form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) ;
ucet: form_state.values[8].clone(),
skladm: form_state.values[9].clone(),
ico: form_state.values[10].clone(), if is_new_entry {
kontakt: form_state.values[11].clone(), let response = grpc_client
telefon: form_state.values[12].clone(), .post_table_data(
skladu: form_state.values[13].clone(), form_state.profile_name.clone(),
fax: form_state.values[14].clone(), form_state.table_name.clone(),
}; data_map,
let response = grpc_client.post_adresar(post_request).await?; )
let new_id = response.into_inner().id; .await
form_state.id = new_id; .context("Failed to post new table data")?;
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID
if response.success {
form_state.id = response.inserted_id;
// After creating a new entry, total_count increases, and current_position becomes this new total_count
form_state.total_count += 1;
form_state.current_position = form_state.total_count;
outcome = SaveOutcome::CreatedNew(response.inserted_id);
} else {
return Err(anyhow::anyhow!(
"Server failed to insert data: {}",
response.message
));
}
} else { } else {
let put_request = PutAdresarRequest { // This assumes form_state.id is valid for an existing record
id: form_state.id, if form_state.id == 0 {
firma: form_state.values[0].clone(), return Err(anyhow::anyhow!(
kz: form_state.values[1].clone(), "Cannot update record: ID is 0, but not classified as new entry."
drc: form_state.values[2].clone(), ));
ulica: form_state.values[3].clone(), }
psc: form_state.values[4].clone(), let response = grpc_client
mesto: form_state.values[5].clone(), .put_table_data(
stat: form_state.values[6].clone(), form_state.profile_name.clone(),
banka: form_state.values[7].clone(), form_state.table_name.clone(),
ucet: form_state.values[8].clone(), form_state.id,
skladm: form_state.values[9].clone(), data_map,
ico: form_state.values[10].clone(), )
kontakt: form_state.values[11].clone(), .await
telefon: form_state.values[12].clone(), .context("Failed to put (update) table data")?;
skladu: form_state.values[13].clone(),
fax: form_state.values[14].clone(), if response.success {
}; outcome = SaveOutcome::UpdatedExisting;
let _ = grpc_client.put_adresar(put_request).await?; } else {
SaveOutcome::UpdatedExisting return Err(anyhow::anyhow!(
}; "Server failed to update data: {}",
response.message
));
}
}
form_state.has_unsaved_changes = false; form_state.has_unsaved_changes = false;
Ok(outcome) Ok(outcome)
} }
/// Discard changes since last save
pub async fn revert( pub async fn revert(
form_state: &mut FormState, form_state: &mut FormState, // Takes &mut FormState to update it
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String> { ) -> Result<String> {
let is_new = *current_position == total_count + 1; if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
let old_total_count = form_state.total_count; // Preserve for correct new position
if is_new { form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
// Clear all fields for new entries form_state.total_count = old_total_count; // Restore total_count
form_state.values.iter_mut().for_each(|v| *v = String::new()); if form_state.total_count > 0 { // Correctly set current_position for new
form_state.has_unsaved_changes = false; form_state.current_position = form_state.total_count + 1;
} else {
form_state.current_position = 1;
}
return Ok("New entry cleared".to_string()); return Ok("New entry cleared".to_string());
} }
let data = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
if form_state.total_count > 0 {
form_state.current_position = 1;
} else {
// No records to revert to, effectively a new entry state.
form_state.reset_to_empty();
return Ok("No saved data to revert to; form cleared.".to_string());
}
}
// Update form fields with saved values let response = grpc_client
form_state.values = vec![ .get_table_data_by_position(
data.firma, form_state.profile_name.clone(),
data.kz, form_state.table_name.clone(),
data.drc, form_state.current_position as i32,
data.ulica, )
data.psc, .await
data.mesto, .context(format!(
data.stat, "Failed to get table data by position {} for table {}.{}",
data.banka, form_state.current_position,
data.ucet, form_state.profile_name,
data.skladm, form_state.table_name
data.ico, ))?;
data.kontakt,
data.telefon,
data.skladu,
data.fax,
];
form_state.has_unsaved_changes = false; form_state.update_from_response(&response.data);
Ok("Changes discarded, reloaded last saved version".to_string()) Ok("Changes discarded, reloaded last saved version".to_string())
} }

View File

@@ -2,6 +2,7 @@
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::services::ui_service::UiService;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
pub async fn handle_action( pub async fn handle_action(
@@ -12,8 +13,7 @@ pub async fn handle_action(
total_count: u64, total_count: u64,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
) -> Result<String> { ) -> Result<String> {
// TODO store unsaved changes without deleting form state values // Check for unsaved changes in both cases
// First check for unsaved changes in both cases
if form_state.has_unsaved_changes() { if form_state.has_unsaved_changes() {
return Ok( return Ok(
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating." "Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
@@ -23,57 +23,43 @@ pub async fn handle_action(
match action { match action {
"previous_entry" => { "previous_entry" => {
let new_position = current_position.saturating_sub(1); let new_position = form_state.current_position.saturating_sub(1);
if new_position >= 1 { if new_position >= 1 {
form_state.current_position = new_position;
*current_position = new_position; *current_position = new_position;
let response = grpc_client.get_adresar_by_position(*current_position).await?;
if new_position <= form_state.total_count {
// Direct field assignments let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(format!("Loaded form entry {}", *current_position))
} else {
Ok("Already at first form entry".into())
}
}
"next_entry" => {
if *current_position <= total_count {
*current_position += 1;
if *current_position <= total_count {
let response = grpc_client.get_adresar_by_position(*current_position).await?;
// Direct field assignments
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input(); let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1 current_input.len() - 1
} else { 0 }; } else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos); form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(load_message)
Ok(format!("Loaded form entry {}", *current_position)) } else {
Ok(format!("Moved to position {}", new_position))
}
} else {
Ok("Already at first position".into())
}
}
"next_entry" => {
if form_state.current_position <= form_state.total_count {
form_state.current_position += 1;
*current_position = form_state.current_position;
if form_state.current_position <= form_state.total_count {
let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
Ok(load_message)
} else { } else {
form_state.reset_to_empty(); form_state.reset_to_empty();
form_state.current_field = 0; form_state.current_field = 0;
@@ -86,6 +72,5 @@ pub async fn handle_action(
} }
} }
_ => Err(anyhow!("Unknown form action: {}", action)) _ => Err(anyhow!("Unknown form action: {}", action))
} }
} }

View File

@@ -21,4 +21,3 @@ pub enum DialogPurpose {
// TODO in the future: // TODO in the future:
// ConfirmQuit, // ConfirmQuit,
} }

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/rat_state.rs // client/src/ui/handlers/rat_state.rs
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::state::app::state::UiState; use crate::state::app::state::UiState;

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/render.rs // client/src/ui/handlers/render.rs
use crate::components::{ use crate::components::{
render_background, render_background,
@@ -11,10 +11,14 @@ use crate::components::{
admin::render_add_table, admin::render_add_table,
admin::add_logic::render_add_logic, admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register}, auth::{login::render_login, register::render_register},
common::find_file_palette,
}; };
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::{
use ratatui::Frame; layout::{Constraint, Direction, Layout},
Frame,
};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
@@ -24,7 +28,9 @@ use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::modes::general::command_navigation::NavigationState;
#[allow(clippy::too_many_arguments)]
pub fn render_ui( pub fn render_ui(
f: &mut Frame, f: &mut Frame,
form_state: &mut FormState, form_state: &mut FormState,
@@ -35,175 +41,154 @@ pub fn render_ui(
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &BufferState, buffer_state: &BufferState,
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_event_handler_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, event_handler_command_input: &str,
current_position: u64, event_handler_command_mode_active: bool,
event_handler_command_message: &str,
navigation_state: &NavigationState,
current_dir: &str, current_dir: &str,
command_input: &str,
command_mode: bool,
command_message: &str,
current_fps: f64, current_fps: f64,
app_state: &AppState, app_state: &AppState,
) { ) {
render_background(f, f.area(), theme); render_background(f, f.area(), theme);
// Adjust layout based on whether buffer list is shown const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let constraints = if app_state.ui.show_buffer_list {
vec![ let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(1)];
Constraint::Length(1), // Buffer list
Constraint::Min(1), // Main content let command_palette_area_height = if navigation_state.active {
Constraint::Length(1), // Status line 1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
Constraint::Length(1), // Command line } else if event_handler_command_mode_active {
] 1
} else { } else {
vec![ 0 // Neither is active
Constraint::Min(1), // Main content
Constraint::Length(1), // Status line (no buffer list)
Constraint::Length(1), // Command line
]
}; };
let root = Layout::default() if command_palette_area_height > 0 {
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
}
let mut main_layout_constraints = vec![Constraint::Min(1)];
if app_state.ui.show_buffer_list {
main_layout_constraints.insert(0, Constraint::Length(1));
}
main_layout_constraints.extend(bottom_area_constraints);
let root_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(main_layout_constraints)
.split(f.area()); .split(f.area());
let mut buffer_list_area = None; let mut chunk_idx = 0;
let main_content_area; let buffer_list_area = if app_state.ui.show_buffer_list {
let status_line_area; let area = Some(root_chunks[chunk_idx]);
let command_line_area; chunk_idx += 1;
area
// Assign areas based on layout
if app_state.ui.show_buffer_list {
buffer_list_area = Some(root[0]);
main_content_area = root[1];
status_line_area = root[2];
command_line_area = root[3];
} else { } else {
main_content_area = root[0]; None
status_line_area = root[1]; };
command_line_area = root[2];
} let main_content_area = root_chunks[chunk_idx];
chunk_idx += 1;
let status_line_area = root_chunks[chunk_idx];
chunk_idx += 1;
let command_render_area = if command_palette_area_height > 0 {
if root_chunks.len() > chunk_idx {
Some(root_chunks[chunk_idx])
} else {
None
}
} else {
None
};
if app_state.ui.show_intro { if app_state.ui.show_intro {
render_intro(f, intro_state, main_content_area, theme); render_intro(f, intro_state, main_content_area, theme);
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
render_register( render_register(
f, f, main_content_area, theme, register_state, app_state,
main_content_area, register_state.current_field() < 4,
theme,
register_state,
app_state,
register_state.current_field < 4,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
render_add_table( render_add_table(
f, f, main_content_area, theme, app_state, &mut admin_state.add_table_state,
main_content_area, is_event_handler_edit_mode,
theme,
app_state,
&mut admin_state.add_table_state,
login_state.current_field < 3,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
render_add_logic( render_add_logic(
f, f, main_content_area, theme, app_state, &mut admin_state.add_logic_state,
main_content_area, is_event_handler_edit_mode, highlight_state,
theme,
app_state,
&mut admin_state.add_logic_state,
is_edit_mode, // Pass the general edit mode status
highlight_state,
); );
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
render_login( render_login(
f, f, main_content_area, theme, login_state, app_state,
main_content_area, login_state.current_field() < 2,
theme,
login_state,
app_state,
login_state.current_field < 2,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_admin { } else if app_state.ui.show_admin {
crate::components::admin::admin_panel::render_admin_panel( crate::components::admin::admin_panel::render_admin_panel(
f, f, app_state, auth_state, admin_state, main_content_area, theme,
app_state, &app_state.profile_tree, &app_state.selected_profile,
auth_state,
admin_state,
main_content_area,
theme,
&app_state.profile_tree,
&app_state.selected_profile,
); );
} else if app_state.ui.show_form { } else if app_state.ui.show_form {
let (sidebar_area, form_area) = calculate_sidebar_layout( let (sidebar_area, form_actual_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar, app_state.ui.show_sidebar, main_content_area
main_content_area
); );
if let Some(sidebar_rect) = sidebar_area { if let Some(sidebar_rect) = sidebar_area {
sidebar::render_sidebar( sidebar::render_sidebar(
f, f, sidebar_rect, theme, &app_state.profile_tree, &app_state.selected_profile
sidebar_rect,
theme,
&app_state.profile_tree,
&app_state.selected_profile
); );
} }
let available_width = form_actual_area.width;
// This change makes the form stay stationary when toggling sidebar let form_render_area = if available_width >= 80 {
let available_width = form_area.width; Layout::default().direction(Direction::Horizontal)
let form_constraint = if available_width >= 80 { .constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
// Use main_content_area for centering when enough space .split(form_actual_area)[1]
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(80),
Constraint::Min(0),
])
.split(main_content_area)[1]
} else { } else {
// Use form_area (post sidebar) when limited space Layout::default().direction(Direction::Horizontal)
Layout::default() .constraints([Constraint::Min(0), Constraint::Length(available_width), Constraint::Min(0)])
.direction(Direction::Horizontal) .split(form_actual_area)[1]
.constraints([
Constraint::Min(0),
Constraint::Length(80.min(available_width)),
Constraint::Min(0),
])
.split(form_area)[1]
}; };
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
// Convert fields to &[&str] and values to &[&String] let values_vec: Vec<&String> = form_state.values.iter().collect();
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect();
let values: Vec<&String> = form_state.values.iter().collect();
render_form( render_form(
f, f, form_render_area, form_state, &fields_vec, &form_state.current_field,
form_constraint, &values_vec, theme, is_event_handler_edit_mode, highlight_state,
form_state, form_state.total_count,
&fields, form_state.current_position,
&form_state.current_field,
&values,
theme,
is_edit_mode,
highlight_state,
total_count,
current_position,
); );
} }
// Render buffer list if enabled and area is available
if let Some(area) = buffer_list_area { if let Some(area) = buffer_list_area {
if app_state.ui.show_buffer_list { render_buffer_list(f, area, theme, buffer_state, app_state);
render_buffer_list(f, area, theme, buffer_state, app_state); }
render_status_line(f, status_line_area, current_dir, theme, is_event_handler_edit_mode, current_fps);
if let Some(palette_or_command_area) = command_render_area { // Use the calculated area
if navigation_state.active {
find_file_palette::render_find_file_palette(
f,
palette_or_command_area, // Use the correct area
theme,
navigation_state, // Pass the navigation_state directly
);
} else if event_handler_command_mode_active {
render_command_line(
f,
palette_or_command_area, // Use the correct area
event_handler_command_input,
true, // Assuming it's always active when this branch is hit
theme,
event_handler_command_message,
);
} }
} }
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);
render_command_line(f, command_line_area, command_input, command_mode, theme, command_message);
} }

View File

@@ -23,42 +23,36 @@ use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui; use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult; use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult; use crate::tui::functions::common::register::RegisterResult;
// Removed: use crate::tui::functions::common::add_table::handle_save_table_action; use crate::ui::handlers::context::DialogPurpose;
// Removed: use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::DialogPurpose; // UiContext removed if not used directly
use crate::tui::functions::common::login; use crate::tui::functions::common::login;
use crate::tui::functions::common::register; use crate::tui::functions::common::register;
use std::time::Instant; use std::time::Instant;
use anyhow::{Context, Result}; use anyhow::{anyhow, Context, Result};
use crossterm::cursor::SetCursorStyle; use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event; use crossterm::event as crossterm_event;
use tracing::{error, info, warn}; // Added warn use tracing::{error, info, warn};
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub async fn run_ui() -> Result<()> { pub async fn run_ui() -> Result<()> {
let config = Config::load().context("Failed to load configuration")?; let config = Config::load().context("Failed to load configuration")?;
let theme = Theme::from_str(&config.colors.theme); let theme = Theme::from_str(&config.colors.theme);
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?; let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
let mut grpc_client = GrpcClient::new().await?; let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
let mut command_handler = CommandHandler::new(); let mut command_handler = CommandHandler::new();
// --- Channel for Login Results --- let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
let (login_result_sender, mut login_result_receiver) = let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
mpsc::channel::<LoginResult>(1); let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
let (register_result_sender, mut register_result_receiver) = let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) =
mpsc::channel::<Result<String>>(1);
let (save_logic_result_sender, _save_logic_result_receiver) = // Prefixed and removed mut
mpsc::channel::<Result<String>>(1);
let mut event_handler = EventHandler::new( let mut event_handler = EventHandler::new(
login_result_sender.clone(), login_result_sender.clone(),
register_result_sender.clone(), register_result_sender.clone(),
save_table_result_sender.clone(), save_table_result_sender.clone(),
save_logic_result_sender.clone(), save_logic_result_sender.clone(),
).await.context("Failed to create event handler")?; )
.await
.context("Failed to create event handler")?;
let event_reader = EventReader::new(); let event_reader = EventReader::new();
let mut auth_state = AuthState::default(); let mut auth_state = AuthState::default();
@@ -69,7 +63,6 @@ pub async fn run_ui() -> Result<()> {
let mut buffer_state = BufferState::default(); let mut buffer_state = BufferState::default();
let mut app_state = AppState::new().context("Failed to create initial app state")?; let mut app_state = AppState::new().context("Failed to create initial app state")?;
// --- DATA: Load auth data from file at startup ---
let mut auto_logged_in = false; let mut auto_logged_in = false;
match load_auth_data() { match load_auth_data() {
Ok(Some(stored_data)) => { Ok(Some(stored_data)) => {
@@ -87,15 +80,34 @@ pub async fn run_ui() -> Result<()> {
error!("Failed to load auth data: {}", e); error!("Failed to load auth data: {}", e);
} }
} }
// --- END DATA ---
let column_names = // Initialize AppState and FormState with table data
UiService::initialize_app_state(&mut grpc_client, &mut app_state) let (initial_profile, initial_table, initial_columns) =
.await.context("Failed to initialize app state from UI service")?; UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
let mut form_state = FormState::new(column_names); .await
.context("Failed to initialize app state and form")?;
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?; let mut form_state = FormState::new(
form_state.reset_to_empty(); initial_profile.clone(),
initial_table.clone(),
initial_columns,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
.await
.context(format!(
"Failed to fetch initial count for table {}.{}",
initial_profile, initial_table
))?;
// Load initial data for the form
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
event_handler.command_message = format!("Error loading initial data: {}", e);
}
} else {
form_state.reset_to_empty();
}
if auto_logged_in { if auto_logged_in {
buffer_state.history = vec![AppView::Form]; buffer_state.history = vec![AppView::Form];
@@ -106,9 +118,10 @@ pub async fn run_ui() -> Result<()> {
let mut last_frame_time = Instant::now(); let mut last_frame_time = Instant::now();
let mut current_fps = 0.0; let mut current_fps = 0.0;
let mut needs_redraw = true; let mut needs_redraw = true;
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
let mut prev_view_table_name = app_state.current_view_table_name.clone();
loop { loop {
// --- Synchronize UI View from Active Buffer ---
if let Some(active_view) = buffer_state.get_active_view() { if let Some(active_view) = buffer_state.get_active_view() {
app_state.ui.show_intro = false; app_state.ui.show_intro = false;
app_state.ui.show_login = false; app_state.ui.show_login = false;
@@ -155,20 +168,59 @@ pub async fn run_ui() -> Result<()> {
AppView::Scratch => {} AppView::Scratch => {}
} }
} }
// --- End Synchronization ---
// --- Handle Pending Table Structure Fetches --- // Handle table change for FormView
if app_state.ui.show_form {
let current_view_profile = app_state.current_view_profile_name.clone();
let current_view_table = app_state.current_view_table_name.clone();
if prev_view_profile_name != current_view_profile || prev_view_table_name != current_view_table {
if let (Some(prof_name), Some(tbl_name)) = (current_view_profile.as_ref(), current_view_table.as_ref()) {
app_state.show_loading_dialog("Loading Table", &format!("Fetching data for {}.{}...", prof_name, tbl_name));
needs_redraw = true;
match grpc_client.get_table_structure(prof_name.clone(), tbl_name.clone()).await {
Ok(structure_response) => {
let new_columns: Vec<String> = structure_response.columns.iter().map(|c| c.name.clone()).collect();
form_state = FormState::new(prof_name.clone(), tbl_name.clone(), new_columns);
if let Err(e) = UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error fetching count: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error loading data: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
app_state.hide_dialog();
}
} else {
form_state.reset_to_empty();
app_state.hide_dialog();
}
}
}
Err(e) => {
app_state.update_dialog_content(&format!("Error fetching table structure: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
app_state.current_view_profile_name = prev_view_profile_name.clone();
app_state.current_view_table_name = prev_view_table_name.clone();
}
}
}
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
needs_redraw = true;
}
}
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() { if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic { if app_state.ui.show_add_logic {
// Ensure admin_state.add_logic_state matches the pending fetch
if admin_state.add_logic_state.profile_name == profile_name && if admin_state.add_logic_state.profile_name == profile_name &&
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) { admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
info!("Fetching table structure for {}.{}", profile_name, table_name); info!("Fetching table structure for {}.{}", profile_name, table_name);
let fetch_message = UiService::initialize_add_logic_table_data( let fetch_message = UiService::initialize_add_logic_table_data(
&mut grpc_client, &mut grpc_client,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
&app_state.profile_tree, // Pass the profile tree &app_state.profile_tree,
).await.unwrap_or_else(|e| { ).await.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e); error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e) format!("Error fetching table structure: {}", e)
@@ -196,7 +248,6 @@ pub async fn run_ui() -> Result<()> {
} }
} }
// --- 3. Draw UI ---
if needs_redraw { if needs_redraw {
terminal.draw(|f| { terminal.draw(|f| {
render_ui( render_ui(
@@ -211,12 +262,11 @@ pub async fn run_ui() -> Result<()> {
&theme, &theme,
event_handler.is_edit_mode, event_handler.is_edit_mode,
&event_handler.highlight_state, &event_handler.highlight_state,
app_state.total_count,
app_state.current_position,
&app_state.current_dir,
&event_handler.command_input, &event_handler.command_input,
event_handler.command_mode, event_handler.command_mode,
&event_handler.command_message, &event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps, current_fps,
&app_state, &app_state,
); );
@@ -224,11 +274,10 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = false; needs_redraw = false;
} }
// --- Handle Pending Column Autocomplete for Table Selection ---
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() { if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
if app_state.ui.show_add_logic { if app_state.ui.show_add_logic {
let profile_name = admin_state.add_logic_state.profile_name.clone(); let profile_name = admin_state.add_logic_state.profile_name.clone();
info!("Fetching columns for table selection: {}.{}", profile_name, table_name); info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await { match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => { Ok(columns) => {
@@ -247,7 +296,6 @@ pub async fn run_ui() -> Result<()> {
} }
} }
// --- Cursor Visibility Logic ---
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state); let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode { match current_mode {
AppMode::Edit => { terminal.show_cursor()?; } AppMode::Edit => { terminal.show_cursor()?; }
@@ -263,16 +311,13 @@ pub async fn run_ui() -> Result<()> {
} }
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; } AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
} }
// --- End Cursor Visibility Logic ---
let total_count = app_state.total_count; let position_before_event = form_state.current_position;
let mut current_position = app_state.current_position;
let position_before_event = current_position;
if app_state.ui.dialog.is_loading { if app_state.ui.dialog.is_loading {
needs_redraw = true; needs_redraw = true;
} }
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new())); let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false; let mut event_processed = false;
if crossterm_event::poll(std::time::Duration::from_millis(1))? { if crossterm_event::poll(std::time::Duration::from_millis(1))? {
@@ -292,42 +337,37 @@ pub async fn run_ui() -> Result<()> {
&mut admin_state, &mut admin_state,
&mut buffer_state, &mut buffer_state,
&mut app_state, &mut app_state,
total_count,
&mut current_position,
).await; ).await;
} }
if event_processed { if event_processed {
needs_redraw = true; needs_redraw = true;
} }
app_state.current_position = current_position;
// --- Check for Login Results from Channel ---
match login_result_receiver.try_recv() { match login_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) { if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true; needs_redraw = true;
} }
} }
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ } Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly."); error!("Login result channel disconnected unexpectedly.");
} }
} }
// --- Check for Register Results from Channel ---
match register_result_receiver.try_recv() { match register_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) { if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true; needs_redraw = true;
} }
} }
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ } Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly."); error!("Register result channel disconnected unexpectedly.");
} }
} }
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() { match save_table_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
app_state.hide_dialog(); app_state.hide_dialog();
@@ -353,13 +393,10 @@ pub async fn run_ui() -> Result<()> {
} }
} }
// --- Centralized Consequence Handling ---
let mut should_exit = false; let mut should_exit = false;
match event_outcome_result { match event_outcome_result {
Ok(outcome) => match outcome { Ok(outcome) => match outcome {
EventOutcome::Ok(_message) => { EventOutcome::Ok(_message) => {}
// Message is often set directly in event_handler.command_message
}
EventOutcome::Exit(message) => { EventOutcome::Exit(message) => {
event_handler.command_message = message; event_handler.command_message = message;
should_exit = true; should_exit = true;
@@ -378,77 +415,93 @@ pub async fn run_ui() -> Result<()> {
format!("Error handling save outcome: {}", e); format!("Error handling save outcome: {}", e);
} }
} }
EventOutcome::ButtonSelected { context: _, index: _ } => { EventOutcome::ButtonSelected { context: _, index: _ } => {}
// Handled within event_handler or specific navigation modules
}
}, },
Err(e) => { Err(e) => {
event_handler.command_message = format!("Error: {}", e); event_handler.command_message = format!("Error: {}", e);
} }
} }
// --- End Consequence Handling ---
// --- Position Change Handling --- // --- MODIFIED: Position Change Handling (operates on form_state) ---
let position_changed = app_state.current_position != position_before_event; let position_changed = form_state.current_position != position_before_event;
let current_total_count = app_state.total_count; // Use current total_count
let mut position_logic_needs_redraw = false; let mut position_logic_needs_redraw = false;
if app_state.ui.show_form { if app_state.ui.show_form { // Only if the form is active
if position_changed && !event_handler.is_edit_mode { if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input(); // This part is okay: update cursor for the current field BEFORE loading new data
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; let current_input_before_load = form_state.get_current_input();
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); let max_cursor_pos_before_load = if !current_input_before_load.is_empty() { current_input_before_load.chars().count() } else { 0 };
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_before_load);
position_logic_needs_redraw = true; position_logic_needs_redraw = true;
if app_state.current_position > current_total_count + 1 { // Validate new form_state.current_position
app_state.current_position = current_total_count + 1; if form_state.total_count > 0 && form_state.current_position > form_state.total_count + 1 {
form_state.current_position = form_state.total_count + 1; // Cap at new entry
} else if form_state.total_count == 0 && form_state.current_position > 1 {
form_state.current_position = 1; // Cap at new entry for empty table
}
if form_state.current_position == 0 && form_state.total_count > 0 {
form_state.current_position = 1; // Don't allow 0 if there are records
} }
if app_state.current_position > current_total_count {
form_state.reset_to_empty();
form_state.current_field = 0;
} else if app_state.current_position >= 1 && app_state.current_position <= current_total_count {
let current_position_to_load = app_state.current_position;
let load_message = UiService::load_adresar_by_position(
&mut grpc_client,
&mut app_state,
&mut form_state,
current_position_to_load,
)
.await.with_context(|| format!("Failed to load adresar by position: {}", current_position_to_load))?;
let current_input_after_load = form_state.get_current_input(); // Load data for the new position OR reset for new entry
let max_cursor_pos_after_load = if !event_handler.is_edit_mode && !current_input_after_load.is_empty() { if (form_state.total_count > 0 && form_state.current_position <= form_state.total_count && form_state.current_position > 0)
current_input_after_load.len() - 1 {
} else { // It's an existing record position
current_input_after_load.len() match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
}; Ok(load_message) => {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_after_load); if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
event_handler.command_message = load_message;
if !load_message.starts_with("Loaded entry") || event_handler.command_message.is_empty() { }
event_handler.command_message = load_message; }
Err(e) => {
event_handler.command_message = format!("Error loading data: {}", e);
// Consider what to do with form_state here - maybe revert position or clear form
}
} }
} else { // current_position is 0 or invalid } else {
app_state.current_position = 1.min(current_total_count + 1); // Position indicates a new entry (or table is empty and position is 1)
if app_state.current_position > current_total_count { // Handles empty db case form_state.reset_to_empty(); // This sets id=0, clears values, and sets current_position correctly
form_state.reset_to_empty(); event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
form_state.current_field = 0;
}
// If db is not empty, this will trigger load in next iteration if position changed to 1
} }
} else if !position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input(); // NOW, after data is loaded or form is reset, get the current input string and its length
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; let current_input_after_load_str = form_state.get_current_input();
let current_input_len_after_load = current_input_after_load_str.chars().count();
let max_cursor_pos_for_readonly_after_load = if current_input_len_after_load > 0 {
current_input_len_after_load.saturating_sub(1)
} else {
0
};
if event_handler.is_edit_mode {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(current_input_len_after_load);
} else {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_for_readonly_after_load);
// The check for empty string is implicitly handled by max_cursor_pos_for_readonly_after_load being 0
}
} else if !position_changed && !event_handler.is_edit_mode && app_state.ui.show_form {
// Update cursor if not editing and position didn't change (e.g. arrow keys within field)
let current_input_str = form_state.get_current_input();
let current_input_len = current_input_str.chars().count();
let max_cursor_pos = if current_input_len > 0 {
current_input_len.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input(); let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input(); let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
@@ -458,18 +511,16 @@ pub async fn run_ui() -> Result<()> {
if position_logic_needs_redraw { if position_logic_needs_redraw {
needs_redraw = true; needs_redraw = true;
} }
// --- End Position Change Handling ---
if should_exit { if should_exit {
return Ok(()); return Ok(());
} }
// --- FPS Calculation ---
let now = Instant::now(); let now = Instant::now();
let frame_duration = now.duration_since(last_frame_time); let frame_duration = now.duration_since(last_frame_time);
last_frame_time = now; last_frame_time = now;
if frame_duration.as_secs_f64() > 1e-6 { // Avoid division by zero if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64(); current_fps = 1.0 / frame_duration.as_secs_f64();
} }
} // End main loop }
} }

View File

@@ -0,0 +1,3 @@
-- Add migration script here
CREATE SCHEMA IF NOT EXISTS gen;

View File

@@ -1,2 +1,3 @@
// src/shared/mod.rs // src/shared/mod.rs
pub mod date_utils; pub mod date_utils;
pub mod schema_qualifier;

View File

@@ -0,0 +1,34 @@
// src/shared/schema_qualifier.rs
use tonic::Status;
/// Qualifies table names with the appropriate schema
///
/// Rules:
/// - Tables created via PostTableDefinition (dynamically created tables) are in 'gen' schema
/// - System tables (like users, profiles) remain in 'public' schema
pub fn qualify_table_name(table_name: &str) -> String {
// Check if table matches the pattern of dynamically created tables (e.g., 2025_something)
if table_name.starts_with(|c: char| c.is_ascii_digit()) && table_name.contains('_') {
format!("gen.\"{}\"", table_name)
} else {
format!("\"{}\"", table_name)
}
}
/// Qualifies table names for data operations
pub fn qualify_table_name_for_data(table_name: &str) -> Result<String, Status> {
Ok(qualify_table_name(table_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qualify_table_name() {
assert_eq!(qualify_table_name("2025_test_schema3"), "gen.\"2025_test_schema3\"");
assert_eq!(qualify_table_name("users"), "\"users\"");
assert_eq!(qualify_table_name("profiles"), "\"profiles\"");
assert_eq!(qualify_table_name("adresar"), "\"adresar\"");
}
}

View File

@@ -1,10 +1,11 @@
// src/table_definition/handlers/post_table_definition.rs
use tonic::Status; use tonic::Status;
use sqlx::{PgPool, Transaction, Postgres}; use sqlx::{PgPool, Transaction, Postgres};
use serde_json::json; use serde_json::json;
use time::OffsetDateTime; use time::OffsetDateTime;
use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse}; use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
const GENERATED_SCHEMA_NAME: &str = "gen";
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[ const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
("text", "TEXT"), ("text", "TEXT"),
("psc", "TEXT"), ("psc", "TEXT"),
@@ -27,7 +28,6 @@ fn sanitize_table_name(s: &str) -> String {
let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "") let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
.trim() .trim()
.to_lowercase(); .to_lowercase();
format!("{}_{}", year, cleaned) format!("{}_{}", year, cleaned)
} }
@@ -47,31 +47,30 @@ fn map_field_type(field_type: &str) -> Result<&str, Status> {
pub async fn post_table_definition( pub async fn post_table_definition(
db_pool: &PgPool, db_pool: &PgPool,
request: PostTableDefinitionRequest, // Removed `mut` since it's not needed here request: PostTableDefinitionRequest,
) -> Result<TableDefinitionResponse, Status> { ) -> Result<TableDefinitionResponse, Status> {
// Validate and sanitize table name let base_name = sanitize_table_name(&request.table_name);
let table_name = sanitize_table_name(&request.table_name); let user_part_cleaned = request.table_name
if !is_valid_identifier(&request.table_name) { .replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
.trim_matches('_')
.to_lowercase();
if !user_part_cleaned.is_empty() && !is_valid_identifier(&user_part_cleaned) {
return Err(Status::invalid_argument("Invalid table name")); return Err(Status::invalid_argument("Invalid table name"));
} else if user_part_cleaned.is_empty() {
return Err(Status::invalid_argument("Table name cannot be empty"));
} }
// Start a transaction to ensure atomicity
let mut tx = db_pool.begin().await let mut tx = db_pool.begin().await
.map_err(|e| Status::internal(format!("Failed to start transaction: {}", e)))?; .map_err(|e| Status::internal(format!("Failed to start transaction: {}", e)))?;
// Execute all database operations within the transaction match execute_table_definition(&mut tx, request, base_name).await {
let result = execute_table_definition(&mut tx, request, table_name).await;
// Commit or rollback based on the result
match result {
Ok(response) => { Ok(response) => {
// Commit the transaction
tx.commit().await tx.commit().await
.map_err(|e| Status::internal(format!("Failed to commit transaction: {}", e)))?; .map_err(|e| Status::internal(format!("Failed to commit transaction: {}", e)))?;
Ok(response) Ok(response)
}, },
Err(e) => { Err(e) => {
// Explicitly roll back the transaction (optional but good for clarity)
let _ = tx.rollback().await; let _ = tx.rollback().await;
Err(e) Err(e)
} }
@@ -83,7 +82,6 @@ async fn execute_table_definition(
mut request: PostTableDefinitionRequest, mut request: PostTableDefinitionRequest,
table_name: String, table_name: String,
) -> Result<TableDefinitionResponse, Status> { ) -> Result<TableDefinitionResponse, Status> {
// Lookup or create profile
let profile = sqlx::query!( let profile = sqlx::query!(
"INSERT INTO profiles (name) VALUES ($1) "INSERT INTO profiles (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
@@ -94,7 +92,6 @@ async fn execute_table_definition(
.await .await
.map_err(|e| Status::internal(format!("Profile error: {}", e)))?; .map_err(|e| Status::internal(format!("Profile error: {}", e)))?;
// Process table links
let mut links = Vec::new(); let mut links = Vec::new();
for link in request.links.drain(..) { for link in request.links.drain(..) {
let linked_table = sqlx::query!( let linked_table = sqlx::query!(
@@ -114,7 +111,6 @@ async fn execute_table_definition(
links.push((linked_id, link.required)); links.push((linked_id, link.required));
} }
// Process columns
let mut columns = Vec::new(); let mut columns = Vec::new();
for col_def in request.columns.drain(..) { for col_def in request.columns.drain(..) {
let col_name = sanitize_identifier(&col_def.name); let col_name = sanitize_identifier(&col_def.name);
@@ -125,20 +121,20 @@ async fn execute_table_definition(
columns.push(format!("\"{}\" {}", col_name, sql_type)); columns.push(format!("\"{}\" {}", col_name, sql_type));
} }
// Process indexes
let mut indexes = Vec::new(); let mut indexes = Vec::new();
for idx in request.indexes.drain(..) { for idx in request.indexes.drain(..) {
let idx_name = sanitize_identifier(&idx); let idx_name = sanitize_identifier(&idx);
if !is_valid_identifier(&idx) { if !is_valid_identifier(&idx) {
return Err(Status::invalid_argument(format!("Invalid index name: {}", idx))); return Err(Status::invalid_argument(format!("Invalid index name: {}", idx)));
} }
if !columns.iter().any(|c| c.starts_with(&format!("\"{}\"", idx_name))) {
return Err(Status::invalid_argument(format!("Index column {} not found", idx_name)));
}
indexes.push(idx_name); indexes.push(idx_name);
} }
// Generate SQL with multiple links
let (create_sql, index_sql) = generate_table_sql(tx, &table_name, &columns, &indexes, &links).await?; let (create_sql, index_sql) = generate_table_sql(tx, &table_name, &columns, &indexes, &links).await?;
// Store main table definition
let table_def = sqlx::query!( let table_def = sqlx::query!(
r#"INSERT INTO table_definitions r#"INSERT INTO table_definitions
(profile_id, table_name, columns, indexes) (profile_id, table_name, columns, indexes)
@@ -146,8 +142,8 @@ async fn execute_table_definition(
RETURNING id"#, RETURNING id"#,
profile.id, profile.id,
&table_name, &table_name,
json!(columns), json!(request.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()),
json!(indexes) json!(request.indexes.iter().map(|i| i.clone()).collect::<Vec<_>>())
) )
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await .await
@@ -160,7 +156,6 @@ async fn execute_table_definition(
Status::internal(format!("Database error: {}", e)) Status::internal(format!("Database error: {}", e))
})?; })?;
// Store relationships
for (linked_id, is_required) in links { for (linked_id, is_required) in links {
sqlx::query!( sqlx::query!(
"INSERT INTO table_definition_links "INSERT INTO table_definition_links
@@ -175,7 +170,6 @@ async fn execute_table_definition(
.map_err(|e| Status::internal(format!("Failed to save link: {}", e)))?; .map_err(|e| Status::internal(format!("Failed to save link: {}", e)))?;
} }
// Execute generated SQL within the transaction
sqlx::query(&create_sql) sqlx::query(&create_sql)
.execute(&mut **tx) .execute(&mut **tx)
.await .await
@@ -201,60 +195,60 @@ async fn generate_table_sql(
indexes: &[String], indexes: &[String],
links: &[(i64, bool)], links: &[(i64, bool)],
) -> Result<(String, Vec<String>), Status> { ) -> Result<(String, Vec<String>), Status> {
let qualified_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, table_name);
let mut system_columns = vec![ let mut system_columns = vec![
"id BIGSERIAL PRIMARY KEY".to_string(), "id BIGSERIAL PRIMARY KEY".to_string(),
"deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(), "deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(),
]; ];
// Add foreign key columns
let mut link_info = Vec::new();
for (linked_id, required) in links { for (linked_id, required) in links {
let linked_table = get_table_name_by_id(tx, *linked_id).await?; let linked_table = get_table_name_by_id(tx, *linked_id).await?;
let qualified_linked_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, linked_table);
// Extract base name after year prefix
let base_name = linked_table.split_once('_') let base_name = linked_table.split_once('_')
.map(|(_, rest)| rest) .map(|(_, rest)| rest)
.unwrap_or(&linked_table) .unwrap_or(&linked_table)
.to_string(); .to_string();
let null_clause = if *required { "NOT NULL" } else { "" }; let null_clause = if *required { "NOT NULL" } else { "" };
system_columns.push( system_columns.push(
format!("\"{0}_id\" BIGINT {1} REFERENCES \"{2}\"(id)", format!("\"{0}_id\" BIGINT {1} REFERENCES {2}(id)",
base_name, null_clause, linked_table base_name, null_clause, qualified_linked_table
) )
); );
link_info.push((base_name, linked_table));
} }
// Combine all columns
let all_columns = system_columns let all_columns = system_columns
.iter() .iter()
.chain(columns.iter()) .chain(columns.iter())
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Build CREATE TABLE statement
let create_sql = format!( let create_sql = format!(
"CREATE TABLE \"{}\" (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)", "CREATE TABLE {} (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)",
table_name, qualified_table,
all_columns.join(",\n ") all_columns.join(",\n ")
); );
// Generate indexes let mut all_indexes = Vec::new();
let mut system_indexes = Vec::new(); for (linked_id, _) in links {
for (base_name, _) in &link_info { let linked_table = get_table_name_by_id(tx, *linked_id).await?;
system_indexes.push(format!( let base_name = linked_table.split_once('_')
"CREATE INDEX idx_{}_{}_fk ON \"{}\" (\"{}_id\")", .map(|(_, rest)| rest)
table_name, base_name, table_name, base_name .unwrap_or(&linked_table)
.to_string();
all_indexes.push(format!(
"CREATE INDEX \"idx_{}_{}_fk\" ON {} (\"{}_id\")",
table_name, base_name, qualified_table, base_name
)); ));
} }
let all_indexes = system_indexes for idx in indexes {
.into_iter() all_indexes.push(format!(
.chain(indexes.iter().map(|idx| { "CREATE INDEX \"idx_{}_{}\" ON {} (\"{}\")",
format!("CREATE INDEX idx_{}_{} ON \"{}\" (\"{}\")", table_name, idx, qualified_table, idx
table_name, idx, table_name, idx) ));
})) }
.collect();
Ok((create_sql, all_indexes)) Ok((create_sql, all_indexes))
} }

View File

@@ -2,7 +2,7 @@
use common::proto::multieko2::table_structure::{ use common::proto::multieko2::table_structure::{
GetTableStructureRequest, TableColumn, TableStructureResponse, GetTableStructureRequest, TableColumn, TableStructureResponse,
}; };
use sqlx::{PgPool, Row}; use sqlx::PgPool;
use tonic::Status; use tonic::Status;
// Helper struct to map query results // Helper struct to map query results
@@ -19,8 +19,8 @@ pub async fn get_table_structure(
request: GetTableStructureRequest, request: GetTableStructureRequest,
) -> Result<TableStructureResponse, Status> { ) -> Result<TableStructureResponse, Status> {
let profile_name = request.profile_name; let profile_name = request.profile_name;
let table_name = request.table_name; // This should be the full table name, e.g., "2025_adresar6" let table_name = request.table_name;
let table_schema = "public"; // Assuming tables are in the 'public' schema let table_schema = "gen";
// 1. Validate Profile // 1. Validate Profile
let profile = sqlx::query!( let profile = sqlx::query!(

View File

@@ -2,6 +2,7 @@
use tonic::Status; use tonic::Status;
use sqlx::PgPool; use sqlx::PgPool;
use common::proto::multieko2::tables_data::{DeleteTableDataRequest, DeleteTableDataResponse}; use common::proto::multieko2::tables_data::{DeleteTableDataRequest, DeleteTableDataResponse};
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn delete_table_data( pub async fn delete_table_data(
db_pool: &PgPool, db_pool: &PgPool,
@@ -36,20 +37,37 @@ pub async fn delete_table_data(
return Err(Status::not_found("Table not found in profile")); return Err(Status::not_found("Table not found in profile"));
} }
// Perform soft delete // Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&request.table_name)?;
// Perform soft delete using qualified table name
let query = format!( let query = format!(
"UPDATE \"{}\" "UPDATE {}
SET deleted = true SET deleted = true
WHERE id = $1 AND deleted = false", WHERE id = $1 AND deleted = false",
request.table_name qualified_table
); );
let rows_affected = sqlx::query(&query) let result = sqlx::query(&query)
.bind(request.record_id) .bind(request.record_id)
.execute(db_pool) .execute(db_pool)
.await .await;
.map_err(|e| Status::internal(format!("Delete operation failed: {}", e)))?
.rows_affected(); let rows_affected = match result {
Ok(result) => result.rows_affected(),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
request.table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Delete operation failed: {}", e)));
}
};
Ok(DeleteTableDataResponse { Ok(DeleteTableDataResponse {
success: rows_affected > 0, success: rows_affected > 0,

View File

@@ -3,6 +3,7 @@ use tonic::Status;
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::collections::HashMap; use std::collections::HashMap;
use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse}; use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse};
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn get_table_data( pub async fn get_table_data(
db_pool: &PgPool, db_pool: &PgPool,
@@ -69,20 +70,36 @@ pub async fn get_table_data(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&table_name)?;
let sql = format!( let sql = format!(
"SELECT {} FROM \"{}\" WHERE id = $1 AND deleted = false", "SELECT {} FROM {} WHERE id = $1 AND deleted = false",
columns_clause, table_name columns_clause, qualified_table
); );
// Execute query // Execute query with enhanced error handling
let row = sqlx::query(&sql) let row_result = sqlx::query(&sql)
.bind(record_id) .bind(record_id)
.fetch_one(db_pool) .fetch_one(db_pool)
.await .await;
.map_err(|e| match e {
sqlx::Error::RowNotFound => Status::not_found("Record not found"), let row = match row_result {
_ => Status::internal(format!("Database error: {}", e)), Ok(row) => row,
})?; Err(sqlx::Error::RowNotFound) => return Err(Status::not_found("Record not found")),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Database error: {}", e)));
}
};
// Build response data // Build response data
let mut data = HashMap::new(); let mut data = HashMap::new();

View File

@@ -5,6 +5,7 @@ use common::proto::multieko2::tables_data::{
GetTableDataByPositionRequest, GetTableDataRequest, GetTableDataResponse GetTableDataByPositionRequest, GetTableDataRequest, GetTableDataResponse
}; };
use super::get_table_data; use super::get_table_data;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn get_table_data_by_position( pub async fn get_table_data_by_position(
db_pool: &PgPool, db_pool: &PgPool,
@@ -27,39 +28,55 @@ pub async fn get_table_data_by_position(
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id; let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
let table_exists = sqlx::query!( let table_exists = sqlx::query_scalar!(
r#"SELECT EXISTS( r#"SELECT EXISTS(
SELECT 1 FROM table_definitions SELECT 1 FROM table_definitions
WHERE profile_id = $1 AND table_name = $2 WHERE profile_id = $1 AND table_name = $2
)"#, ) AS "exists!""#,
profile_id, profile_id,
table_name table_name
) )
.fetch_one(db_pool) .fetch_one(db_pool)
.await .await
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))? .map_err(|e| Status::internal(format!("Table verification error: {}", e)))?;
.exists
.unwrap_or(false);
if !table_exists { if !table_exists {
return Err(Status::not_found("Table not found")); return Err(Status::not_found("Table not found"));
} }
let id: i64 = sqlx::query_scalar( // Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&table_name)?;
let id_result = sqlx::query_scalar(
&format!( &format!(
r#"SELECT id FROM "{}" r#"SELECT id FROM {}
WHERE deleted = FALSE WHERE deleted = FALSE
ORDER BY id ASC ORDER BY id ASC
OFFSET $1 OFFSET $1
LIMIT 1"#, LIMIT 1"#,
table_name qualified_table
) )
) )
.bind(request.position - 1) .bind(request.position - 1)
.fetch_optional(db_pool) .fetch_optional(db_pool)
.await .await;
.map_err(|e| Status::internal(format!("Position query failed: {}", e)))?
.ok_or_else(|| Status::not_found("Position out of bounds"))?; let id: i64 = match id_result {
Ok(Some(id)) => id,
Ok(None) => return Err(Status::not_found("Position out of bounds")),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Position query failed: {}", e)));
}
};
get_table_data( get_table_data(
db_pool, db_pool,

View File

@@ -3,59 +3,93 @@ use tonic::Status;
use sqlx::PgPool; use sqlx::PgPool;
use common::proto::multieko2::common::CountResponse; use common::proto::multieko2::common::CountResponse;
use common::proto::multieko2::tables_data::GetTableDataCountRequest; use common::proto::multieko2::tables_data::GetTableDataCountRequest;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // 1. IMPORT THE FUNCTION
pub async fn get_table_data_count( pub async fn get_table_data_count(
db_pool: &PgPool, db_pool: &PgPool,
request: GetTableDataCountRequest, request: GetTableDataCountRequest,
) -> Result<CountResponse, Status> { ) -> Result<CountResponse, Status> {
let profile_name = request.profile_name; // We still need to verify that the table is logically defined for the profile.
let table_name = request.table_name; // The schema qualifier handles *how* to access it physically, but this check
// ensures the request is valid in the context of the application's definitions.
// Lookup profile
let profile = sqlx::query!( let profile = sqlx::query!(
"SELECT id FROM profiles WHERE name = $1", "SELECT id FROM profiles WHERE name = $1",
profile_name request.profile_name
) )
.fetch_optional(db_pool) .fetch_optional(db_pool)
.await .await
.map_err(|e| Status::internal(format!("Profile lookup error: {}", e)))?; .map_err(|e| Status::internal(format!("Profile lookup error for '{}': {}", request.profile_name, e)))?;
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id; let profile_id = match profile {
Some(p) => p.id,
None => return Err(Status::not_found(format!("Profile '{}' not found", request.profile_name))),
};
// Verify table exists and belongs to profile let table_defined_for_profile = sqlx::query_scalar!(
let table_exists = sqlx::query!(
r#"SELECT EXISTS( r#"SELECT EXISTS(
SELECT 1 FROM table_definitions SELECT 1 FROM table_definitions
WHERE profile_id = $1 AND table_name = $2 WHERE profile_id = $1 AND table_name = $2
)"#, ) AS "exists!" "#, // Added AS "exists!" for clarity with sqlx macro
profile_id, profile_id,
table_name request.table_name
) )
.fetch_one(db_pool) .fetch_one(db_pool)
.await .await
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))? .map_err(|e| Status::internal(format!("Table definition verification error for '{}.{}': {}", request.profile_name, request.table_name, e)))?;
.exists
.unwrap_or(false);
if !table_exists { if !table_defined_for_profile {
return Err(Status::not_found("Table not found")); // If the table isn't even defined for this profile in table_definitions,
// it's an error, regardless of whether a physical table with that name exists somewhere.
return Err(Status::not_found(format!(
"Table '{}' is not defined for profile '{}'",
request.table_name, request.profile_name
)));
} }
// Get count of non-deleted records // 2. QUALIFY THE TABLE NAME using the imported function
let query = format!( let qualified_table_name = qualify_table_name_for_data(&request.table_name)?;
// 3. USE THE QUALIFIED NAME in the SQL query
let query_sql = format!(
r#" r#"
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM "{}" FROM {}
WHERE deleted = FALSE WHERE deleted = FALSE
"#, "#,
table_name qualified_table_name // Use the schema-qualified name here
); );
let count: i64 = sqlx::query_scalar::<_, Option<i64>>(&query) // The rest of the logic remains largely the same, but error messages can be more specific.
let count_result = sqlx::query_scalar::<_, Option<i64>>(&query_sql)
.fetch_one(db_pool) .fetch_one(db_pool)
.await .await;
.map_err(|e| Status::internal(format!("Count query failed: {}", e)))?
.unwrap_or(0);
Ok(CountResponse { count }) match count_result {
Ok(Some(count_val)) => Ok(CountResponse { count: count_val }),
Ok(None) => {
// This case should ideally not be reached with COUNT(*),
// as it always returns a row, even if the count is 0.
// If it does, it might indicate an issue or an empty table if the query was different.
// For COUNT(*), a 0 count is expected if no non-deleted rows.
Ok(CountResponse { count: 0 })
}
Err(e) => {
// Check if the error is "relation does not exist" (PostgreSQL error code 42P01)
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
// This means the table (e.g., gen."2025_test_schema3") does not physically exist,
// even though it was defined in table_definitions. This is an inconsistency.
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}.",
request.table_name, qualified_table_name
)));
}
}
// For other errors, provide a general message.
Err(Status::internal(format!(
"Count query failed for table {}: {}",
qualified_table_name, e
)))
}
}
} }

View File

@@ -6,6 +6,7 @@ use chrono::{DateTime, Utc};
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse}; use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
use crate::steel::server::execution::{self, Value}; use crate::steel::server::execution::{self, Value};
use crate::steel::server::functions::SteelContext; use crate::steel::server::functions::SteelContext;
@@ -97,7 +98,7 @@ pub async fn post_table_data(
// Validate all data columns // Validate all data columns
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect(); let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
for key in data.keys() { for key in data.keys() {
if !system_columns_set.contains(key.as_str()) && if !system_columns_set.contains(key.as_str()) &&
!user_columns.contains(&&key.to_string()) { !user_columns.contains(&&key.to_string()) {
return Err(Status::invalid_argument(format!("Invalid column: {}", key))); return Err(Status::invalid_argument(format!("Invalid column: {}", key)));
} }
@@ -123,13 +124,12 @@ pub async fn post_table_data(
// Create execution context // Create execution context
let context = SteelContext { let context = SteelContext {
current_table: table_name.clone(), current_table: table_name.clone(), // Keep base name for scripts
profile_id, profile_id,
row_data: data.clone(), row_data: data.clone(),
db_pool: Arc::new(db_pool.clone()), db_pool: Arc::new(db_pool.clone()),
}; };
// Execute validation script // Execute validation script
let script_result = execution::execute_script( let script_result = execution::execute_script(
script_record.script, script_record.script,
@@ -220,17 +220,36 @@ pub async fn post_table_data(
return Err(Status::invalid_argument("No valid columns to insert")); return Err(Status::invalid_argument("No valid columns to insert"));
} }
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&table_name)?;
let sql = format!( let sql = format!(
"INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING id", "INSERT INTO {} ({}) VALUES ({}) RETURNING id",
table_name, qualified_table,
columns_list.join(", "), columns_list.join(", "),
placeholders.join(", ") placeholders.join(", ")
); );
let inserted_id: i64 = sqlx::query_scalar_with(&sql, params) // Execute query with enhanced error handling
let result = sqlx::query_scalar_with::<_, i64, _>(&sql, params)
.fetch_one(db_pool) .fetch_one(db_pool)
.await .await;
.map_err(|e| Status::internal(format!("Insert failed: {}", e)))?;
let inserted_id = match result {
Ok(id) => id,
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Insert failed: {}", e)));
}
};
Ok(PostTableDataResponse { Ok(PostTableDataResponse {
success: true, success: true,

View File

@@ -5,6 +5,7 @@ use sqlx::postgres::PgArguments;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse}; use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse};
use std::collections::HashMap; use std::collections::HashMap;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn put_table_data( pub async fn put_table_data(
db_pool: &PgPool, db_pool: &PgPool,
@@ -13,18 +14,18 @@ pub async fn put_table_data(
let profile_name = request.profile_name; let profile_name = request.profile_name;
let table_name = request.table_name; let table_name = request.table_name;
let record_id = request.id; let record_id = request.id;
// Preprocess and validate data // Preprocess and validate data
let mut processed_data = HashMap::new(); let mut processed_data = HashMap::new();
let mut null_fields = Vec::new(); let mut null_fields = Vec::new();
for (key, value) in request.data { for (key, value) in request.data {
let trimmed = value.trim().to_string(); let trimmed = value.trim().to_string();
if key == "firma" && trimmed.is_empty() { if key == "firma" && trimmed.is_empty() {
return Err(Status::invalid_argument("Firma cannot be empty")); return Err(Status::invalid_argument("Firma cannot be empty"));
} }
// Store fields that should be set to NULL // Store fields that should be set to NULL
if key != "firma" && trimmed.is_empty() { if key != "firma" && trimmed.is_empty() {
null_fields.push(key); null_fields.push(key);
@@ -103,7 +104,6 @@ pub async fn put_table_data(
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))? .ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
}; };
// TODO strong testing by user pick in the future
match sql_type { match sql_type {
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => { "TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(") if let Some(max_len) = sql_type.strip_prefix("VARCHAR(")
@@ -121,7 +121,7 @@ pub async fn put_table_data(
let val = value.parse::<bool>() let val = value.parse::<bool>()
.map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?; .map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?;
params.add(val) params.add(val)
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}: {}", col, e)))?; .map_err(|e| Status::internal(format!("Failed to add boolean parameter for {} {}", col, e)))?;
}, },
"TIMESTAMPTZ" => { "TIMESTAMPTZ" => {
let dt = DateTime::parse_from_rfc3339(value) let dt = DateTime::parse_from_rfc3339(value)
@@ -154,25 +154,39 @@ pub async fn put_table_data(
params.add(record_id) params.add(record_id)
.map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?; .map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?;
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&table_name)?;
let set_clause = set_clauses.join(", "); let set_clause = set_clauses.join(", ");
let sql = format!( let sql = format!(
"UPDATE \"{}\" SET {} WHERE id = ${} AND deleted = FALSE RETURNING id", "UPDATE {} SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
table_name, qualified_table,
set_clause, set_clause,
param_idx param_idx
); );
let result = sqlx::query_scalar_with::<Postgres, i64, _>(&sql, params) let result = sqlx::query_scalar_with::<Postgres, i64, _>(&sql, params)
.fetch_optional(db_pool) .fetch_optional(db_pool)
.await .await;
.map_err(|e| Status::internal(format!("Update failed: {}", e)))?;
match result { match result {
Some(updated_id) => Ok(PutTableDataResponse { Ok(Some(updated_id)) => Ok(PutTableDataResponse {
success: true, success: true,
message: "Data updated successfully".into(), message: "Data updated successfully".into(),
updated_id, updated_id,
}), }),
None => Err(Status::not_found("Record not found or already deleted")), Ok(None) => Err(Status::not_found("Record not found or already deleted")),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
Err(Status::internal(format!("Update failed: {}", e)))
}
} }
} }