diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index ca69cc7..0000000 --- a/client/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -canvas_config.toml.txt -ui_debug.log diff --git a/client/Cargo.toml b/client/Cargo.toml deleted file mode 100644 index 428b140..0000000 --- a/client/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -name = "client" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -anyhow = { workspace = true } -async-trait = "0.1.88" -common = { path = "../common" } -canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap", "validation"] } - -ratatui = { workspace = true } -crossterm = { workspace = true } -prost-types = { workspace = true } -dirs = "6.0.0" -dotenvy = "0.15.7" -lazy_static = "1.5.0" -prost = "0.13.5" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -time = "0.3.41" -tokio = { version = "1.44.2", features = ["full", "macros"] } -toml = { workspace = true } -tonic = "0.13.0" -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] } -unicode-segmentation = "1.12.0" -unicode-width.workspace = true - -[features] -default = ["validation"] -ui-debug = [] -validation = [] - -[dev-dependencies] -rstest = "0.25.0" -tokio-test = "0.4.4" -uuid = { version = "1.17.0", features = ["v4"] } -futures = "0.3.31" diff --git a/client/config.toml b/client/config.toml deleted file mode 100644 index 764dd4b..0000000 --- a/client/config.toml +++ /dev/null @@ -1,132 +0,0 @@ -# config.toml -[keybindings] - -enter_command_mode = [":", "ctrl+;"] -next_buffer = ["space+b+n"] -previous_buffer = ["space+b+p"] -close_buffer = ["space+b+d"] -revert = ["space+b+r"] - -[keybindings.general] -up = ["k", "Up"] -down = ["j", "Down"] -left = ["h", "Left"] -right = ["l", "Right"] -next = ["Tab"] -previous = ["Shift+Tab"] -select = ["Enter"] -esc = ["esc"] -open_search = ["ctrl+f"] - -[keybindings.common] -save = ["ctrl+s"] -quit = ["ctrl+q"] - -force_quit = ["ctrl+shift+q"] -save_and_quit = ["ctrl+shift+s"] -move_up = ["Up"] -move_down = ["Down"] -toggle_sidebar = ["ctrl+t"] -toggle_buffer_list = ["ctrl+b"] - -# MODE SPECIFIC -# READ ONLY MODE -[keybindings.read_only] -enter_edit_mode_before = ["i"] -enter_edit_mode_after = ["a"] -previous_entry = ["left","q"] -next_entry = ["right","1"] - -enter_highlight_mode = ["v"] -enter_highlight_mode_linewise = ["shift+v"] - -### AUTOGENERATED CANVAS CONFIG -# Required -move_up = ["k", "Up"] -move_left = ["h", "Left"] -move_right = ["l", "Right"] -move_down = ["j", "Down"] -# Optional -move_line_end = ["$"] -move_word_next = ["w"] -next_field = ["Tab"] -move_word_prev = ["b"] -move_word_end = ["e"] -move_last_line = ["shift+g"] -move_word_end_prev = ["ge"] -move_line_start = ["0"] -move_first_line = ["g+g"] -prev_field = ["Shift+Tab"] - -[keybindings.highlight] -exit_highlight_mode = ["esc"] -enter_highlight_mode_linewise = ["shift+v"] - -### AUTOGENERATED CANVAS CONFIG -# Required -move_left = ["h", "Left"] -move_right = ["l", "Right"] -move_up = ["k", "Up"] -move_down = ["j", "Down"] -# Optional -move_word_next = ["w"] -move_line_start = ["0"] -move_line_end = ["$"] -move_word_prev = ["b"] -move_word_end = ["e"] - - -[keybindings.edit] -# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE -# exit_edit_mode = ["esc","ctrl+e"] -# exit_suggestion_mode = ["esc"] -# select_suggestion = ["enter"] -# next_field = ["enter"] -enter_decider = ["enter"] -exit = ["esc", "ctrl+e"] -suggestion_down = ["ctrl+n", "tab"] -suggestion_up = ["ctrl+p", "shift+tab"] - -### AUTOGENERATED CANVAS CONFIG -# Required -move_right = ["Right"] -delete_char_backward = ["Backspace"] -next_field = ["Tab", "Enter"] -move_up = ["Up"] -move_down = ["Down"] -prev_field = ["Shift+Tab"] -move_left = ["Left"] -# Optional -move_last_line = ["Ctrl+End"] -delete_char_forward = ["Delete"] -move_word_prev = ["Ctrl+Left"] -# move_word_end = ["e"] -# move_word_end_prev = ["ge"] -move_first_line = ["Ctrl+Home"] -move_word_next = ["Ctrl+Right"] -move_line_start = ["Home"] -move_line_end = ["End"] - -[keybindings.command] -exit_command_mode = ["ctrl+g", "esc"] -command_execute = ["enter"] -command_backspace = ["backspace"] -save = ["w"] -quit = ["q"] -force_quit = ["q!"] -save_and_quit = ["wq"] -revert = ["r"] -find_file_palette_toggle = ["ff"] - -[editor] -keybinding_mode = "vim" # Options: "default", "vim", "emacs" - -[colors] -theme = "dark" -# Options: "light", "dark", "high_contrast" - - - - - - diff --git a/client/docs/canvas_add_functionality.md b/client/docs/canvas_add_functionality.md deleted file mode 100644 index 39216ae..0000000 --- a/client/docs/canvas_add_functionality.md +++ /dev/null @@ -1,124 +0,0 @@ -## How Canvas Library Custom Functionality Works - -### 1. **The Canvas Library Calls YOUR Custom Code First** - -When you call `ActionDispatcher::dispatch()`, here's what happens: - -```rust -// Inside canvas library (canvas/src/actions/edit.rs): -pub async fn execute_canvas_action( - action: CanvasAction, - state: &mut S, - ideal_cursor_column: &mut usize, -) -> Result { - // 1. FIRST: Canvas library calls YOUR custom handler - if let Some(result) = state.handle_feature_action(&action, &context) { - return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it - } - - // 2. ONLY IF your code returns None: Canvas handles generic actions - handle_generic_canvas_action(action, state, ideal_cursor_column).await -} -``` - -### 2. **Your Extension Point: `handle_feature_action`** - -You add custom functionality by implementing `handle_feature_action` in your states: - -```rust -// In src/state/pages/auth.rs -impl CanvasState for LoginState { - // ... other methods ... - - fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option { - match action { - // Custom login-specific actions - CanvasAction::Custom(action_str) if action_str == "submit_login" => { - if self.username.is_empty() || self.password.is_empty() { - Some("Please fill in all required fields".to_string()) - } else { - // Trigger login process - Some(format!("Logging in user: {}", self.username)) - } - } - - CanvasAction::Custom(action_str) if action_str == "clear_form" => { - self.username.clear(); - self.password.clear(); - self.set_has_unsaved_changes(false); - Some("Login form cleared".to_string()) - } - - // Custom behavior for standard actions - CanvasAction::NextField => { - // Custom validation when moving between fields - if self.current_field == 0 && self.username.is_empty() { - Some("Username cannot be empty".to_string()) - } else { - None // Let canvas library handle the normal field movement - } - } - - // Let canvas library handle everything else - _ => None, - } - } -} -``` - -### 3. **Multiple Ways to Add Custom Functionality** - -#### A) **Custom Actions via Config** -```toml -# In config.toml -[keybindings.edit] -submit_login = ["ctrl+enter"] -clear_form = ["ctrl+r"] -``` - -#### B) **Override Standard Actions** -```rust -fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option { - match action { - CanvasAction::InsertChar('p') if self.current_field == 1 => { - // Custom behavior when typing 'p' in password field - Some("Password field - use secure input".to_string()) - } - _ => None, // Let canvas handle normally - } -} -``` - -#### C) **Context-Aware Logic** -```rust -fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option { - match action { - CanvasAction::MoveDown => { - // Custom logic based on current state - if context.current_field == 1 && context.current_input.len() < 8 { - Some("Password should be at least 8 characters".to_string()) - } else { - None // Normal field movement - } - } - _ => None, - } -} -``` - -## The Canvas Library Philosophy - -**Canvas Library = Generic behavior + Your extension points** - -- ✅ **Canvas handles**: Character insertion, cursor movement, field navigation, etc. -- ✅ **You handle**: Validation, submission, clearing, app-specific logic -- ✅ **You decide**: Return `Some(message)` to override, `None` to use canvas default - -## Summary - -You **don't communicate with the library elsewhere**. Instead: - -1. **Canvas library calls your code first** via `handle_feature_action` -2. **Your code decides** whether to handle the action or let canvas handle it -3. **Canvas library handles** generic form behavior when you return `None` - diff --git a/client/docs/project_structure.txt b/client/docs/project_structure.txt deleted file mode 100644 index 1c43ff9..0000000 --- a/client/docs/project_structure.txt +++ /dev/null @@ -1,49 +0,0 @@ -client/ -├── Cargo.toml -├── config.toml -└── src/ - ├── main.rs # Entry point with minimal code - ├── lib.rs # Core exports - ├── app.rs # Application lifecycle and main loop - │ - ├── ui/ # UI components and rendering - │ ├── mod.rs - │ ├── theme.rs # Theme definitions (from colors.rs) - │ ├── layout.rs # Layout definitions - │ ├── render.rs # Main render coordinator - │ └── components/ # UI components - │ ├── mod.rs - │ ├── command_line.rs - │ ├── form.rs - │ ├── preview_card.rs - │ └── status_line.rs - │ - ├── input/ # Input handling - │ ├── mod.rs - │ ├── handler.rs # Main input handler (lightweight coordinator) - │ ├── commands.rs # Command processing - │ ├── navigation.rs # Navigation between entries and fields - │ └── edit.rs # Edit mode logic - │ - ├── editor/ # Text editing functionality - │ ├── mod.rs - │ ├── cursor.rs # Cursor movement - │ └── text.rs # Text manipulation (word movements, etc.) - │ - ├── state/ # Application state - │ ├── mod.rs - │ ├── app_state.rs # Main application state - │ └── form_state.rs # Form state - │ - ├── model/ # Data models - │ ├── mod.rs - │ └── entry.rs # Entry model with business logic - │ - ├── service/ # External services - │ ├── mod.rs - │ ├── terminal.rs # Terminal setup and management - │ └── grpc.rs # gRPC client (extracted from terminal.rs) - │ - └── config/ # Configuration - ├── mod.rs - └── keybindings.rs # Keybinding definitions and matching diff --git a/client/src/bottom_panel/command_line.rs b/client/src/bottom_panel/command_line.rs deleted file mode 100644 index 3a5b809..0000000 --- a/client/src/bottom_panel/command_line.rs +++ /dev/null @@ -1,69 +0,0 @@ -// src/components/common/command_line.rs - -use ratatui::{ - widgets::{Block, Paragraph}, - style::Style, - layout::Rect, - Frame, -}; -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, // 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) - } - } - }; - - let content_width = UnicodeWidthStr::width(display_text.as_str()); - let available_width = area.width as usize; - let padding_needed = available_width.saturating_sub(content_width); - - let display_text_padded = if padding_needed > 0 { - format!("{}{}", display_text, " ".repeat(padding_needed)) - } else { - // 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::() - display_text - }; - - // Determine style based on active state, but apply to the whole paragraph - let text_style = if active { - Style::default().fg(theme.accent) - } 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) - }; - - let paragraph = Paragraph::new(display_text_padded) - .block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area - .style(text_style); // Style for the text itself - - f.render_widget(paragraph, area); -} diff --git a/client/src/bottom_panel/find_file_palette.rs b/client/src/bottom_panel/find_file_palette.rs deleted file mode 100644 index 15516af..0000000 --- a/client/src/bottom_panel/find_file_palette.rs +++ /dev/null @@ -1,142 +0,0 @@ -// src/bottom_panel/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 = - 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); -} diff --git a/client/src/bottom_panel/layout.rs b/client/src/bottom_panel/layout.rs deleted file mode 100644 index 15c4731..0000000 --- a/client/src/bottom_panel/layout.rs +++ /dev/null @@ -1,98 +0,0 @@ -// src/bottom_panel/layout.rs - -use ratatui::{layout::Constraint, layout::Rect, Frame}; -use crate::bottom_panel::{status_line::render_status_line, command_line::render_command_line}; -use crate::bottom_panel::find_file_palette; -use crate::config::colors::themes::Theme; -use crate::modes::general::command_navigation::NavigationState; -use crate::state::app::state::AppState; -use crate::pages::routing::Router; - -/// Calculate the layout constraints for the bottom panel (status line + command line/palette). -pub fn bottom_panel_constraints( - app_state: &AppState, - navigation_state: &NavigationState, - event_handler_command_mode_active: bool, -) -> Vec { - let mut status_line_height = 1; - #[cfg(feature = "ui-debug")] - { - if let Some(debug_state) = &app_state.debug_state { - if debug_state.is_error { - status_line_height = 4; - } - } - } - - const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15; - let command_palette_area_height = if navigation_state.active { - 1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT - } else if event_handler_command_mode_active { - 1 - } else { - 0 - }; - - let mut constraints = vec![Constraint::Length(status_line_height)]; - if command_palette_area_height > 0 { - constraints.push(Constraint::Length(command_palette_area_height)); - } - constraints -} - -/// Render the bottom panel (status line + command line/palette). -pub fn render_bottom_panel( - f: &mut Frame, - root_chunks: &[Rect], - chunk_idx: &mut usize, - current_dir: &str, - theme: &Theme, - current_fps: f64, - app_state: &AppState, - router: &Router, - navigation_state: &NavigationState, - event_handler_command_input: &str, - event_handler_command_mode_active: bool, - event_handler_command_message: &str, -) { - // --- Status line area --- - let status_line_area = root_chunks[*chunk_idx]; - *chunk_idx += 1; - - // --- Command line / palette area --- - let command_render_area = if root_chunks.len() > *chunk_idx { - Some(root_chunks[*chunk_idx]) - } else { - None - }; - if command_render_area.is_some() { - *chunk_idx += 1; - } - - // --- Render status line --- - render_status_line( - f, - status_line_area, - current_dir, - theme, - current_fps, - app_state, - router, - ); - - // --- Render command line or palette --- - if let Some(area) = command_render_area { - if navigation_state.active { - find_file_palette::render_find_file_palette(f, area, theme, navigation_state); - } else if event_handler_command_mode_active { - render_command_line( - f, - area, - event_handler_command_input, - true, - theme, - event_handler_command_message, - ); - } - } -} diff --git a/client/src/bottom_panel/mod.rs b/client/src/bottom_panel/mod.rs deleted file mode 100644 index 81e5bfd..0000000 --- a/client/src/bottom_panel/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// src/bottom_panel/mod.rs - -pub mod status_line; -pub mod command_line; -pub mod layout; -pub mod find_file_palette; diff --git a/client/src/bottom_panel/status_line.rs b/client/src/bottom_panel/status_line.rs deleted file mode 100644 index b23374f..0000000 --- a/client/src/bottom_panel/status_line.rs +++ /dev/null @@ -1,169 +0,0 @@ -// client/src/components/common/status_line.rs -use crate::config::colors::themes::Theme; -use crate::state::app::state::AppState; -use ratatui::{ - layout::Rect, - style::Style, - text::{Line, Span, Text}, - widgets::{Paragraph, Wrap}, - Frame, -}; -use crate::pages::routing::Page; -use crate::pages::routing::Router; -use std::path::Path; -use unicode_width::UnicodeWidthStr; - -pub fn render_status_line( - f: &mut Frame, - area: Rect, - current_dir: &str, - theme: &Theme, - current_fps: f64, - app_state: &AppState, - router: &Router, -) { - #[cfg(feature = "ui-debug")] - { - if let Some(debug_state) = &app_state.debug_state { - let paragraph = if debug_state.is_error { - // --- THIS IS THE CRITICAL LOGIC FOR ERRORS --- - // 1. Create a `Text` object, which can contain multiple lines. - let error_text = Text::from(debug_state.displayed_message.clone()); - - // 2. Create a Paragraph from the Text and TELL IT TO WRAP. - Paragraph::new(error_text) - .wrap(Wrap { trim: true }) // This line makes the text break into new rows. - .style(Style::default().bg(theme.highlight).fg(theme.bg)) - } else { - // --- This is for normal, single-line info messages --- - Paragraph::new(debug_state.displayed_message.as_str()) - .style(Style::default().fg(theme.accent).bg(theme.bg)) - }; - f.render_widget(paragraph, area); - } else { - // Fallback for when debug state is None - let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg)); - f.render_widget(paragraph, area); - } - return; // Stop here and don't render the normal status line. - } - - // --- The normal status line rendering logic (unchanged) --- - let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION")); - let mode_text = if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path_ref(path) { - match editor.mode() { - canvas::AppMode::Edit => "[EDIT]", - canvas::AppMode::ReadOnly => "[READ-ONLY]", - canvas::AppMode::Highlight => "[VISUAL]", - _ => "", - } - } else { - "" - } - } else { - "" // No canvas active - }; - - let home_dir = dirs::home_dir() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(); - let display_dir = if current_dir.starts_with(&home_dir) { - current_dir.replacen(&home_dir, "~", 1) - } else { - current_dir.to_string() - }; - - let available_width = area.width as usize; - let mode_width = UnicodeWidthStr::width(mode_text); - let program_info_width = UnicodeWidthStr::width(program_info.as_str()); - let fps_text = format!("{:.0} FPS", current_fps); - let fps_width = UnicodeWidthStr::width(fps_text.as_str()); - let separator = " | "; - let separator_width = UnicodeWidthStr::width(separator); - - let fixed_width_with_fps = mode_width - + separator_width - + separator_width - + program_info_width - + separator_width - + fps_width; - - let show_fps = fixed_width_with_fps <= available_width; - - let remaining_width_for_dir = available_width.saturating_sub( - mode_width - + separator_width - + separator_width - + program_info_width - + (if show_fps { - separator_width + fps_width - } else { - 0 - }), - ); - - let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) - <= remaining_width_for_dir - { - display_dir - } else { - let dir_name = Path::new(current_dir) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(current_dir); - if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir { - dir_name.to_string() - } else { - dir_name - .chars() - .take(remaining_width_for_dir) - .collect::() - } - }; - - 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(separator, Style::default().fg(theme.border)), - Span::styled( - dir_display_text_str.as_str(), - Style::default().fg(theme.fg), - ), - Span::styled(separator, Style::default().fg(theme.border)), - Span::styled( - program_info.as_str(), - Style::default().fg(theme.secondary), - ), - ]; - - if show_fps { - line_spans - .push(Span::styled(separator, Style::default().fg(theme.border))); - line_spans.push(Span::styled( - fps_text.as_str(), - Style::default().fg(theme.secondary), - )); - } - - 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), - )); - } - - let paragraph = - Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg)); - - f.render_widget(paragraph, area); -} diff --git a/client/src/buffer/functions.rs b/client/src/buffer/functions.rs deleted file mode 100644 index 1c22329..0000000 --- a/client/src/buffer/functions.rs +++ /dev/null @@ -1,35 +0,0 @@ -// src/buffer/functions/buffer.rs - -use crate::buffer::state::BufferState; -use crate::buffer::state::AppView; - -pub fn get_view_layer(view: &AppView) -> u8 { - match view { - AppView::Intro => 1, - AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2, - AppView::Form(_) | AppView::Scratch => 3, - } -} - -/// Switches the active buffer index. -pub fn switch_buffer(buffer_state: &mut BufferState, next: bool) -> bool { - if buffer_state.history.len() <= 1 { - return false; - } - - let len = buffer_state.history.len(); - let current_index = buffer_state.active_index; - let new_index = if next { - (current_index + 1) % len - } else { - (current_index + len - 1) % len - }; - - if new_index != current_index { - buffer_state.active_index = new_index; - true - } else { - false - } -} - diff --git a/client/src/buffer/logic.rs b/client/src/buffer/logic.rs deleted file mode 100644 index 86e2a08..0000000 --- a/client/src/buffer/logic.rs +++ /dev/null @@ -1,20 +0,0 @@ -// src/buffer/logic.rs -use crossterm::event::{KeyCode, KeyModifiers}; -use crate::config::binds::config::Config; -use crate::state::app::state::UiState; - -/// Toggle the buffer list visibility based on keybindings. -pub fn toggle_buffer_list( - ui_state: &mut UiState, - config: &Config, - key: KeyCode, - modifiers: KeyModifiers, -) -> bool { - if let Some(action) = config.get_common_action(key, modifiers) { - if action == "toggle_buffer_list" { - ui_state.show_buffer_list = !ui_state.show_buffer_list; - return true; - } - } - false -} diff --git a/client/src/buffer/mod.rs b/client/src/buffer/mod.rs deleted file mode 100644 index a937ca3..0000000 --- a/client/src/buffer/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -// src/buffer/mod.rs - -pub mod state; -pub mod functions; -pub mod ui; -pub mod logic; - -pub use state::{AppView, BufferState}; -pub use functions::*; -pub use ui::render_buffer_list; -pub use logic::toggle_buffer_list; diff --git a/client/src/buffer/state.rs b/client/src/buffer/state.rs deleted file mode 100644 index dc311f4..0000000 --- a/client/src/buffer/state.rs +++ /dev/null @@ -1,123 +0,0 @@ -// src/buffer/state/buffer.rs - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AppView { - Intro, - Login, - Register, - Admin, - AddTable, - AddLogic, - Form(String), - Scratch, -} - -impl AppView { - /// Returns the display name for the view. - /// For Form, pass the current table name to get dynamic naming. - pub fn display_name(&self) -> &str { - match self { - AppView::Intro => "Intro", - AppView::Login => "Login", - AppView::Register => "Register", - AppView::Admin => "Admin_Panel", - AppView::AddTable => "Add_Table", - AppView::AddLogic => "Add_Logic", - AppView::Form(_) => "Form", - AppView::Scratch => "*scratch*", - } - } - - /// Returns the display name with dynamic context (for Form buffers) - pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String { - match self { - AppView::Form(path) => { - // Derive table name from "profile/table" path - let table = path.split('/').nth(1).unwrap_or(""); - if !table.is_empty() { - table.to_string() - } else { - current_table_name.unwrap_or("Data Form").to_string() - } - } - _ => self.display_name().to_string(), - } - } -} - -#[derive(Debug, Clone)] -pub struct BufferState { - pub history: Vec, - pub active_index: usize, -} - -impl Default for BufferState { - fn default() -> Self { - Self { - history: vec![AppView::Intro], - active_index: 0, - } - } -} - -impl BufferState { - pub fn update_history(&mut self, view: AppView) { - let existing_pos = self.history.iter().position(|v| v == &view); - match existing_pos { - Some(pos) => self.active_index = pos, - None => { - self.history.push(view.clone()); - self.active_index = self.history.len() - 1; - } - } - } - - pub fn get_active_view(&self) -> Option<&AppView> { - self.history.get(self.active_index) - } - - pub fn close_active_buffer(&mut self) -> bool { - if self.history.is_empty() { - self.history.push(AppView::Intro); - self.active_index = 0; - return false; - } - - let current_index = self.active_index; - self.history.remove(current_index); - - if self.history.is_empty() { - self.history.push(AppView::Intro); - self.active_index = 0; - } else if self.active_index >= self.history.len() { - self.active_index = self.history.len() - 1; - } - true - } - - pub fn close_buffer_with_intro_fallback(&mut self, current_table_name: Option<&str>) -> String { - let current_view_cloned = self.get_active_view().cloned(); - - if let Some(AppView::Intro) = current_view_cloned { - if self.history.len() == 1 { - self.close_active_buffer(); - return "Intro buffer reset".to_string(); - } - } - - let closed_name = current_view_cloned - .as_ref() - .map(|v| v.display_name_with_context(current_table_name)) - .unwrap_or_else(|| "Unknown".to_string()); - - if self.close_active_buffer() { - if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) { - format!("Closed '{}' - returned to Intro", closed_name) - } else { - format!("Closed '{}'", closed_name) - } - } else { - format!("Buffer '{}' could not be closed", closed_name) - } - } -} diff --git a/client/src/buffer/ui.rs b/client/src/buffer/ui.rs deleted file mode 100644 index 0386845..0000000 --- a/client/src/buffer/ui.rs +++ /dev/null @@ -1,80 +0,0 @@ -// src/buffer/ui.rs - -use crate::config::colors::themes::Theme; -use crate::buffer::state::BufferState; -use crate::state::app::state::AppState; // Add this import -use ratatui::{ - layout::{Alignment, Rect}, - style::Style, - text::{Line, Span}, - widgets::Paragraph, - Frame, -}; -use unicode_width::UnicodeWidthStr; -use crate::buffer::functions::get_view_layer; - -pub fn render_buffer_list( - f: &mut Frame, - area: Rect, - theme: &Theme, - buffer_state: &BufferState, - app_state: &AppState, -) { - // --- Style Definitions --- - let active_style = Style::default() - .fg(theme.bg) - .bg(theme.highlight); - - let inactive_style = Style::default() - .fg(theme.fg) - .bg(theme.bg); - - // --- Determine Active Layer --- - let active_layer = match buffer_state.history.get(buffer_state.active_index) { - Some(view) => get_view_layer(view), - None => 1, - }; - - // --- Create Spans --- - let mut spans = Vec::new(); - let mut current_width = 0; - - let current_table_name = app_state.current_view_table_name.as_deref(); - - for (original_index, view) in buffer_state.history.iter().enumerate() { - // Filter: Only process views matching the active layer - if get_view_layer(view) != active_layer { - continue; - } - - let is_active = original_index == buffer_state.active_index; - let buffer_name = view.display_name_with_context(current_table_name); - let buffer_text = format!(" {} ", buffer_name); - let text_width = UnicodeWidthStr::width(buffer_text.as_str()); - - // Calculate width needed for this buffer (separator + text) - let needed_width = text_width; - if current_width + needed_width > area.width as usize { - break; - } - - // Add the buffer text itself - let text_style = if is_active { active_style } else { inactive_style }; - spans.push(Span::styled(buffer_text, text_style)); - current_width += text_width; - } - - // --- Filler Span --- - let remaining_width = area.width.saturating_sub(current_width as u16); - if !spans.is_empty() || remaining_width > 0 { - spans.push(Span::styled( - " ".repeat(remaining_width as usize), - inactive_style, - )); - } - - // --- Render --- - let buffer_line = Line::from(spans); - let paragraph = Paragraph::new(buffer_line).alignment(Alignment::Left); - f.render_widget(paragraph, area); -} diff --git a/client/src/components/common.rs b/client/src/components/common.rs deleted file mode 100644 index 50597f9..0000000 --- a/client/src/components/common.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/components/common.rs - -pub mod text_editor; -pub mod background; -pub mod autocomplete; - -pub use text_editor::*; -pub use background::*; -pub use autocomplete::*; diff --git a/client/src/components/common/autocomplete.rs b/client/src/components/common/autocomplete.rs deleted file mode 100644 index 48cf0b0..0000000 --- a/client/src/components/common/autocomplete.rs +++ /dev/null @@ -1,153 +0,0 @@ -// src/components/common/autocomplete.rs - -use crate::config::colors::themes::Theme; -use common::proto::komp_ac::search::search_response::Hit; -use crate::pages::forms::FormState; -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - widgets::{Block, List, ListItem, ListState}, - Frame, -}; -use unicode_width::UnicodeWidthStr; - -/// Renders an opaque dropdown list for simple string-based suggestions. -/// THIS IS THE RESTORED FUNCTION. -pub fn render_autocomplete_dropdown( - f: &mut Frame, - input_rect: Rect, - frame_area: Rect, - theme: &Theme, - suggestions: &[String], - selected_index: Option, -) { - if suggestions.is_empty() { - return; - } - let max_suggestion_width = - suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16; - let horizontal_padding: u16 = 2; - let dropdown_width = (max_suggestion_width + horizontal_padding).max(10); - let dropdown_height = (suggestions.len() as u16).min(5); - - let mut dropdown_area = Rect { - x: input_rect.x, - y: input_rect.y + 1, - width: dropdown_width, - height: dropdown_height, - }; - - if dropdown_area.bottom() > frame_area.height { - dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); - } - if dropdown_area.right() > frame_area.width { - dropdown_area.x = frame_area.width.saturating_sub(dropdown_width); - } - dropdown_area.x = dropdown_area.x.max(0); - dropdown_area.y = dropdown_area.y.max(0); - - let background_block = - Block::default().style(Style::default().bg(Color::DarkGray)); - f.render_widget(background_block, dropdown_area); - - let items: Vec = suggestions - .iter() - .enumerate() - .map(|(i, s)| { - let is_selected = selected_index == Some(i); - let s_width = s.width() as u16; - let padding_needed = dropdown_width.saturating_sub(s_width); - let padded_s = - format!("{}{}", s, " ".repeat(padding_needed as usize)); - - ListItem::new(padded_s).style(if is_selected { - Style::default() - .fg(theme.bg) - .bg(theme.highlight) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg).bg(Color::DarkGray) - }) - }) - .collect(); - - let list = List::new(items); - let mut list_state = ListState::default(); - list_state.select(selected_index); - - f.render_stateful_widget(list, dropdown_area, &mut list_state); -} - -/// Renders an opaque dropdown list for rich `Hit`-based suggestions. -/// RENAMED from render_rich_autocomplete_dropdown -pub fn render_hit_autocomplete_dropdown( - f: &mut Frame, - input_rect: Rect, - frame_area: Rect, - theme: &Theme, - suggestions: &[Hit], - selected_index: Option, - form_state: &FormState, -) { - if suggestions.is_empty() { - return; - } - - let display_names: Vec = suggestions - .iter() - .map(|hit| form_state.get_display_name_for_hit(hit)) - .collect(); - - let max_suggestion_width = - display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16; - let horizontal_padding: u16 = 2; - let dropdown_width = (max_suggestion_width + horizontal_padding).max(10); - let dropdown_height = (suggestions.len() as u16).min(5); - - let mut dropdown_area = Rect { - x: input_rect.x, - y: input_rect.y + 1, - width: dropdown_width, - height: dropdown_height, - }; - - if dropdown_area.bottom() > frame_area.height { - dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); - } - if dropdown_area.right() > frame_area.width { - dropdown_area.x = frame_area.width.saturating_sub(dropdown_width); - } - dropdown_area.x = dropdown_area.x.max(0); - dropdown_area.y = dropdown_area.y.max(0); - - let background_block = - Block::default().style(Style::default().bg(Color::DarkGray)); - f.render_widget(background_block, dropdown_area); - - let items: Vec = display_names - .iter() - .enumerate() - .map(|(i, s)| { - let is_selected = selected_index == Some(i); - let s_width = s.width() as u16; - let padding_needed = dropdown_width.saturating_sub(s_width); - let padded_s = - format!("{}{}", s, " ".repeat(padding_needed as usize)); - - ListItem::new(padded_s).style(if is_selected { - Style::default() - .fg(theme.bg) - .bg(theme.highlight) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg).bg(Color::DarkGray) - }) - }) - .collect(); - - let list = List::new(items); - let mut list_state = ListState::default(); - list_state.select(selected_index); - - f.render_stateful_widget(list, dropdown_area, &mut list_state); -} diff --git a/client/src/components/common/background.rs b/client/src/components/common/background.rs deleted file mode 100644 index d5610b7..0000000 --- a/client/src/components/common/background.rs +++ /dev/null @@ -1,15 +0,0 @@ -// src/components/handlers/background.rs -use ratatui::{ - widgets::{Block}, - layout::Rect, - style::Style, - Frame, -}; -use crate::config::colors::themes::Theme; - -pub fn render_background(f: &mut Frame, area: Rect, theme: &Theme) { - let background = Block::default() - .style(Style::default().bg(theme.bg)); - - f.render_widget(background, area); -} diff --git a/client/src/components/common/text_editor.rs b/client/src/components/common/text_editor.rs deleted file mode 100644 index 70eac79..0000000 --- a/client/src/components/common/text_editor.rs +++ /dev/null @@ -1,331 +0,0 @@ -// src/components/common/text_editor.rs -use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; -use crossterm::event::{KeyEvent, KeyCode, KeyModifiers}; -use ratatui::style::{Color, Style, Modifier}; -use tui_textarea::{Input, Key, TextArea, CursorMove}; -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VimMode { - Normal, - Insert, - Visual, - Operator(char), -} - -impl VimMode { - pub fn cursor_style(&self) -> Style { - let color = match self { - Self::Normal => Color::Reset, - Self::Insert => Color::LightBlue, - Self::Visual => Color::LightYellow, - Self::Operator(_) => Color::LightGreen, - }; - Style::default().fg(color).add_modifier(Modifier::REVERSED) - } -} - -impl fmt::Display for VimMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { - match self { - Self::Normal => write!(f, "NORMAL"), - Self::Insert => write!(f, "INSERT"), - Self::Visual => write!(f, "VISUAL"), - Self::Operator(c) => write!(f, "OPERATOR({})", c), - } - } -} - -#[derive(Debug, Clone, PartialEq)] -enum Transition { - Nop, - Mode(VimMode), - Pending(Input), -} - -#[derive(Debug, Clone)] -pub struct VimState { - pub mode: VimMode, - pub pending: Input, -} - -impl Default for VimState { - fn default() -> Self { - Self { - mode: VimMode::Normal, - pending: Input::default(), - } - } -} - -impl VimState { - pub fn new(mode: VimMode) -> Self { - Self { - mode, - pending: Input::default(), - } - } - - fn with_pending(self, pending: Input) -> Self { - Self { - mode: self.mode, - pending, - } - } - - fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition { - if input.key == Key::Null { - return Transition::Nop; - } - - match self.mode { - VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => { - match input { - Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back), - Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down), - Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up), - Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward), - Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward), - Input { key: Key::Char('e'), ctrl: false, .. } => { - textarea.move_cursor(CursorMove::WordEnd); - if matches!(self.mode, VimMode::Operator(_)) { - textarea.move_cursor(CursorMove::Forward); - } - } - Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack), - Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head), - Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End), - Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head), - Input { key: Key::Char('D'), .. } => { - textarea.delete_line_by_end(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('C'), .. } => { - textarea.delete_line_by_end(); - textarea.cancel_selection(); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('p'), .. } => { - textarea.paste(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('u'), ctrl: false, .. } => { - textarea.undo(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('r'), ctrl: true, .. } => { - textarea.redo(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('x'), .. } => { - textarea.delete_next_char(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('i'), .. } => { - textarea.cancel_selection(); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('a'), .. } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::Forward); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('A'), .. } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::End); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('o'), .. } => { - textarea.move_cursor(CursorMove::End); - textarea.insert_newline(); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('O'), .. } => { - textarea.move_cursor(CursorMove::Head); - textarea.insert_newline(); - textarea.move_cursor(CursorMove::Up); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('I'), .. } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::Head); - return Transition::Mode(VimMode::Insert); - } - Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => { - textarea.start_selection(); - return Transition::Mode(VimMode::Visual); - } - Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => { - textarea.move_cursor(CursorMove::Head); - textarea.start_selection(); - textarea.move_cursor(CursorMove::End); - return Transition::Mode(VimMode::Visual); - } - Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => { - textarea.cancel_selection(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('g'), ctrl: false, .. } if matches!( - self.pending, - Input { key: Key::Char('g'), ctrl: false, .. } - ) => { - textarea.move_cursor(CursorMove::Top) - } - Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom), - Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => { - textarea.move_cursor(CursorMove::Head); - textarea.start_selection(); - let cursor = textarea.cursor(); - textarea.move_cursor(CursorMove::Down); - if cursor == textarea.cursor() { - textarea.move_cursor(CursorMove::End); - } - } - Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => { - textarea.start_selection(); - return Transition::Mode(VimMode::Operator(op)); - } - Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => { - textarea.move_cursor(CursorMove::Forward); - textarea.copy(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => { - textarea.move_cursor(CursorMove::Forward); - textarea.cut(); - return Transition::Mode(VimMode::Normal); - } - Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => { - textarea.move_cursor(CursorMove::Forward); - textarea.cut(); - return Transition::Mode(VimMode::Insert); - } - // Arrow keys work in normal mode - Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up), - Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down), - Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back), - Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward), - input => return Transition::Pending(input), - } - - // Handle the pending operator - match self.mode { - VimMode::Operator('y') => { - textarea.copy(); - Transition::Mode(VimMode::Normal) - } - VimMode::Operator('d') => { - textarea.cut(); - Transition::Mode(VimMode::Normal) - } - VimMode::Operator('c') => { - textarea.cut(); - Transition::Mode(VimMode::Insert) - } - _ => Transition::Nop, - } - } - VimMode::Insert => match input { - Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => { - Transition::Mode(VimMode::Normal) - } - input => { - textarea.input(input); - Transition::Mode(VimMode::Insert) - } - }, - } - } -} - -pub struct TextEditor; - -impl TextEditor { - pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> { - let mut textarea = TextArea::default(); - - if editor_config.show_line_numbers { - textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); - } - - textarea.set_tab_length(editor_config.tab_width); - - textarea - } - - pub fn handle_input( - textarea: &mut TextArea<'static>, - key_event: KeyEvent, - keybinding_mode: &EditorKeybindingMode, - vim_state: &mut VimState, - ) -> bool { - match keybinding_mode { - EditorKeybindingMode::Vim => { - Self::handle_vim_input(textarea, key_event, vim_state) - } - _ => { - let tui_input: Input = key_event.into(); - textarea.input(tui_input) - } - } - } - - fn handle_vim_input( - textarea: &mut TextArea<'static>, - key_event: KeyEvent, - vim_state: &mut VimState, - ) -> bool { - let input = Self::convert_key_event_to_input(key_event); - - *vim_state = match vim_state.transition(input, textarea) { - Transition::Mode(mode) if vim_state.mode != mode => { - // Update cursor style based on mode - textarea.set_cursor_style(mode.cursor_style()); - VimState::new(mode) - } - Transition::Nop | Transition::Mode(_) => vim_state.clone(), - Transition::Pending(input) => vim_state.clone().with_pending(input), - }; - - true // Always consider input as handled in vim mode - } - - fn convert_key_event_to_input(key_event: KeyEvent) -> Input { - let key = match key_event.code { - KeyCode::Char(c) => Key::Char(c), - KeyCode::Enter => Key::Enter, - KeyCode::Left => Key::Left, - KeyCode::Right => Key::Right, - KeyCode::Up => Key::Up, - KeyCode::Down => Key::Down, - KeyCode::Backspace => Key::Backspace, - KeyCode::Delete => Key::Delete, - KeyCode::Home => Key::Home, - KeyCode::End => Key::End, - KeyCode::PageUp => Key::PageUp, - KeyCode::PageDown => Key::PageDown, - KeyCode::Tab => Key::Tab, - KeyCode::Esc => Key::Esc, - _ => Key::Null, - }; - - Input { - key, - ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL), - alt: key_event.modifiers.contains(KeyModifiers::ALT), - shift: key_event.modifiers.contains(KeyModifiers::SHIFT), - } - } - - pub fn get_vim_mode_status(vim_state: &VimState) -> String { - vim_state.mode.to_string() - } - - pub fn is_vim_insert_mode(vim_state: &VimState) -> bool { - matches!(vim_state.mode, VimMode::Insert) - } - - pub fn is_vim_normal_mode(vim_state: &VimState) -> bool { - matches!(vim_state.mode, VimMode::Normal) - } -} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs deleted file mode 100644 index ce068b7..0000000 --- a/client/src/components/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/components/mod.rs - -pub mod common; -pub mod utils; - -pub use common::*; -pub use utils::*; diff --git a/client/src/components/utils.rs b/client/src/components/utils.rs deleted file mode 100644 index 1145983..0000000 --- a/client/src/components/utils.rs +++ /dev/null @@ -1,4 +0,0 @@ -// src/components/utils.rs -pub mod text; - -pub use text::*; diff --git a/client/src/components/utils/text.rs b/client/src/components/utils/text.rs deleted file mode 100644 index d37239f..0000000 --- a/client/src/components/utils/text.rs +++ /dev/null @@ -1,29 +0,0 @@ -// src/components/utils/text.rs - -use unicode_width::UnicodeWidthStr; -use unicode_segmentation::UnicodeSegmentation; - -/// Truncates a string to a maximum width, adding an ellipsis if truncated. -/// Considers unicode character widths. -pub fn truncate_string(s: &str, max_width: usize) -> String { - if UnicodeWidthStr::width(s) <= max_width { - s.to_string() - } else { - let ellipsis = "…"; - let ellipsis_width = UnicodeWidthStr::width(ellipsis); - let mut truncated_width = 0; - let mut end_byte_index = 0; - - // Iterate over graphemes to handle multi-byte characters correctly - for (i, g) in s.grapheme_indices(true) { - let char_width = UnicodeWidthStr::width(g); - if truncated_width + char_width + ellipsis_width > max_width { - break; - } - truncated_width += char_width; - end_byte_index = i + g.len(); - } - - format!("{}{}", &s[..end_byte_index], ellipsis) - } -} diff --git a/client/src/config/binds.rs b/client/src/config/binds.rs deleted file mode 100644 index 8c4bed9..0000000 --- a/client/src/config/binds.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/config/binds.rs - -pub mod config; -pub mod key_sequences; - -pub use config::*; -pub use key_sequences::*; diff --git a/client/src/config/binds/config.rs b/client/src/config/binds/config.rs deleted file mode 100644 index 65a8564..0000000 --- a/client/src/config/binds/config.rs +++ /dev/null @@ -1,868 +0,0 @@ -// src/config/binds/config.rs - -use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere -use std::collections::HashMap; -use std::path::Path; -use anyhow::{Context, Result}; -use crossterm::event::{KeyCode, KeyModifiers}; -use canvas::CanvasKeyMap; - -// NEW: Editor Keybinding Mode Enum -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum EditorKeybindingMode { - #[serde(rename = "default")] - Default, - #[serde(rename = "vim")] - Vim, - #[serde(rename = "emacs")] - Emacs, -} - -impl Default for EditorKeybindingMode { - fn default() -> Self { - EditorKeybindingMode::Default - } -} - -// NEW: Editor Configuration Struct -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EditorConfig { - #[serde(default)] - pub keybinding_mode: EditorKeybindingMode, - #[serde(default = "default_show_line_numbers")] - pub show_line_numbers: bool, - #[serde(default = "default_tab_width")] - pub tab_width: u8, -} - -fn default_show_line_numbers() -> bool { - true -} - -fn default_tab_width() -> u8 { - 4 -} - -impl Default for EditorConfig { - fn default() -> Self { - EditorConfig { - keybinding_mode: EditorKeybindingMode::default(), - show_line_numbers: default_show_line_numbers(), - tab_width: default_tab_width(), - } - } -} - -#[derive(Debug, Deserialize, Default)] -pub struct ColorsConfig { - #[serde(default = "default_theme")] - pub theme: String, -} - -fn default_theme() -> String { - "light".to_string() -} - -#[derive(Debug, Deserialize)] -pub struct Config { - #[serde(rename = "keybindings")] - pub keybindings: ModeKeybindings, - #[serde(default)] - pub colors: ColorsConfig, - // NEW: Add editor configuration - #[serde(default)] - pub editor: EditorConfig, -} - -// ... (rest of your Config struct and impl Config remains the same) -// Make sure ModeKeybindings is also deserializable if it's not already -#[derive(Debug, Deserialize, Default)] // Added Default here if not present -pub struct ModeKeybindings { - #[serde(default)] - pub general: HashMap>, - #[serde(default)] - pub read_only: HashMap>, - #[serde(default)] - pub edit: HashMap>, - #[serde(default)] - pub highlight: HashMap>, - #[serde(default)] - pub command: HashMap>, - #[serde(default)] - pub common: HashMap>, - #[serde(flatten)] - pub global: HashMap>, -} - -impl Config { - /// Loads the configuration from "config.toml" in the client crate directory. - pub fn load() -> Result { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let config_path = Path::new(manifest_dir).join("config.toml"); - let config_str = std::fs::read_to_string(&config_path) - .with_context(|| format!("Failed to read config file at {:?}", config_path))?; - let config: Config = toml::from_str(&config_str) - .with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message - Ok(config) - } - - pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Common actions for Edit/Read-only modes - pub fn get_common_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers) - } - - /// Gets an action for a key in Read-Only mode, also checking common keybindings. - pub fn get_read_only_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Gets an action for a key in Edit mode, also checking common keybindings. - pub fn get_edit_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.edit, key, modifiers) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Gets an action for a key in Highlight mode, also checking common/global keybindings. - pub fn get_highlight_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.highlight, key, modifiers) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Gets an action for a key in Command mode, also checking common keybindings. - pub fn get_command_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { - self.get_action_for_key_in_mode(&self.keybindings.command, key, modifiers) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - - /// Context-aware keybinding resolution - pub fn get_action_for_current_context( - &self, - command_mode: bool, - key: KeyCode, - modifiers: KeyModifiers - ) -> Option<&str> { - if command_mode { - self.get_command_action_for_key(key, modifiers) - } else { - // fallback: read-only + common + global - self.get_read_only_action_for_key(key, modifiers) - .or_else(|| self.get_common_action(key, modifiers)) - .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) - } - } - - /// Helper function to get an action for a key in a specific mode. - pub fn get_action_for_key_in_mode<'a>( - &self, - mode_bindings: &'a HashMap>, - key: KeyCode, - modifiers: KeyModifiers, - ) -> Option<&'a str> { - for (action, bindings) in mode_bindings { - for binding in bindings { - if Self::matches_keybinding(binding, key, modifiers) { - return Some(action.as_str()); - } - } - } - None - } - - /// Checks if a sequence of keys matches any keybinding. - pub fn matches_key_sequence(&self, sequence: &[KeyCode]) -> Option<&str> { - if sequence.is_empty() { - return None; - } - - // Convert key sequence to a string (for simple character sequences). - let sequence_str: String = sequence.iter().filter_map(|key| { - if let KeyCode::Char(c) = key { - Some(*c) - } else { - None - } - }).collect(); - - if sequence_str.is_empty() { - return None; - } - - // Check if this sequence matches any binding in the mode-specific sections. - for (action, bindings) in &self.keybindings.read_only { - for binding in bindings { - if binding == &sequence_str { - return Some(action); - } - } - } - - for (action, bindings) in &self.keybindings.edit { - for binding in bindings { - if binding == &sequence_str { - return Some(action); - } - } - } - - for (action, bindings) in &self.keybindings.command { - for binding in bindings { - if binding == &sequence_str { - return Some(action); - } - } - } - - // Check common keybindings - for (action, bindings) in &self.keybindings.common { - for binding in bindings { - if binding == &sequence_str { - return Some(action); - } - } - } - - // Finally check global bindings - for (action, bindings) in &self.keybindings.global { - for binding in bindings { - if binding == &sequence_str { - return Some(action); - } - } - } - - None - } - - /// Checks if a keybinding matches a key and modifiers. - fn matches_keybinding( - binding: &str, - key: KeyCode, - modifiers: KeyModifiers, - ) -> bool { - - // Normalize binding once - let binding_lc = binding.to_lowercase(); - - // Robust handling for Shift+Tab - // Accept either BackTab (with or without SHIFT flagged) or Tab+SHIFT - if binding_lc == "shift+tab" || binding_lc == "backtab" { - return match key { - KeyCode::BackTab => true, - KeyCode::Tab => modifiers.contains(KeyModifiers::SHIFT), - _ => false, - }; - } - - // If binding contains '+', distinguish between: - // - modifier combos (e.g., ctrl+shift+s) => single key + modifiers - // - multi-key sequences (e.g., space+b+r, g+g) => NOT a single key - if binding_lc.contains('+') { - let parts: Vec<&str> = binding_lc.split('+').collect(); - let is_modifier = |t: &str| { - matches!( - t, - "ctrl" | "control" | "shift" | "alt" | "super" | "windows" | "cmd" | "hyper" | "meta" - ) - }; - let non_modifier_count = parts.iter().filter(|p| !is_modifier(p)).count(); - if non_modifier_count > 1 { - // This is a multi-key sequence (e.g., space+b+r, g+g), not a single keybind. - // It must be handled by the sequence engine, not here. - return false; - } - } - - // Robust handling for shift+ (letters) - // Many terminals send uppercase Char without SHIFT bit. - if binding_lc.starts_with("shift+") { - let parts: Vec<&str> = binding.split('+').collect(); - if parts.len() == 2 && parts[1].chars().count() == 1 { - let base = parts[1].chars().next().unwrap(); - let upper = base.to_ascii_uppercase(); - let lower = base.to_ascii_lowercase(); - if let KeyCode::Char(actual) = key { - // Accept uppercase char regardless of SHIFT bit - if actual == upper { - return true; - } - // Also accept lowercase char with SHIFT flagged (some terms do this) - if actual == lower && modifiers.contains(KeyModifiers::SHIFT) { - return true; - } - } - } - } - - // Handle multi-character bindings (all standard keys without modifiers) - if binding.len() > 1 && !binding.contains('+') { - return match binding_lc.as_str() { - // Navigation keys - "left" => key == KeyCode::Left, - "right" => key == KeyCode::Right, - "up" => key == KeyCode::Up, - "down" => key == KeyCode::Down, - "home" => key == KeyCode::Home, - "end" => key == KeyCode::End, - "pageup" | "pgup" => key == KeyCode::PageUp, - "pagedown" | "pgdn" => key == KeyCode::PageDown, - - // Editing keys - "insert" | "ins" => key == KeyCode::Insert, - "delete" | "del" => key == KeyCode::Delete, - "backspace" => key == KeyCode::Backspace, - - // Tab keys - "tab" => key == KeyCode::Tab, - "backtab" => key == KeyCode::BackTab, - - // Special keys - "enter" | "return" => key == KeyCode::Enter, - "escape" | "esc" => key == KeyCode::Esc, - "space" => key == KeyCode::Char(' '), - - // Function keys F1-F24 - "f1" => key == KeyCode::F(1), - "f2" => key == KeyCode::F(2), - "f3" => key == KeyCode::F(3), - "f4" => key == KeyCode::F(4), - "f5" => key == KeyCode::F(5), - "f6" => key == KeyCode::F(6), - "f7" => key == KeyCode::F(7), - "f8" => key == KeyCode::F(8), - "f9" => key == KeyCode::F(9), - "f10" => key == KeyCode::F(10), - "f11" => key == KeyCode::F(11), - "f12" => key == KeyCode::F(12), - "f13" => key == KeyCode::F(13), - "f14" => key == KeyCode::F(14), - "f15" => key == KeyCode::F(15), - "f16" => key == KeyCode::F(16), - "f17" => key == KeyCode::F(17), - "f18" => key == KeyCode::F(18), - "f19" => key == KeyCode::F(19), - "f20" => key == KeyCode::F(20), - "f21" => key == KeyCode::F(21), - "f22" => key == KeyCode::F(22), - "f23" => key == KeyCode::F(23), - "f24" => key == KeyCode::F(24), - - // Lock keys - "capslock" => key == KeyCode::CapsLock, - "scrolllock" => key == KeyCode::ScrollLock, - "numlock" => key == KeyCode::NumLock, - - // System keys - "printscreen" => key == KeyCode::PrintScreen, - "pause" => key == KeyCode::Pause, - "menu" => key == KeyCode::Menu, - "keypadbegin" => key == KeyCode::KeypadBegin, - - // Media keys - "mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play), - "mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause), - "mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause), - "mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse), - "mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop), - "mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward), - "mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind), - "mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext), - "mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious), - "mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record), - "medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume), - "mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume), - "mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume), - - // Multi-key sequences need special handling - "gg" => false, // This needs sequence handling - _ => { - // Handle single characters and punctuation - if binding.len() == 1 { - if let Some(c) = binding.chars().next() { - key == KeyCode::Char(c) - } else { - false - } - } else { - false - } - } - }; - } - - // Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A") - let parts: Vec<&str> = binding.split('+').collect(); - let mut expected_modifiers = KeyModifiers::empty(); - let mut expected_key = None; - - for part in parts { - let part_lc = part.to_lowercase(); - match part.to_lowercase().as_str() { - // Modifiers - "ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL, - "shift" => expected_modifiers |= KeyModifiers::SHIFT, - "alt" => expected_modifiers |= KeyModifiers::ALT, - "super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER, - "hyper" => expected_modifiers |= KeyModifiers::HYPER, - "meta" => expected_modifiers |= KeyModifiers::META, - - // Navigation keys - "left" => expected_key = Some(KeyCode::Left), - "right" => expected_key = Some(KeyCode::Right), - "up" => expected_key = Some(KeyCode::Up), - "down" => expected_key = Some(KeyCode::Down), - "home" => expected_key = Some(KeyCode::Home), - "end" => expected_key = Some(KeyCode::End), - "pageup" | "pgup" => expected_key = Some(KeyCode::PageUp), - "pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown), - - // Editing keys - "insert" | "ins" => expected_key = Some(KeyCode::Insert), - "delete" | "del" => expected_key = Some(KeyCode::Delete), - "backspace" => expected_key = Some(KeyCode::Backspace), - - // Tab keys - "tab" => expected_key = Some(KeyCode::Tab), - "backtab" => expected_key = Some(KeyCode::BackTab), - - // Special keys - "enter" | "return" => expected_key = Some(KeyCode::Enter), - "escape" | "esc" => expected_key = Some(KeyCode::Esc), - "space" => expected_key = Some(KeyCode::Char(' ')), - - // Function keys - "f1" => expected_key = Some(KeyCode::F(1)), - "f2" => expected_key = Some(KeyCode::F(2)), - "f3" => expected_key = Some(KeyCode::F(3)), - "f4" => expected_key = Some(KeyCode::F(4)), - "f5" => expected_key = Some(KeyCode::F(5)), - "f6" => expected_key = Some(KeyCode::F(6)), - "f7" => expected_key = Some(KeyCode::F(7)), - "f8" => expected_key = Some(KeyCode::F(8)), - "f9" => expected_key = Some(KeyCode::F(9)), - "f10" => expected_key = Some(KeyCode::F(10)), - "f11" => expected_key = Some(KeyCode::F(11)), - "f12" => expected_key = Some(KeyCode::F(12)), - "f13" => expected_key = Some(KeyCode::F(13)), - "f14" => expected_key = Some(KeyCode::F(14)), - "f15" => expected_key = Some(KeyCode::F(15)), - "f16" => expected_key = Some(KeyCode::F(16)), - "f17" => expected_key = Some(KeyCode::F(17)), - "f18" => expected_key = Some(KeyCode::F(18)), - "f19" => expected_key = Some(KeyCode::F(19)), - "f20" => expected_key = Some(KeyCode::F(20)), - "f21" => expected_key = Some(KeyCode::F(21)), - "f22" => expected_key = Some(KeyCode::F(22)), - "f23" => expected_key = Some(KeyCode::F(23)), - "f24" => expected_key = Some(KeyCode::F(24)), - - // Lock keys - "capslock" => expected_key = Some(KeyCode::CapsLock), - "scrolllock" => expected_key = Some(KeyCode::ScrollLock), - "numlock" => expected_key = Some(KeyCode::NumLock), - - // System keys - "printscreen" => expected_key = Some(KeyCode::PrintScreen), - "pause" => expected_key = Some(KeyCode::Pause), - "menu" => expected_key = Some(KeyCode::Menu), - "keypadbegin" => expected_key = Some(KeyCode::KeypadBegin), - - // Special characters and colon (legacy support) - ":" => expected_key = Some(KeyCode::Char(':')), - - // Single character (letters, numbers, punctuation) - part => { - if part.len() == 1 { - if let Some(c) = part.chars().next() { - expected_key = Some(KeyCode::Char(c)); - } - } - } - } - } - - modifiers == expected_modifiers && Some(key) == expected_key - } - - /// Gets an action for a command string. - pub fn get_action_for_command(&self, command: &str) -> Option<&str> { - // First check command mode bindings - for (action, bindings) in &self.keybindings.command { - for binding in bindings { - if binding == command { - return Some(action); - } - } - } - - // Then check common bindings - for (action, bindings) in &self.keybindings.common { - for binding in bindings { - if binding == command { - return Some(action); - } - } - } - - // Finally check global bindings - for (action, bindings) in &self.keybindings.global { - for binding in bindings { - if binding == command { - return Some(action); - } - } - } - - None - } - - /// Checks if a key is bound to entering Edit mode (before cursor). - pub fn is_enter_edit_mode_before(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.read_only.get("enter_edit_mode_before") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - false - } - } - - /// Checks if a key is bound to entering Edit mode (after cursor). - pub fn is_enter_edit_mode_after(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.read_only.get("enter_edit_mode_after") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - false - } - } - - /// Checks if a key is bound to entering Edit mode. - pub fn is_enter_edit_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - self.is_enter_edit_mode_before(key, modifiers) || self.is_enter_edit_mode_after(key, modifiers) - } - - /// Checks if a key is bound to exiting Edit mode. - pub fn is_exit_edit_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.edit.get("exit_edit_mode") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - false - } - } - - /// Checks if a key is bound to entering Command mode. - /// This method is no longer used in event.rs since we now handle command mode entry only in read-only mode directly. - pub fn is_enter_command_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.command.get("enter_command_mode") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - false - } - } - - - /// Checks if a key is bound to exiting Command mode. - pub fn is_exit_command_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.command.get("exit_command_mode") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - false - } - } - - /// Checks if a key is bound to executing a command. - pub fn is_command_execute(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.command.get("command_execute") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - // Fall back to Enter key if no command_execute is defined. - key == KeyCode::Enter && modifiers.is_empty() - } - } - - /// Checks if a key is bound to backspacing in Command mode. - pub fn is_command_backspace(&self, key: KeyCode, modifiers: KeyModifiers) -> bool { - if let Some(bindings) = self.keybindings.command.get("command_backspace") { - bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers)) - } else { - // Fall back to Backspace key if no command_backspace is defined. - key == KeyCode::Backspace && modifiers.is_empty() - } - } - - /// Checks if a key is bound to a specific action. - pub fn has_key_for_action(&self, action: &str, key_char: char) -> bool { - // Check all mode-specific keybindings for the action - if let Some(bindings) = self.keybindings.read_only.get(action) { - if bindings.iter().any(|binding| binding == &key_char.to_string()) { - return true; - } - } - - if let Some(bindings) = self.keybindings.edit.get(action) { - if bindings.iter().any(|binding| binding == &key_char.to_string()) { - return true; - } - } - - if let Some(bindings) = self.keybindings.command.get(action) { - if bindings.iter().any(|binding| binding == &key_char.to_string()) { - return true; - } - } - - if let Some(bindings) = self.keybindings.common.get(action) { - if bindings.iter().any(|binding| binding == &key_char.to_string()) { - return true; - } - } - - if let Some(bindings) = self.keybindings.global.get(action) { - if bindings.iter().any(|binding| binding == &key_char.to_string()) { - return true; - } - } - - false - } - - /// This method handles all keybinding formats, both with and without + - pub fn matches_key_sequence_generalized(&self, sequence: &[KeyCode]) -> Option<&str> { - if sequence.is_empty() { - return None; - } - - // Get string representations of the sequence - let sequence_str = sequence.iter() - .map(|k| crate::config::binds::key_sequences::key_to_string(k)) - .collect::>() - .join(""); - - // Add the missing sequence_plus definition - let sequence_plus = sequence.iter() - .map(|k| crate::config::binds::key_sequences::key_to_string(k)) - .collect::>() - .join("+"); - - // Check for matches in all binding formats across all modes - // First check read_only mode - if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.read_only, &sequence_str, &sequence_plus, sequence) { - return Some(action); - } - - // Then check edit mode - if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.edit, &sequence_str, &sequence_plus, sequence) { - return Some(action); - } - - // Then check command mode - if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.command, &sequence_str, &sequence_plus, sequence) { - return Some(action); - } - - // Then check common keybindings - if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.common, &sequence_str, &sequence_plus, sequence) { - return Some(action); - } - - // Finally check global bindings - if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.global, &sequence_str, &sequence_plus, sequence) { - return Some(action); - } - - None - } - - /// Helper method to check a specific mode's bindings against a key sequence - fn check_bindings_for_sequence<'a>( - &self, - mode_bindings: &'a HashMap>, - sequence_str: &str, - sequence_plus: &str, - sequence: &[KeyCode] - ) -> Option<&'a str> { - for (action, bindings) in mode_bindings { - for binding in bindings { - let normalized_binding = binding.to_lowercase(); - - // Check if binding matches any of our formats - if normalized_binding == sequence_str || normalized_binding == sequence_plus { - return Some(action); - } - - // Special case for + format in bindings - if binding.contains('+') { - let normalized_sequence = sequence.iter() - .map(|k| crate::config::binds::key_sequences::key_to_string(k)) - .collect::>(); - - let binding_parts: Vec<&str> = binding.split('+').collect(); - - if binding_parts.len() == sequence.len() { - let matches = binding_parts.iter().enumerate().all(|(i, part)| { - part.to_lowercase() == normalized_sequence[i].to_lowercase() - }); - - if matches { - return Some(action); - } - } - } - } - } - None - } - - /// Check if the current key sequence is a prefix of a longer binding - pub fn is_key_sequence_prefix(&self, sequence: &[KeyCode]) -> bool { - if sequence.is_empty() { - return false; - } - - // Get string representation of the sequence - let sequence_str = sequence.iter() - .map(|k| crate::config::binds::key_sequences::key_to_string(k)) - .collect::>() - .join(""); - - // Check in each mode if our sequence is a prefix - if self.is_prefix_in_mode(&self.keybindings.read_only, &sequence_str, sequence) { - return true; - } - - if self.is_prefix_in_mode(&self.keybindings.edit, &sequence_str, sequence) { - return true; - } - - if self.is_prefix_in_mode(&self.keybindings.command, &sequence_str, sequence) { - return true; - } - - if self.is_prefix_in_mode(&self.keybindings.common, &sequence_str, sequence) { - return true; - } - - if self.is_prefix_in_mode(&self.keybindings.global, &sequence_str, sequence) { - return true; - } - - false - } - - /// Helper method to check if a sequence is a prefix in a specific mode - fn is_prefix_in_mode( - &self, - mode_bindings: &HashMap>, - sequence_str: &str, - sequence: &[KeyCode] - ) -> bool { - for (_, bindings) in mode_bindings { - for binding in bindings { - let normalized_binding = binding.to_lowercase(); - - // Check standard format - if normalized_binding.starts_with(sequence_str) && - normalized_binding.len() > sequence_str.len() { - return true; - } - - // Check + format - if binding.contains('+') { - let binding_parts: Vec<&str> = binding.split('+').collect(); - let sequence_parts = sequence.iter() - .map(|k| crate::config::binds::key_sequences::key_to_string(k)) - .collect::>(); - - if binding_parts.len() > sequence_parts.len() { - let prefix_matches = sequence_parts.iter().enumerate().all(|(i, part)| { - binding_parts.get(i).map_or(false, |b| b.to_lowercase() == part.to_lowercase()) - }); - - if prefix_matches { - return true; - } - } - } - } - } - false - } - - /// Unified action resolver for app-level actions - pub fn get_app_action( - &self, - key_code: crossterm::event::KeyCode, - modifiers: crossterm::event::KeyModifiers, - ) -> Option<&str> { - // First check common actions - if let Some(action) = self.get_common_action(key_code, modifiers) { - return Some(action); - } - - // Then check read-only mode actions - if let Some(action) = self.get_read_only_action_for_key(key_code, modifiers) { - return Some(action); - } - - // Then check highlight mode actions - if let Some(action) = self.get_highlight_action_for_key(key_code, modifiers) { - return Some(action); - } - - // Then check edit mode actions - if let Some(action) = self.get_edit_action_for_key(key_code, modifiers) { - return Some(action); - } - - None - } - - // Normalize bindings for canvas consumption: - // - "shift+" -> also add "" - // - "shift+tab" -> also add "backtab" - // This keeps your config human-friendly while making the canvas happy. - fn normalize_for_canvas( - map: &HashMap>, - ) -> HashMap> { - let mut out: HashMap> = HashMap::new(); - for (action, bindings) in map { - let mut new_list: Vec = Vec::new(); - for b in bindings { - new_list.push(b.clone()); - let blc = b.to_lowercase(); - if blc.starts_with("shift+") { - let parts: Vec<&str> = b.split('+').collect(); - if parts.len() == 2 && parts[1].chars().count() == 1 { - let ch = parts[1].chars().next().unwrap(); - new_list.push(ch.to_ascii_uppercase().to_string()); - } - if blc == "shift+tab" { - new_list.push("backtab".to_string()); - } - } - if blc == "shift+tab" { - new_list.push("backtab".to_string()); - } - } - out.insert(action.clone(), new_list); - } - out - } - - pub fn build_canvas_keymap(&self) -> CanvasKeyMap { - let ro = Self::normalize_for_canvas(&self.keybindings.read_only); - let ed = Self::normalize_for_canvas(&self.keybindings.edit); - let hl = Self::normalize_for_canvas(&self.keybindings.highlight); - CanvasKeyMap::from_mode_maps(&ro, &ed, &hl) - } -} - - diff --git a/client/src/config/binds/key_sequences.rs b/client/src/config/binds/key_sequences.rs deleted file mode 100644 index 82568c5..0000000 --- a/client/src/config/binds/key_sequences.rs +++ /dev/null @@ -1,175 +0,0 @@ -// client/src/config/key_sequences.rs -use crossterm::event::{KeyCode, KeyModifiers}; -use std::time::{Duration, Instant}; -use tracing::info; - -#[derive(Debug, Clone, PartialEq)] -pub struct ParsedKey { - pub code: KeyCode, - pub modifiers: KeyModifiers, -} - -#[derive(Debug, Clone)] -pub struct KeySequenceTracker { - pub current_sequence: Vec, - pub last_key_time: Instant, - pub timeout: Duration, -} - -impl KeySequenceTracker { - pub fn new(timeout_ms: u64) -> Self { - Self { - current_sequence: Vec::new(), - last_key_time: Instant::now(), - timeout: Duration::from_millis(timeout_ms), - } - } - - pub fn reset(&mut self) { - info!("KeySequenceTracker.reset() from {:?}", self.current_sequence); - self.current_sequence.clear(); - self.last_key_time = Instant::now(); - } - - pub fn add_key(&mut self, key: KeyCode) -> bool { - let now = Instant::now(); - if now.duration_since(self.last_key_time) > self.timeout { - info!("KeySequenceTracker timeout — reset before adding {:?}", key); - self.reset(); - } - - self.current_sequence.push(key); - self.last_key_time = now; - info!("KeySequenceTracker state after add: {:?}", self.current_sequence); - true - } - - pub fn get_sequence(&self) -> Vec { - self.current_sequence.clone() - } - - // Convert a sequence of keys to a string representation - pub fn sequence_to_string(&self) -> String { - self.current_sequence.iter().map(|k| key_to_string(k)).collect() - } - - // Convert a sequence to a format with + between keys - pub fn sequence_to_plus_format(&self) -> String { - if self.current_sequence.is_empty() { - return String::new(); - } - - let parts: Vec = self.current_sequence.iter() - .map(|k| key_to_string(k)) - .collect(); - - parts.join("+") - } -} - -// Helper function to convert any KeyCode to a string representation -pub fn key_to_string(key: &KeyCode) -> String { - match key { - KeyCode::Char(' ') => "space".to_string(), - KeyCode::Char(c) => c.to_string(), - KeyCode::Left => "left".to_string(), - KeyCode::Right => "right".to_string(), - KeyCode::Up => "up".to_string(), - KeyCode::Down => "down".to_string(), - KeyCode::Esc => "esc".to_string(), - KeyCode::Enter => "enter".to_string(), - KeyCode::Backspace => "backspace".to_string(), - KeyCode::Delete => "delete".to_string(), - KeyCode::Tab => "tab".to_string(), - KeyCode::BackTab => "backtab".to_string(), - KeyCode::Home => "home".to_string(), - KeyCode::End => "end".to_string(), - KeyCode::PageUp => "pageup".to_string(), - KeyCode::PageDown => "pagedown".to_string(), - KeyCode::Insert => "insert".to_string(), - _ => format!("{:?}", key).to_lowercase(), - } -} - -// Helper function to convert a string to a KeyCode -pub fn string_to_keycode(s: &str) -> Option { - match s.to_lowercase().as_str() { - "space" => Some(KeyCode::Char(' ')), - "left" => Some(KeyCode::Left), - "right" => Some(KeyCode::Right), - "up" => Some(KeyCode::Up), - "down" => Some(KeyCode::Down), - "esc" => Some(KeyCode::Esc), - "enter" => Some(KeyCode::Enter), - "backspace" => Some(KeyCode::Backspace), - "delete" => Some(KeyCode::Delete), - "tab" => Some(KeyCode::Tab), - "backtab" => Some(KeyCode::BackTab), - "home" => Some(KeyCode::Home), - "end" => Some(KeyCode::End), - "pageup" => Some(KeyCode::PageUp), - "pagedown" => Some(KeyCode::PageDown), - "insert" => Some(KeyCode::Insert), - s if s.len() == 1 => s.chars().next().map(KeyCode::Char), - _ => None, - } -} - -pub fn parse_binding(binding: &str) -> Vec { - let mut sequence = Vec::new(); - - // Split into multi-key sequence: - // - If contains space → sequence split by space - // - Else split by '+' - let parts: Vec<&str> = if binding.contains(' ') { - binding.split(' ').collect() - } else { - binding.split('+').collect() - }; - - for part in parts { - if let Some(parsed) = parse_key_part(part) { - sequence.push(parsed); - } - } - - sequence -} - -fn is_compound_key(part: &str) -> bool { - matches!(part.to_lowercase().as_str(), - "esc" | "up" | "down" | "left" | "right" | "enter" | - "backspace" | "delete" | "tab" | "backtab" | "home" | - "end" | "pageup" | "pagedown" | "insert" | "space" - ) -} - -fn parse_key_part(part: &str) -> Option { - let mut modifiers = KeyModifiers::empty(); - let mut code = None; - - if part.contains('+') { - // This handles modifiers like "ctrl+s", "super+shift+f5" - let components: Vec<&str> = part.split('+').collect(); - - for component in components { - match component.to_lowercase().as_str() { - "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, - "shift" => modifiers |= KeyModifiers::SHIFT, - "alt" => modifiers |= KeyModifiers::ALT, - "super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER, - "hyper" => modifiers |= KeyModifiers::HYPER, - "meta" => modifiers |= KeyModifiers::META, - _ => { - // Last component is the key - code = string_to_keycode(component); - } - } - } - } else { - // Simple key without modifiers - code = string_to_keycode(part); - } - - code.map(|code| ParsedKey { code, modifiers }) -} diff --git a/client/src/config/colors.rs b/client/src/config/colors.rs deleted file mode 100644 index ea2553f..0000000 --- a/client/src/config/colors.rs +++ /dev/null @@ -1,4 +0,0 @@ -// src/config/colors.rs -pub mod themes; - -pub use themes::*; diff --git a/client/src/config/colors/themes.rs b/client/src/config/colors/themes.rs deleted file mode 100644 index 53e76ab..0000000 --- a/client/src/config/colors/themes.rs +++ /dev/null @@ -1,116 +0,0 @@ -// src/config/colors/themes.rs -use ratatui::style::Color; -use canvas::CanvasTheme; - -#[derive(Debug, Clone)] -pub struct Theme { - pub bg: Color, - pub fg: Color, - pub accent: Color, - pub secondary: Color, - pub highlight: Color, - pub warning: Color, - pub border: Color, - pub highlight_bg: Color, - pub inactive_highlight_bg: Color, // admin panel no idea what it really is -} - -impl Theme { - pub fn from_str(theme_name: &str) -> Self { - match theme_name.to_lowercase().as_str() { - "dark" => Self::dark(), - "high_contrast" => Self::high_contrast(), - _ => Self::light(), - } - } - - // Default light theme - pub fn light() -> Self { - Self { - bg: Color::Rgb(245, 245, 245), // Light gray - fg: Color::Rgb(64, 64, 64), // Dark gray - accent: Color::Rgb(173, 216, 230), // Pastel blue - secondary: Color::Rgb(255, 165, 0), // Orange for secondary - highlight: Color::Rgb(152, 251, 152), // Pastel green - warning: Color::Rgb(255, 182, 193), // Pastel pink - border: Color::Rgb(220, 220, 220), // Light gray border - highlight_bg: Color::Rgb(70, 70, 70), // Darker grey for highlight background - inactive_highlight_bg: Color::Rgb(50, 50, 50), - } - } - - // High-contrast dark theme - pub fn dark() -> Self { - Self { - bg: Color::Rgb(30, 30, 30), // Dark background - fg: Color::Rgb(255, 255, 255), // White text - accent: Color::Rgb(0, 191, 255), // Bright blue - secondary: Color::Rgb(255, 215, 0), // Gold for secondary - highlight: Color::Rgb(50, 205, 50), // Bright green - warning: Color::Rgb(255, 99, 71), // Bright red - border: Color::Rgb(100, 100, 100), // Medium gray border - highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background - inactive_highlight_bg: Color::Rgb(50, 50, 50), - } - } - - // High-contrast light theme - pub fn high_contrast() -> Self { - Self { - bg: Color::Rgb(255, 255, 255), // White background - fg: Color::Rgb(0, 0, 0), // Black text - accent: Color::Rgb(0, 0, 255), // Blue - secondary: Color::Rgb(255, 140, 0), // Dark orange for secondary - highlight: Color::Rgb(0, 128, 0), // Green - warning: Color::Rgb(255, 0, 0), // Red - border: Color::Rgb(0, 0, 0), // Black border - highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background - inactive_highlight_bg: Color::Rgb(50, 50, 50), - } - } -} - -impl Default for Theme { - fn default() -> Self { - Self::light() // Default to light theme - } -} - -impl CanvasTheme for Theme { - fn bg(&self) -> Color { - self.bg - } - - fn fg(&self) -> Color { - self.fg - } - - fn border(&self) -> Color { - self.border - } - - fn accent(&self) -> Color { - self.accent - } - - fn secondary(&self) -> Color { - self.secondary - } - - fn highlight(&self) -> Color { - self.highlight - } - - fn highlight_bg(&self) -> Color { - self.highlight_bg - } - - fn warning(&self) -> Color { - self.warning - } - - fn suggestion_gray(&self) -> Color { - // Neutral gray for suggestions - Color::Rgb(128, 128, 128) - } -} diff --git a/client/src/config/mod.rs b/client/src/config/mod.rs deleted file mode 100644 index 917ed57..0000000 --- a/client/src/config/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// src/config/mod.rs - -pub mod binds; -pub mod colors; -pub mod storage; diff --git a/client/src/config/storage.rs b/client/src/config/storage.rs deleted file mode 100644 index 1c4e7c8..0000000 --- a/client/src/config/storage.rs +++ /dev/null @@ -1,4 +0,0 @@ -// src/config/storage.rs -pub mod storage; - -pub use storage::*; diff --git a/client/src/config/storage/storage.rs b/client/src/config/storage/storage.rs deleted file mode 100644 index 849d212..0000000 --- a/client/src/config/storage/storage.rs +++ /dev/null @@ -1,101 +0,0 @@ -// src/config/storage/storage.rs -use serde::{Deserialize, Serialize}; -use std::fs::{self, File}; -use std::io::Write; -use std::path::PathBuf; -use anyhow::{Context, Result}; -use tracing::{error, info}; - -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; - -pub const APP_NAME: &str = "komp_ac_client"; -pub const TOKEN_FILE_NAME: &str = "auth.token"; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct StoredAuthData { - pub access_token: String, - pub user_id: String, - pub role: String, - pub username: String, -} - -pub fn get_token_storage_path() -> Result { - let state_dir = dirs::state_dir() - .or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state"))) - .ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?; - - let app_state_dir = state_dir.join(APP_NAME); - fs::create_dir_all(&app_state_dir) - .with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?; - - Ok(app_state_dir.join(TOKEN_FILE_NAME)) -} - -pub fn save_auth_data(data: &StoredAuthData) -> Result<()> { - let path = get_token_storage_path()?; - - let json_data = serde_json::to_string(data) - .context("Failed to serialize auth data")?; - - let mut file = File::create(&path) - .with_context(|| format!("Failed to create token file at {:?}", path))?; - - file.write_all(json_data.as_bytes()) - .context("Failed to write token data to file")?; - - // Set file permissions to 600 (owner read/write only) on Unix - #[cfg(unix)] - { - file.set_permissions(std::fs::Permissions::from_mode(0o600)) - .context("Failed to set token file permissions")?; - } - - info!("Auth data saved to {:?}", path); - Ok(()) -} - -pub fn load_auth_data() -> Result> { - let path = get_token_storage_path()?; - - if !path.exists() { - info!("Token file not found at {:?}", path); - return Ok(None); - } - - let json_data = fs::read_to_string(&path) - .with_context(|| format!("Failed to read token file at {:?}", path))?; - - if json_data.trim().is_empty() { - info!("Token file is empty at {:?}", path); - return Ok(None); - } - - match serde_json::from_str::(&json_data) { - Ok(data) => { - info!("Auth data loaded from {:?}", path); - Ok(Some(data)) - } - Err(e) => { - error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e); - if let Err(del_e) = fs::remove_file(&path) { - error!("Failed to delete corrupt token file: {}", del_e); - } - Ok(None) - } - } -} - -pub fn delete_auth_data() -> Result<()> { - let path = get_token_storage_path()?; - - if path.exists() { - fs::remove_file(&path) - .with_context(|| format!("Failed to delete token file at {:?}", path))?; - info!("Token file deleted from {:?}", path); - } else { - info!("Token file not found for deletion at {:?}", path); - } - - Ok(()) -} diff --git a/client/src/dialog/functions.rs b/client/src/dialog/functions.rs deleted file mode 100644 index 60946fd..0000000 --- a/client/src/dialog/functions.rs +++ /dev/null @@ -1,82 +0,0 @@ -// src/dialog/functions.rs - -use crate::dialog::DialogState; -use crate::state::app::state::AppState; -use crate::ui::handlers::context::DialogPurpose; - -impl AppState { - pub fn show_dialog( - &mut self, - title: &str, - message: &str, - buttons: Vec, - purpose: DialogPurpose, - ) { - self.ui.dialog.dialog_title = title.to_string(); - self.ui.dialog.dialog_message = message.to_string(); - self.ui.dialog.dialog_buttons = buttons; - self.ui.dialog.dialog_active_button_index = 0; - self.ui.dialog.purpose = Some(purpose); - self.ui.dialog.is_loading = false; - self.ui.dialog.dialog_show = true; - } - - pub fn show_loading_dialog(&mut self, title: &str, message: &str) { - self.ui.dialog.dialog_title = title.to_string(); - self.ui.dialog.dialog_message = message.to_string(); - self.ui.dialog.dialog_buttons.clear(); - self.ui.dialog.dialog_active_button_index = 0; - self.ui.dialog.purpose = None; - self.ui.dialog.is_loading = true; - self.ui.dialog.dialog_show = true; - } - - pub fn update_dialog_content( - &mut self, - message: &str, - buttons: Vec, - purpose: DialogPurpose, - ) { - if self.ui.dialog.dialog_show { - self.ui.dialog.dialog_message = message.to_string(); - self.ui.dialog.dialog_buttons = buttons; - self.ui.dialog.dialog_active_button_index = 0; - self.ui.dialog.purpose = Some(purpose); - self.ui.dialog.is_loading = false; - } - } - - pub fn hide_dialog(&mut self) { - self.ui.dialog.dialog_show = false; - self.ui.dialog.dialog_title.clear(); - self.ui.dialog.dialog_message.clear(); - self.ui.dialog.dialog_buttons.clear(); - self.ui.dialog.dialog_active_button_index = 0; - self.ui.dialog.purpose = None; - self.ui.dialog.is_loading = false; - } - - pub fn next_dialog_button(&mut self) { - if !self.ui.dialog.dialog_buttons.is_empty() { - let next_index = (self.ui.dialog.dialog_active_button_index + 1) - % self.ui.dialog.dialog_buttons.len(); - self.ui.dialog.dialog_active_button_index = next_index; - } - } - - pub fn previous_dialog_button(&mut self) { - if !self.ui.dialog.dialog_buttons.is_empty() { - let len = self.ui.dialog.dialog_buttons.len(); - let prev_index = - (self.ui.dialog.dialog_active_button_index + len - 1) % len; - self.ui.dialog.dialog_active_button_index = prev_index; - } - } - - pub fn get_active_dialog_button_label(&self) -> Option<&str> { - self.ui.dialog - .dialog_buttons - .get(self.ui.dialog.dialog_active_button_index) - .map(|s| s.as_str()) - } -} diff --git a/client/src/dialog/logic.rs b/client/src/dialog/logic.rs deleted file mode 100644 index eee8ab8..0000000 --- a/client/src/dialog/logic.rs +++ /dev/null @@ -1,206 +0,0 @@ -// src/dialog/logic.rs - -// TODO(dialog-refactor): -// Currently this module (`handle_dialog_event`) contains page-specific logic -// (e.g. Login, Register, Admin, SaveTable). This couples the dialog crate -// to application pages and business logic. -// -// Refactor plan: -// 1. Keep dialog generic: only handle navigation (next/prev/select) and return -// a `DialogResult` (Dismissed | Selected { purpose, index }). -// 2. Move all page-specific actions (e.g. login::back_to_main, register::back_to_login, -// handle_delete_selected_columns, buffer_state.update_history) into the -// respective page or event handler (e.g. modes/handlers/event.rs). -// 3. Dialog crate should only provide state, rendering, and generic navigation. -// Pages decide what to do when a dialog button is pressed. - -use crossterm::event::{Event, KeyCode}; -use crate::config::binds::config::Config; -use crate::ui::handlers::context::DialogPurpose; -use crate::state::app::state::AppState; -use crate::buffer::AppView; -use crate::buffer::state::BufferState; -use crate::modes::handlers::event::EventOutcome; -use crate::pages::register; -use crate::pages::login; -use crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns; -use crate::pages::routing::{Router, Page}; -use anyhow::Result; - -/// Handles key events specifically when a dialog is active. -/// Returns Some(Result) if the event was handled (consumed), -/// otherwise returns None. -pub async fn handle_dialog_event( - event: &Event, - config: &Config, - app_state: &mut AppState, - buffer_state: &mut BufferState, - router: &mut Router, -) -> Option> { - if let Event::Key(key) = event { - // Always allow Esc to dismiss - if key.code == KeyCode::Esc { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string()))); - } - - // Check general bindings for dialog actions - if let Some(action) = config.get_general_action(key.code, key.modifiers) { - match action { - "move_down" | "next_option" => { - let current_index = app_state.ui.dialog.dialog_active_button_index; - let num_buttons = app_state.ui.dialog.dialog_buttons.len(); - if num_buttons > 0 && current_index < num_buttons - 1 { - app_state.ui.dialog.dialog_active_button_index += 1; - } - return Some(Ok(EventOutcome::Ok(String::new()))); - } - "move_up" | "previous_option" => { - let current_index = app_state.ui.dialog.dialog_active_button_index; - if current_index > 0 { - app_state.ui.dialog.dialog_active_button_index -= 1; - } - return Some(Ok(EventOutcome::Ok(String::new()))); - } - "select" => { - let selected_index = app_state.ui.dialog.dialog_active_button_index; - let purpose = match app_state.ui.dialog.purpose { - Some(p) => p, - None => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Internal Error: Dialog context lost".to_string(), - ))); - } - }; - - // Handle Dialog Actions Directly Here - match purpose { - DialogPurpose::LoginSuccess => match selected_index { - 0 => { - // "Menu" button selected - app_state.hide_dialog(); - if let Page::Login(state) = &mut router.current { - let message = - login::back_to_main(state, app_state, buffer_state).await; - return Some(Ok(EventOutcome::Ok(message))); - } - return Some(Ok(EventOutcome::Ok( - "Login state not active".to_string(), - ))); - } - 1 => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string()))); - } - _ => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Unknown dialog button selected".to_string(), - ))); - } - }, - DialogPurpose::LoginFailed => match selected_index { - 0 => { - // "OK" button selected - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Login failed dialog dismissed".to_string(), - ))); - } - _ => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Unknown dialog button selected".to_string(), - ))); - } - }, - DialogPurpose::RegisterSuccess => match selected_index { - 0 => { - // "OK" button for RegisterSuccess - app_state.hide_dialog(); - if let Page::Register(state) = &mut router.current { - let message = - register::back_to_login(state, app_state, buffer_state) - .await; - return Some(Ok(EventOutcome::Ok(message))); - } - return Some(Ok(EventOutcome::Ok( - "Register state not active".to_string(), - ))); - } - _ => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Unknown dialog button selected".to_string(), - ))); - } - }, - DialogPurpose::RegisterFailed => match selected_index { - 0 => { - // "OK" button for RegisterFailed - app_state.hide_dialog(); // Just dismiss - return Some(Ok(EventOutcome::Ok( - "Register failed dialog dismissed".to_string(), - ))); - } - _ => { - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok( - "Unknown dialog button selected".to_string(), - ))); - } - }, - DialogPurpose::ConfirmDeleteColumns => match selected_index { - 0 => { - // "Confirm" button selected - if let Page::AddTable(page) = &mut router.current { - let outcome_message = handle_delete_selected_columns(&mut page.state); - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok(outcome_message))); - } - return Some(Ok(EventOutcome::Ok( - "AddTable page not active".to_string(), - ))); - } - 1 => { - // "Cancel" button selected - app_state.hide_dialog(); - return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string()))); - } - _ => { /* Handle unexpected index */ } - }, - DialogPurpose::SaveTableSuccess => match selected_index { - 0 => { - // "OK" button selected - app_state.hide_dialog(); - buffer_state.update_history(AppView::Admin); // Navigate back - return Some(Ok(EventOutcome::Ok( - "Save success dialog dismissed.".to_string(), - ))); - } - _ => { /* Handle unexpected index */ } - }, - DialogPurpose::SaveLogicSuccess => match selected_index { - 0 => { - // "OK" button selected - app_state.hide_dialog(); - buffer_state.update_history(AppView::Admin); - return Some(Ok(EventOutcome::Ok( - "Save success dialog dismissed.".to_string(), - ))); - } - _ => { /* Handle unexpected index */ } - }, - } - } - _ => {} // Ignore other general actions when dialog is shown - } - } - // If it was a key event but not handled above, consume it - Some(Ok(EventOutcome::Ok(String::new()))) - } else { - // If it wasn't a key event, consume it too while dialog is active - Some(Ok(EventOutcome::Ok(String::new()))) - } -} diff --git a/client/src/dialog/mod.rs b/client/src/dialog/mod.rs deleted file mode 100644 index 9e3b737..0000000 --- a/client/src/dialog/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -// src/dialog/mod.rs - -pub mod ui; -pub mod logic; -pub mod state; -pub mod functions; - -pub use ui::render_dialog; -pub use logic::handle_dialog_event; -pub use state::DialogState; diff --git a/client/src/dialog/state.rs b/client/src/dialog/state.rs deleted file mode 100644 index 3ca8a55..0000000 --- a/client/src/dialog/state.rs +++ /dev/null @@ -1,26 +0,0 @@ -// src/dialog/state.rs -use crate::ui::handlers::context::DialogPurpose; - -pub struct DialogState { - pub dialog_show: bool, - pub dialog_title: String, - pub dialog_message: String, - pub dialog_buttons: Vec, - pub dialog_active_button_index: usize, - pub purpose: Option, - pub is_loading: bool, -} - -impl Default for DialogState { - fn default() -> Self { - Self { - dialog_show: false, - dialog_title: String::new(), - dialog_message: String::new(), - dialog_buttons: Vec::new(), - dialog_active_button_index: 0, - purpose: None, - is_loading: false, - } - } -} diff --git a/client/src/dialog/ui.rs b/client/src/dialog/ui.rs deleted file mode 100644 index 3f6679e..0000000 --- a/client/src/dialog/ui.rs +++ /dev/null @@ -1,185 +0,0 @@ -// src/dialog/ui.rs - -use crate::config::colors::themes::Theme; -use ratatui::{ - layout::{Constraint, Direction, Layout, Margin, Rect}, - prelude::Alignment, - style::{Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, Paragraph, Clear}, - Frame, -}; -use unicode_segmentation::UnicodeSegmentation; // For grapheme clusters -use unicode_width::UnicodeWidthStr; // For accurate width calculation - -pub fn render_dialog( - f: &mut Frame, - area: Rect, - theme: &Theme, - dialog_title: &str, - dialog_message: &str, - dialog_buttons: &[String], - dialog_active_button_index: usize, - is_loading: bool, -) { - // Calculate required height based on the actual number of lines in the message - let message_lines: Vec<_> = dialog_message.lines().collect(); - let message_height = message_lines.len() as u16; - let button_row_height = if dialog_buttons.is_empty() { 0 } else { 3 }; - let vertical_padding = 2; // Block borders (top/bottom) - let inner_vertical_margin = 2; // Margin inside block (top/bottom) - - // Calculate required height based on actual message lines - let required_inner_height = - message_height + button_row_height + inner_vertical_margin; - let required_total_height = required_inner_height + vertical_padding; - - // Use a fixed percentage width, clamped to min/max - let width_percentage: u16 = 60; - let dialog_width = (area.width * width_percentage / 100) - .max(20) // Minimum width - .min(area.width); // Maximum width - - // Ensure height doesn't exceed available area - let dialog_height = required_total_height.min(area.height); - - // Calculate centered area manually - let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2; - let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2; - let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); - - // Clear the area first before drawing the dialog - f.render_widget(Clear, dialog_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.accent)) - .title(format!(" {} ", dialog_title)) // Add padding to title - .style(Style::default().bg(theme.bg)); - - f.render_widget(block, dialog_area); - - // Calculate inner area *after* defining the block - let inner_area = dialog_area.inner(Margin { - horizontal: 2, // Left/Right padding inside border - vertical: 1, // Top/Bottom padding inside border - }); - - if is_loading { - // --- Loading State --- - let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading - .style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC)) - .alignment(Alignment::Center); - // Render loading message centered in the inner area - f.render_widget(loading_text, inner_area); - } else { - // --- Normal State (Message + Buttons) --- - - // Layout for Message and Buttons based on actual message height - let mut constraints = vec![ - // Allocate space for message, ensuring at least 1 line height - Constraint::Length(message_height.max(1)), // Use actual calculated height - ]; - if button_row_height > 0 { - constraints.push(Constraint::Length(button_row_height)); - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(inner_area); - - // Render Message - let available_width = inner_area.width as usize; - let ellipsis = "..."; - let ellipsis_width = UnicodeWidthStr::width(ellipsis); - - let processed_lines: Vec = message_lines - .into_iter() - .map(|line| { - let line_width = UnicodeWidthStr::width(line); - if line_width > available_width { - // Truncate with ellipsis - let mut truncated_len = 0; - let mut current_width = 0; - for (idx, grapheme) in line.grapheme_indices(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - if current_width + grapheme_width - > available_width.saturating_sub(ellipsis_width) - { - break; - } - current_width += grapheme_width; - truncated_len = idx + grapheme.len(); - } - let truncated_line = - format!("{}{}", &line[..truncated_len], ellipsis); - Line::from(Span::styled( - truncated_line, - Style::default().fg(theme.fg), - )) - } else { - Line::from(Span::styled(line, Style::default().fg(theme.fg))) - } - }) - .collect(); - - let message_paragraph = - Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center); - f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk - - // Render Buttons if they exist and there's a chunk for them - if !dialog_buttons.is_empty() && chunks.len() > 1 { - let button_area = chunks[1]; - let button_count = dialog_buttons.len(); - - let button_constraints = std::iter::repeat(Constraint::Ratio( - 1, - button_count as u32, - )) - .take(button_count) - .collect::>(); - - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints(button_constraints) - .horizontal_margin(1) // Add space between buttons - .split(button_area); - - for (i, button_label) in dialog_buttons.iter().enumerate() { - if i >= button_chunks.len() { - break; - } - - let is_active = i == dialog_active_button_index; - let (button_style, border_style) = if is_active { - ( - Style::default() - .fg(theme.highlight) - .add_modifier(Modifier::BOLD), - Style::default().fg(theme.accent), - ) - } else { - ( - Style::default().fg(theme.fg), - Style::default().fg(theme.border), - ) - }; - - let button_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(border_style); - - f.render_widget( - Paragraph::new(button_label.as_str()) - .block(button_block) - .style(button_style) - .alignment(Alignment::Center), - button_chunks[i], - ); - } - } - } -} diff --git a/client/src/input/action.rs b/client/src/input/action.rs deleted file mode 100644 index 4d8ec80..0000000 --- a/client/src/input/action.rs +++ /dev/null @@ -1,41 +0,0 @@ -// src/input/action.rs -use crate::movement::MovementAction; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BufferAction { - Next, - Previous, - Close, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CoreAction { - Save, - ForceQuit, - SaveAndQuit, - Revert, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AppAction { - // Global/UI - ToggleSidebar, - ToggleBufferList, - OpenSearch, - FindFilePaletteToggle, - - // Buffers - Buffer(BufferAction), - - // Command mode - EnterCommandMode, - ExitCommandMode, - CommandExecute, - CommandBackspace, - - // Navigation across UI - Navigate(MovementAction), - - // Core actions - Core(CoreAction), -} diff --git a/client/src/input/engine.rs b/client/src/input/engine.rs deleted file mode 100644 index b77a1c0..0000000 --- a/client/src/input/engine.rs +++ /dev/null @@ -1,195 +0,0 @@ -// src/input/engine.rs -use crate::config::binds::config::Config; -use crate::config::binds::key_sequences::KeySequenceTracker; -use crate::input::action::{AppAction, BufferAction, CoreAction}; -use crate::movement::MovementAction; -use crate::modes::handlers::mode_manager::AppMode; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::input::leader::{leader_has_any_start, leader_is_prefix, leader_match_action}; -use tracing::info; - -#[derive(Debug, Clone, Copy)] -pub struct InputContext { - pub app_mode: AppMode, - pub overlay_active: bool, - pub allow_navigation_capture: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum InputOutcome { - Action(AppAction), - Pending, // sequence in progress - PassThrough, // let page/canvas handle it -} - -pub struct InputEngine { - seq: KeySequenceTracker, - leader_seq: KeySequenceTracker, -} - -impl InputEngine { - pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self { - Self { - seq: KeySequenceTracker::new(normal_timeout_ms), - leader_seq: KeySequenceTracker::new(leader_timeout_ms), - } - } - - pub fn reset_sequence(&mut self) { - info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence); - self.seq.reset(); - self.leader_seq.reset(); - } - - pub fn has_active_sequence(&self) -> bool { - !self.seq.current_sequence.is_empty() - || !self.leader_seq.current_sequence.is_empty() - } - - pub fn process_key( - &mut self, - key_event: KeyEvent, - ctx: &InputContext, - config: &Config, - ) -> InputOutcome { - // Command mode keys are special (exit/execute/backspace) and typed chars - if ctx.app_mode == AppMode::Command { - if config.is_exit_command_mode(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::ExitCommandMode); - } - if config.is_command_execute(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::CommandExecute); - } - if config.is_command_backspace(key_event.code, key_event.modifiers) { - self.seq.reset(); - return InputOutcome::Action(AppAction::CommandBackspace); - } - // Let command-line collect characters and other keys pass through - self.seq.reset(); - return InputOutcome::PassThrough; - } - - // If overlays are active, do not intercept (palette, navigation, etc.) - if ctx.overlay_active { - self.seq.reset(); - // Also reset leader sequence to avoid leaving a stale "space" active - info!("Overlay active → reset leader_seq (was {:?})", self.leader_seq.current_sequence); - self.leader_seq.reset(); - return InputOutcome::PassThrough; - } - - // Space-led multi-key sequences (leader = space) - let space = KeyCode::Char(' '); - let leader_active = !self.leader_seq.current_sequence.is_empty() - && self.leader_seq.current_sequence[0] == space; - - // Keep collecting leader sequence even if allow_navigation_capture is false. - if leader_active { - self.leader_seq.add_key(key_event.code); - let sequence = self.leader_seq.get_sequence(); - info!( - "Leader active updated: {:?} (added {:?})", - sequence, key_event.code - ); - - if let Some(action_str) = leader_match_action(config, &sequence) { - info!("Leader matched '{}' with sequence {:?}", action_str, sequence); - if let Some(app_action) = map_action_string(action_str, ctx) { - self.leader_seq.reset(); - return InputOutcome::Action(app_action); - } - self.leader_seq.reset(); - return InputOutcome::PassThrough; - } - - if leader_is_prefix(config, &sequence) { - info!("Leader prefix continuing..."); - return InputOutcome::Pending; - } - - info!("Leader sequence reset (no match/prefix)."); - self.leader_seq.reset(); - // fall through to regular handling of this key - } else if ctx.allow_navigation_capture - && key_event.code == space - && leader_has_any_start(config) - { - // Start a leader sequence only if capturing is allowed - self.leader_seq.reset(); - self.leader_seq.add_key(space); - info!("Leader started: {:?}", self.leader_seq.get_sequence()); - return InputOutcome::Pending; - } - - // Single-key mapping: try general binds first (arrows, open_search, enter_command_mode) - if let Some(action_str) = - config.get_general_action(key_event.code, key_event.modifiers) - { - if let Some(app_action) = map_action_string(action_str, ctx) { - return InputOutcome::Action(app_action); - } - // Unknown to app layer (likely canvas movement etc.) → pass - return InputOutcome::PassThrough; - } - - // Then app-level common/read-only/edit/highlight for UI toggles or core actions - if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) { - if let Some(app_action) = map_action_string(action_str, ctx) { - return InputOutcome::Action(app_action); - } - } - - InputOutcome::PassThrough - } -} - -fn str_to_movement(s: &str) -> Option { - match s { - "up" => Some(MovementAction::Up), - "down" => Some(MovementAction::Down), - "left" => Some(MovementAction::Left), - "right" => Some(MovementAction::Right), - "next" => Some(MovementAction::Next), - "previous" => Some(MovementAction::Previous), - "select" => Some(MovementAction::Select), - "esc" => Some(MovementAction::Esc), - _ => None, - } -} - -fn map_action_string(action: &str, ctx: &InputContext) -> Option { - match action { - // Global/UI - "toggle_sidebar" => Some(AppAction::ToggleSidebar), - "toggle_buffer_list" => Some(AppAction::ToggleBufferList), - "open_search" => Some(AppAction::OpenSearch), - "find_file_palette_toggle" => Some(AppAction::FindFilePaletteToggle), - - // Buffers - "next_buffer" => Some(AppAction::Buffer(BufferAction::Next)), - "previous_buffer" => Some(AppAction::Buffer(BufferAction::Previous)), - "close_buffer" => Some(AppAction::Buffer(BufferAction::Close)), - - // Command mode - "enter_command_mode" => Some(AppAction::EnterCommandMode), - "exit_command_mode" => Some(AppAction::ExitCommandMode), - "command_execute" => Some(AppAction::CommandExecute), - "command_backspace" => Some(AppAction::CommandBackspace), - - // Navigation across UI (only if allowed) - s if str_to_movement(s).is_some() && ctx.allow_navigation_capture => { - Some(AppAction::Navigate(str_to_movement(s).unwrap())) - } - - // Core actions - "save" => Some(AppAction::Core(CoreAction::Save)), - "force_quit" => Some(AppAction::Core(CoreAction::ForceQuit)), - "save_and_quit" => Some(AppAction::Core(CoreAction::SaveAndQuit)), - "revert" => Some(AppAction::Core(CoreAction::Revert)), - - // Unknown to app layer: ignore (canvas-specific actions, etc.) - _ => None, - } -} diff --git a/client/src/input/leader.rs b/client/src/input/leader.rs deleted file mode 100644 index dd5353a..0000000 --- a/client/src/input/leader.rs +++ /dev/null @@ -1,74 +0,0 @@ -// src/input/leader.rs -use crate::config::binds::config::Config; -use crate::config::binds::key_sequences::parse_binding; -use crossterm::event::KeyCode; - -/// Collect leader (= space-prefixed) bindings from *all* binding maps -fn leader_bindings<'a>(config: &'a Config) -> Vec<(&'a str, Vec)> { - let mut out = Vec::new(); - - // Include all keybinding maps, not just global - let all_modes: Vec<&std::collections::HashMap>> = vec![ - &config.keybindings.general, - &config.keybindings.read_only, - &config.keybindings.edit, - &config.keybindings.highlight, - &config.keybindings.command, - &config.keybindings.common, - &config.keybindings.global, - ]; - - for mode in all_modes { - for (action, bindings) in mode { - for b in bindings { - let parsed = parse_binding(b); - if parsed.first().map(|pk| pk.code) == Some(KeyCode::Char(' ')) { - let codes = - parsed.into_iter().map(|pk| pk.code).collect::>(); - out.push((action.as_str(), codes)); - } - } - } - } - out -} - -/// Is there any leader binding configured at all? -pub fn leader_has_any_start(config: &Config) -> bool { - leader_bindings(config) - .iter() - .any(|(_, seq)| seq.first() == Some(&KeyCode::Char(' '))) -} - -/// Is `sequence` a prefix of any configured leader sequence? -pub fn leader_is_prefix(config: &Config, sequence: &[KeyCode]) -> bool { - if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') { - return false; - } - for (_, full) in leader_bindings(config) { - if full.len() > sequence.len() - && full.iter().zip(sequence.iter()).all(|(a, b)| a == b) - { - return true; - } - } - false -} - -/// Is `sequence` an exact leader match? If yes, return the action string. -pub fn leader_match_action<'a>( - config: &'a Config, - sequence: &[KeyCode], -) -> Option<&'a str> { - if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') { - return None; - } - for (action, full) in leader_bindings(config) { - if full.len() == sequence.len() - && full.iter().zip(sequence.iter()).all(|(a, b)| a == b) - { - return Some(action); - } - } - None -} diff --git a/client/src/input/mod.rs b/client/src/input/mod.rs deleted file mode 100644 index 056520f..0000000 --- a/client/src/input/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// src/input/mod.rs -pub mod action; -pub mod engine; -pub mod leader; diff --git a/client/src/lib.rs b/client/src/lib.rs deleted file mode 100644 index cf1da55..0000000 --- a/client/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -// client/src/lib.rs -pub mod ui; -pub mod tui; -pub mod config; -pub mod state; -pub mod components; -pub mod modes; -pub mod services; -pub mod utils; -pub mod buffer; -pub mod sidebar; -pub mod dialog; -pub mod search; -pub mod bottom_panel; -pub mod pages; -pub mod movement; -pub mod input; - -pub use ui::run_ui; - diff --git a/client/src/main.rs b/client/src/main.rs deleted file mode 100644 index a3c762b..0000000 --- a/client/src/main.rs +++ /dev/null @@ -1,41 +0,0 @@ -// client/src/main.rs -use client::run_ui; -#[cfg(feature = "ui-debug")] -use client::utils::debug_logger::UiDebugWriter; -use dotenvy::dotenv; -use anyhow::Result; -use tracing_subscriber::EnvFilter; -use std::env; - -#[tokio::main] -async fn main() -> Result<()> { - #[cfg(feature = "ui-debug")] - { - use std::sync::Once; - static INIT_LOGGER: Once = Once::new(); - - INIT_LOGGER.call_once(|| { - let writer = UiDebugWriter::new(); - let _ = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .with_target(false) - .without_time() - .with_writer(move || writer.clone()) - // Filter out noisy grpc/h2 internals - .with_env_filter("client=debug,tonic=info,h2=info,tower=info") - .try_init(); - - client::utils::debug_logger::spawn_file_logger(); - }); - } - - #[cfg(not(feature = "ui-debug"))] - { - if env::var("ENABLE_TRACING").is_ok() { - let _ = tracing_subscriber::fmt::try_init(); - } - } - - dotenv().ok(); - run_ui().await -} diff --git a/client/src/modes/canvas.rs b/client/src/modes/canvas.rs deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/modes/canvas/common_mode.rs b/client/src/modes/canvas/common_mode.rs deleted file mode 100644 index a80f7ea..0000000 --- a/client/src/modes/canvas/common_mode.rs +++ /dev/null @@ -1,95 +0,0 @@ -// src/modes/canvas/common_mode.rs - -use crate::tui::terminal::core::TerminalCore; -use crate::state::pages::auth::AuthState; -use crate::state::app::state::AppState; -use crate::services::grpc_client::GrpcClient; -use crate::services::auth::AuthClient; -use crate::modes::handlers::event::EventOutcome; -crate::pages::forms::logic::SaveOutcome; -use anyhow::{Context, Result}; -use crate::tui::functions::common::{ - form::{save as form_save, revert as form_revert}, - login::{save as login_save, revert as login_revert}, - register::{revert as register_revert}, -}; -use crate::pages::routing::{Router, Page}; - -pub async fn handle_core_action( - action: &str, - auth_state: &mut AuthState, - grpc_client: &mut GrpcClient, - auth_client: &mut AuthClient, - terminal: &mut TerminalCore, - app_state: &mut AppState, - router: &mut Router, -) -> Result { - match action { - "save" => { - match &mut router.current { - Page::Login(state) => { - let message = login_save(auth_state, state, auth_client, app_state) - .await - .context("Login save action failed")?; - Ok(EventOutcome::Ok(message)) - } - Page::Form(form_state) => { - let save_outcome = form_save(app_state, form_state, grpc_client) - .await - .context("Form save action failed")?; - let message = match save_outcome { - SaveOutcome::NoChange => "No changes to save.".to_string(), - SaveOutcome::UpdatedExisting => "Entry updated.".to_string(), - SaveOutcome::CreatedNew(_) => "New entry created.".to_string(), - }; - Ok(EventOutcome::DataSaved(save_outcome, message)) - } - _ => Ok(EventOutcome::Ok("Save not applicable".into())), - } - } - "force_quit" => { - terminal.cleanup()?; - Ok(EventOutcome::Exit("Force exiting without saving.".to_string())) - } - "save_and_quit" => { - let message = match &mut router.current { - Page::Login(state) => { - login_save(auth_state, state, auth_client, app_state) - .await - .context("Login save and quit action failed")? - } - Page::Form(form_state) => { - let save_outcome = form_save(app_state, form_state, grpc_client).await?; - match save_outcome { - SaveOutcome::NoChange => "No changes to save.".to_string(), - SaveOutcome::UpdatedExisting => "Entry updated.".to_string(), - SaveOutcome::CreatedNew(_) => "New entry created.".to_string(), - } - } - _ => "Save not applicable".to_string(), - }; - terminal.cleanup()?; - Ok(EventOutcome::Exit(format!("{}. Exiting application.", message))) - } - "revert" => { - match &mut router.current { - Page::Login(state) => { - let message = login_revert(state, app_state).await; - Ok(EventOutcome::Ok(message)) - } - Page::Register(state) => { - let message = register_revert(state, app_state).await; - Ok(EventOutcome::Ok(message)) - } - Page::Form(form_state) => { - let message = form_revert(form_state, grpc_client) - .await - .context("Form revert action failed")?; - Ok(EventOutcome::Ok(message)) - } - _ => Ok(EventOutcome::Ok("Revert not applicable".into())), - } - } - _ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))), - } -} diff --git a/client/src/modes/common.rs b/client/src/modes/common.rs deleted file mode 100644 index cd5e640..0000000 --- a/client/src/modes/common.rs +++ /dev/null @@ -1,6 +0,0 @@ -// src/client/modes/common.rs -pub mod command_mode; -pub mod highlight; -pub mod commands; - -pub use commands::*; diff --git a/client/src/modes/common/command_mode.rs b/client/src/modes/common/command_mode.rs deleted file mode 100644 index 91ef6a4..0000000 --- a/client/src/modes/common/command_mode.rs +++ /dev/null @@ -1,129 +0,0 @@ -// src/modes/common/command_mode.rs - -use crossterm::event::{KeyEvent, KeyCode, KeyModifiers}; -use crate::config::binds::config::Config; -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; -use crate::modes::common::commands::CommandHandler; -use crate::tui::terminal::core::TerminalCore; -use crate::pages::forms::logic::{save, revert ,SaveOutcome}; -use crate::modes::handlers::event::EventOutcome; -use crate::pages::routing::{Router, Page}; -use anyhow::Result; - -pub async fn handle_command_event( - key: KeyEvent, - config: &Config, - app_state: &mut AppState, - router: &mut Router, - command_input: &mut String, - command_message: &mut String, - grpc_client: &mut GrpcClient, - command_handler: &mut CommandHandler, - terminal: &mut TerminalCore, - current_position: &mut u64, - total_count: u64, -) -> Result { - // Exit command mode - if config.is_exit_command_mode(key.code, key.modifiers) { - command_input.clear(); - *command_message = "".to_string(); - return Ok(EventOutcome::Ok("Exited command mode".to_string())); - } - - // Execute command - if config.is_command_execute(key.code, key.modifiers) { - return process_command( - config, - app_state, - router, - command_input, - command_message, - grpc_client, - command_handler, - terminal, - current_position, - total_count, - ) - .await; - } - - // Backspace - if config.is_command_backspace(key.code, key.modifiers) { - command_input.pop(); - return Ok(EventOutcome::Ok("".to_string())); - } - - // Regular character input - if let KeyCode::Char(c) = key.code { - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT { - command_input.push(c); - return Ok(EventOutcome::Ok("".to_string())); - } - } - - Ok(EventOutcome::Ok("".to_string())) -} - -async fn process_command( - config: &Config, - app_state: &mut AppState, - router: &mut Router, - command_input: &mut String, - command_message: &mut String, - grpc_client: &mut GrpcClient, - command_handler: &mut CommandHandler, - terminal: &mut TerminalCore, - current_position: &mut u64, - total_count: u64, -) -> Result { - let command = command_input.trim().to_string(); - if command.is_empty() { - *command_message = "Empty command".to_string(); - return Ok(EventOutcome::Ok(command_message.clone())); - } - - let action = config.get_action_for_command(&command).unwrap_or("unknown"); - - match action { - "force_quit" | "save_and_quit" | "quit" => { - let (should_exit, message) = command_handler - .handle_command(action, terminal, app_state, router) - .await?; - command_input.clear(); - if should_exit { - Ok(EventOutcome::Exit(message)) - } else { - Ok(EventOutcome::Ok(message)) - } - } - "save" => { - if let Page::Form(path) = &router.current { - let outcome = save(app_state, path, grpc_client).await?; - let message = match outcome { - SaveOutcome::CreatedNew(_) => "New entry created".to_string(), - SaveOutcome::UpdatedExisting => "Entry updated".to_string(), - SaveOutcome::NoChange => "No changes to save".to_string(), - }; - command_input.clear(); - Ok(EventOutcome::DataSaved(outcome, message)) - } else { - Ok(EventOutcome::Ok("Not in a form page".to_string())) - } - } - "revert" => { - if let Page::Form(path) = &router.current { - let message = revert(app_state, path, grpc_client).await?; - command_input.clear(); - Ok(EventOutcome::Ok(message)) - } else { - Ok(EventOutcome::Ok("Not in a form page".to_string())) - } - } - _ => { - let message = format!("Unhandled action: {}", action); - command_input.clear(); - Ok(EventOutcome::Ok(message)) - } - } -} diff --git a/client/src/modes/common/commands.rs b/client/src/modes/common/commands.rs deleted file mode 100644 index 2796608..0000000 --- a/client/src/modes/common/commands.rs +++ /dev/null @@ -1,69 +0,0 @@ -// src/modes/common/commands.rs -use crate::tui::terminal::core::TerminalCore; -use crate::state::app::state::AppState; -use crate::pages::routing::{Router, Page}; -use anyhow::Result; - -pub struct CommandHandler; - -impl CommandHandler { - pub fn new() -> Self { - Self - } - - pub async fn handle_command( - &mut self, - action: &str, - terminal: &mut TerminalCore, - app_state: &mut AppState, - router: &Router, - ) -> Result<(bool, String)> { - match action { - "quit" => self.handle_quit(terminal, app_state, router).await, - "force_quit" => self.handle_force_quit(terminal).await, - "save_and_quit" => self.handle_save_quit(terminal).await, - _ => Ok((false, format!("Unknown command: {}", action))), - } - } - - async fn handle_quit( - &self, - terminal: &mut TerminalCore, - app_state: &mut AppState, - router: &Router, - ) -> Result<(bool, String)> { - // Use router to check unsaved changes - let has_unsaved = match &router.current { - Page::Login(page) => page.state.has_unsaved_changes(), - Page::Register(state) => state.has_unsaved_changes(), - Page::Form(path) => app_state - .form_state_for_path_ref(path) - .map(|fs| fs.has_unsaved_changes()) - .unwrap_or(false), - _ => false, - }; - - if !has_unsaved { - terminal.cleanup()?; - Ok((true, "Exiting.".into())) - } else { - Ok((false, "No changes saved. Use :q! to force quit.".into())) - } - } - - async fn handle_force_quit( - &self, - terminal: &mut TerminalCore, - ) -> Result<(bool, String)> { - terminal.cleanup()?; - Ok((true, "Force exiting without saving.".into())) - } - - async fn handle_save_quit( - &mut self, - terminal: &mut TerminalCore, - ) -> Result<(bool, String)> { - terminal.cleanup()?; - Ok((true, "State saved. Exiting.".into())) - } -} diff --git a/client/src/modes/common/highlight.rs b/client/src/modes/common/highlight.rs deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/modes/general.rs b/client/src/modes/general.rs deleted file mode 100644 index 8f566f6..0000000 --- a/client/src/modes/general.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/client/modes/general.rs -pub mod navigation; -pub mod command_navigation; diff --git a/client/src/modes/general/command_navigation.rs b/client/src/modes/general/command_navigation.rs deleted file mode 100644 index 1d01729..0000000 --- a/client/src/modes/general/command_navigation.rs +++ /dev/null @@ -1,394 +0,0 @@ -// src/modes/general/command_navigation.rs -use crate::config::binds::config::Config; -use crate::modes::handlers::event::EventOutcome; -use anyhow::Result; -use common::proto::komp_ac::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, - dependents_map: HashMap>, - root_tables: Vec, -} - -impl TableDependencyGraph { - pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self { - let mut dependents_map: HashMap> = HashMap::new(); - let mut all_tables_set: HashSet = HashSet::new(); - let mut table_dependencies: HashMap> = 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 = 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 { - 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, - pub filtered_options: Vec<(usize, String)>, - pub navigation_type: NavigationType, - pub current_path: String, - pub graph: Option, - pub all_options: Vec, -} - -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) { - 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(); - } - - 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(); - } - - 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() { - 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(); - } - } else { - self.input.push(c); - self.update_filtered_options(); - } - } - } - } - - 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 !self.current_path.is_empty() { - if let Some(last_slash_idx) = self.current_path.rfind('/') { - self.input = self.current_path[last_slash_idx + 1..].to_string(); - self.current_path = self.current_path[..last_slash_idx].to_string(); - } else { - self.input = self.current_path.clone(); - self.current_path.clear(); - } - self.update_options_for_path(); - 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() { - self.input = selected_option_str.to_string(); - self.update_filtered_options(); - } - } - - 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) - } - } - } - } - - // --- START FIX --- - pub fn get_selected_value(&self) -> Option { - match self.navigation_type { - NavigationType::FindFile => { - // Return the highlighted option, not the raw input buffer. - self.get_selected_option_str().map(|s| s.to_string()) - } - 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) - } - }) - } - } - } - // --- END FIX --- - - 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(); - } - } - self.update_filtered_options(); - } - - fn update_filtered_options(&mut self) { - let filter_text = match self.navigation_type { - NavigationType::FindFile => &self.input, - NavigationType::TableTree => &self.input, - } - .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); - } - } -} - - -pub async fn handle_command_navigation_event( - navigation_state: &mut NavigationState, - key: KeyEvent, - config: &Config, -) -> Result { - 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::Tab => { - if let Some(selected_opt_str) = navigation_state.get_selected_option_str() { - if navigation_state.input == selected_opt_str { - 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())) { - 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 { - navigation_state.autocomplete_selected(); - } - } - 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 outcome = match navigation_state.navigation_type { - // --- START FIX --- - NavigationType::FindFile => { - // The purpose of this palette is to select a table. - // Emit a TableSelected event instead of a generic Ok message. - EventOutcome::TableSelected { - path: selected_value, - } - } - // --- END FIX --- - NavigationType::TableTree => { - EventOutcome::TableSelected { - path: selected_value, - } - } - }; - navigation_state.deactivate(); - Ok(outcome) - } else { - Ok(EventOutcome::Ok("No selection".to_string())) - } - } - _ => Ok(EventOutcome::Ok(String::new())), - } - } else { - Ok(EventOutcome::Ok(String::new())) - } - } - } -} diff --git a/client/src/modes/general/navigation.rs b/client/src/modes/general/navigation.rs deleted file mode 100644 index 5d93eb0..0000000 --- a/client/src/modes/general/navigation.rs +++ /dev/null @@ -1,195 +0,0 @@ -// src/modes/general/navigation.rs - -use crossterm::event::KeyEvent; -use crate::config::binds::config::Config; -use crate::state::app::state::AppState; -use crate::pages::routing::{Router, Page}; -use crate::pages::forms::FormState; -use crate::ui::handlers::context::UiContext; -use crate::modes::handlers::event::EventOutcome; -use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState}; -use canvas::DataProvider; -use anyhow::Result; - -pub async fn handle_navigation_event( - key: KeyEvent, - config: &Config, - app_state: &mut AppState, - router: &mut Router, - command_mode: &mut bool, - command_input: &mut String, - command_message: &mut String, - navigation_state: &mut NavigationState, -) -> Result { - // 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) { - match action { - "up" => { - up(app_state, router); - return Ok(EventOutcome::Ok(String::new())); - } - "down" => { - down(app_state, router); - return Ok(EventOutcome::Ok(String::new())); - } - "next_option" => { - next_option(app_state, router); - return Ok(EventOutcome::Ok(String::new())); - } - "previous_option" => { - previous_option(app_state, router); - return Ok(EventOutcome::Ok(String::new())); - } - "next_field" => { - if let Page::Form(path) = &router.current { - if let Some(fs) = app_state.form_state_for_path(path) { - next_field(fs); - } - } - return Ok(EventOutcome::Ok(String::new())); - } - "prev_field" => { - if let Page::Form(path) = &router.current { - if let Some(fs) = app_state.form_state_for_path(path) { - prev_field(fs); - } - } - return Ok(EventOutcome::Ok(String::new())); - } - "enter_command_mode" => { - handle_enter_command_mode(command_mode, command_input, command_message); - return Ok(EventOutcome::Ok(String::new())); - } - "select" => { - let (context, index) = match &router.current { - Page::Intro(state) => (UiContext::Intro, state.focused_button_index), - Page::Login(state) if state.focus_outside_canvas => { - (UiContext::Login, state.focused_button_index) - } - Page::Register(state) if state.focus_outside_canvas => { - (UiContext::Register, state.focused_button_index) - } - Page::Admin(state) => { - (UiContext::Admin, state.get_selected_index().unwrap_or(0)) - } - _ if app_state.ui.dialog.dialog_show => { - (UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index) - } - _ => return Ok(EventOutcome::Ok("Select (No Action)".to_string())), - }; - return Ok(EventOutcome::ButtonSelected { context, index }); - } - _ => {} - } - } - Ok(EventOutcome::Ok(String::new())) -} - -pub fn up(app_state: &mut AppState, router: &mut Router) { - match &mut router.current { - Page::Login(page) if page.focus_outside_canvas => { - if page.focused_button_index == 0 { - page.focus_outside_canvas = false; - let last_field_index = page.state.field_count().saturating_sub(1); - page.state.set_current_field(last_field_index); - } else { - page.focused_button_index = - page.focused_button_index.saturating_sub(1); - } - } - Page::Register(state) if state.focus_outside_canvas => { - if state.focused_button_index == 0 { - state.focus_outside_canvas = false; - let last_field_index = state.state.field_count().saturating_sub(1); - state.set_current_field(last_field_index); - } else { - state.focused_button_index = - state.focused_button_index.saturating_sub(1); - } - } - Page::Intro(state) => state.previous_option(), - Page::Admin(state) => state.previous(), - _ => {} - } -} - -pub fn down(app_state: &mut AppState, router: &mut Router) { - match &mut router.current { - Page::Login(state) if state.focus_outside_canvas => { - let num_general_elements = 2; - if state.focused_button_index < num_general_elements - 1 { - state.focused_button_index += 1; - } - } - Page::Register(state) if state.focus_outside_canvas => { - let num_general_elements = 2; - if state.focused_button_index < num_general_elements - 1 { - state.focused_button_index += 1; - } - } - Page::Intro(state) => state.next_option(), - Page::Admin(state) => state.next(), - _ => {} - } -} - -pub fn next_option(app_state: &mut AppState, router: &mut Router) { - match &mut router.current { - Page::Intro(state) => state.next_option(), - Page::Admin(state) => { - let option_count = app_state.profile_tree.profiles.len(); - if option_count > 0 { - state.focused_button_index = - (state.focused_button_index + 1) % option_count; - } - } - _ => {} - } -} - -pub fn previous_option(app_state: &mut AppState, router: &mut Router) { - match &mut router.current { - Page::Intro(state) => state.previous_option(), - Page::Admin(state) => { - let option_count = app_state.profile_tree.profiles.len(); - if option_count > 0 { - state.focused_button_index = if state.focused_button_index == 0 { - option_count.saturating_sub(1) - } else { - state.focused_button_index - 1 - }; - } - } - _ => {} - } -} - -pub fn next_field(form_state: &mut FormState) { - if !form_state.fields.is_empty() { - form_state.current_field = (form_state.current_field + 1) % form_state.fields.len(); - } -} - -pub fn prev_field(form_state: &mut FormState) { - if !form_state.fields.is_empty() { - if form_state.current_field == 0 { - form_state.current_field = form_state.fields.len() - 1; - } else { - form_state.current_field -= 1; - } - } -} - -pub fn handle_enter_command_mode( - command_mode: &mut bool, - command_input: &mut String, - command_message: &mut String, -) { - *command_mode = true; - command_input.clear(); - command_message.clear(); -} diff --git a/client/src/modes/handlers.rs b/client/src/modes/handlers.rs deleted file mode 100644 index 01db6da..0000000 --- a/client/src/modes/handlers.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/modes/handlers.rs -pub mod event; -pub mod mode_manager; diff --git a/client/src/modes/handlers/event.rs b/client/src/modes/handlers/event.rs deleted file mode 100644 index 8f9faa1..0000000 --- a/client/src/modes/handlers/event.rs +++ /dev/null @@ -1,1093 +0,0 @@ -// src/modes/handlers/event.rs -use crate::config::binds::config::Config; -use crate::input::engine::{InputContext, InputEngine, InputOutcome}; -use crate::input::action::{AppAction, BufferAction, CoreAction}; -use crate::buffer::{AppView, BufferState, switch_buffer, toggle_buffer_list}; -use crate::sidebar::toggle_sidebar; -use crate::search::event::handle_search_palette_event; -use crate::pages::admin_panel::add_logic; -use crate::pages::admin_panel::add_table; -use crate::pages::register::suggestions::RoleSuggestionsProvider; -use crate::pages::admin::main::logic::handle_admin_navigation; -use crate::pages::admin::admin; -use crate::modes::general::command_navigation::{ - handle_command_navigation_event, NavigationState, -}; -use crate::modes::{ - common::{command_mode, commands::CommandHandler}, - general::navigation, - handlers::mode_manager::{AppMode, ModeManager}, -}; -use crate::services::auth::AuthClient; -use crate::services::grpc_client::GrpcClient; -use canvas::AppMode as CanvasMode; -use canvas::DataProvider; -use crate::state::app::state::AppState; -use crate::pages::admin::AdminState; -use crate::state::pages::auth::AuthState; -use crate::state::pages::auth::UserRole; -use crate::pages::login::LoginState; -use crate::pages::register::RegisterState; -use crate::pages::intro::IntroState; -use crate::pages::login; -use crate::pages::register; -use crate::pages::intro; -use crate::pages::login::logic::LoginResult; -use crate::pages::register::RegisterResult; -use crate::pages::routing::{Router, Page}; -use crate::movement::MovementAction; -use crate::dialog; -use crate::pages::forms; -use crate::pages::forms::FormState; -use crate::pages::forms::logic::{save, revert, SaveOutcome}; -use crate::search::state::SearchState; -use crate::tui::{ - terminal::core::TerminalCore, -}; -use crate::ui::handlers::context::UiContext; -use canvas::KeyEventOutcome; -use canvas::SuggestionsProvider; -use anyhow::Result; -use common::proto::komp_ac::search::search_response::Hit; -use crossterm::event::{Event, KeyCode}; -use tokio::sync::mpsc; -use tokio::sync::mpsc::unbounded_channel; -use tracing::info; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EventOutcome { - Ok(String), - Exit(String), - DataSaved(SaveOutcome, String), - ButtonSelected { context: UiContext, index: usize }, - TableSelected { path: String }, -} - -impl EventOutcome { - pub fn get_message_if_ok(&self) -> String { - match self { - EventOutcome::Ok(msg) => msg.clone(), - _ => String::new(), - } - } -} - -pub struct EventHandler { - pub command_mode: bool, - pub command_input: String, - pub command_message: String, - pub edit_mode_cooldown: bool, - pub ideal_cursor_column: usize, - pub input_engine: InputEngine, - pub auth_client: AuthClient, - pub grpc_client: GrpcClient, - pub login_result_sender: mpsc::Sender, - pub register_result_sender: mpsc::Sender, - pub save_table_result_sender: add_table::nav::SaveTableResultSender, - pub save_logic_result_sender: add_logic::nav::SaveLogicResultSender, - pub navigation_state: NavigationState, - pub search_result_sender: mpsc::UnboundedSender>, - pub search_result_receiver: mpsc::UnboundedReceiver>, - pub autocomplete_result_sender: mpsc::UnboundedSender>, - pub autocomplete_result_receiver: mpsc::UnboundedReceiver>, -} - -impl EventHandler { - pub async fn new( - login_result_sender: mpsc::Sender, - register_result_sender: mpsc::Sender, - save_table_result_sender: add_table::nav::SaveTableResultSender, - save_logic_result_sender: add_logic::nav::SaveLogicResultSender, - grpc_client: GrpcClient, - ) -> Result { - let (search_tx, search_rx) = unbounded_channel(); - let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); - Ok(EventHandler { - command_mode: false, - command_input: String::new(), - command_message: String::new(), - edit_mode_cooldown: false, - ideal_cursor_column: 0, - input_engine: InputEngine::new(400, 5000), - auth_client: AuthClient::with_channel( - grpc_client.channel() - ).await?, - grpc_client, - login_result_sender, - register_result_sender, - save_table_result_sender, - save_logic_result_sender, - navigation_state: NavigationState::new(), - search_result_sender: search_tx, - search_result_receiver: search_rx, - autocomplete_result_sender: autocomplete_tx, - autocomplete_result_receiver: autocomplete_rx, - }) - } - - pub fn is_navigation_active(&self) -> bool { - self.navigation_state.active - } - - pub fn activate_find_file(&mut self, options: Vec) { - self.navigation_state.activate_find_file(options); - } - - // Helper functions - replace the removed event_helper functions - fn get_current_field_for_state(&self, router: &Router, app_state: &AppState) -> usize { - match &router.current { - Page::Login(state) => state.current_field(), - Page::Register(state) => state.current_field(), - Page::Form(path) => app_state - .editor_for_path_ref(path) - .map(|e| e.data_provider().current_field()) - .unwrap_or(0), - _ => 0, - } - } - - fn get_current_cursor_pos_for_state(&self, router: &Router, app_state: &AppState) -> usize { - match &router.current { - Page::Login(state) => state.current_cursor_pos(), - Page::Register(state) => state.current_cursor_pos(), - Page::Form(path) => app_state - .form_state_for_path_ref(path) - .map(|fs| fs.current_cursor_pos()) - .unwrap_or(0), - _ => 0, - } - } - - fn get_has_unsaved_changes_for_state(&self, router: &Router, app_state: &AppState) -> bool { - match &router.current { - Page::Login(state) => state.has_unsaved_changes(), - Page::Register(state) => state.has_unsaved_changes(), - Page::Form(path) => app_state - .form_state_for_path_ref(path) - .map(|fs| fs.has_unsaved_changes()) - .unwrap_or(false), - _ => false, - } - } - - fn get_current_input_for_state<'a>( - &'a self, - router: &'a Router, - app_state: &'a AppState, - ) -> &'a str { - match &router.current { - Page::Login(state) => state.get_current_input(), - Page::Register(state) => state.get_current_input(), - Page::Form(path) => app_state - .form_state_for_path_ref(path) - .map(|fs| fs.get_current_input()) - .unwrap_or(""), - _ => "", - } - } - - fn set_current_cursor_pos_for_state( - &mut self, - router: &mut Router, - app_state: &mut AppState, - pos: usize, - ) { - match &mut router.current { - Page::Login(state) => state.set_current_cursor_pos(pos), - Page::Register(state) => state.set_current_cursor_pos(pos), - Page::Form(path) => { - if let Some(fs) = app_state.form_state_for_path(path) { - fs.set_current_cursor_pos(pos); - } - } - _ => {}, - } - } - - fn get_cursor_pos_for_mixed_state(&self, router: &Router, app_state: &AppState) -> usize { - match &router.current { - Page::Login(state) => state.current_cursor_pos(), - Page::Register(state) => state.current_cursor_pos(), - Page::Form(path) => app_state - .form_state_for_path_ref(path) - .map(|fs| fs.current_cursor_pos()) - .unwrap_or(0), - _ => 0, - } - } - - #[allow(clippy::too_many_arguments)] - pub async fn handle_event( - &mut self, - event: Event, - config: &Config, - terminal: &mut TerminalCore, - command_handler: &mut CommandHandler, - auth_state: &mut AuthState, - buffer_state: &mut BufferState, - app_state: &mut AppState, - router: &mut Router, - ) -> Result { - if app_state.ui.show_search_palette { - if let Event::Key(key_event) = event { - info!( - "RAW KEY: code={:?} mods={:?} active_seq={} ", - key_event.code, - key_event.modifiers, - self.input_engine.has_active_sequence(), - ); - if let Some(message) = handle_search_palette_event( - key_event, - app_state, - &mut self.grpc_client, - self.search_result_sender.clone(), - ).await? { - return Ok(EventOutcome::Ok(message)); - } - return Ok(EventOutcome::Ok(String::new())); - } - } - - let mut current_mode = - ModeManager::derive_mode(app_state, self, router); - - 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, router); - } - 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); - - let current_view = match &router.current { - Page::Intro(_) => AppView::Intro, - Page::Login(_) => AppView::Login, - Page::Register(_) => AppView::Register, - Page::Admin(_) => AppView::Admin, - Page::AddLogic(_) => AppView::AddLogic, - Page::AddTable(_) => AppView::AddTable, - Page::Form(path) => AppView::Form(path.clone()), - }; - buffer_state.update_history(current_view); - - if app_state.ui.dialog.dialog_show { - if let Event::Key(key_event) = event { - if let Some(dialog_result) = dialog::handle_dialog_event( - &Event::Key(key_event), - config, - app_state, - buffer_state, - router, - ) - .await - { - return dialog_result; - } - } else if let Event::Resize(_, _) = event { - } - return Ok(EventOutcome::Ok(String::new())); - } - - if let Event::Key(key_event) = event { - let key_code = key_event.code; - let modifiers = key_event.modifiers; - info!( - "RAW KEY: code={:?} mods={:?} pre_active_seq={}", - key_code, modifiers, self.input_engine.has_active_sequence() - ); - - let overlay_active = self.command_mode - || app_state.ui.show_search_palette - || self.navigation_state.active; - - // Determine if canvas is in edit mode (we avoid capturing navigation then) - let in_form_edit_mode = matches!( - &router.current, - Page::Form(path) if { - if let Some(editor) = app_state.editor_for_path_ref(path) { - editor.mode() == CanvasMode::Edit - } else { false } - } - ); - - // Centralized key -> action resolution - let allow_nav = self.input_engine.has_active_sequence() - || (!in_form_edit_mode && !overlay_active); - let input_ctx = InputContext { - app_mode: current_mode, - overlay_active, - allow_navigation_capture: allow_nav, - }; - info!( - "InputContext: app_mode={:?}, overlay_active={}, in_form_edit_mode={}, allow_nav={}, has_active_seq={}", - current_mode, overlay_active, in_form_edit_mode, allow_nav, self.input_engine.has_active_sequence() - ); - - let outcome = self.input_engine.process_key(key_event, &input_ctx, config); - info!( - "ENGINE OUTCOME: {:?} post_active_seq={}", - outcome, self.input_engine.has_active_sequence() - ); - match outcome { - InputOutcome::Action(action) => { - if let Some(outcome) = self - .handle_app_action( - action, - key_event, // pass original key - config, - terminal, - command_handler, - auth_state, - buffer_state, - app_state, - router, - ) - .await? - { - return Ok(outcome); - } - // No early return on None (e.g., Navigate) — fall through - } - InputOutcome::Pending => { - // waiting for more keys in a sequence - return Ok(EventOutcome::Ok(String::new())); - } - InputOutcome::PassThrough => { - // fall through to page/canvas handlers - } - } - - // LOGIN: canvas <-> buttons focus handoff - // Do not let Login canvas receive keys when overlays/palettes are active - - if !overlay_active { - if let Page::Login(login_page) = &mut router.current { - let outcome = - login::event::handle_login_event(event.clone(), app_state, login_page)?; - // Only return if the login page actually consumed the key - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } - } else if let Page::Register(register_page) = &mut router.current { - let outcome = crate::pages::register::event::handle_register_event( - event, - app_state, - register_page, - )?; - // Only stop if page actually consumed the key; else fall through to global handling - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } - } else if let Page::Form(path_str) = &router.current { - let path = path_str.clone(); - - if let Event::Key(_key_event) = event { - // Do NOT call the input engine here again. The top-level - // process_key call above already ran for this key. - // If we are waiting for more leader keys, swallow the key. - info!("Form branch: has_active_seq={}", self.input_engine.has_active_sequence()); - if self.input_engine.has_active_sequence() { - info!("Form branch suppressing key {:?}, leader in progress", key_event.code); - return Ok(EventOutcome::Ok(String::new())); - } - - // Otherwise, forward to the form editor/canvas. - let outcome = forms::event::handle_form_event( - event, - app_state, - &path, - &mut self.ideal_cursor_column, - )?; - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } - } - } else if let Page::AddLogic(add_logic_page) = &mut router.current { - // Allow ":" (enter_command_mode) even when inside AddLogic canvas - if let Some(action) = - config.get_general_action(key_event.code, key_event.modifiers) - { - if action == "enter_command_mode" - && !self.command_mode - && !app_state.ui.show_search_palette - && !self.navigation_state.active - { - self.command_mode = true; - self.command_input.clear(); - self.command_message.clear(); - self.input_engine.reset_sequence(); - self.set_focus_outside(router, true); - return Ok(EventOutcome::Ok(String::new())); - } - } - let movement_action_early = if let Some(act) = - config.get_general_action(key_event.code, key_event.modifiers) - { - match act { - "up" => Some(MovementAction::Up), - "down" => Some(MovementAction::Down), - "left" => Some(MovementAction::Left), - "right" => Some(MovementAction::Right), - "next" => Some(MovementAction::Next), - "previous" => Some(MovementAction::Previous), - "select" => Some(MovementAction::Select), - "esc" => Some(MovementAction::Esc), - _ => None, - } - } else { None }; - - let outcome = add_logic::event::handle_add_logic_event( - key_event, - movement_action_early, - config, - app_state, - add_logic_page, - self.grpc_client.clone(), - self.save_logic_result_sender.clone(), - )?; - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } - } else if let Page::AddTable(add_table_page) = &mut router.current { - // Allow ":" (enter_command_mode) even when inside AddTable canvas - if let Some(action) = - config.get_general_action(key_event.code, key_event.modifiers) - { - if action == "enter_command_mode" - && !self.command_mode - && !app_state.ui.show_search_palette - && !self.navigation_state.active - { - self.command_mode = true; - self.command_input.clear(); - self.command_message.clear(); - self.input_engine.reset_sequence(); - self.set_focus_outside(router, true); - return Ok(EventOutcome::Ok(String::new())); - } - } - - // Handle AddTable before global actions so canvas gets first shot at keys. - // Map keys to MovementAction (same as AddLogic early handler) - let movement_action_early = if let Some(act) = - config.get_general_action(key_event.code, key_event.modifiers) - { - match act { - "up" => Some(MovementAction::Up), - "down" => Some(MovementAction::Down), - "left" => Some(MovementAction::Left), - "right" => Some(MovementAction::Right), - "next" => Some(MovementAction::Next), - "previous" => Some(MovementAction::Previous), - "select" => Some(MovementAction::Select), - "esc" => Some(MovementAction::Esc), - _ => None, - } - } else { - None - }; - - let outcome = add_table::event::handle_add_table_event( - key_event, - movement_action_early, - config, - app_state, - add_table_page, - self.grpc_client.clone(), - self.save_table_result_sender.clone(), - )?; - // Only stop if the page consumed the key; else let global handling proceed. - if !outcome.get_message_if_ok().is_empty() { - return Ok(outcome); - } - } else if let Page::Admin(admin_state) = &mut router.current { - if matches!(auth_state.role, Some(UserRole::Admin)) { - if let Event::Key(key_event) = event { - if admin::event::handle_admin_event( - key_event, - config, - app_state, - buffer_state, - router, - &mut self.command_message, - )? { - return Ok(EventOutcome::Ok(self.command_message.clone())); - } - } - } - } - } - - // Sidebar/buffer toggles now handled via AppAction in the engine - - if current_mode == AppMode::General { - // General mode specific key mapping now handled via AppAction - } - - match current_mode { - AppMode::General => { - // Map keys to MovementAction - let movement_action = if let Some(act) = - config.get_general_action(key_event.code, key_event.modifiers) - { - use crate::movement::MovementAction; - match act { - "up" => Some(MovementAction::Up), - "down" => Some(MovementAction::Down), - "left" => Some(MovementAction::Left), - "right" => Some(MovementAction::Right), - "next" => Some(MovementAction::Next), - "previous" => Some(MovementAction::Previous), - "select" => Some(MovementAction::Select), - "esc" => Some(MovementAction::Esc), - _ => None, - } - } else { - None - }; - - // Let the current page handle decoupled movement first - if let Some(ma) = movement_action { - match &mut router.current { - Page::Intro(state) => { - if state.handle_movement(ma) { - return Ok(EventOutcome::Ok(String::new())); - } - } - _ => {} - } - } - - // Generic navigation for the rest (Intro/Login/Register/Form) - let nav_outcome = if matches!(&router.current, Page::AddTable(_) | Page::AddLogic(_)) { - // Skip generic navigation for AddTable/AddLogic (they have their own handlers) - Ok(EventOutcome::Ok(String::new())) - } else { - navigation::handle_navigation_event( - key_event, - config, - app_state, - router, - &mut self.command_mode, - &mut self.command_input, - &mut self.command_message, - &mut self.navigation_state, - ).await - }; - - match nav_outcome { - Ok(EventOutcome::ButtonSelected { context, index }) => { - let message = match context { - UiContext::Intro => { - intro::handle_intro_selection( - app_state, - buffer_state, - index, - ); - if let Page::Admin(admin_state) = &mut router.current { - if !app_state.profile_tree.profiles.is_empty() { - admin_state.profile_list_state.select(Some(0)); - } - } - format!("Intro Option {} selected", index) - } - UiContext::Login => { - if let Page::Login(login_state) = &mut router.current { - match index { - 0 => login::initiate_login( - login_state, - app_state, - self.auth_client.clone(), - self.login_result_sender.clone(), - ), - 1 => login::back_to_main( - login_state, - app_state, - buffer_state, - ) - .await, - _ => "Invalid Login Option".to_string(), - } - } else { - "Invalid state".to_string() - } - } - UiContext::Register => { - if let Page::Register(register_state) = &mut router.current { - match index { - 0 => register::initiate_registration( - register_state, - app_state, - self.auth_client.clone(), - self.register_result_sender.clone(), - ), - 1 => register::back_to_login( - register_state, - app_state, - buffer_state, - ) - .await, - _ => "Invalid Login Option".to_string(), - } - } else { - "Invalid state".to_string() - } - } - UiContext::Admin => { - if let Page::Admin(admin_state) = &router.current { - admin::tui::handle_admin_selection( - app_state, - admin_state, - ); - } - format!("Admin Option {} selected", index) - } - UiContext::Dialog => { - "Internal error: Unexpected dialog state".to_string() - } - }; - return Ok(EventOutcome::Ok(message)); - } - other => return other, - } - } - - AppMode::Command => { - // Command-mode keys already handled by the engine. - // Collect characters not handled (typed command input). - match key_code { - KeyCode::Char(c) => { - self.command_input.push(c); - return Ok(EventOutcome::Ok(String::new())); - } - _ => { - self.input_engine.reset_sequence(); - return Ok(EventOutcome::Ok(String::new())); - } - } - } - } - } else if let Event::Resize(_, _) = event { - return Ok(EventOutcome::Ok("Resized".to_string())); - } - - self.edit_mode_cooldown = false; - Ok(EventOutcome::Ok(self.command_message.clone())) - } - - fn is_processed_command(&self, command: &str) -> bool { - matches!(command, "w" | "q" | "q!" | "wq" | "r") - } - - async fn handle_core_action( - &mut self, - action: &str, - auth_state: &mut AuthState, - terminal: &mut TerminalCore, - app_state: &mut AppState, - router: &mut Router, - ) -> Result { - match action { - "save" => { - if let Page::Login(login_state) = &mut router.current { - let message = login::logic::save( - auth_state, - login_state, - &mut self.auth_client, - app_state, - ) - .await?; - Ok(EventOutcome::Ok(message)) - } else { - if let Page::Form(path) = &router.current { - forms::event::save_form(app_state, path, &mut self.grpc_client).await - } else { - Ok(EventOutcome::Ok("Nothing to save".to_string())) - } - } - } - "force_quit" => { - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path(path) { - editor.cleanup_cursor()?; - } - } - terminal.cleanup()?; - Ok(EventOutcome::Exit( - "Force exiting without saving.".to_string(), - )) - } - "save_and_quit" => { - let message = if let Page::Login(login_state) = &mut router.current { - login::logic::save( - auth_state, - login_state, - &mut self.auth_client, - app_state, - ) - .await? - } else if let Page::Form(path) = &router.current { - let save_result = forms::event::save_form(app_state, path, &mut self.grpc_client).await?; - match save_result { - EventOutcome::DataSaved(_, msg) => msg, - EventOutcome::Ok(msg) => msg, - _ => "Saved".to_string(), - } - } else { - "No changes to save.".to_string() - }; - - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path(path) { - editor.cleanup_cursor()?; - } - } - terminal.cleanup()?; - Ok(EventOutcome::Exit(format!( - "{}. Exiting application.", - message - ))) - } - "revert" => { - let message = if let Page::Login(login_state) = &mut router.current { - login::logic::revert(login_state, app_state).await - } else if let Page::Register(register_state) = &mut router.current { - register::revert( - register_state, - app_state, - ) - .await - } else { - if let Page::Form(path) = &router.current { - return forms::event::revert_form(app_state, path, &mut self.grpc_client).await; - } else { - "Nothing to revert".to_string() - } - }; - Ok(EventOutcome::Ok(message)) - } - _ => Ok(EventOutcome::Ok(format!( - "Core action not handled: {}", - action - ))), - } - } - - fn is_mode_transition_action(action: &str) -> bool { - matches!(action, - "exit" | - "exit_edit_mode" | - "enter_edit_mode_before" | - "enter_edit_mode_after" | - "enter_command_mode" | - "exit_command_mode" | - "enter_highlight_mode" | - "enter_highlight_mode_linewise" | - "exit_highlight_mode" | - "save" | - "quit" | - "Force_quit" | - "save_and_quit" | - "revert" | - "enter_decider" | - "trigger_autocomplete" | - "suggestion_up" | - "suggestion_down" | - "previous_entry" | - "next_entry" | - "toggle_sidebar" | - "toggle_buffer_list" | - "next_buffer" | - "previous_buffer" | - "close_buffer" | - "open_search" | - "find_file_palette_toggle" - ) - } - - fn set_focus_outside(&mut self, router: &mut Router, outside: bool) { - match &mut router.current { - Page::Login(state) => state.focus_outside_canvas = outside, - Page::Register(state) => state.focus_outside_canvas = outside, - Page::Intro(state) => state.focus_outside_canvas = outside, - Page::Admin(state) => state.focus_outside_canvas = outside, - Page::AddLogic(state) => state.focus_outside_canvas = outside, - Page::AddTable(state) => state.focus_outside_canvas = outside, - _ => {} - } - } - - fn set_focused_button(&mut self, router: &mut Router, index: usize) { - match &mut router.current { - Page::Login(state) => state.focused_button_index = index, - Page::Register(state) => state.focused_button_index = index, - Page::Intro(state) => state.focused_button_index = index, - Page::Admin(state) => state.focused_button_index = index, - Page::AddLogic(state) => state.focused_button_index = index, - Page::AddTable(state) => state.focused_button_index = index, - _ => {} - } - } - - fn is_focus_outside(&self, router: &Router) -> bool { - match &router.current { - Page::Login(state) => state.focus_outside_canvas, - Page::Register(state) => state.focus_outside_canvas, - Page::Intro(state) => state.focus_outside_canvas, - Page::Admin(state) => state.focus_outside_canvas, - Page::AddLogic(state) => state.focus_outside_canvas, - Page::AddTable(state) => state.focus_outside_canvas, - _ => false, - } - } - - fn focused_button(&self, router: &Router) -> usize { - match &router.current { - Page::Login(state) => state.focused_button_index, - Page::Register(state) => state.focused_button_index, - Page::Intro(state) => state.focused_button_index, - Page::Admin(state) => state.focused_button_index, - Page::AddLogic(state) => state.focused_button_index, - Page::AddTable(state) => state.focused_button_index, - _ => 0, - } - } - - async fn execute_command( - &mut self, - key_event: crossterm::event::KeyEvent, - config: &Config, - terminal: &mut TerminalCore, - command_handler: &mut CommandHandler, - app_state: &mut AppState, - router: &mut Router, - ) -> Result { - let (mut current_position, total_count) = if let Page::Form(path) = &router.current { - if let Some(fs) = app_state.form_state_for_path_ref(path) { - (fs.current_position, fs.total_count) - } else { - (1, 0) - } - } else { - (1, 0) - }; - - let outcome = command_mode::handle_command_event( - key_event, - config, - app_state, - router, - &mut self.command_input, - &mut self.command_message, - &mut self.grpc_client, - command_handler, - terminal, - &mut current_position, - total_count, - ) - .await?; - - if let Page::Form(path) = &router.current { - if let Some(fs) = app_state.form_state_for_path(path) { - fs.current_position = current_position; - } - } - - self.command_mode = false; - self.input_engine.reset_sequence(); - let new_mode = ModeManager::derive_mode(app_state, self, router); - app_state.update_mode(new_mode); - Ok(outcome) - } - - #[allow(clippy::too_many_arguments)] - async fn handle_app_action( - &mut self, - action: AppAction, - key_event: crossterm::event::KeyEvent, - config: &Config, - terminal: &mut TerminalCore, - command_handler: &mut CommandHandler, - auth_state: &mut AuthState, - buffer_state: &mut BufferState, - app_state: &mut AppState, - router: &mut Router, - ) -> Result> { - match action { - AppAction::ToggleSidebar => { - app_state.ui.show_sidebar = !app_state.ui.show_sidebar; - let message = format!( - "Sidebar {}", - if app_state.ui.show_sidebar { - "shown" - } else { - "hidden" - } - ); - Ok(Some(EventOutcome::Ok(message))) - } - AppAction::ToggleBufferList => { - app_state.ui.show_buffer_list = !app_state.ui.show_buffer_list; - let message = format!( - "Buffer {}", - if app_state.ui.show_buffer_list { - "shown" - } else { - "hidden" - } - ); - Ok(Some(EventOutcome::Ok(message))) - } - AppAction::Buffer(BufferAction::Next) => { - if switch_buffer(buffer_state, true) { - return Ok(Some(EventOutcome::Ok( - "Switched to next buffer".to_string(), - ))); - } - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::Buffer(BufferAction::Previous) => { - if switch_buffer(buffer_state, false) { - return Ok(Some(EventOutcome::Ok( - "Switched to previous buffer".to_string(), - ))); - } - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::Buffer(BufferAction::Close) => { - let current_table_name = app_state.current_view_table_name.as_deref(); - let message = buffer_state - .close_buffer_with_intro_fallback(current_table_name); - Ok(Some(EventOutcome::Ok(message))) - } - AppAction::OpenSearch => { - if let Page::Form(_) = &router.current { - if let Some(table_name) = app_state.current_view_table_name.clone() { - app_state.ui.show_search_palette = true; - app_state.search_state = Some(SearchState::new(table_name)); - self.set_focus_outside(router, true); - return Ok(Some(EventOutcome::Ok( - "Search palette opened".to_string(), - ))); - } - } - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::FindFilePaletteToggle => { - if matches!(&router.current, Page::Form(_) | Page::Intro(_)) { - let mut all_table_paths: Vec = app_state - .profile_tree - .profiles - .iter() - .flat_map(|profile| { - profile.tables.iter().map(move |table| { - format!("{}/{}", profile.name, table.name) - }) - }) - .collect(); - all_table_paths.sort(); - - self.navigation_state.activate_find_file(all_table_paths); - self.command_mode = false; - self.command_input.clear(); - self.command_message.clear(); - self.input_engine.reset_sequence(); - return Ok(Some(EventOutcome::Ok( - "Table selection palette activated".to_string(), - ))); - } - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::EnterCommandMode => { - if !self.is_in_form_edit_mode(router, app_state) - && !self.command_mode - && !app_state.ui.show_search_palette - && !self.navigation_state.active - { - self.command_mode = true; - self.command_input.clear(); - self.command_message.clear(); - self.input_engine.reset_sequence(); - - // Keep focus outside so canvas won’t consume keystrokes - self.set_focus_outside(router, true); - } - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::ExitCommandMode => { - self.command_input.clear(); - self.command_message.clear(); - self.command_mode = false; - self.input_engine.reset_sequence(); - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path(path) { - editor.set_mode(CanvasMode::ReadOnly); - } - } - Ok(Some(EventOutcome::Ok( - "Exited command mode".to_string(), - ))) - } - AppAction::CommandExecute => { - // Execute using the actual configured key that triggered the action - let out = self - .execute_command( - key_event, - config, - terminal, - command_handler, - app_state, - router, - ) - .await?; - Ok(Some(out)) - } - AppAction::CommandBackspace => { - self.command_input.pop(); - self.input_engine.reset_sequence(); - Ok(Some(EventOutcome::Ok(String::new()))) - } - AppAction::Core(core) => { - let s = match core { - CoreAction::Save => "save", - CoreAction::ForceQuit => "force_quit", - CoreAction::SaveAndQuit => "save_and_quit", - CoreAction::Revert => "revert", - }; - let out = self - .handle_core_action(s, auth_state, terminal, app_state, router) - .await?; - Ok(Some(out)) - } - AppAction::Navigate(_ma) => { - // Movement is still handled by page/nav code paths that - // follow after PassThrough. We return None here to keep flow. - Ok(None) - } - } - } - - fn is_in_form_edit_mode(&self, router: &Router, app_state: &AppState) -> bool { - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path_ref(path) { - return editor.mode() == CanvasMode::Edit; - } - } - false - } -} diff --git a/client/src/modes/handlers/mode_manager.rs b/client/src/modes/handlers/mode_manager.rs deleted file mode 100644 index 4400e7e..0000000 --- a/client/src/modes/handlers/mode_manager.rs +++ /dev/null @@ -1,56 +0,0 @@ -// src/modes/handlers/mode_manager.rs - -use crate::state::app::state::AppState; -use crate::modes::handlers::event::EventHandler; -use crate::pages::routing::{Router, Page}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AppMode { - /// General mode = when focus is outside any canvas - /// (Intro, Admin, Login/Register buttons, AddTable/AddLogic menus, dialogs, etc.) - General, - - /// Command overlay (":" or "ctrl+;"), available globally - Command, -} - -pub struct ModeManager; - -impl ModeManager { - /// Determine current mode: - /// - If navigation palette is active → General - /// - If command overlay is active → Command - /// - If focus is inside a canvas (Form, Login, Register, AddTable, AddLogic) → let canvas handle its own mode - /// - Otherwise → General - pub fn derive_mode( - app_state: &AppState, - event_handler: &EventHandler, - router: &Router, - ) -> AppMode { - // Navigation palette always forces General - if event_handler.navigation_state.active { - return AppMode::General; - } - - // Explicit command overlay flag - if event_handler.command_mode { - return AppMode::Command; - } - - // If focus is inside a canvas, we don't duplicate canvas modes here. - // Canvas crate owns ReadOnly/Edit/Highlight internally. - match &router.current { - Page::Form(_) => AppMode::General, // Form always has its own canvas - Page::Login(state) if !state.focus_outside_canvas => AppMode::General, - Page::Register(state) if !state.focus_outside_canvas => AppMode::General, - Page::AddTable(state) if !state.focus_outside_canvas => AppMode::General, - Page::AddLogic(state) if !state.focus_outside_canvas => AppMode::General, - _ => AppMode::General, - } - } - - /// Command overlay can be entered from anywhere (General or Canvas). - pub fn can_enter_command_mode(_current_mode: AppMode) -> bool { - true - } -} diff --git a/client/src/modes/mod.rs b/client/src/modes/mod.rs deleted file mode 100644 index 971fc8a..0000000 --- a/client/src/modes/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/client/modes/mod.rs -pub mod handlers; -pub mod general; -pub mod common; -pub mod canvas; - -pub use handlers::*; -pub use general::*; -pub use common::*; diff --git a/client/src/movement/actions.rs b/client/src/movement/actions.rs deleted file mode 100644 index 74b6251..0000000 --- a/client/src/movement/actions.rs +++ /dev/null @@ -1,12 +0,0 @@ -// src/movement/actions.rs -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MovementAction { - Next, - Previous, - Up, - Down, - Left, - Right, - Select, - Esc, -} diff --git a/client/src/movement/lib.rs b/client/src/movement/lib.rs deleted file mode 100644 index 3fd8032..0000000 --- a/client/src/movement/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -// src/movement/lib.rs - -use crate::movement::MovementAction; - -#[inline] -pub fn move_focus( - order: &[T], - current: &mut T, - action: MovementAction, -) -> bool { - if order.is_empty() { - return false; - } - if let Some(pos) = order.iter().position(|k| *k == *current) { - match action { - MovementAction::Previous | MovementAction::Up | MovementAction::Left => { - if pos > 0 { - *current = order[pos - 1]; - return true; - } - } - MovementAction::Next | MovementAction::Down | MovementAction::Right => { - if pos + 1 < order.len() { - *current = order[pos + 1]; - return true; - } - } - _ => {} - } - } - false -} diff --git a/client/src/movement/mod.rs b/client/src/movement/mod.rs deleted file mode 100644 index 0501ad9..0000000 --- a/client/src/movement/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// src/movement/mod.rs -pub mod actions; -pub mod lib; - -pub use actions::MovementAction; -pub use lib::move_focus; diff --git a/client/src/pages/admin/admin/event.rs b/client/src/pages/admin/admin/event.rs deleted file mode 100644 index 52ec956..0000000 --- a/client/src/pages/admin/admin/event.rs +++ /dev/null @@ -1,65 +0,0 @@ -// src/pages/admin/admin/event.rs -use anyhow::Result; -use crossterm::event::KeyEvent; - -use crate::buffer::state::BufferState; -use crate::config::binds::config::Config; -use crate::pages::admin::main::logic::handle_admin_navigation; -use crate::state::app::state::AppState; -use crate::pages::routing::{Router, Page}; - -/// Handle all Admin page-specific key events (movement + actions). -/// Returns true if the key was handled (so the caller should stop propagation). -pub fn handle_admin_event( - key_event: KeyEvent, - config: &Config, - app_state: &mut AppState, - buffer_state: &mut BufferState, - router: &mut Router, - command_message: &mut String, -) -> Result { - if let Page::Admin(admin_state) = &mut router.current { - // 1) Map general action to MovementAction (same mapping used in event.rs) - let movement_action = if let Some(act) = - config.get_general_action(key_event.code, key_event.modifiers) - { - use crate::movement::MovementAction; - match act { - "up" => Some(MovementAction::Up), - "down" => Some(MovementAction::Down), - "left" => Some(MovementAction::Left), - "right" => Some(MovementAction::Right), - "next" => Some(MovementAction::Next), - "previous" => Some(MovementAction::Previous), - "select" => Some(MovementAction::Select), - "esc" => Some(MovementAction::Esc), - _ => None, - } - } else { - None - }; - - if let Some(ma) = movement_action { - if admin_state.handle_movement(app_state, ma) { - return Ok(true); - } - } - - // 2) Rich Admin navigation (buttons, selections, etc.) - if handle_admin_navigation( - key_event, - config, - app_state, - buffer_state, - router, - command_message, - ) { - return Ok(true); - } - - // If we reached here, nothing was handled - return Ok(false); - } - - Ok(false) -} diff --git a/client/src/pages/admin/admin/loader.rs b/client/src/pages/admin/admin/loader.rs deleted file mode 100644 index a902833..0000000 --- a/client/src/pages/admin/admin/loader.rs +++ /dev/null @@ -1,54 +0,0 @@ -// src/pages/admin/admin/loader.rs -use anyhow::{Context, Result}; - -use crate::pages::admin::{AdminFocus, AdminState}; -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; - -/// Refresh admin data and ensure focus and selections are valid. -pub async fn refresh_admin_state( - grpc_client: &mut GrpcClient, - app_state: &mut AppState, - admin_state: &mut AdminState, -) -> Result<()> { - // Fetch latest profile tree - let refreshed_tree = grpc_client - .get_profile_tree() - .await - .context("Failed to refresh profile tree for Admin panel")?; - app_state.profile_tree = refreshed_tree; - - // Populate profile names for AdminState's list - let profile_names = app_state - .profile_tree - .profiles - .iter() - .map(|p| p.name.clone()) - .collect::>(); - admin_state.set_profiles(profile_names); - - // Ensure a sane focus - if admin_state.current_focus == AdminFocus::default() - || !matches!( - admin_state.current_focus, - AdminFocus::InsideProfilesList - | AdminFocus::Tables - | AdminFocus::InsideTablesList - | AdminFocus::Button1 - | AdminFocus::Button2 - | AdminFocus::Button3 - | AdminFocus::ProfilesPane - ) - { - admin_state.current_focus = AdminFocus::ProfilesPane; - } - - // Ensure a selection exists when profiles are present - if admin_state.profile_list_state.selected().is_none() - && !app_state.profile_tree.profiles.is_empty() - { - admin_state.profile_list_state.select(Some(0)); - } - - Ok(()) -} diff --git a/client/src/pages/admin/admin/mod.rs b/client/src/pages/admin/admin/mod.rs deleted file mode 100644 index 1a4d8c2..0000000 --- a/client/src/pages/admin/admin/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/pages/admin/admin/mod.rs - -pub mod state; -pub mod ui; -pub mod tui; -pub mod event; -pub mod loader; - -pub use state::{AdminState, AdminFocus}; diff --git a/client/src/pages/admin/admin/state.rs b/client/src/pages/admin/admin/state.rs deleted file mode 100644 index 9b76e72..0000000 --- a/client/src/pages/admin/admin/state.rs +++ /dev/null @@ -1,193 +0,0 @@ -// src/pages/admin/admin/state.rs -use ratatui::widgets::ListState; -use crate::movement::{move_focus, MovementAction}; -use crate::state::app::state::AppState; - -/// Focus states for the admin panel -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AdminFocus { - #[default] - ProfilesPane, - InsideProfilesList, - Tables, - InsideTablesList, - Button1, - Button2, - Button3, -} - -/// Full admin panel state (for logged-in admins) -#[derive(Default, Clone, Debug)] -pub struct AdminState { - pub profiles: Vec, - pub profile_list_state: ListState, - pub table_list_state: ListState, - pub selected_profile_index: Option, - pub selected_table_index: Option, - pub current_focus: AdminFocus, - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -impl AdminState { - pub fn get_selected_index(&self) -> Option { - self.profile_list_state.selected() - } - - pub fn get_selected_profile_name(&self) -> Option<&String> { - self.profile_list_state.selected().and_then(|i| self.profiles.get(i)) - } - - pub fn set_profiles(&mut self, new_profiles: Vec) { - let current_selection_index = self.profile_list_state.selected(); - self.profiles = new_profiles; - - if self.profiles.is_empty() { - self.profile_list_state.select(None); - } else { - let new_selection = match current_selection_index { - Some(index) => Some(index.min(self.profiles.len() - 1)), - None => Some(0), - }; - self.profile_list_state.select(new_selection); - } - } - - pub fn next(&mut self) { - if self.profiles.is_empty() { - self.profile_list_state.select(None); - return; - } - let i = match self.profile_list_state.selected() { - Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 }, - None => 0, - }; - self.profile_list_state.select(Some(i)); - } - - pub fn previous(&mut self) { - if self.profiles.is_empty() { - self.profile_list_state.select(None); - return; - } - let i = match self.profile_list_state.selected() { - Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 }, - None => self.profiles.len() - 1, - }; - self.profile_list_state.select(Some(i)); - } - - pub fn handle_movement( - &mut self, - app: &AppState, - action: MovementAction, - ) -> bool { - use AdminFocus::*; - - const ORDER: [AdminFocus; 5] = [ - ProfilesPane, - Tables, - Button1, - Button2, - Button3, - ]; - - match (self.current_focus, action) { - (ProfilesPane, MovementAction::Select) => { - if !app.profile_tree.profiles.is_empty() - && self.profile_list_state.selected().is_none() - { - self.profile_list_state.select(Some(0)); - } - self.current_focus = InsideProfilesList; - return true; - } - (Tables, MovementAction::Select) => { - let p_idx = self - .selected_profile_index - .or_else(|| self.profile_list_state.selected()); - if let Some(pi) = p_idx { - let len = app - .profile_tree - .profiles - .get(pi) - .map(|p| p.tables.len()) - .unwrap_or(0); - if len > 0 && self.table_list_state.selected().is_none() { - self.table_list_state.select(Some(0)); - } - } - self.current_focus = InsideTablesList; - return true; - } - _ => {} - } - - match self.current_focus { - InsideProfilesList => match action { - MovementAction::Up => { - if !app.profile_tree.profiles.is_empty() { - let curr = self.profile_list_state.selected().unwrap_or(0); - let next = curr.saturating_sub(1); - self.profile_list_state.select(Some(next)); - } - true - } - MovementAction::Down => { - let len = app.profile_tree.profiles.len(); - if len > 0 { - let curr = self.profile_list_state.selected().unwrap_or(0); - let next = if curr + 1 < len { curr + 1 } else { curr }; - self.profile_list_state.select(Some(next)); - } - true - } - MovementAction::Esc => { - self.current_focus = ProfilesPane; - true - } - MovementAction::Next | MovementAction::Previous => true, - MovementAction::Select => false, - _ => false, - }, - InsideTablesList => { - let tables_len = { - let p_idx = self - .selected_profile_index - .or_else(|| self.profile_list_state.selected()); - p_idx.and_then(|pi| app.profile_tree.profiles.get(pi)) - .map(|p| p.tables.len()) - .unwrap_or(0) - }; - match action { - MovementAction::Up => { - if tables_len > 0 { - let curr = self.table_list_state.selected().unwrap_or(0); - let next = curr.saturating_sub(1); - self.table_list_state.select(Some(next)); - } - true - } - MovementAction::Down => { - if tables_len > 0 { - let curr = self.table_list_state.selected().unwrap_or(0); - let next = if curr + 1 < tables_len { curr + 1 } else { curr }; - self.table_list_state.select(Some(next)); - } - true - } - MovementAction::Esc => { - self.current_focus = Tables; - true - } - MovementAction::Next | MovementAction::Previous => true, - MovementAction::Select => false, - _ => false, - } - } - _ => { - move_focus(&ORDER, &mut self.current_focus, action) - } - } - } -} diff --git a/client/src/pages/admin/admin/tui.rs b/client/src/pages/admin/admin/tui.rs deleted file mode 100644 index 40373e3..0000000 --- a/client/src/pages/admin/admin/tui.rs +++ /dev/null @@ -1,12 +0,0 @@ -// src/pages/admin/admin/tui.rs -use crate::state::app::state::AppState; -use crate::pages::admin::AdminState; - -pub fn handle_admin_selection(app_state: &mut AppState, admin_state: &AdminState) { - let profiles = &app_state.profile_tree.profiles; - if let Some(selected_index) = admin_state.get_selected_index() { - if let Some(profile) = profiles.get(selected_index) { - app_state.selected_profile = Some(profile.name.clone()); - } - } -} diff --git a/client/src/pages/admin/admin/ui.rs b/client/src/pages/admin/admin/ui.rs deleted file mode 100644 index cec2913..0000000 --- a/client/src/pages/admin/admin/ui.rs +++ /dev/null @@ -1,180 +0,0 @@ -// src/pages/admin/admin/ui.rs - -use crate::config::colors::themes::Theme; -use crate::pages::admin::{AdminFocus, AdminState}; -use crate::state::app::state::AppState; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::Style, - text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, List, ListItem, Paragraph}, - Frame, -}; - -pub fn render_admin_panel_admin( - f: &mut Frame, - area: Rect, - app_state: &AppState, - admin_state: &mut AdminState, - theme: &Theme, -) { - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) - .split(area); - let panes_area = main_chunks[0]; - let buttons_area = main_chunks[1]; - - let pane_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), // Profiles - Constraint::Percentage(40), // Tables - Constraint::Percentage(35), // Dependencies - ].as_ref()) - .split(panes_area); - - let profiles_pane = pane_chunks[0]; - let tables_pane = pane_chunks[1]; - let deps_pane = pane_chunks[2]; - - // --- Profiles Pane (Left) --- - let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList); - let profile_border_style = if profile_pane_has_focus { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.border) - }; - let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style); - let profiles_inner_area = profiles_block.inner(profiles_pane); - f.render_widget(profiles_block, profiles_pane); - let profile_list_items: Vec = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| { - let is_persistently_selected = admin_state.selected_profile_index == Some(idx); - let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList; - let prefix = if is_persistently_selected { "[*] " } else { "[ ] " }; - let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) } - else if is_persistently_selected { Style::default().fg(theme.accent) } - else { Style::default().fg(theme.fg) }; - ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)])) - }).collect(); - let profile_list = List::new(profile_list_items) - .highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() }) - .highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " }); - f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state); - - - // --- Tables Pane (Middle) --- - let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList); - let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) }; - - let profile_to_display_tables_for_idx: Option; - if admin_state.current_focus == AdminFocus::InsideProfilesList { - profile_to_display_tables_for_idx = admin_state.profile_list_state.selected(); - } else { - profile_to_display_tables_for_idx = admin_state.selected_profile_index - .or_else(|| admin_state.profile_list_state.selected()); - } - let tables_pane_title_profile_name = profile_to_display_tables_for_idx - .and_then(|idx| app_state.profile_tree.profiles.get(idx)) - .map_or("None Selected", |p| p.name.as_str()); - let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style); - let tables_inner_area = tables_block.inner(tables_pane); - f.render_widget(tables_block, tables_pane); - - let table_list_items_for_display: Vec = - if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx - .and_then(|idx| app_state.profile_tree.profiles.get(idx)) { - profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| { - let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) && - profile_to_display_tables_for_idx == admin_state.selected_profile_index; - let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) && - admin_state.current_focus == AdminFocus::InsideTablesList; - let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " }; - let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) } - else if is_table_persistently_selected { Style::default().fg(theme.accent) } - else { Style::default().fg(theme.fg) }; - ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)])) - }).collect() - } else { - vec![ListItem::new("Select a profile to see tables")] - }; - let table_list = List::new(table_list_items_for_display) - .highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() }) - .highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " }); - f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state); - - - // --- Dependencies Pane (Right) --- - let mut deps_pane_title_table_name = "N/A".to_string(); - let dependencies_to_display: Vec; - - if admin_state.current_focus == AdminFocus::InsideTablesList { - // If navigating tables, show dependencies for the '>' highlighted table. - // The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic). - if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx { - if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) { - if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table - if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) { - deps_pane_title_table_name = navigated_table.name.clone(); - dependencies_to_display = navigated_table.depends_on.clone(); - } else { - dependencies_to_display = Vec::new(); // Navigated table index out of bounds - } - } else { - dependencies_to_display = Vec::new(); // No table navigated with '>' - } - } else { - dependencies_to_display = Vec::new(); // Profile for tables out of bounds - } - } else { - dependencies_to_display = Vec::new(); // No profile active for table display - } - } else { - // Otherwise, show dependencies for the '[*]' persistently selected table & profile. - if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile - if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) { - if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table - if let Some(selected_table) = selected_profile.tables.get(t_idx) { - deps_pane_title_table_name = selected_table.name.clone(); - dependencies_to_display = selected_table.depends_on.clone(); - } else { dependencies_to_display = Vec::new(); } - } else { dependencies_to_display = Vec::new(); } - } else { dependencies_to_display = Vec::new(); } - } else { dependencies_to_display = Vec::new(); } - } - - let deps_block = Block::default() - .title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)); - let deps_inner_area = deps_block.inner(deps_pane); - f.render_widget(deps_block, deps_pane); - - let mut deps_content = Text::default(); - deps_content.lines.push(Line::from(Span::styled( - "Depends On:", - Style::default().fg(theme.accent), - ))); - - if !dependencies_to_display.is_empty() { - for dep in dependencies_to_display { - deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg))); - } - } else { - deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary))); - } - let deps_paragraph = Paragraph::new(deps_content); - f.render_widget(deps_paragraph, deps_inner_area); - - // --- Buttons Row --- - let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area); - let btn_base_style = Style::default().fg(theme.secondary); - let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } }; - let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center); - let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center); - let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center); - f.render_widget(btn1, button_chunks[0]); - f.render_widget(btn2, button_chunks[1]); - f.render_widget(btn3, button_chunks[2]); -} diff --git a/client/src/pages/admin/main/logic.rs b/client/src/pages/admin/main/logic.rs deleted file mode 100644 index 6812c5d..0000000 --- a/client/src/pages/admin/main/logic.rs +++ /dev/null @@ -1,412 +0,0 @@ -// src/pages/admin/main/logic.rs -use crate::pages::admin::{AdminFocus, AdminState}; -use crate::state::app::state::AppState; -use crate::config::binds::config::Config; -use crate::buffer::state::{BufferState, AppView}; -use ratatui::widgets::ListState; -use crate::pages::admin_panel::add_table::state::{AddTableFormState, LinkDefinition}; -use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus, AddLogicFormState}; -use crate::pages::routing::{Page, Router}; - -// Helper functions list_select_next and list_select_previous remain the same -fn list_select_next(list_state: &mut ListState, item_count: usize) { - if item_count == 0 { - list_state.select(None); - return; - } - let i = match list_state.selected() { - Some(i) => if i >= item_count - 1 { 0 } else { i + 1 }, - None => 0, - }; - list_state.select(Some(i)); -} - -fn list_select_previous(list_state: &mut ListState, item_count: usize) { - if item_count == 0 { - list_state.select(None); - return; - } - let i = match list_state.selected() { - Some(i) => if i == 0 { item_count - 1 } else { i - 1 }, - None => if item_count > 0 { item_count - 1 } else { 0 }, - }; - list_state.select(Some(i)); -} - -pub fn handle_admin_navigation( - key: crossterm::event::KeyEvent, - config: &Config, - app_state: &mut AppState, - buffer_state: &mut BufferState, - router: &mut Router, - command_message: &mut String, -) -> bool { - let action = config.get_general_action(key.code, key.modifiers).map(String::from); - - // Check if we're in admin page, but don't borrow mutably yet - let is_admin = matches!(&router.current, Page::Admin(_)); - if !is_admin { - return false; - } - - // Get the current focus without borrowing mutably - let current_focus = if let Page::Admin(admin_state) = &router.current { - admin_state.current_focus - } else { - return false; - }; - - let profile_count = app_state.profile_tree.profiles.len(); - let mut handled = false; - - match current_focus { - AdminFocus::ProfilesPane => { - // Now we can borrow mutably since we're not reassigning router.current - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - - match action.as_deref() { - Some("select") => { - admin_state.current_focus = AdminFocus::InsideProfilesList; - if !app_state.profile_tree.profiles.is_empty() { - if admin_state.profile_list_state.selected().is_none() { - admin_state.profile_list_state.select(Some(0)); - } - } - *command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string(); - handled = true; - } - Some("next_option") | Some("move_down") => { - admin_state.current_focus = AdminFocus::Tables; - *command_message = "Focus: Tables Pane".to_string(); - handled = true; - } - Some("previous_option") | Some("move_up") => { - *command_message = "At first focusable pane.".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::InsideProfilesList => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - - match action.as_deref() { - Some("move_up") => { - if profile_count > 0 { - list_select_previous(&mut admin_state.profile_list_state, profile_count); - *command_message = "".to_string(); - handled = true; - } - } - Some("move_down") => { - if profile_count > 0 { - list_select_next(&mut admin_state.profile_list_state, profile_count); - *command_message = "".to_string(); - handled = true; - } - } - Some("select") => { - admin_state.selected_profile_index = admin_state.profile_list_state.selected(); - admin_state.selected_table_index = None; - if let Some(profile_idx) = admin_state.selected_profile_index { - if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) { - if !profile.tables.is_empty() { - admin_state.table_list_state.select(Some(0)); - } else { - admin_state.table_list_state.select(None); - } - } - } else { - admin_state.table_list_state.select(None); - } - *command_message = format!( - "Profile '{}' set as active.", - admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string()) - ); - handled = true; - } - Some("exit_table_scroll") => { - admin_state.current_focus = AdminFocus::ProfilesPane; - *command_message = "Focus: Profiles Pane".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::Tables => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - - match action.as_deref() { - Some("select") => { - admin_state.current_focus = AdminFocus::InsideTablesList; - let current_profile_idx = admin_state.selected_profile_index - .or_else(|| admin_state.profile_list_state.selected()); - if let Some(profile_idx) = current_profile_idx { - if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) { - if !profile.tables.is_empty() { - if admin_state.table_list_state.selected().is_none() { - admin_state.table_list_state.select(Some(0)); - } - } else { - admin_state.table_list_state.select(None); - } - } else { - admin_state.table_list_state.select(None); - } - } else { - admin_state.table_list_state.select(None); - *command_message = "Select a profile first to view its tables.".to_string(); - } - if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() { - *command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string(); - } else if admin_state.table_list_state.selected().is_none() { - if current_profile_idx.is_none() { - *command_message = "No profile selected to view tables.".to_string(); - } else { - *command_message = "No tables in selected profile.".to_string(); - } - admin_state.current_focus = AdminFocus::Tables; - } - handled = true; - } - Some("previous_option") | Some("move_up") => { - admin_state.current_focus = AdminFocus::ProfilesPane; - *command_message = "Focus: Profiles Pane".to_string(); - handled = true; - } - Some("next_option") | Some("move_down") => { - admin_state.current_focus = AdminFocus::Button1; - *command_message = "Focus: Add Logic Button".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::InsideTablesList => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - - match action.as_deref() { - Some("move_up") => { - let current_profile_idx = admin_state.selected_profile_index - .or_else(|| admin_state.profile_list_state.selected()); - if let Some(p_idx) = current_profile_idx { - if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { - if !profile.tables.is_empty() { - list_select_previous(&mut admin_state.table_list_state, profile.tables.len()); - *command_message = "".to_string(); - handled = true; - } else { - *command_message = "No tables to navigate.".to_string(); - handled = true; - } - } - } else { - *command_message = "No active profile for tables.".to_string(); - handled = true; - } - } - Some("move_down") => { - let current_profile_idx = admin_state.selected_profile_index - .or_else(|| admin_state.profile_list_state.selected()); - if let Some(p_idx) = current_profile_idx { - if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { - if !profile.tables.is_empty() { - list_select_next(&mut admin_state.table_list_state, profile.tables.len()); - *command_message = "".to_string(); - handled = true; - } else { - *command_message = "No tables to navigate.".to_string(); - handled = true; - } - } - } else { - *command_message = "No active profile for tables.".to_string(); - handled = true; - } - } - Some("select") => { - admin_state.selected_table_index = admin_state.table_list_state.selected(); - let table_name = admin_state.selected_profile_index - .and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) - .and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) - .map_or("N/A", |t| t.name.as_str()); - *command_message = format!("Table '{}' set as active.", table_name); - handled = true; - } - Some("exit_table_scroll") => { - admin_state.current_focus = AdminFocus::Tables; - *command_message = "Focus: Tables Pane".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::Button1 => { // Add Logic Button - match action.as_deref() { - Some("select") => { - // Extract needed data first, before any router reassignment - let (selected_profile_idx, selected_table_idx) = if let Page::Admin(admin_state) = &router.current { - (admin_state.selected_profile_index, admin_state.selected_table_index) - } else { - return false; - }; - - if let Some(p_idx) = selected_profile_idx { - if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { - if let Some(t_idx) = selected_table_idx { - if let Some(table) = profile.tables.get(t_idx) { - // Create AddLogic page with selected profile & table - let add_logic_form = AddLogicFormState::new_with_table( - &config.editor, - profile.name.clone(), - Some(table.id), - table.name.clone(), - ); - - // Store table info for later fetching - app_state.pending_table_structure_fetch = Some(( - profile.name.clone(), - table.name.clone(), - )); - - // Now it's safe to reassign router.current - router.current = Page::AddLogic(add_logic_form); - buffer_state.update_history(AppView::AddLogic); - - *command_message = format!( - "Opening Add Logic for table '{}' in profile '{}'...", - table.name, profile.name - ); - } else { - *command_message = "Error: Selected table data not found.".to_string(); - } - } else { - *command_message = "Select a table first!".to_string(); - } - } else { - *command_message = "Error: Selected profile data not found.".to_string(); - } - } else { - *command_message = "Select a profile first!".to_string(); - } - handled = true; - } - Some("previous_option") | Some("move_up") => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - admin_state.current_focus = AdminFocus::Tables; - *command_message = "Focus: Tables Pane".to_string(); - handled = true; - } - Some("next_option") | Some("move_down") => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - admin_state.current_focus = AdminFocus::Button2; - *command_message = "Focus: Add Table Button".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::Button2 => { // Add Table Button - match action.as_deref() { - Some("select") => { - // Extract needed data first - let selected_profile_idx = if let Page::Admin(admin_state) = &router.current { - admin_state.selected_profile_index - } else { - return false; - }; - - if let Some(p_idx) = selected_profile_idx { - if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { - let selected_profile_name = profile.name.clone(); - // Prepare links from the selected profile's existing tables - let available_links: Vec = profile.tables.iter() - .map(|table| LinkDefinition { - linked_table_name: table.name.clone(), - is_required: false, - selected: false, - }).collect(); - - // Build decoupled AddTable page and route into it - let mut page = AddTableFormState::new(selected_profile_name.clone()); - page.state.links = available_links; - - // Now safe to reassign router.current - router.current = Page::AddTable(page); - buffer_state.update_history(AppView::AddTable); - - *command_message = format!( - "Opening Add Table for profile '{}'...", - selected_profile_name - ); - handled = true; - } else { - *command_message = "Error: Selected profile index out of bounds.".to_string(); - handled = true; - } - } else { - *command_message = "Please select a profile ([*]) first to add a table.".to_string(); - handled = true; - } - } - Some("previous_option") | Some("move_up") => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - admin_state.current_focus = AdminFocus::Button1; - *command_message = "Focus: Add Logic Button".to_string(); - handled = true; - } - Some("next_option") | Some("move_down") => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - admin_state.current_focus = AdminFocus::Button3; - *command_message = "Focus: Change Table Button".to_string(); - handled = true; - } - _ => handled = false, - } - } - - AdminFocus::Button3 => { // Change Table Button - match action.as_deref() { - Some("select") => { - *command_message = "Action: Change Table (Not Implemented)".to_string(); - handled = true; - } - Some("previous_option") | Some("move_up") => { - let Page::Admin(admin_state) = &mut router.current else { - return false; - }; - admin_state.current_focus = AdminFocus::Button2; - *command_message = "Focus: Add Table Button".to_string(); - handled = true; - } - Some("next_option") | Some("move_down") => { - *command_message = "At last focusable button.".to_string(); - handled = true; - } - _ => handled = false, - } - } - } - handled -} diff --git a/client/src/pages/admin/main/mod.rs b/client/src/pages/admin/main/mod.rs deleted file mode 100644 index 0513b00..0000000 --- a/client/src/pages/admin/main/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/pages/admin/main/mod.rs - -pub mod state; -pub mod ui; -pub mod logic; - -pub use state::NonAdminState; diff --git a/client/src/pages/admin/main/state.rs b/client/src/pages/admin/main/state.rs deleted file mode 100644 index cd5d6f2..0000000 --- a/client/src/pages/admin/main/state.rs +++ /dev/null @@ -1,55 +0,0 @@ -// src/pages/admin/main/state.rs -use ratatui::widgets::ListState; - -/// State for non-admin users (simple profile browser) -#[derive(Default, Clone, Debug)] -pub struct NonAdminState { - pub profiles: Vec, // profile names - pub profile_list_state: ListState, // highlight state - pub selected_profile_index: Option, // persistent selection -} - -impl NonAdminState { - pub fn get_selected_index(&self) -> Option { - self.profile_list_state.selected() - } - - pub fn set_profiles(&mut self, new_profiles: Vec) { - let current_selection_index = self.profile_list_state.selected(); - self.profiles = new_profiles; - - if self.profiles.is_empty() { - self.profile_list_state.select(None); - } else { - let new_selection = match current_selection_index { - Some(index) => Some(index.min(self.profiles.len() - 1)), - None => Some(0), - }; - self.profile_list_state.select(new_selection); - } - } - - pub fn next(&mut self) { - if self.profiles.is_empty() { - self.profile_list_state.select(None); - return; - } - let i = match self.profile_list_state.selected() { - Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 }, - None => 0, - }; - self.profile_list_state.select(Some(i)); - } - - pub fn previous(&mut self) { - if self.profiles.is_empty() { - self.profile_list_state.select(None); - return; - } - let i = match self.profile_list_state.selected() { - Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 }, - None => self.profiles.len() - 1, - }; - self.profile_list_state.select(Some(i)); - } -} diff --git a/client/src/pages/admin/main/ui.rs b/client/src/pages/admin/main/ui.rs deleted file mode 100644 index 6f39d1a..0000000 --- a/client/src/pages/admin/main/ui.rs +++ /dev/null @@ -1,128 +0,0 @@ -// src/pages/admin/main/ui.rs - -use crate::config::colors::themes::Theme; -use crate::state::pages::auth::AuthState; -use crate::state::app::state::AppState; -use crate::pages::admin::AdminState; -use common::proto::komp_ac::table_definition::ProfileTreeResponse; -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::Style, - text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap}, - Frame, -}; -use crate::state::pages::auth::UserRole; -use crate::pages::admin::admin::ui::render_admin_panel_admin; - -pub fn render_admin_panel( - f: &mut Frame, - app_state: &AppState, - auth_state: &AuthState, - admin_state: &mut AdminState, - area: Rect, - theme: &Theme, - profile_tree: &ProfileTreeResponse, - selected_profile: &Option, -) { - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.accent)) - .style(Style::default().bg(theme.bg)); - - let inner_area = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)]) - .split(inner_area); - - // Content - let content_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(chunks[1]); - - match auth_state.role { - Some(UserRole::Admin) => { - render_admin_panel_admin(f, chunks[1], app_state, admin_state, theme); - } - _ => { - render_admin_panel_non_admin( - f, - admin_state, - &content_chunks, - theme, - profile_tree, - selected_profile, - ); - } - } -} - -/// Renders the view for non-admin users (profile list and details). -fn render_admin_panel_non_admin( - f: &mut Frame, - admin_state: &mut AdminState, - content_chunks: &[Rect], - theme: &Theme, - profile_tree: &ProfileTreeResponse, - selected_profile: &Option, -) { - // Profile list - Use data from admin_state - let items: Vec = admin_state - .profiles - .iter() - .map(|p| { - ListItem::new(Line::from(vec![ - Span::styled( - if Some(p) == selected_profile.as_ref() { "✓ " } else { " " }, - Style::default().fg(theme.accent), - ), - Span::styled(p, Style::default().fg(theme.fg)), - ])) - }) - .collect(); - - let list = List::new(items) - .block(Block::default().title("Profiles")) - .highlight_style(Style::default().bg(theme.highlight).fg(theme.bg)); - - f.render_stateful_widget(list, content_chunks[0], &mut admin_state.profile_list_state); - - // Profile details - Use selection info from admin_state - if let Some(profile) = admin_state - .get_selected_index() - .and_then(|i| profile_tree.profiles.get(i)) - { - let mut text = Text::default(); - text.lines.push(Line::from(vec![ - Span::styled("Profile: ", Style::default().fg(theme.accent)), - Span::styled(&profile.name, Style::default().fg(theme.highlight)), - ])); - - text.lines.push(Line::from("")); - text.lines.push(Line::from(Span::styled( - "Tables:", - Style::default().fg(theme.accent), - ))); - - for table in &profile.tables { - let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)]; - if !table.depends_on.is_empty() { - line.push(Span::styled( - format!(" → {}", table.depends_on.join(", ")), - Style::default().fg(theme.secondary), - )); - } - text.lines.push(Line::from(line)); - } - - let details_widget = Paragraph::new(text) - .block(Block::default().title("Details")) - .wrap(Wrap { trim: true }); - f.render_widget(details_widget, content_chunks[1]); - } -} diff --git a/client/src/pages/admin/mod.rs b/client/src/pages/admin/mod.rs deleted file mode 100644 index f50e913..0000000 --- a/client/src/pages/admin/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/pages/admin/mod.rs - -pub mod main; // non-admin -pub mod admin; // full admin panel - -pub use main::NonAdminState; -pub use admin::{AdminState, AdminFocus}; diff --git a/client/src/pages/admin_panel/add_logic/event.rs b/client/src/pages/admin_panel/add_logic/event.rs deleted file mode 100644 index 188e0e6..0000000 --- a/client/src/pages/admin_panel/add_logic/event.rs +++ /dev/null @@ -1,159 +0,0 @@ -// src/pages/admin_panel/add_logic/event.rs - -use anyhow::Result; -use crate::config::binds::config::Config; -use crate::movement::{move_focus, MovementAction}; -use crate::pages::admin_panel::add_logic::nav::SaveLogicResultSender; -use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicFormState}; -use crate::components::common::text_editor::TextEditor; -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; -use crate::modes::handlers::event::EventOutcome; -use canvas::{AppMode as CanvasMode, DataProvider}; -use crossterm::event::KeyEvent; - -/// Focus traversal order for non-canvas navigation -const ADD_LOGIC_FOCUS_ORDER: [AddLogicFocus; 6] = [ - AddLogicFocus::InputLogicName, - AddLogicFocus::InputTargetColumn, - AddLogicFocus::InputDescription, - AddLogicFocus::ScriptContentPreview, - AddLogicFocus::SaveButton, - AddLogicFocus::CancelButton, -]; - -/// Handles all AddLogic page-specific events. -/// Return a non-empty Ok(message) only when the page actually consumed the key, -/// otherwise return Ok("") to let global handling proceed. -pub fn handle_add_logic_event( - key_event: KeyEvent, - movement: Option, - config: &Config, - app_state: &mut AppState, - add_logic_page: &mut AddLogicFormState, - grpc_client: GrpcClient, - save_logic_sender: SaveLogicResultSender, -) -> Result { - // 1) Script editor fullscreen mode - if add_logic_page.state.current_focus == AddLogicFocus::InsideScriptContent { - match key_event.code { - crossterm::event::KeyCode::Esc => { - add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview; - add_logic_page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok("Exited script editing.".to_string())); - } - _ => { - let changed = { - let mut editor_borrow = - add_logic_page.state.script_content_editor.borrow_mut(); - TextEditor::handle_input( - &mut editor_borrow, - key_event, - &add_logic_page.state.editor_keybinding_mode, - &mut add_logic_page.state.vim_state, - ) - }; - if changed { - add_logic_page.state.has_unsaved_changes = true; - return Ok(EventOutcome::Ok("Script updated".to_string())); - } - return Ok(EventOutcome::Ok(String::new())); - } - } - } - - // 2) Inside canvas: forward to FormEditor - let inside_canvas_inputs = matches!( - add_logic_page.state.current_focus, - AddLogicFocus::InputLogicName - | AddLogicFocus::InputTargetColumn - | AddLogicFocus::InputDescription - ); - - if inside_canvas_inputs { - // Only allow leaving the canvas with Down/Next when the form editor - // is in ReadOnly mode. In Edit mode, keep focus inside the canvas. - let in_edit_mode = add_logic_page.editor.mode() == CanvasMode::Edit; - if !in_edit_mode { - if let Some(ma) = movement { - let last_idx = add_logic_page - .editor - .data_provider() - .field_count() - .saturating_sub(1); - let at_last = add_logic_page.editor.current_field() >= last_idx; - if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) { - add_logic_page.state.last_canvas_field = last_idx; - add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview; - add_logic_page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok("Moved to Script Preview".to_string())); - } - } - } - - match add_logic_page.handle_key_event(key_event) { - canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => { - add_logic_page.sync_from_editor(); - return Ok(EventOutcome::Ok(msg)); - } - canvas::keymap::KeyEventOutcome::Consumed(None) => { - add_logic_page.sync_from_editor(); - return Ok(EventOutcome::Ok("Input updated".into())); - } - canvas::keymap::KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok(String::new())); - } - canvas::keymap::KeyEventOutcome::NotMatched => { - // fall through - } - } - } - - // 3) Outside canvas - if let Some(ma) = movement { - let mut current = add_logic_page.state.current_focus; - if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) { - add_logic_page.state.current_focus = current; - add_logic_page.focus_outside_canvas = !matches!( - add_logic_page.state.current_focus, - AddLogicFocus::InputLogicName - | AddLogicFocus::InputTargetColumn - | AddLogicFocus::InputDescription - ); - return Ok(EventOutcome::Ok(String::new())); - } - - match ma { - MovementAction::Select => match add_logic_page.state.current_focus { - AddLogicFocus::ScriptContentPreview => { - add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent; - add_logic_page.focus_outside_canvas = false; - return Ok(EventOutcome::Ok( - "Fullscreen script editing. Esc to exit.".to_string(), - )); - } - AddLogicFocus::SaveButton => { - if let Some(msg) = add_logic_page.state.save_logic() { - return Ok(EventOutcome::Ok(msg)); - } else { - return Ok(EventOutcome::Ok("Saved (no changes)".to_string())); - } - } - AddLogicFocus::CancelButton => { - return Ok(EventOutcome::Ok("Cancelled Add Logic".to_string())); - } - _ => {} - }, - MovementAction::Esc => { - if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview { - add_logic_page.state.current_focus = AddLogicFocus::InputDescription; - add_logic_page.focus_outside_canvas = false; - return Ok(EventOutcome::Ok("Back to Description".to_string())); - } - } - _ => {} - } - } - - Ok(EventOutcome::Ok(String::new())) -} diff --git a/client/src/pages/admin_panel/add_logic/loader.rs b/client/src/pages/admin_panel/add_logic/loader.rs deleted file mode 100644 index fe0b427..0000000 --- a/client/src/pages/admin_panel/add_logic/loader.rs +++ /dev/null @@ -1,115 +0,0 @@ -// src/pages/admin_panel/add_logic/loader.rs -use anyhow::{Context, Result}; -use tracing::{error, info, warn}; - -use crate::pages::admin_panel::add_logic::state::AddLogicFormState; -use crate::pages::routing::{Page, Router}; -use crate::services::grpc_client::GrpcClient; -use crate::services::ui_service::UiService; -use crate::state::app::state::AppState; - -/// Process pending table structure fetch for AddLogic page. -/// Returns true if UI needs a redraw. -pub async fn process_pending_table_structure_fetch( - app_state: &mut AppState, - router: &mut Router, - grpc_client: &mut GrpcClient, - command_message: &mut String, -) -> Result { - let mut needs_redraw = false; - - if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() { - if let Page::AddLogic(page) = &mut router.current { - if page.profile_name() == profile_name - && page.selected_table_name().map(|s| s.as_str()) == Some(table_name.as_str()) - { - info!( - "Fetching table structure for {}.{}", - profile_name, table_name - ); - - let fetch_message = UiService::initialize_add_logic_table_data( - grpc_client, - &mut page.state, // keep state here, UiService expects AddLogicState - &app_state.profile_tree, - ) - .await - .unwrap_or_else(|e| { - error!( - "Error initializing add_logic_table_data for {}.{}: {}", - profile_name, table_name, e - ); - format!("Error fetching table structure: {}", e) - }); - - if !fetch_message.contains("Error") && !fetch_message.contains("Warning") { - info!("{}", fetch_message); - } else { - *command_message = fetch_message; - } - - // 🔑 Rebuild FormEditor with updated state (so suggestions work) - page.editor = canvas::FormEditor::new(page.state.clone()); - - needs_redraw = true; - } else { - error!( - "Mismatch in pending_table_structure_fetch: app_state wants {}.{}, \ - but AddLogic state is for {}.{:?}", - profile_name, - table_name, - page.profile_name(), - page.selected_table_name() - ); - } - } else { - warn!( - "Pending table structure fetch for {}.{} but AddLogic view is not active. Ignored.", - profile_name, table_name - ); - } - } - - Ok(needs_redraw) -} - -/// If the AddLogic page is awaiting columns for a selected table in the script editor, -/// fetch them and update the state. Returns true if UI needs a redraw. -pub async fn maybe_fetch_columns_for_awaiting_table( - grpc_client: &mut GrpcClient, - page: &mut AddLogicFormState, - command_message: &mut String, -) -> Result { - if let Some(table_name) = page - .state - .script_editor_awaiting_column_autocomplete - .clone() - { - let profile_name = page.state.profile_name.clone(); - - info!( - "Fetching columns for table selection: {}.{}", - profile_name, table_name - ); - match UiService::fetch_columns_for_table(grpc_client, &profile_name, &table_name).await { - Ok(columns) => { - page.state.set_columns_for_table_autocomplete(columns.clone()); - info!("Loaded {} columns for table '{}'", columns.len(), table_name); - *command_message = - format!("Columns for '{}' loaded. Select a column.", table_name); - } - Err(e) => { - error!( - "Failed to fetch columns for {}.{}: {}", - profile_name, table_name, e - ); - page.state.script_editor_awaiting_column_autocomplete = None; - page.state.deactivate_script_editor_autocomplete(); - *command_message = format!("Error loading columns for '{}': {}", table_name, e); - } - } - return Ok(true); - } - - Ok(false) -} diff --git a/client/src/pages/admin_panel/add_logic/mod.rs b/client/src/pages/admin_panel/add_logic/mod.rs deleted file mode 100644 index 26660b4..0000000 --- a/client/src/pages/admin_panel/add_logic/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/pages/admin_panel/add_logic/mod.rs - -pub mod ui; -pub mod nav; -pub mod state; -pub mod loader; -pub mod event; diff --git a/client/src/pages/admin_panel/add_logic/nav.rs b/client/src/pages/admin_panel/add_logic/nav.rs deleted file mode 100644 index c5c805e..0000000 --- a/client/src/pages/admin_panel/add_logic/nav.rs +++ /dev/null @@ -1,6 +0,0 @@ -// src/pages/admin_panel/add_logic/nav.rs - -use anyhow::Result; -use tokio::sync::mpsc; - -pub type SaveLogicResultSender = mpsc::Sender>; diff --git a/client/src/pages/admin_panel/add_logic/state.rs b/client/src/pages/admin_panel/add_logic/state.rs deleted file mode 100644 index eb1bbd7..0000000 --- a/client/src/pages/admin_panel/add_logic/state.rs +++ /dev/null @@ -1,570 +0,0 @@ -// src/pages/admin_panel/add_logic/state.rs -use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; -use crate::components::common::text_editor::{TextEditor, VimState}; -use canvas::{DataProvider, AppMode, FormEditor, SuggestionItem}; -use crossterm::event::KeyCode; -use std::cell::RefCell; -use std::rc::Rc; -use tui_textarea::TextArea; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AddLogicFocus { - #[default] - InputLogicName, - InputTargetColumn, - InputDescription, - ScriptContentPreview, - InsideScriptContent, - SaveButton, - CancelButton, -} - -#[derive(Clone, Debug)] -pub struct AddLogicState { - pub profile_name: String, - pub selected_table_id: Option, - pub selected_table_name: Option, - pub logic_name_input: String, - pub target_column_input: String, - pub script_content_editor: Rc>>, - pub description_input: String, - pub current_focus: AddLogicFocus, - pub last_canvas_field: usize, - pub logic_name_cursor_pos: usize, - pub target_column_cursor_pos: usize, - pub description_cursor_pos: usize, - pub has_unsaved_changes: bool, - pub editor_keybinding_mode: EditorKeybindingMode, - pub vim_state: VimState, - - // New fields for Target Column Autocomplete - pub table_columns_for_suggestions: Vec, // All columns for the table - pub target_column_suggestions: Vec, // Filtered suggestions - pub show_target_column_suggestions: bool, - pub selected_target_column_suggestion_index: Option, - pub in_target_column_suggestion_mode: bool, - - // Script Editor Autocomplete - pub script_editor_autocomplete_active: bool, - pub script_editor_suggestions: Vec, - pub script_editor_selected_suggestion_index: Option, - pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column) - pub all_table_names: Vec, - pub script_editor_filter_text: String, - - // New fields for same-profile table names and column autocomplete - pub same_profile_table_names: Vec, // Tables from same profile only - pub script_editor_awaiting_column_autocomplete: Option, // Table name waiting for column fetch - pub app_mode: canvas::AppMode, -} - -impl AddLogicState { - pub fn new(editor_config: &EditorConfig) -> Self { - let editor = TextEditor::new_textarea(editor_config); - AddLogicState { - profile_name: "default".to_string(), - selected_table_id: None, - selected_table_name: None, - logic_name_input: String::new(), - target_column_input: String::new(), - script_content_editor: Rc::new(RefCell::new(editor)), - description_input: String::new(), - current_focus: AddLogicFocus::InputLogicName, - last_canvas_field: 2, - logic_name_cursor_pos: 0, - target_column_cursor_pos: 0, - description_cursor_pos: 0, - has_unsaved_changes: false, - editor_keybinding_mode: editor_config.keybinding_mode.clone(), - vim_state: VimState::default(), - - table_columns_for_suggestions: Vec::new(), - target_column_suggestions: Vec::new(), - show_target_column_suggestions: false, - selected_target_column_suggestion_index: None, - in_target_column_suggestion_mode: false, - - script_editor_autocomplete_active: false, - script_editor_suggestions: Vec::new(), - script_editor_selected_suggestion_index: None, - script_editor_trigger_position: None, - all_table_names: Vec::new(), - script_editor_filter_text: String::new(), - - same_profile_table_names: Vec::new(), - script_editor_awaiting_column_autocomplete: None, - app_mode: canvas::AppMode::Edit, - } - } - - pub const INPUT_FIELD_COUNT: usize = 3; - - /// Build canvas SuggestionItem list for target column - pub fn column_suggestions_sync(&self, query: &str) -> Vec { - let q = query.to_lowercase(); - self.table_columns_for_suggestions - .iter() - .filter(|c| q.is_empty() || c.to_lowercase().contains(&q)) - .map(|c| SuggestionItem { - display_text: c.clone(), - value_to_store: c.clone(), - }) - .collect() - } - - /// Updates the target_column_suggestions based on current input. - pub fn update_target_column_suggestions(&mut self) { - let current_input = self.target_column_input.to_lowercase(); - if self.table_columns_for_suggestions.is_empty() { - self.target_column_suggestions.clear(); - self.show_target_column_suggestions = false; - self.selected_target_column_suggestion_index = None; - return; - } - - if current_input.is_empty() { - self.target_column_suggestions = self.table_columns_for_suggestions.clone(); - } else { - self.target_column_suggestions = self - .table_columns_for_suggestions - .iter() - .filter(|name| name.to_lowercase().contains(¤t_input)) - .cloned() - .collect(); - } - - self.show_target_column_suggestions = !self.target_column_suggestions.is_empty(); - if self.show_target_column_suggestions { - if let Some(selected_idx) = self.selected_target_column_suggestion_index { - if selected_idx >= self.target_column_suggestions.len() { - self.selected_target_column_suggestion_index = Some(0); - } - } else { - self.selected_target_column_suggestion_index = Some(0); - } - } else { - self.selected_target_column_suggestion_index = None; - } - } - - /// Updates script editor suggestions based on current filter text - pub fn update_script_editor_suggestions(&mut self) { - let mut suggestions = vec!["sql".to_string()]; - - if self.selected_table_name.is_some() { - suggestions.extend(self.table_columns_for_suggestions.clone()); - } - - let current_selected_table_name = self.selected_table_name.as_deref(); - suggestions.extend( - self.same_profile_table_names - .iter() - .filter(|tn| Some(tn.as_str()) != current_selected_table_name) - .cloned() - ); - - if self.script_editor_filter_text.is_empty() { - self.script_editor_suggestions = suggestions; - } else { - let filter_lower = self.script_editor_filter_text.to_lowercase(); - self.script_editor_suggestions = suggestions - .into_iter() - .filter(|suggestion| suggestion.to_lowercase().contains(&filter_lower)) - .collect(); - } - - // Update selection index - if self.script_editor_suggestions.is_empty() { - self.script_editor_selected_suggestion_index = None; - self.script_editor_autocomplete_active = false; - } else if let Some(selected_idx) = self.script_editor_selected_suggestion_index { - if selected_idx >= self.script_editor_suggestions.len() { - self.script_editor_selected_suggestion_index = Some(0); - } - } else { - self.script_editor_selected_suggestion_index = Some(0); - } - } - - /// Checks if a suggestion is a table name (for triggering column autocomplete) - pub fn is_table_name_suggestion(&self, suggestion: &str) -> bool { - // Not "sql" - if suggestion == "sql" { - return false; - } - if self.table_columns_for_suggestions.contains(&suggestion.to_string()) { - return false; - } - self.same_profile_table_names.contains(&suggestion.to_string()) - } - - /// Sets table columns for autocomplete suggestions - pub fn set_table_columns(&mut self, columns: Vec) { - self.table_columns_for_suggestions = columns.clone(); - if !columns.is_empty() { - self.update_target_column_suggestions(); - } - } - - /// Sets all available table names for autocomplete suggestions - pub fn set_all_table_names(&mut self, table_names: Vec) { - self.all_table_names = table_names; - } - - /// Sets table names from the same profile for autocomplete suggestions - pub fn set_same_profile_table_names(&mut self, table_names: Vec) { - self.same_profile_table_names = table_names; - } - - /// Triggers waiting for column autocomplete for a specific table - pub fn trigger_column_autocomplete_for_table(&mut self, table_name: String) { - self.script_editor_awaiting_column_autocomplete = Some(table_name); - } - - /// Updates autocomplete with columns for a specific table - pub fn set_columns_for_table_autocomplete(&mut self, columns: Vec) { - self.script_editor_suggestions = columns; - self.script_editor_selected_suggestion_index = if self.script_editor_suggestions.is_empty() { - None - } else { - Some(0) - }; - self.script_editor_autocomplete_active = !self.script_editor_suggestions.is_empty(); - self.script_editor_awaiting_column_autocomplete = None; - } - - /// Deactivates script editor autocomplete and clears related state - pub fn deactivate_script_editor_autocomplete(&mut self) { - self.script_editor_autocomplete_active = false; - self.script_editor_suggestions.clear(); - self.script_editor_selected_suggestion_index = None; - self.script_editor_trigger_position = None; - self.script_editor_filter_text.clear(); - } - - /// Helper method to validate and save logic - pub fn save_logic(&mut self) -> Option { - if self.logic_name_input.trim().is_empty() { - return Some("Logic name is required".to_string()); - } - - if self.target_column_input.trim().is_empty() { - return Some("Target column is required".to_string()); - } - - let script_content = { - let editor_borrow = self.script_content_editor.borrow(); - editor_borrow.lines().join("\n") - }; - - if script_content.trim().is_empty() { - return Some("Script content is required".to_string()); - } - - // Here you would typically save to database/storage - // For now, just clear the form and mark as saved - self.has_unsaved_changes = false; - Some(format!("Logic '{}' saved successfully", self.logic_name_input.trim())) - } - - /// Helper method to clear the form - pub fn clear_form(&mut self) -> Option { - let profile = self.profile_name.clone(); - let table_id = self.selected_table_id; - let table_name = self.selected_table_name.clone(); - let editor_config = EditorConfig::default(); // You might want to preserve the actual config - - *self = Self::new(&editor_config); - self.profile_name = profile; - self.selected_table_id = table_id; - self.selected_table_name = table_name; - - Some("Form cleared".to_string()) - } -} - -impl Default for AddLogicState { - fn default() -> Self { - let mut state = Self::new(&EditorConfig::default()); - state.app_mode = canvas::AppMode::Edit; - state - } -} - -impl DataProvider for AddLogicState { - fn field_count(&self) -> usize { - 3 // Logic Name, Target Column, Description - } - - fn field_name(&self, index: usize) -> &str { - match index { - 0 => "Logic Name", - 1 => "Target Column", - 2 => "Description", - _ => "", - } - } - - fn field_value(&self, index: usize) -> &str { - match index { - 0 => &self.logic_name_input, - 1 => &self.target_column_input, - 2 => &self.description_input, - _ => "", - } - } - - fn set_field_value(&mut self, index: usize, value: String) { - match index { - 0 => self.logic_name_input = value, - 1 => self.target_column_input = value, - 2 => self.description_input = value, - _ => {} - } - self.has_unsaved_changes = true; - } - - fn supports_suggestions(&self, field_index: usize) -> bool { - // Only Target Column supports suggestions - field_index == 1 - } -} - -// Wrapper that owns both the raw state and its FormEditor (like LoginFormState) -pub struct AddLogicFormState { - pub state: AddLogicState, - pub editor: FormEditor, - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -// manual Debug because FormEditor may not implement Debug -impl std::fmt::Debug for AddLogicFormState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AddLogicFormState") - .field("state", &self.state) - .field("focus_outside_canvas", &self.focus_outside_canvas) - .field("focused_button_index", &self.focused_button_index) - .finish() - } -} - -impl AddLogicFormState { - pub fn new(editor_config: &EditorConfig) -> Self { - let state = AddLogicState::new(editor_config); - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - pub fn new_with_table( - editor_config: &EditorConfig, - profile_name: String, - table_id: Option, - table_name: String, - ) -> Self { - let mut state = AddLogicState::new(editor_config); - state.profile_name = profile_name; - state.selected_table_id = table_id; - state.selected_table_name = Some(table_name); - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - pub fn from_state(state: AddLogicState) -> Self { - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - /// Sync state from editor's data provider snapshot - pub fn sync_from_editor(&mut self) { - self.state = self.editor.data_provider().clone(); - } - - // === Delegates to AddLogicState fields === - - pub fn current_focus(&self) -> AddLogicFocus { - self.state.current_focus - } - - pub fn set_current_focus(&mut self, focus: AddLogicFocus) { - self.state.current_focus = focus; - } - - pub fn has_unsaved_changes(&self) -> bool { - self.state.has_unsaved_changes - } - - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.state.has_unsaved_changes = changed; - } - - pub fn profile_name(&self) -> &str { - &self.state.profile_name - } - - pub fn selected_table_name(&self) -> Option<&String> { - self.state.selected_table_name.as_ref() - } - - pub fn selected_table_id(&self) -> Option { - self.state.selected_table_id - } - - pub fn script_content_editor(&self) -> &Rc>> { - &self.state.script_content_editor - } - - pub fn script_content_editor_mut(&mut self) -> &mut Rc>> { - &mut self.state.script_content_editor - } - - pub fn vim_state(&self) -> &VimState { - &self.state.vim_state - } - - pub fn vim_state_mut(&mut self) -> &mut VimState { - &mut self.state.vim_state - } - - pub fn editor_keybinding_mode(&self) -> &EditorKeybindingMode { - &self.state.editor_keybinding_mode - } - - pub fn script_editor_autocomplete_active(&self) -> bool { - self.state.script_editor_autocomplete_active - } - - pub fn script_editor_suggestions(&self) -> &Vec { - &self.state.script_editor_suggestions - } - - pub fn script_editor_selected_suggestion_index(&self) -> Option { - self.state.script_editor_selected_suggestion_index - } - - pub fn target_column_suggestions(&self) -> &Vec { - &self.state.target_column_suggestions - } - - pub fn selected_target_column_suggestion_index(&self) -> Option { - self.state.selected_target_column_suggestion_index - } - - pub fn in_target_column_suggestion_mode(&self) -> bool { - self.state.in_target_column_suggestion_mode - } - - pub fn show_target_column_suggestions(&self) -> bool { - self.state.show_target_column_suggestions - } - - // === Delegates to FormEditor === - - pub fn mode(&self) -> AppMode { - self.editor.mode() - } - - pub fn cursor_position(&self) -> usize { - self.editor.cursor_position() - } - - pub fn handle_key_event( - &mut self, - key_event: crossterm::event::KeyEvent, - ) -> canvas::keymap::KeyEventOutcome { - // Customize behavior for Target Column (field index 1) in Edit mode, - // mirroring how Register page does suggestions for Role. - let in_target_col_field = self.editor.current_field() == 1; - let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit; - - if in_target_col_field && in_edit_mode { - match key_event.code { - // Tab: open suggestions if inactive; otherwise cycle next - KeyCode::Tab => { - if !self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(1) { - let items = self.state.column_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(1, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } else { - self.editor.suggestions_next(); - } - return canvas::keymap::KeyEventOutcome::Consumed(None); - } - // Shift+Tab: cycle suggestions too (fallback to next) - KeyCode::BackTab => { - if self.editor.is_suggestions_active() { - self.editor.suggestions_next(); - return canvas::keymap::KeyEventOutcome::Consumed(None); - } - } - // Enter: apply selected suggestion (if active) - KeyCode::Enter => { - if self.editor.is_suggestions_active() { - let _ = self.editor.apply_suggestion(); - return canvas::keymap::KeyEventOutcome::Consumed(None); - } - } - // Esc: close suggestions if active - KeyCode::Esc => { - if self.editor.is_suggestions_active() { - self.editor.close_suggestions(); - return canvas::keymap::KeyEventOutcome::Consumed(None); - } - } - // Character input: mutate then refresh suggestions if active - KeyCode::Char(_) => { - let outcome = self.editor.handle_key_event(key_event); - if self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(1) { - let items = self.state.column_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(1, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } - return outcome; - } - // Backspace/Delete: mutate then refresh suggestions if active - KeyCode::Backspace | KeyCode::Delete => { - let outcome = self.editor.handle_key_event(key_event); - if self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(1) { - let items = self.state.column_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(1, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } - return outcome; - } - _ => { /* fall through */ } - } - } - // Default: let canvas handle it - self.editor.handle_key_event(key_event) - } -} diff --git a/client/src/pages/admin_panel/add_logic/ui.rs b/client/src/pages/admin_panel/add_logic/ui.rs deleted file mode 100644 index 909b001..0000000 --- a/client/src/pages/admin_panel/add_logic/ui.rs +++ /dev/null @@ -1,302 +0,0 @@ -// src/pages/admin_panel/add_logic/ui.rs -use crate::config::colors::themes::Theme; -use crate::state::app::state::AppState; -use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState, AddLogicFormState}; -use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Paragraph}, - Frame, -}; -use crate::components::common::autocomplete; -use crate::dialog; -use crate::config::binds::config::EditorKeybindingMode; - -pub fn render_add_logic( - f: &mut Frame, - area: Rect, - theme: &Theme, - app_state: &AppState, - add_logic_state: &mut AddLogicFormState, -) { - let main_block = Block::default() - .title(" Add New Logic Script ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) - .style(Style::default().bg(theme.bg)); - let inner_area = main_block.inner(area); - f.render_widget(main_block, area); - - // Handle full-screen script editing - if add_logic_state.current_focus() == AddLogicFocus::InsideScriptContent { - let mut editor_ref = add_logic_state - .state - .script_content_editor - .borrow_mut(); - - let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) { - theme.highlight - } else { - theme.secondary - }; - let border_style = Style::default().fg(border_style_color); - - editor_ref.set_cursor_line_style(Style::default()); - editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - - let script_title_hint = match add_logic_state.editor_keybinding_mode() { - EditorKeybindingMode::Vim => { - let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(add_logic_state.vim_state()); - format!("Script {}", vim_mode_status) - } - EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { - if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) { - "Script (Editing)".to_string() - } else { - "Script".to_string() - } - } - }; - - editor_ref.set_block( - Block::default() - .title(Span::styled(script_title_hint, Style::default().fg(theme.fg))) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(border_style), - ); - f.render_widget(&*editor_ref, inner_area); - - // Drop the editor borrow before accessing autocomplete state - drop(editor_ref); - - // === SCRIPT EDITOR AUTOCOMPLETE RENDERING === - if add_logic_state.script_editor_autocomplete_active() && !add_logic_state.script_editor_suggestions().is_empty() { - // Get the current cursor position from textarea - let current_cursor = { - let editor_borrow = add_logic_state.script_content_editor().borrow(); - editor_borrow.cursor() // Returns (row, col) as (usize, usize) - }; - - let (cursor_line, cursor_col) = current_cursor; - - // Account for TextArea's block borders (1 for each side) - let block_offset_x = 1; - let block_offset_y = 1; - - // Position autocomplete at current cursor position - // Add 1 to column to position dropdown right after the cursor - let autocomplete_x = cursor_col + 1; - let autocomplete_y = cursor_line; - - let input_rect = Rect { - x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)), - y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)), - width: 1, // Minimum width for positioning - height: 1, - }; - - // Render autocomplete dropdown - autocomplete::render_autocomplete_dropdown( - f, - input_rect, - f.area(), // Full frame area for clamping - theme, - add_logic_state.script_editor_suggestions(), - add_logic_state.script_editor_selected_suggestion_index(), - ); - } - - return; // Exit early for fullscreen mode - } - - // Regular layout with preview - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Top info - Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure) - Constraint::Min(5), // Script preview - Constraint::Length(3), // Buttons - ]) - .split(inner_area); - - let top_info_area = main_chunks[0]; - let canvas_area = main_chunks[1]; - let script_content_area = main_chunks[2]; - let buttons_area = main_chunks[3]; - - // Top info - let table_label = if let Some(name) = add_logic_state.selected_table_name() { - name.clone() - } else if let Some(id) = add_logic_state.selected_table_id() { - format!("ID {}", id) - } else { - "Global (Not Selected)".to_string() - }; - - let profile_text = Paragraph::new(vec![ - Line::from(Span::styled( - format!("Profile: {}", add_logic_state.profile_name()), - Style::default().fg(theme.fg), - )), - Line::from(Span::styled( - format!("Table: {}", table_label), - Style::default().fg(theme.fg), - )), - ]) - .block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme.secondary)), - ); - f.render_widget(profile_text, top_info_area); - - // Canvas - USING CANVAS LIBRARY - let focus_on_canvas_inputs = matches!( - add_logic_state.current_focus(), - AddLogicFocus::InputLogicName - | AddLogicFocus::InputTargetColumn - | AddLogicFocus::InputDescription - ); - - let editor = &add_logic_state.editor; - let active_field_rect = render_canvas(f, canvas_area, editor, theme); - - // --- Canvas suggestions dropdown (Target Column, etc.) --- - if editor.mode() == canvas::AppMode::Edit { - if let Some(input_rect) = active_field_rect { - render_suggestions_dropdown( - f, - f.area(), - input_rect, - &DefaultCanvasTheme, - editor, - ); - } - } - - // Script content preview - { - let mut editor_ref = add_logic_state.script_content_editor().borrow_mut(); - editor_ref.set_cursor_line_style(Style::default()); - - let is_script_preview_focused = add_logic_state.current_focus() == AddLogicFocus::ScriptContentPreview; - - if is_script_preview_focused { - editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - } else { - let underscore_cursor_style = Style::default() - .add_modifier(Modifier::UNDERLINED) - .fg(theme.secondary); - editor_ref.set_cursor_style(underscore_cursor_style); - } - - let border_style_color = if is_script_preview_focused { - theme.highlight - } else { - theme.secondary - }; - - let title_text = "Script Preview"; // Title doesn't need to change based on focus here - - let title_style = if is_script_preview_focused { - Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg) - }; - - editor_ref.set_block( - Block::default() - .title(Span::styled(title_text, title_style)) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(border_style_color)), - ); - f.render_widget(&*editor_ref, script_content_area); - } - - // Buttons - let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| { - let is_focused = current_focus_state == button_focus; - let base_style = Style::default().fg(if is_focused { - theme.highlight - } else { - theme.secondary - }); - if is_focused { - base_style.add_modifier(Modifier::BOLD) - } else { - base_style - } - }; - - let get_button_border_style = |is_focused: bool, current_theme: &Theme| { - if is_focused { - Style::default().fg(current_theme.highlight) - } else { - Style::default().fg(current_theme.secondary) - } - }; - - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(buttons_area); - - let save_button = Paragraph::new(" Save Logic ") - .style(get_button_style( - AddLogicFocus::SaveButton, - add_logic_state.current_focus(), - )) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style( - add_logic_state.current_focus() == AddLogicFocus::SaveButton, - theme, - )), - ); - f.render_widget(save_button, button_chunks[0]); - - let cancel_button = Paragraph::new(" Cancel ") - .style(get_button_style( - AddLogicFocus::CancelButton, - add_logic_state.current_focus(), - )) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style( - add_logic_state.current_focus() == AddLogicFocus::CancelButton, - theme, - )), - ); - f.render_widget(cancel_button, button_chunks[1]); - - // Dialog - if app_state.ui.dialog.dialog_show { - dialog::render_dialog( - f, - f.area(), - theme, - &app_state.ui.dialog.dialog_title, - &app_state.ui.dialog.dialog_message, - &app_state.ui.dialog.dialog_buttons, - app_state.ui.dialog.dialog_active_button_index, - app_state.ui.dialog.is_loading, - ); - } -} diff --git a/client/src/pages/admin_panel/add_table/event.rs b/client/src/pages/admin_panel/add_table/event.rs deleted file mode 100644 index ae5c8ab..0000000 --- a/client/src/pages/admin_panel/add_table/event.rs +++ /dev/null @@ -1,287 +0,0 @@ -// src/pages/admin_panel/add_table/event.rs - -use anyhow::Result; -use crate::config::binds::config::Config; -use crate::movement::{move_focus, MovementAction}; -use crate::pages::admin_panel::add_table::logic::{ - handle_add_column_action, handle_delete_selected_columns, -}; -use crate::pages::admin_panel::add_table::loader::handle_save_table_action; -use crate::pages::admin_panel::add_table::nav::SaveTableResultSender; -use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState}; -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; -use crate::modes::handlers::event::EventOutcome; -use canvas::{AppMode as CanvasMode, DataProvider}; -use crossterm::event::KeyEvent; - -/// Focus traversal order for AddTable (outside canvas) -const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 10] = [ - AddTableFocus::InputTableName, - AddTableFocus::InputColumnName, - AddTableFocus::InputColumnType, - AddTableFocus::AddColumnButton, - AddTableFocus::ColumnsTable, - AddTableFocus::IndexesTable, - AddTableFocus::LinksTable, - AddTableFocus::SaveButton, - AddTableFocus::DeleteSelectedButton, - AddTableFocus::CancelButton, -]; - -/// Handles all AddTable page-specific events. -/// Return a non-empty Ok(message) only when the page actually consumed the key, -/// otherwise return Ok("") to let global handling proceed. -pub fn handle_add_table_event( - key_event: KeyEvent, - movement: Option, - config: &Config, - app_state: &mut AppState, - page: &mut AddTableFormState, - mut grpc_client: GrpcClient, - save_result_sender: SaveTableResultSender, -) -> Result { - // 1) Inside canvas (FormEditor) - let inside_canvas_inputs = matches!( - page.current_focus(), - AddTableFocus::InputTableName - | AddTableFocus::InputColumnName - | AddTableFocus::InputColumnType - ); - - if inside_canvas_inputs { - // Disable global shortcuts while typing - page.focus_outside_canvas = false; - - // Only allow leaving the canvas with Down/Next when in ReadOnly mode - let in_edit_mode = page.editor.mode() == CanvasMode::Edit; - if !in_edit_mode { - if let Some(ma) = movement { - let last_idx = page.editor.data_provider().field_count().saturating_sub(1); - let at_last = page.editor.current_field() >= last_idx; - if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) { - page.state.last_canvas_field = last_idx; - page.set_current_focus(AddTableFocus::AddColumnButton); - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok("Moved to Add button".to_string())); - } - } - } - - // Let the FormEditor handle typing - match page.editor.handle_key_event(key_event) { - canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => { - page.sync_from_editor(); - return Ok(EventOutcome::Ok(msg)); - } - canvas::keymap::KeyEventOutcome::Consumed(None) => { - page.sync_from_editor(); - return Ok(EventOutcome::Ok("Input updated".into())); - } - canvas::keymap::KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok(String::new())); - } - canvas::keymap::KeyEventOutcome::NotMatched => { - // fall through - } - } - } - - // 2) Outside canvas - if let Some(ma) = movement { - // Block outer moves when "inside" any table and handle locally - match page.current_focus() { - AddTableFocus::InsideColumnsTable => { - match ma { - MovementAction::Up => { - if let Some(i) = page.state.column_table_state.selected() { - let next = i.saturating_sub(1); - page.state.column_table_state.select(Some(next)); - } else if !page.state.columns.is_empty() { - page.state.column_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Down => { - if let Some(i) = page.state.column_table_state.selected() { - let last = page.state.columns.len().saturating_sub(1); - let next = if i < last { i + 1 } else { i }; - page.state.column_table_state.select(Some(next)); - } else if !page.state.columns.is_empty() { - page.state.column_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Select => { - if let Some(i) = page.state.column_table_state.selected() { - if let Some(col) = page.state.columns.get_mut(i) { - col.selected = !col.selected; - page.state.has_unsaved_changes = true; - } - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Esc => { - page.state.column_table_state.select(None); - page.set_current_focus(AddTableFocus::ColumnsTable); - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Next | MovementAction::Previous => { - // Block outer movement while inside - return Ok(EventOutcome::Ok(String::new())); - } - _ => {} - } - } - AddTableFocus::InsideIndexesTable => { - match ma { - MovementAction::Up => { - if let Some(i) = page.state.index_table_state.selected() { - let next = i.saturating_sub(1); - page.state.index_table_state.select(Some(next)); - } else if !page.state.indexes.is_empty() { - page.state.index_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Down => { - if let Some(i) = page.state.index_table_state.selected() { - let last = page.state.indexes.len().saturating_sub(1); - let next = if i < last { i + 1 } else { i }; - page.state.index_table_state.select(Some(next)); - } else if !page.state.indexes.is_empty() { - page.state.index_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Select => { - if let Some(i) = page.state.index_table_state.selected() { - if let Some(ix) = page.state.indexes.get_mut(i) { - ix.selected = !ix.selected; - page.state.has_unsaved_changes = true; - } - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Esc => { - page.state.index_table_state.select(None); - page.set_current_focus(AddTableFocus::IndexesTable); - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Next | MovementAction::Previous => { - return Ok(EventOutcome::Ok(String::new())); - } - _ => {} - } - } - AddTableFocus::InsideLinksTable => { - match ma { - MovementAction::Up => { - if let Some(i) = page.state.link_table_state.selected() { - let next = i.saturating_sub(1); - page.state.link_table_state.select(Some(next)); - } else if !page.state.links.is_empty() { - page.state.link_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Down => { - if let Some(i) = page.state.link_table_state.selected() { - let last = page.state.links.len().saturating_sub(1); - let next = if i < last { i + 1 } else { i }; - page.state.link_table_state.select(Some(next)); - } else if !page.state.links.is_empty() { - page.state.link_table_state.select(Some(0)); - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Select => { - if let Some(i) = page.state.link_table_state.selected() { - if let Some(link) = page.state.links.get_mut(i) { - link.selected = !link.selected; - page.state.has_unsaved_changes = true; - } - } - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Esc => { - page.state.link_table_state.select(None); - page.set_current_focus(AddTableFocus::LinksTable); - page.focus_outside_canvas = true; - return Ok(EventOutcome::Ok(String::new())); - } - MovementAction::Next | MovementAction::Previous => { - return Ok(EventOutcome::Ok(String::new())); - } - _ => {} - } - } - _ => {} - } - - let mut current = page.current_focus(); - if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) { - page.set_current_focus(current); - page.focus_outside_canvas = !matches!( - page.current_focus(), - AddTableFocus::InputTableName - | AddTableFocus::InputColumnName - | AddTableFocus::InputColumnType - ); - return Ok(EventOutcome::Ok(String::new())); - } - - // 3) Rich actions - match ma { - MovementAction::Select => match page.current_focus() { - AddTableFocus::AddColumnButton => { - if let Some(msg) = page.state.add_column_from_inputs() { - // Focus is set by the state method; just bubble message - return Ok(EventOutcome::Ok(msg)); - } - } - AddTableFocus::SaveButton => { - if page.state.table_name.is_empty() { - return Ok(EventOutcome::Ok("Cannot save: Table name is empty".into())); - } - if page.state.columns.is_empty() { - return Ok(EventOutcome::Ok("Cannot save: No columns defined".into())); - } - app_state.show_loading_dialog("Saving", "Please wait..."); - let state_clone = page.state.clone(); - let sender_clone = save_result_sender.clone(); - tokio::spawn(async move { - let result = handle_save_table_action(&mut grpc_client, &state_clone).await; - let _ = sender_clone.send(result).await; - }); - return Ok(EventOutcome::Ok("Saving table...".into())); - } - AddTableFocus::DeleteSelectedButton => { - let msg = page - .state - .delete_selected_items() - .unwrap_or_else(|| "No items selected for deletion".to_string()); - return Ok(EventOutcome::Ok(msg)); - } - AddTableFocus::CancelButton => { - return Ok(EventOutcome::Ok("Cancelled Add Table".to_string())); - } - _ => {} - }, - _ => {} - } - } - - Ok(EventOutcome::Ok(String::new())) -} diff --git a/client/src/pages/admin_panel/add_table/loader.rs b/client/src/pages/admin_panel/add_table/loader.rs deleted file mode 100644 index 692edc6..0000000 --- a/client/src/pages/admin_panel/add_table/loader.rs +++ /dev/null @@ -1,78 +0,0 @@ -// src/pages/admin_panel/add_table/loader.rs - -use anyhow::{anyhow, Result}; -use tracing::debug; - -use crate::pages::admin_panel::add_table::state::AddTableState; -use crate::services::grpc_client::GrpcClient; -use common::proto::komp_ac::table_definition::{ - ColumnDefinition as ProtoColumnDefinition, PostTableDefinitionRequest, TableLink as ProtoTableLink, -}; - -/// Prepares and sends the request to save the new table definition via gRPC. -pub async fn handle_save_table_action( - grpc_client: &mut GrpcClient, - add_table_state: &AddTableState, - ) -> Result { - if add_table_state.table_name.is_empty() { - return Err(anyhow!("Table name cannot be empty.")); - } - if add_table_state.columns.is_empty() { - return Err(anyhow!("Table must have at least one column.")); - } - - let proto_columns: Vec = add_table_state - .columns - .iter() - .map(|col| ProtoColumnDefinition { - name: col.name.clone(), - field_type: col.data_type.clone(), - }) - .collect(); - - let proto_indexes: Vec = add_table_state - .indexes - .iter() - .filter(|idx| idx.selected) - .map(|idx| idx.name.clone()) - .collect(); - - let proto_links: Vec = add_table_state - .links - .iter() - .filter(|link| link.selected) - .map(|link| ProtoTableLink { - linked_table_name: link.linked_table_name.clone(), - required: false, - }) - .collect(); - - let request = PostTableDefinitionRequest { - table_name: add_table_state.table_name.clone(), - columns: proto_columns, - indexes: proto_indexes, - links: proto_links, - profile_name: add_table_state.profile_name.clone(), - }; - - debug!("Sending PostTableDefinitionRequest: {:?}", request); - - match grpc_client.post_table_definition(request).await { - Ok(response) => { - if response.success { - Ok(format!( - "Table '{}' saved successfully.", - add_table_state.table_name - )) - } else { - let error_message = if !response.sql.is_empty() { - format!("Server failed to save table: {}", response.sql) - } else { - "Server failed to save table (unknown reason).".to_string() - }; - Err(anyhow!(error_message)) - } - } - Err(e) => Err(anyhow!("gRPC call failed: {}", e)), - } -} diff --git a/client/src/pages/admin_panel/add_table/logic.rs b/client/src/pages/admin_panel/add_table/logic.rs deleted file mode 100644 index 0708f10..0000000 --- a/client/src/pages/admin_panel/add_table/logic.rs +++ /dev/null @@ -1,24 +0,0 @@ -// src/pages/admin_panel/add_table/logic.rs - -use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus}; - -/// Thin wrapper around AddTableState::add_column_from_inputs -/// Returns Some(AddTableFocus) for compatibility with old call sites. -pub fn handle_add_column_action( - add_table_state: &mut AddTableState, - command_message: &mut String, -) -> Option { - if let Some(msg) = add_table_state.add_column_from_inputs() { - *command_message = msg; - // State sets focus internally; return it explicitly for old call sites - return Some(add_table_state.current_focus); - } - None -} - -/// Thin wrapper around AddTableState::delete_selected_items -pub fn handle_delete_selected_columns(add_table_state: &mut AddTableState) -> String { - add_table_state - .delete_selected_items() - .unwrap_or_else(|| "No items selected for deletion".to_string()) -} diff --git a/client/src/pages/admin_panel/add_table/mod.rs b/client/src/pages/admin_panel/add_table/mod.rs deleted file mode 100644 index 7e9f921..0000000 --- a/client/src/pages/admin_panel/add_table/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// src/pages/admin_panel/add_table/mod.rs - -pub mod ui; -pub mod nav; -pub mod state; -pub mod logic; -pub mod event; -pub mod loader; diff --git a/client/src/pages/admin_panel/add_table/nav.rs b/client/src/pages/admin_panel/add_table/nav.rs deleted file mode 100644 index c2e5377..0000000 --- a/client/src/pages/admin_panel/add_table/nav.rs +++ /dev/null @@ -1,6 +0,0 @@ -// src/pages/admin_panel/add_table/nav.rs - -use anyhow::Result; -use tokio::sync::mpsc; - -pub type SaveTableResultSender = mpsc::Sender>; diff --git a/client/src/pages/admin_panel/add_table/state.rs b/client/src/pages/admin_panel/add_table/state.rs deleted file mode 100644 index 842e390..0000000 --- a/client/src/pages/admin_panel/add_table/state.rs +++ /dev/null @@ -1,332 +0,0 @@ -// src/pages/admin_panel/add_table/state.rs - -use canvas::{DataProvider, AppMode}; -use canvas::FormEditor; -use ratatui::widgets::TableState; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ColumnDefinition { - pub name: String, - pub data_type: String, - pub selected: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IndexDefinition { - pub name: String, - pub selected: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LinkDefinition { - pub linked_table_name: String, - pub is_required: bool, - pub selected: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AddTableFocus { - #[default] - InputTableName, // Field 0 for CanvasState - InputColumnName, // Field 1 for CanvasState - InputColumnType, // Field 2 for CanvasState - AddColumnButton, - // Result Tables - ColumnsTable, - IndexesTable, - LinksTable, - // Inside Tables (Scrolling Focus) - InsideColumnsTable, - InsideIndexesTable, - InsideLinksTable, - // Buttons - SaveButton, - DeleteSelectedButton, - CancelButton, -} - -#[derive(Debug, Clone)] -pub struct AddTableState { - pub profile_name: String, - pub table_name: String, - pub table_name_input: String, - pub column_name_input: String, - pub column_type_input: String, - pub columns: Vec, - pub indexes: Vec, - pub links: Vec, - pub current_focus: AddTableFocus, - pub last_canvas_field: usize, - pub column_table_state: TableState, - pub index_table_state: TableState, - pub link_table_state: TableState, - pub table_name_cursor_pos: usize, - pub column_name_cursor_pos: usize, - pub column_type_cursor_pos: usize, - pub has_unsaved_changes: bool, - pub app_mode: canvas::AppMode, -} - -impl Default for AddTableState { - fn default() -> Self { - AddTableState { - profile_name: "default".to_string(), - table_name: String::new(), - table_name_input: String::new(), - column_name_input: String::new(), - column_type_input: String::new(), - columns: Vec::new(), - indexes: Vec::new(), - links: Vec::new(), - current_focus: AddTableFocus::InputTableName, - last_canvas_field: 2, - column_table_state: TableState::default(), - index_table_state: TableState::default(), - link_table_state: TableState::default(), - table_name_cursor_pos: 0, - column_name_cursor_pos: 0, - column_type_cursor_pos: 0, - has_unsaved_changes: false, - app_mode: canvas::AppMode::Edit, - } - } -} - -impl AddTableState { - pub const INPUT_FIELD_COUNT: usize = 3; - - /// Helper method to add a column from current inputs - pub fn add_column_from_inputs(&mut self) -> Option { - let table_name_in = self.table_name_input.trim().to_string(); - let column_name_in = self.column_name_input.trim().to_string(); - let column_type_in = self.column_type_input.trim().to_string(); - - // Case: "only table name" provided → set it and stay on TableName - if !table_name_in.is_empty() && column_name_in.is_empty() && column_type_in.is_empty() { - self.table_name = table_name_in; - self.table_name_input.clear(); - self.table_name_cursor_pos = 0; - self.current_focus = AddTableFocus::InputTableName; - self.has_unsaved_changes = true; - return Some(format!("Table name set to '{}'.", self.table_name)); - } - - // Column validation - if column_name_in.is_empty() || column_type_in.is_empty() { - return Some("Both column name and type are required".to_string()); - } - if self.columns.iter().any(|col| col.name == column_name_in) { - return Some("Column name already exists".to_string()); - } - - // If table_name input present while adding first column, apply it too - if !table_name_in.is_empty() { - self.table_name = table_name_in; - self.table_name_input.clear(); - self.table_name_cursor_pos = 0; - } - - // Add the column - self.columns.push(ColumnDefinition { - name: column_name_in.clone(), - data_type: column_type_in.clone(), - selected: false, - }); - // Add a corresponding (unselected) index with the same name - self.indexes.push(IndexDefinition { - name: column_name_in.clone(), - selected: false, - }); - - // Clear column inputs and set focus for next entry - self.column_name_input.clear(); - self.column_type_input.clear(); - self.column_name_cursor_pos = 0; - self.column_type_cursor_pos = 0; - self.current_focus = AddTableFocus::InputColumnName; - self.last_canvas_field = 1; - self.has_unsaved_changes = true; - - Some(format!("Column '{}' added successfully", column_name_in)) - } - - /// Helper method to delete selected items - pub fn delete_selected_items(&mut self) -> Option { - let mut deleted_items: Vec = Vec::new(); - - // Remove selected columns - let selected_col_names: std::collections::HashSet = self - .columns - .iter() - .filter(|c| c.selected) - .map(|c| c.name.clone()) - .collect(); - if !selected_col_names.is_empty() { - self.columns.retain(|col| { - if selected_col_names.contains(&col.name) { - deleted_items.push(format!("column '{}'", col.name)); - false - } else { - true - } - }); - // Also purge indexes for deleted columns - self.indexes - .retain(|idx| !selected_col_names.contains(&idx.name)); - } - - // Remove selected indexes - let initial_index_count = self.indexes.len(); - self.indexes.retain(|idx| { - if idx.selected { - deleted_items.push(format!("index '{}'", idx.name)); - false - } else { - true - } - }); - - // Remove selected links - let initial_link_count = self.links.len(); - self.links.retain(|link| { - if link.selected { - deleted_items.push(format!("link to '{}'", link.linked_table_name)); - false - } else { - true - } - }); - - if deleted_items.is_empty() { - Some("No items selected for deletion".to_string()) - } else { - self.has_unsaved_changes = true; - self.column_table_state.select(None); - self.index_table_state.select(None); - Some(format!("Deleted: {}", deleted_items.join(", "))) - } - } -} - -impl DataProvider for AddTableState { - fn field_count(&self) -> usize { - 3 // Table name, Column name, Column type - } - - fn field_name(&self, index: usize) -> &str { - match index { - 0 => "Table name", - 1 => "Name", - 2 => "Type", - _ => "", - } - } - - fn field_value(&self, index: usize) -> &str { - match index { - 0 => &self.table_name_input, - 1 => &self.column_name_input, - 2 => &self.column_type_input, - _ => "", - } - } - - fn set_field_value(&mut self, index: usize, value: String) { - match index { - 0 => self.table_name_input = value, - 1 => self.column_name_input = value, - 2 => self.column_type_input = value, - _ => {} - } - self.has_unsaved_changes = true; - } - - fn supports_suggestions(&self, _field_index: usize) -> bool { - false // AddTableState doesn’t use suggestions - } -} - -pub struct AddTableFormState { - pub state: AddTableState, - pub editor: FormEditor, - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -impl std::fmt::Debug for AddTableFormState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AddTableFormState") - .field("state", &self.state) - .field("focus_outside_canvas", &self.focus_outside_canvas) - .field("focused_button_index", &self.focused_button_index) - .finish() - } -} - -impl AddTableFormState { - pub fn new(profile_name: String) -> Self { - let mut state = AddTableState::default(); - state.profile_name = profile_name; - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - pub fn from_state(state: AddTableState) -> Self { - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - /// Sync state from editor’s snapshot - pub fn sync_from_editor(&mut self) { - self.state = self.editor.data_provider().clone(); - } - - // === Delegates to AddTableState fields === - pub fn current_focus(&self) -> AddTableFocus { - self.state.current_focus - } - pub fn set_current_focus(&mut self, focus: AddTableFocus) { - self.state.current_focus = focus; - } - pub fn profile_name(&self) -> &str { - &self.state.profile_name - } - pub fn table_name(&self) -> &str { - &self.state.table_name - } - pub fn columns(&self) -> &Vec { - &self.state.columns - } - pub fn indexes(&self) -> &Vec { - &self.state.indexes - } - pub fn links(&self) -> &Vec { - &self.state.links - } - pub fn column_table_state(&mut self) -> &mut TableState { - &mut self.state.column_table_state - } - pub fn index_table_state(&mut self) -> &mut TableState { - &mut self.state.index_table_state - } - pub fn link_table_state(&mut self) -> &mut TableState { - &mut self.state.link_table_state - } - pub fn set_focused_button(&mut self, index: usize) { - self.focused_button_index = index; - } - pub fn focused_button(&self) -> usize { - self.focused_button_index - } -} diff --git a/client/src/pages/admin_panel/add_table/ui.rs b/client/src/pages/admin_panel/add_table/ui.rs deleted file mode 100644 index a5bba15..0000000 --- a/client/src/pages/admin_panel/add_table/ui.rs +++ /dev/null @@ -1,560 +0,0 @@ -// src/pages/admin_panel/add_table/ui.rs -use crate::config::colors::themes::Theme; -use crate::state::app::state::AppState; -use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState}; -use canvas::render_canvas; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table}, - Frame, -}; -use crate::dialog; - -/// Renders the Add New Table page layout, structuring the display of table information, -/// input fields, and action buttons. Adapts layout based on terminal width. -pub fn render_add_table( - f: &mut Frame, - area: Rect, - theme: &Theme, - app_state: &AppState, - add_table_state: &mut AddTableFormState, -) { - // --- Configuration --- - // Threshold width to switch between wide and narrow layouts - const NARROW_LAYOUT_THRESHOLD: u16 = 120; // Adjust this value as needed - - // --- State Checks --- - let focus_on_canvas_inputs = matches!( - add_table_state.current_focus(), - AddTableFocus::InputTableName - | AddTableFocus::InputColumnName - | AddTableFocus::InputColumnType - ); - - // --- Main Page Block --- - let main_block = Block::default() - .title(" Add New Table ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) - .style(Style::default().bg(theme.bg)); - let inner_area = main_block.inner(area); - f.render_widget(main_block, area); - - // --- Fullscreen Columns Table Check (Narrow Screens Only) --- - if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus() == AddTableFocus::InsideColumnsTable { - // Render ONLY the columns table taking the full inner area - let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen - let column_rows: Vec> = add_table_state - .columns() - .iter() - .map(|col_def| { - Row::new(vec![ - Cell::from(if col_def.selected { "[*]" } else { "[ ]" }), - Cell::from(col_def.name.clone()), - Cell::from(col_def.data_type.clone()), - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let header_cells = ["Sel", "Name", "Type"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let header = Row::new(header_cells).height(1).bottom_margin(1); - let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)]) - .header(header) - .block( - Block::default() - .title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(columns_border_style), - ) - .row_highlight_style( - Style::default() - .add_modifier(Modifier::REVERSED) - .fg(theme.highlight), - ) - .highlight_symbol(" > "); // Use the inside symbol - f.render_stateful_widget(columns_table, inner_area, add_table_state.column_table_state()); - return; // IMPORTANT: Stop rendering here for fullscreen mode - } - - // --- Fullscreen Indexes Table Check --- - if add_table_state.current_focus() == AddTableFocus::InsideIndexesTable { // Remove width check - // Render ONLY the indexes table taking the full inner area - let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen - let index_rows: Vec> = add_table_state - .indexes() - .iter() - .map(|index_def| { - Row::new(vec![ - Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), - Cell::from(index_def.name.clone()), - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let index_header_cells = ["Sel", "Column Name"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let index_header = Row::new(index_header_cells).height(1).bottom_margin(1); - let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)]) - .header(index_header) - .block( - Block::default() - .title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(indexes_border_style), - ) - .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight)) - .highlight_symbol(" > "); // Use the inside symbol - f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state()); - return; // IMPORTANT: Stop rendering here for fullscreen mode - } - - // --- Fullscreen Links Table Check --- - if add_table_state.current_focus() == AddTableFocus::InsideLinksTable { - // Render ONLY the links table taking the full inner area - let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen - let link_rows: Vec> = add_table_state - .links() - .iter() - .map(|link_def| { - Row::new(vec![ - Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first - Cell::from(link_def.linked_table_name.clone()), // Table name second - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let link_header_cells = ["Sel", "Available Table"] - - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let link_header = Row::new(link_header_cells).height(1).bottom_margin(1); - let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)]) - .header(link_header) - .block( - Block::default() - .title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(links_border_style), - ) - .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight)) - .highlight_symbol(" > "); // Use the inside symbol - f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state()); - return; // IMPORTANT: Stop rendering here for fullscreen mode - } - - // --- Area Variable Declarations --- - let top_info_area: Rect; - let columns_area: Rect; - let canvas_area: Rect; - let add_button_area: Rect; - let indexes_area: Rect; - let links_area: Rect; - let bottom_buttons_area: Rect; - - // --- Layout Decision --- - if area.width >= NARROW_LAYOUT_THRESHOLD { - // --- WIDE Layout (Based on first screenshot) --- - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Top Info (Profile/Table Name) - Increased to 3 lines - Constraint::Min(10), // Middle Area (Columns | Right Pane) - Constraint::Length(3), // Bottom Buttons - ]) - .split(inner_area); - - top_info_area = main_chunks[0]; - let middle_area = main_chunks[1]; - bottom_buttons_area = main_chunks[2]; - - // Split Middle Horizontally - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), // Left: Columns Table - Constraint::Percentage(50), // Right: Inputs etc. - ]) - .split(middle_area); - - columns_area = middle_chunks[0]; - let right_pane_area = middle_chunks[1]; - - // Split Right Pane Vertically - let right_pane_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Input Canvas Area - Constraint::Length(3), // Add Button Area - Constraint::Min(5), // Indexes & Links Area - ]) - .split(right_pane_area); - - canvas_area = right_pane_chunks[0]; - add_button_area = right_pane_chunks[1]; - let indexes_links_area = right_pane_chunks[2]; - - // Split Indexes/Links Horizontally - let indexes_links_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), // Indexes Table - Constraint::Percentage(50), // Links Table - ]) - .split(indexes_links_area); - indexes_area = indexes_links_chunks[0]; - links_area = indexes_links_chunks[1]; - - // --- Top Info Rendering (Wide - 2 lines) --- - let profile_text = Paragraph::new(vec![ - Line::from(Span::styled( - format!("Profile: {}", add_table_state.profile_name()), - theme.fg, - )), - Line::from(Span::styled( - format!("Table name: {}", add_table_state.table_name()), - theme.fg, - )), - ]) - .block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme.secondary)), - ); - f.render_widget(profile_text, top_info_area); - } else { - // --- NARROW Layout (Based on second screenshot) --- - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Top: Profile & Table Name (Single Row) - Constraint::Length(5), // Column Definition Input Canvas Area - Constraint::Length(3), // Add Button Area - Constraint::Min(5), // Columns Table Area - Constraint::Min(5), // Indexes & Links Area - Constraint::Length(3), // Bottom: Save/Cancel Buttons - ]) - .split(inner_area); - - top_info_area = main_chunks[0]; - canvas_area = main_chunks[1]; - add_button_area = main_chunks[2]; - columns_area = main_chunks[3]; - let indexes_links_area = main_chunks[4]; - bottom_buttons_area = main_chunks[5]; - - // Split Indexes/Links Horizontally - let indexes_links_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), // Indexes Table - Constraint::Percentage(50), // Links Table - ]) - .split(indexes_links_area); - indexes_area = indexes_links_chunks[0]; - links_area = indexes_links_chunks[1]; - - // --- Top Info Rendering (Narrow - 1 line) --- - let top_info_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(top_info_area); - - let profile_text = Paragraph::new(Span::styled( - format!("Profile: {}", add_table_state.profile_name()), - theme.fg, - )) - .alignment(Alignment::Left); - f.render_widget(profile_text, top_info_chunks[0]); - - let table_name_text = Paragraph::new(Span::styled( - format!("Table: {}", add_table_state.table_name()), - theme.fg, - )) - .alignment(Alignment::Left); - f.render_widget(table_name_text, top_info_chunks[1]); - } - - // --- Common Widget Rendering (Uses calculated areas) --- - - // --- Columns Table Rendering --- - let columns_focused = matches!(add_table_state.current_focus(), AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable); - let columns_border_style = if columns_focused { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.secondary) - }; - let column_rows: Vec> = add_table_state - .columns() - .iter() - .map(|col_def| { - Row::new(vec![ - Cell::from(if col_def.selected { "[*]" } else { "[ ]" }), - Cell::from(col_def.name.clone()), - Cell::from(col_def.data_type.clone()), - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let header_cells = ["Sel", "Name", "Type"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let header = Row::new(header_cells).height(1).bottom_margin(1); - let columns_table = Table::new( - column_rows, - [ // Define constraints for 3 columns: Sel, Name, Type - Constraint::Length(5), - Constraint::Percentage(60), - Constraint::Percentage(35), - ], - ) - .header(header) - .block( - Block::default() - .title(Span::styled(" Columns ", theme.fg)) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(columns_border_style), - ) - .row_highlight_style( - Style::default() - .add_modifier(Modifier::REVERSED) - .fg(theme.highlight), - ) - .highlight_symbol(" > "); - f.render_stateful_widget( - columns_table, - columns_area, - &mut add_table_state.column_table_state(), - ); - - // --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY --- - let _active_field_rect = render_canvas(f, canvas_area, &add_table_state.editor, theme); - - // --- Button Style Helpers --- - let get_button_style = |button_focus: AddTableFocus, current_focus| { - // Only handles text style (FG + Bold) now, no BG - let is_focused = current_focus == button_focus; - let base_style = Style::default().fg(if is_focused { - theme.highlight // Highlighted text color - } else { - theme.secondary // Normal text color - }); - if is_focused { - base_style.add_modifier(Modifier::BOLD) - } else { - base_style - } - }; - // Updated signature to accept bool and theme - let get_button_border_style = |is_focused: bool, theme: &Theme| { - if is_focused { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.secondary) - } - }; - - // --- Add Button Rendering --- - // Determine if the add button is focused - let is_add_button_focused = add_table_state.current_focus() == AddTableFocus::AddColumnButton; - - // Create the Add button Paragraph widget - let add_button = Paragraph::new(" Add ") - .style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus())) // Use existing closure - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme - ); - - // Render the button in its designated area - f.render_widget(add_button, add_button_area); - - // --- Indexes Table Rendering --- - let indexes_focused = matches!(add_table_state.current_focus(), AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable); - let indexes_border_style = if indexes_focused { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.secondary) - }; - let index_rows: Vec> = add_table_state - .indexes() - .iter() - .map(|index_def| { // Use index_def now - Row::new(vec![ - Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection - Cell::from(index_def.name.clone()), - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let index_header_cells = ["Sel", "Column Name"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let index_header = Row::new(index_header_cells).height(1).bottom_margin(1); - let indexes_table = - Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)]) - .header(index_header) - .block( - Block::default() - .title(Span::styled(" Indexes ", theme.fg)) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(indexes_border_style), - ) - .row_highlight_style( - Style::default() - .add_modifier(Modifier::REVERSED) - .fg(theme.highlight), - ) - .highlight_symbol(" > "); - f.render_stateful_widget( - indexes_table, - indexes_area, - &mut add_table_state.index_table_state(), - ); - - // --- Links Table Rendering --- - let links_focused = matches!(add_table_state.current_focus(), AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable); - let links_border_style = if links_focused { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.secondary) - }; - let link_rows: Vec> = add_table_state - .links() - .iter() - .map(|link_def| { - Row::new(vec![ - Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), - Cell::from(link_def.linked_table_name.clone()), - ]) - .style(Style::default().fg(theme.fg)) - }) - .collect(); - let link_header_cells = ["Sel", "Available Table"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.accent))); - let link_header = Row::new(link_header_cells).height(1).bottom_margin(1); - let links_table = - Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)]) - .header(link_header) - .block( - Block::default() - .title(Span::styled(" Links ", theme.fg)) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(links_border_style), - ) - .row_highlight_style( - Style::default() - .add_modifier(Modifier::REVERSED) - .fg(theme.highlight), - ) - .highlight_symbol(" > "); - f.render_stateful_widget( - links_table, - links_area, - &mut add_table_state.link_table_state(), - ); - - // --- Save/Cancel Buttons Rendering --- - let bottom_button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(33), // Save Button - Constraint::Percentage(34), // Delete Button - Constraint::Percentage(33), // Cancel Button - ]) - .split(bottom_buttons_area); - - let save_button = Paragraph::new(" Save table ") - .style(if add_table_state.current_focus() == AddTableFocus::SaveButton { - Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.secondary) - }) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style( - add_table_state.current_focus() == AddTableFocus::SaveButton, // Pass bool - theme, - )), - ); - f.render_widget(save_button, bottom_button_chunks[0]); - - let delete_button = Paragraph::new(" Delete Selected ") - .style(if add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton { - Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.secondary) - }) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style( - add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton, - theme, - )), - ); - f.render_widget(delete_button, bottom_button_chunks[1]); - - let cancel_button = Paragraph::new(" Cancel ") - .style(if add_table_state.current_focus() == AddTableFocus::CancelButton { - Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.secondary) - }) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(get_button_border_style( - add_table_state.current_focus() == AddTableFocus::CancelButton, - theme, - )), - ); - f.render_widget(cancel_button, bottom_button_chunks[2]); - - // --- DIALOG --- - // Render the dialog overlay if it's active - if app_state.ui.dialog.dialog_show { - dialog::render_dialog( - f, - f.area(), // Render over the whole frame area - theme, - &app_state.ui.dialog.dialog_title, - &app_state.ui.dialog.dialog_message, - &app_state.ui.dialog.dialog_buttons, - app_state.ui.dialog.dialog_active_button_index, - app_state.ui.dialog.is_loading, - ); - } -} diff --git a/client/src/pages/admin_panel/mod.rs b/client/src/pages/admin_panel/mod.rs deleted file mode 100644 index 3cd0c06..0000000 --- a/client/src/pages/admin_panel/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// src/pages/admin_panel/mod.rs - -pub mod add_table; -pub mod add_logic; diff --git a/client/src/pages/forms/event.rs b/client/src/pages/forms/event.rs deleted file mode 100644 index 2a63e04..0000000 --- a/client/src/pages/forms/event.rs +++ /dev/null @@ -1,62 +0,0 @@ -// src/pages/forms/event.rs - -use anyhow::Result; -use crossterm::event::Event; -use canvas::keymap::KeyEventOutcome; -use crate::{ - state::app::state::AppState, - pages::forms::{FormState, logic}, - modes::handlers::event::EventOutcome, -}; - -pub fn handle_form_event( - event: Event, - app_state: &mut AppState, - path: &str, - ideal_cursor_column: &mut usize, -) -> Result { - if let Event::Key(key_event) = event { - if let Some(editor) = app_state.editor_for_path(path) { - match editor.handle_key_event(key_event) { - KeyEventOutcome::Consumed(Some(msg)) => { - return Ok(EventOutcome::Ok(msg)); - } - KeyEventOutcome::Consumed(None) => { - return Ok(EventOutcome::Ok("Form input updated".into())); - } - KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok("Waiting for next key...".into())); - } - KeyEventOutcome::NotMatched => { - // fall through to navigation / save / revert - } - } - } - } - - Ok(EventOutcome::Ok(String::new())) -} - -// Save wrapper -pub async fn save_form( - app_state: &mut AppState, - path: &str, - grpc_client: &mut crate::services::grpc_client::GrpcClient, -) -> Result { - let outcome = logic::save(app_state, path, grpc_client).await?; - let message = match outcome { - logic::SaveOutcome::NoChange => "No changes to save.".to_string(), - logic::SaveOutcome::UpdatedExisting => "Entry updated.".to_string(), - logic::SaveOutcome::CreatedNew(_) => "New entry created.".to_string(), - }; - Ok(EventOutcome::DataSaved(outcome, message)) -} - -pub async fn revert_form( - app_state: &mut AppState, - path: &str, - grpc_client: &mut crate::services::grpc_client::GrpcClient, -) -> Result { - let message = logic::revert(app_state, path, grpc_client).await?; - Ok(EventOutcome::Ok(message)) -} diff --git a/client/src/pages/forms/loader.rs b/client/src/pages/forms/loader.rs deleted file mode 100644 index 5836578..0000000 --- a/client/src/pages/forms/loader.rs +++ /dev/null @@ -1,39 +0,0 @@ -// src/pages/forms/loader.rs -use anyhow::{Context, Result}; -use crate::{ - state::app::state::AppState, - services::grpc_client::GrpcClient, - services::ui_service::UiService, // ✅ import UiService - config::binds::Config, - pages::forms::FormState, -}; - -pub async fn ensure_form_loaded_and_count( - grpc_client: &mut GrpcClient, - app_state: &mut AppState, - config: &Config, - profile: &str, - table: &str, -) -> Result<()> { - let path = format!("{}/{}", profile, table); - - app_state.ensure_form_editor(&path, config, || { - FormState::new(profile.to_string(), table.to_string(), vec![]) - }); - - if let Some(form_state) = app_state.form_state_for_path(&path) { - UiService::fetch_and_set_table_count(grpc_client, form_state) - .await - .context("Failed to fetch table count")?; - - if form_state.total_count > 0 { - UiService::load_table_data_by_position(grpc_client, form_state) - .await - .context("Failed to load table data")?; - } else { - form_state.reset_to_empty(); - } - } - - Ok(()) -} diff --git a/client/src/pages/forms/logic.rs b/client/src/pages/forms/logic.rs deleted file mode 100644 index 41e5018..0000000 --- a/client/src/pages/forms/logic.rs +++ /dev/null @@ -1,184 +0,0 @@ -// src/pages/forms/logic.rs -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; -use crate::pages::forms::FormState; -use crate::utils::data_converter; -use anyhow::{anyhow, Context, Result}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SaveOutcome { - NoChange, - UpdatedExisting, - CreatedNew(i64), -} - -pub async fn save( - app_state: &mut AppState, - path: &str, - grpc_client: &mut GrpcClient, -) -> Result { - if let Some(fs) = app_state.form_state_for_path(path) { - if !fs.has_unsaved_changes { - return Ok(SaveOutcome::NoChange); - } - - let profile_name = fs.profile_name.clone(); - let table_name = fs.table_name.clone(); - let fields = fs.fields.clone(); - let values = fs.values.clone(); - let id = fs.id; - let total_count = fs.total_count; - let current_position = fs.current_position; - - let cache_key = format!("{}.{}", profile_name, table_name); - let schema = app_state - .schema_cache - .get(&cache_key) - .ok_or_else(|| { - anyhow!( - "Schema for table '{}' not found in cache. Cannot save.", - table_name - ) - })?; - - let data_map: HashMap = fields - .iter() - .zip(values.iter()) - .map(|(field_def, value)| (field_def.data_key.clone(), value.clone())) - .collect(); - - let converted_data = - data_converter::convert_and_validate_data(&data_map, schema) - .map_err(|user_error| anyhow!(user_error))?; - - let is_new_entry = id == 0 - || (total_count > 0 && current_position > total_count) - || (total_count == 0 && current_position == 1); - - let outcome = if is_new_entry { - let response = grpc_client - .post_table_data(profile_name.clone(), table_name.clone(), converted_data) - .await - .context("Failed to post new table data")?; - - if response.success { - if let Some(fs) = app_state.form_state_for_path(path) { - fs.id = response.inserted_id; - fs.total_count += 1; - fs.current_position = fs.total_count; - fs.has_unsaved_changes = false; - } - SaveOutcome::CreatedNew(response.inserted_id) - } else { - return Err(anyhow!("Server failed to insert data: {}", response.message)); - } - } else { - if id == 0 { - return Err(anyhow!( - "Cannot update record: ID is 0, but not classified as new entry." - )); - } - let response = grpc_client - .put_table_data(profile_name.clone(), table_name.clone(), id, converted_data) - .await - .context("Failed to put (update) table data")?; - - if response.success { - if let Some(fs) = app_state.form_state_for_path(path) { - fs.has_unsaved_changes = false; - } - SaveOutcome::UpdatedExisting - } else { - return Err(anyhow!("Server failed to update data: {}", response.message)); - } - }; - - Ok(outcome) - } else { - Ok(SaveOutcome::NoChange) - } -} - -pub async fn revert( - app_state: &mut AppState, - path: &str, - grpc_client: &mut GrpcClient, -) -> Result { - if let Some(fs) = app_state.form_state_for_path(path) { - if fs.id == 0 - || (fs.total_count > 0 && fs.current_position > fs.total_count) - || (fs.total_count == 0 && fs.current_position == 1) - { - let old_total_count = fs.total_count; - fs.reset_to_empty(); - fs.total_count = old_total_count; - if fs.total_count > 0 { - fs.current_position = fs.total_count + 1; - } else { - fs.current_position = 1; - } - return Ok("New entry cleared".to_string()); - } - - if fs.current_position == 0 || fs.current_position > fs.total_count { - if fs.total_count > 0 { - fs.current_position = 1; - } else { - fs.reset_to_empty(); - return Ok("No saved data to revert to; form cleared.".to_string()); - } - } - - let response = grpc_client - .get_table_data_by_position( - fs.profile_name.clone(), - fs.table_name.clone(), - fs.current_position as i32, - ) - .await - .context(format!( - "Failed to get table data by position {} for table {}.{}", - fs.current_position, fs.profile_name, fs.table_name - ))?; - - fs.update_from_response(&response.data, fs.current_position); - Ok("Changes discarded, reloaded last saved version".to_string()) - } else { - Ok("Nothing to revert".to_string()) - } -} - -pub async fn handle_action( - action: &str, - form_state: &mut FormState, - _grpc_client: &mut GrpcClient, - ideal_cursor_column: &mut usize, -) -> Result { - if form_state.has_unsaved_changes() { - return Ok( - "Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating." - .to_string(), - ); - } - - let total_count = form_state.total_count; - - match action { - "previous_entry" => { - if form_state.current_position > 1 { - form_state.current_position -= 1; - *ideal_cursor_column = 0; - } - } - "next_entry" => { - if form_state.current_position <= total_count { - form_state.current_position += 1; - *ideal_cursor_column = 0; - } - } - _ => return Err(anyhow!("Unknown form action: {}", action)), - } - - Ok(String::new()) -} diff --git a/client/src/pages/forms/mod.rs b/client/src/pages/forms/mod.rs deleted file mode 100644 index de211a6..0000000 --- a/client/src/pages/forms/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -// src/pages/forms/mod.rs - -pub mod ui; -pub mod state; -pub mod logic; -pub mod event; -pub mod loader; - -pub use ui::*; -pub use state::*; -pub use logic::*; -pub use event::*; -pub use loader::*; diff --git a/client/src/pages/forms/state.rs b/client/src/pages/forms/state.rs deleted file mode 100644 index e15676c..0000000 --- a/client/src/pages/forms/state.rs +++ /dev/null @@ -1,343 +0,0 @@ -// src/pages/forms/state.rs - -use canvas::{DataProvider, AppMode}; -#[cfg(feature = "validation")] -use canvas::{CharacterLimits, ValidationConfig, ValidationConfigBuilder}; -#[cfg(feature = "validation")] -use canvas::validation::limits::CountMode; -use common::proto::komp_ac::search::search_response::Hit; -use std::collections::HashMap; - -fn json_value_to_string(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => String::new(), - } -} - -#[derive(Debug, Clone)] -pub struct FieldDefinition { - pub display_name: String, - pub data_key: String, - pub is_link: bool, - pub link_target_table: Option, -} - -#[derive(Debug, Clone)] -pub struct FormState { - pub id: i64, - pub profile_name: String, - pub table_name: String, - pub total_count: u64, - pub current_position: u64, - pub fields: Vec, - pub values: Vec, - pub current_field: usize, - pub has_unsaved_changes: bool, - pub current_cursor_pos: usize, - pub autocomplete_active: bool, - pub autocomplete_suggestions: Vec, - pub selected_suggestion_index: Option, - pub autocomplete_loading: bool, - pub link_display_map: HashMap, - pub app_mode: AppMode, - // Validation 1 (character limits) per field. None = no validation for that field. - // Leave room for future rules (patterns, masks, etc.). - pub char_limits: Vec>, -} - -#[cfg(feature = "validation")] -#[derive(Debug, Clone)] -pub struct CharLimitsRule { - pub min: Option, - pub max: Option, - pub warn_at: Option, - pub count_mode: CountMode, -} - -impl FormState { - // Add this method - pub fn deactivate_autocomplete(&mut self) { - self.autocomplete_active = false; - self.autocomplete_suggestions.clear(); - self.selected_suggestion_index = None; - self.autocomplete_loading = false; - } - - pub fn new( - profile_name: String, - table_name: String, - fields: Vec, - ) -> Self { - let values = vec![String::new(); fields.len()]; - let len = values.len(); - FormState { - id: 0, - profile_name, - table_name, - total_count: 0, - current_position: 1, - fields, - values, - current_field: 0, - has_unsaved_changes: false, - current_cursor_pos: 0, - autocomplete_active: false, - autocomplete_suggestions: Vec::new(), - selected_suggestion_index: None, - autocomplete_loading: false, - link_display_map: HashMap::new(), - app_mode: canvas::AppMode::Edit, - char_limits: vec![None; len], - } - } - - pub fn get_display_name_for_hit(&self, hit: &Hit) -> String { - if let Ok(content_map) = - serde_json::from_str::>( - &hit.content_json, - ) - { - const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"]; - let mut keys: Vec<_> = content_map - .keys() - .filter(|k| !IGNORED_KEYS.contains(&k.as_str())) - .cloned() - .collect(); - keys.sort(); - - let values: Vec<_> = keys - .iter() - .map(|key| { - content_map - .get(key) - .map(json_value_to_string) - .unwrap_or_default() - }) - .filter(|s| !s.is_empty()) - .take(1) - .collect(); - - let display_part = values.first().cloned().unwrap_or_default(); - if display_part.is_empty() { - format!("ID: {}", hit.id) - } else { - format!("{} | ID: {}", display_part, hit.id) - } - } else { - format!("ID: {} (parse error)", hit.id) - } - } - - pub fn reset_to_empty(&mut self) { - self.id = 0; - self.values.iter_mut().for_each(|v| v.clear()); - self.current_field = 0; - self.current_cursor_pos = 0; - self.has_unsaved_changes = false; - if self.total_count > 0 { - self.current_position = self.total_count + 1; - } else { - self.current_position = 1; - } - self.deactivate_autocomplete(); - self.link_display_map.clear(); - } - - pub fn get_current_input(&self) -> &str { - self.values - .get(self.current_field) - .map(|s| s.as_str()) - .unwrap_or("") - } - - pub fn get_current_input_mut(&mut self) -> &mut String { - self.link_display_map.remove(&self.current_field); - self.values - .get_mut(self.current_field) - .expect("Invalid current_field index") - } - - pub fn update_from_response( - &mut self, - response_data: &HashMap, - new_position: u64, - ) { - self.values = self - .fields - .iter() - .map(|field_def| { - response_data - .get(&field_def.data_key) - .cloned() - .unwrap_or_default() - }) - .collect(); - - let id_str_opt = response_data - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case("id")) - .map(|(_, v)| v); - - if let Some(id_str) = id_str_opt { - if let Ok(parsed_id) = id_str.parse::() { - self.id = parsed_id; - } else { - tracing::error!( - "Failed to parse 'id' field '{}' for table {}.{}", - id_str, - self.profile_name, - self.table_name - ); - self.id = 0; - } - } else { - self.id = 0; - } - - self.current_position = new_position; - self.has_unsaved_changes = false; - self.current_field = 0; - self.current_cursor_pos = 0; - self.deactivate_autocomplete(); - self.link_display_map.clear(); - } - - // NEW: Keep the rich suggestions methods for compatibility - pub fn get_rich_suggestions(&self) -> Option<&[Hit]> { - if self.autocomplete_active { - Some(&self.autocomplete_suggestions) - } else { - None - } - } - - pub fn activate_rich_suggestions(&mut self, suggestions: Vec) { - self.autocomplete_suggestions = suggestions; - self.autocomplete_active = !self.autocomplete_suggestions.is_empty(); - self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None }; - self.autocomplete_loading = false; - } - - // Legacy method compatibility - pub fn fields(&self) -> Vec<&str> { - self.fields - .iter() - .map(|f| f.display_name.as_str()) - .collect() - } - - pub fn get_display_value_for_field(&self, index: usize) -> &str { - if let Some(display_text) = self.link_display_map.get(&index) { - return display_text.as_str(); - } - self.values - .get(index) - .map(|s| s.as_str()) - .unwrap_or("") - } - - pub fn has_display_override(&self, index: usize) -> bool { - self.link_display_map.contains_key(&index) - } - - pub fn current_mode(&self) -> AppMode { - self.app_mode - } - - // Add missing methods that used to come from CanvasState trait - pub fn has_unsaved_changes(&self) -> bool { - self.has_unsaved_changes - } - - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.has_unsaved_changes = changed; - } - - pub fn current_field(&self) -> usize { - self.current_field - } - - pub fn set_current_field(&mut self, index: usize) { - if index < self.fields.len() { - self.current_field = index; - } - self.deactivate_autocomplete(); - } - - pub fn current_cursor_pos(&self) -> usize { - self.current_cursor_pos - } - - pub fn set_current_cursor_pos(&mut self, pos: usize) { - self.current_cursor_pos = pos; - } - - #[cfg(feature = "validation")] - pub fn set_character_limits_rules( - &mut self, - rules: Vec>, - ) { - if rules.len() == self.fields.len() { - self.char_limits = rules; - } else { - tracing::warn!( - "Character limits count {} != field count {} for {}.{}", - rules.len(), - self.fields.len(), - self.profile_name, - self.table_name - ); - } - } -} - -// Step 2: Implement DataProvider for FormState -impl DataProvider for FormState { - fn field_count(&self) -> usize { - self.fields.len() - } - - fn field_name(&self, index: usize) -> &str { - &self.fields[index].display_name - } - - fn field_value(&self, index: usize) -> &str { - &self.values[index] - } - - fn set_field_value(&mut self, index: usize, value: String) { - if let Some(v) = self.values.get_mut(index) { - *v = value; - self.has_unsaved_changes = true; - } - } - - fn supports_suggestions(&self, field_index: usize) -> bool { - self.fields.get(field_index).map(|f| f.is_link).unwrap_or(false) - } - - // Validation 1: Provide character-limit-based validation to canvas - // Only compiled when the "validation" feature is enabled on canvas. - #[cfg(feature = "validation")] - fn validation_config(&self, index: usize) -> Option { - let rule = self.char_limits.get(index)?.as_ref()?; - let mut limits = match (rule.min, rule.max) { - (Some(min), Some(max)) => CharacterLimits::new_range(min, max), - (None, Some(max)) => CharacterLimits::new(max), - (Some(min), None) => CharacterLimits::new_range(min, usize::MAX), - (None, None) => CharacterLimits::new(usize::MAX), - }; - limits = limits.with_count_mode(rule.count_mode); - if let Some(warn) = rule.warn_at { - limits = limits.with_warning_threshold(warn); - } - Some( - ValidationConfigBuilder::new() - .with_character_limits(limits) - .build(), - ) - } -} diff --git a/client/src/pages/forms/ui.rs b/client/src/pages/forms/ui.rs deleted file mode 100644 index b6f2261..0000000 --- a/client/src/pages/forms/ui.rs +++ /dev/null @@ -1,72 +0,0 @@ -// src/pages/forms/ui.rs -use crate::config::colors::themes::Theme; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, - style::Style, - widgets::{Block, Borders, Paragraph}, - Frame, -}; -use canvas::{ - render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor, -}; -use crate::pages::forms::FormState; - -pub fn render_form_page( - f: &mut Frame, - area: Rect, - editor: &FormEditor, - table_name: &str, - theme: &Theme, - total_count: u64, - current_position: u64, -) { - let card_title = format!(" {} ", table_name); - - let adresar_card = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.border)) - .title(card_title) - .style(Style::default().bg(theme.bg).fg(theme.fg)); - - f.render_widget(adresar_card, area); - - let inner_area = area.inner(Margin { - horizontal: 1, - vertical: 1, - }); - - let main_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(1)]) - .split(inner_area); - - 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 { - 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) - .style(Style::default().fg(theme.fg)) - .alignment(Alignment::Left); - f.render_widget(count_para, main_layout[0]); - - // --- FORM RENDERING (Using persistent FormEditor) --- - let active_field_rect = render_canvas(f, main_layout[1], editor, theme); - if let Some(active_rect) = active_field_rect { - render_suggestions_dropdown( - f, - main_layout[1], - active_rect, - &DefaultCanvasTheme, - editor, - ); - } -} diff --git a/client/src/pages/intro/logic.rs b/client/src/pages/intro/logic.rs deleted file mode 100644 index 50594d9..0000000 --- a/client/src/pages/intro/logic.rs +++ /dev/null @@ -1,73 +0,0 @@ -// src/pages/intro/logic.rs -use crate::state::app::state::AppState; -use crate::buffer::state::{AppView, BufferState}; - -/// Handles intro screen selection by updating view history and managing focus state. -/// 0: Continue (restores last form or default) -/// 1: Admin view -/// 2: Login view -/// 3: Register view (with focus reset) -pub fn handle_intro_selection( - app_state: &mut AppState, - buffer_state: &mut BufferState, - index: usize, -) { - match index { - // Continue: go to the most recent existing Form tab, or open a sensible default - 0 => { - // 1) Try to switch to an already open Form buffer (most recent) - if let Some(existing_path) = buffer_state - .history - .iter() - .rev() - .find_map(|view| { - if let AppView::Form(p) = view { - Some(p.clone()) - } else { - None - } - }) - { - buffer_state.update_history(AppView::Form(existing_path)); - return; - } - - // 2) Otherwise pick a fallback path - let fallback_path = if let (Some(profile), Some(table)) = ( - app_state.current_view_profile_name.clone(), - app_state.current_view_table_name.clone(), - ) { - Some(format!("{}/{}", profile, table)) - } else if let Some(any_key) = app_state.form_editor.keys().next().cloned() { - // Use any existing editor key if available - Some(any_key) - } else { - // Otherwise pick the first available table from the profile tree - let mut found: Option = None; - for prof in &app_state.profile_tree.profiles { - if let Some(tbl) = prof.tables.first() { - found = Some(format!("{}/{}", prof.name, tbl.name)); - break; - } - } - found - }; - - if let Some(path) = fallback_path { - buffer_state.update_history(AppView::Form(path)); - } else { - // No sensible default; stay on Intro - } - } - 1 => { - buffer_state.update_history(AppView::Admin); - } - 2 => { - buffer_state.update_history(AppView::Login); - } - 3 => { - buffer_state.update_history(AppView::Register); - } - _ => return, - } -} diff --git a/client/src/pages/intro/mod.rs b/client/src/pages/intro/mod.rs deleted file mode 100644 index 38142d8..0000000 --- a/client/src/pages/intro/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/pages/intro/mod.rs - -pub mod state; -pub mod ui; -pub mod logic; - -pub use state::*; -pub use ui::render_intro; -pub use logic::*; diff --git a/client/src/pages/intro/state.rs b/client/src/pages/intro/state.rs deleted file mode 100644 index fd76708..0000000 --- a/client/src/pages/intro/state.rs +++ /dev/null @@ -1,52 +0,0 @@ -// src/state/pages/intro.rs -use crate::movement::MovementAction; - -#[derive(Default, Clone, Debug)] -pub struct IntroState { - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -impl IntroState { - pub fn new() -> Self { - Self { - focus_outside_canvas: true, - focused_button_index: 0, - } - } - - pub fn next_option(&mut self) { - if self.focused_button_index < 3 { - self.focused_button_index += 1; - } - } - - pub fn previous_option(&mut self) { - if self.focused_button_index > 0 { - self.focused_button_index -= 1; - } - } -} - -impl IntroState { - pub fn handle_movement(&mut self, action: MovementAction) -> bool { - match action { - MovementAction::Next | MovementAction::Right | MovementAction::Down => { - self.next_option(); - true - } - MovementAction::Previous | MovementAction::Left | MovementAction::Up => { - self.previous_option(); - true - } - MovementAction::Select => { - // Actual selection handled in event loop (UiContext::Intro) - false - } - MovementAction::Esc => { - // Nothing special for Intro, but could be used to quit - true - } - } - } -} diff --git a/client/src/pages/intro/ui.rs b/client/src/pages/intro/ui.rs deleted file mode 100644 index aa6bab3..0000000 --- a/client/src/pages/intro/ui.rs +++ /dev/null @@ -1,88 +0,0 @@ -// src/pages/intro/ui.rs -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Paragraph}, - prelude::Margin, - Frame, -}; -use crate::config::colors::themes::Theme; -use crate::pages::intro::IntroState; - -pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) { - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.accent)) - .style(Style::default().bg(theme.bg)); - - let inner_area = block.inner(area); - f.render_widget(block, area); - - // Center layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(40), - Constraint::Length(5), - Constraint::Percentage(40), - ]) - .split(inner_area); - - // Title - let title = Line::from(vec![ - Span::styled("komp_ac", Style::default().fg(theme.highlight)), - Span::styled(" v", Style::default().fg(theme.fg)), - Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)), - ]); - let title_para = Paragraph::new(title) - .alignment(Alignment::Center); - f.render_widget(title_para, chunks[1]); - - // Buttons - now with 4 options - let button_area = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - ]) - .split(chunks[1].inner(Margin { - horizontal: 1, - vertical: 1 - })); - - let buttons = ["Continue", "Admin", "Login", "Register"]; - for (i, &text) in buttons.iter().enumerate() { - let active = intro_state.focused_button_index == i; - render_button(f, button_area[i], text, active, theme); - } -} - -fn render_button(f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) { - let button_style = Style::default() - .fg(if selected { theme.highlight } else { theme.fg }) - .bg(theme.bg) - .add_modifier(if selected { - ratatui::style::Modifier::BOLD - } else { - ratatui::style::Modifier::empty() - }); - - let border_style = Style::default() - .fg(if selected { theme.accent } else { theme.border }); - - let button = Paragraph::new(text) - .style(button_style) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .border_style(border_style), - ); - - f.render_widget(button, area); -} diff --git a/client/src/pages/login/event.rs b/client/src/pages/login/event.rs deleted file mode 100644 index fa67e85..0000000 --- a/client/src/pages/login/event.rs +++ /dev/null @@ -1,71 +0,0 @@ -// src/pages/login/event.rs -use anyhow::Result; -use crossterm::event::{Event, KeyCode, KeyModifiers}; -use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode}; -use crate::{ - state::app::state::AppState, - pages::login::LoginFormState, - modes::handlers::event::EventOutcome, -}; -use canvas::DataProvider; - -/// Handles all Login page-specific events -pub fn handle_login_event( - event: Event, - app_state: &mut AppState, - login_page: &mut LoginFormState, -) -> Result { - if let Event::Key(key_event) = event { - let key_code = key_event.code; - let modifiers = key_event.modifiers; - - // From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button - if login_page.focus_outside_canvas - && login_page.focused_button_index == 0 - && matches!(key_code, KeyCode::Up | KeyCode::Char('k')) - && modifiers.is_empty() - { - login_page.focus_outside_canvas = false; - login_page.editor.set_mode(CanvasMode::ReadOnly); - return Ok(EventOutcome::Ok(String::new())); - } - - // Focus handoff: inside canvas → buttons - if !login_page.focus_outside_canvas { - let last_idx = login_page.editor.data_provider().field_count().saturating_sub(1); - let at_last = login_page.editor.current_field() >= last_idx; - if login_page.editor.mode() == CanvasMode::ReadOnly - && at_last - && matches!( - (key_code, modifiers), - (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) - ) - { - login_page.focus_outside_canvas = true; - login_page.focused_button_index = 0; - login_page.editor.set_mode(CanvasMode::ReadOnly); - return Ok(EventOutcome::Ok("Focus moved to buttons".into())); - } - } - - // Forward to canvas if focus is inside - if !login_page.focus_outside_canvas { - match login_page.handle_key_event(key_event) { - KeyEventOutcome::Consumed(Some(msg)) => { - return Ok(EventOutcome::Ok(msg)); - } - KeyEventOutcome::Consumed(None) => { - return Ok(EventOutcome::Ok("Login input updated".into())); - } - KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok("Waiting for next key...".into())); - } - KeyEventOutcome::NotMatched => { - // fall through to button handling - } - } - } - } - - Ok(EventOutcome::Ok(String::new())) -} diff --git a/client/src/pages/login/logic.rs b/client/src/pages/login/logic.rs deleted file mode 100644 index bd7525b..0000000 --- a/client/src/pages/login/logic.rs +++ /dev/null @@ -1,243 +0,0 @@ -// src/pages/login/logic.rs - -use crate::services::auth::AuthClient; -use crate::state::pages::auth::AuthState; -use crate::state::app::state::AppState; -use crate::buffer::state::{AppView, BufferState}; -use crate::config::storage::storage::{StoredAuthData, save_auth_data}; -use crate::ui::handlers::context::DialogPurpose; -use common::proto::komp_ac::auth::LoginResponse; -use crate::pages::login::LoginFormState; -use crate::state::pages::auth::UserRole; -use canvas::DataProvider; -use anyhow::{Context, Result, anyhow}; -use tokio::spawn; -use tokio::sync::mpsc; -use tracing::{info, error}; - -#[derive(Debug)] -pub enum LoginResult { - Success(LoginResponse), - Failure(String), - ConnectionError(String), -} - -/// Attempts to log the user in using the provided credentials via gRPC. -/// Updates AuthState and AppState on success or failure. -pub async fn save( - auth_state: &mut AuthState, - login_state: &mut LoginFormState, - auth_client: &mut AuthClient, - app_state: &mut AppState, -) -> Result { - let identifier = login_state.username().to_string(); - let password = login_state.password().to_string(); - - // --- Client-side validation --- - if identifier.trim().is_empty() { - let error_message = "Username/Email cannot be empty.".to_string(); - app_state.show_dialog( - "Login Failed", - &error_message, - vec!["OK".to_string()], - DialogPurpose::LoginFailed, - ); - login_state.set_error_message(Some(error_message.clone())); - return Err(anyhow!(error_message)); - } - - // Clear previous error/dialog state before attempting - login_state.set_error_message(None); - app_state.hide_dialog(); - - // Call the gRPC login method - match auth_client.login(identifier.clone(), password).await - .with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier)) - { - Ok(response) => { - // Store authentication details - auth_state.auth_token = Some(response.access_token.clone()); - auth_state.user_id = Some(response.user_id.clone()); - auth_state.role = Some(UserRole::from_str(&response.role)); - auth_state.decoded_username = Some(response.username.clone()); - - login_state.set_has_unsaved_changes(false); - login_state.set_error_message(None); - - let success_message = format!( - "Login Successful!\n\n\ - Username: {}\n\ - User ID: {}\n\ - Role: {}", - response.username, - response.user_id, - response.role - ); - - app_state.show_dialog( - "Login Success", - &success_message, - vec!["Menu".to_string(), "Exit".to_string()], - DialogPurpose::LoginSuccess, - ); - - login_state.username_mut().clear(); - login_state.password_mut().clear(); - login_state.set_current_cursor_pos(0); - - Ok("Login successful, details shown in dialog.".to_string()) - } - Err(e) => { - let error_message = format!("{}", e); - app_state.show_dialog( - "Login Failed", - &error_message, - vec!["OK".to_string()], - DialogPurpose::LoginFailed, - ); - login_state.set_error_message(Some(error_message.clone())); - login_state.set_has_unsaved_changes(true); - login_state.username_mut().clear(); - login_state.password_mut().clear(); - Err(e) - } - } -} - -/// Reverts the login form fields to empty and returns to the previous screen (Intro). -pub async fn revert( - login_state: &mut LoginFormState, - app_state: &mut AppState, -) -> String { - // Clear the underlying state - login_state.clear(); - - // Also clear values inside the editor’s data provider - { - let dp = login_state.editor.data_provider_mut(); - dp.set_field_value(0, "".to_string()); - dp.set_field_value(1, "".to_string()); - dp.set_current_field(0); - dp.set_current_cursor_pos(0); - dp.set_has_unsaved_changes(false); - } - - app_state.hide_dialog(); - "Login reverted".to_string() -} - -/// Clears login form and navigates back to main menu. -pub async fn back_to_main( - login_state: &mut LoginFormState, - app_state: &mut AppState, - buffer_state: &mut BufferState, -) -> String { - login_state.clear(); - app_state.hide_dialog(); - - buffer_state.close_active_buffer(); - buffer_state.update_history(AppView::Intro); - - "Returned to main menu".to_string() -} - -/// Validates input, shows loading, and spawns the login task. -pub fn initiate_login( - login_state: &mut LoginFormState, - app_state: &mut AppState, - mut auth_client: AuthClient, - sender: mpsc::Sender, -) -> String { - login_state.sync_from_editor(); - let username = login_state.username().to_string(); - let password = login_state.password().to_string(); - - if username.trim().is_empty() { - app_state.show_dialog( - "Login Failed", - "Username/Email cannot be empty.", - vec!["OK".to_string()], - DialogPurpose::LoginFailed, - ); - "Username cannot be empty.".to_string() - } else { - app_state.show_loading_dialog("Logging In", "Please wait..."); - - spawn(async move { - let login_outcome = match auth_client.login(username.clone(), password).await - .with_context(|| format!("Spawned login task failed for identifier: {}", username)) - { - Ok(response) => LoginResult::Success(response), - Err(e) => LoginResult::Failure(format!("{}", e)), - }; - if let Err(e) = sender.send(login_outcome).await { - error!("Failed to send login result: {}", e); - } - }); - - "Login initiated.".to_string() - } -} - -/// Handles the result received from the login task. -/// Returns true if a redraw is needed. -pub fn handle_login_result( - result: LoginResult, - app_state: &mut AppState, - auth_state: &mut AuthState, - login_state: &mut LoginFormState, -) -> bool { - match result { - LoginResult::Success(response) => { - auth_state.auth_token = Some(response.access_token.clone()); - auth_state.user_id = Some(response.user_id.clone()); - auth_state.role = Some(UserRole::from_str(&response.role)); - auth_state.decoded_username = Some(response.username.clone()); - - let data_to_store = StoredAuthData { - access_token: response.access_token.clone(), - user_id: response.user_id.clone(), - role: response.role.clone(), - username: response.username.clone(), - }; - if let Err(e) = save_auth_data(&data_to_store) { - error!("Failed to save auth data to file: {}", e); - } - - let success_message = format!( - "Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}", - response.username, response.user_id, response.role - ); - app_state.update_dialog_content( - &success_message, - vec!["Menu".to_string(), "Exit".to_string()], - DialogPurpose::LoginSuccess, - ); - info!(message = %success_message, "Login successful"); - } - LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => { - app_state.update_dialog_content( - &err_msg, - vec!["OK".to_string()], - DialogPurpose::LoginFailed, - ); - login_state.set_error_message(Some(err_msg.clone())); - error!(error = %err_msg, "Login failed/connection error"); - } - } - - login_state.username_mut().clear(); - login_state.password_mut().clear(); - login_state.set_has_unsaved_changes(false); - login_state.set_current_cursor_pos(0); - - true -} - -pub async fn handle_action(action: &str) -> Result { - match action { - "previous_entry" => Ok("Previous entry not implemented".into()), - "next_entry" => Ok("Next entry not implemented".into()), - _ => Err(anyhow!("Unknown login action: {}", action)), - } -} diff --git a/client/src/pages/login/mod.rs b/client/src/pages/login/mod.rs deleted file mode 100644 index 716c3cc..0000000 --- a/client/src/pages/login/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -// src/pages/login/mod.rs - -pub mod state; -pub mod ui; -pub mod logic; -pub mod event; - -pub use state::*; -pub use ui::render_login; -pub use logic::*; -pub use event::*; diff --git a/client/src/pages/login/state.rs b/client/src/pages/login/state.rs deleted file mode 100644 index fc099a9..0000000 --- a/client/src/pages/login/state.rs +++ /dev/null @@ -1,248 +0,0 @@ -// src/pages/login/state.rs - -use canvas::{AppMode, DataProvider}; -use canvas::FormEditor; -use std::fmt; - -#[derive(Debug, Clone)] -pub struct LoginState { - pub username: String, - pub password: String, - pub error_message: Option, - pub current_field: usize, - pub current_cursor_pos: usize, - pub has_unsaved_changes: bool, - pub login_request_pending: bool, - pub app_mode: AppMode, -} - -impl Default for LoginState { - fn default() -> Self { - Self { - username: String::new(), - password: String::new(), - error_message: None, - current_field: 0, - current_cursor_pos: 0, - has_unsaved_changes: false, - login_request_pending: false, - app_mode: canvas::AppMode::Edit, - } - } -} - -impl LoginState { - pub fn new() -> Self { - Self { - app_mode: canvas::AppMode::Edit, - ..Default::default() - } - } - - pub fn current_field(&self) -> usize { - self.current_field - } - - pub fn current_cursor_pos(&self) -> usize { - self.current_cursor_pos - } - - pub fn set_current_field(&mut self, index: usize) { - if index < 2 { - self.current_field = index; - } - } - - pub fn set_current_cursor_pos(&mut self, pos: usize) { - self.current_cursor_pos = pos; - } - - pub fn get_current_input(&self) -> &str { - match self.current_field { - 0 => &self.username, - 1 => &self.password, - _ => "", - } - } - - pub fn get_current_input_mut(&mut self) -> &mut String { - match self.current_field { - 0 => &mut self.username, - 1 => &mut self.password, - _ => panic!("Invalid current_field index in LoginState"), - } - } - - pub fn current_mode(&self) -> AppMode { - self.app_mode - } - - pub fn has_unsaved_changes(&self) -> bool { - self.has_unsaved_changes - } - - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.has_unsaved_changes = changed; - } -} - -// Implement DataProvider for LoginState -impl DataProvider for LoginState { - fn field_count(&self) -> usize { - 2 - } - - fn field_name(&self, index: usize) -> &str { - match index { - 0 => "Username/Email", - 1 => "Password", - _ => "", - } - } - - fn field_value(&self, index: usize) -> &str { - match index { - 0 => &self.username, - 1 => &self.password, - _ => "", - } - } - - fn set_field_value(&mut self, index: usize, value: String) { - match index { - 0 => self.username = value, - 1 => self.password = value, - _ => {} - } - self.has_unsaved_changes = true; - } - - fn supports_suggestions(&self, _field_index: usize) -> bool { - false // Login form doesn't support suggestions - } -} - -/// Wrapper that owns both the raw login state and its editor - -pub struct LoginFormState { - pub state: LoginState, - pub editor: FormEditor, - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -// manual debug because FormEditor doesnt implement debug -impl fmt::Debug for LoginFormState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LoginFormState") - .field("state", &self.state) // ✅ only print the data - .finish() - } -} - -impl LoginFormState { - /// Sync the editor's data provider back into our state - pub fn sync_from_editor(&mut self) { - // FormEditor holds the authoritative data - let dp = self.editor.data_provider(); - self.state = dp.clone(); // LoginState implements Clone - } - - /// Create a new LoginFormState with default LoginState and FormEditor - pub fn new() -> Self { - let state = LoginState::default(); - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - // === Delegates to LoginState fields === - - pub fn username(&self) -> &str { - &self.state.username - } - - pub fn username_mut(&mut self) -> &mut String { - &mut self.state.username - } - - pub fn password(&self) -> &str { - &self.state.password - } - - pub fn password_mut(&mut self) -> &mut String { - &mut self.state.password - } - - pub fn error_message(&self) -> Option<&String> { - self.state.error_message.as_ref() - } - - pub fn set_error_message(&mut self, msg: Option) { - self.state.error_message = msg; - } - - pub fn has_unsaved_changes(&self) -> bool { - self.state.has_unsaved_changes - } - - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.state.has_unsaved_changes = changed; - } - - pub fn clear(&mut self) { - self.state.username.clear(); - self.state.password.clear(); - self.state.error_message = None; - self.state.has_unsaved_changes = false; - self.state.login_request_pending = false; - self.state.current_cursor_pos = 0; - } - - // === Delegates to LoginState cursor/input === - - pub fn current_field(&self) -> usize { - self.state.current_field() - } - - pub fn set_current_field(&mut self, index: usize) { - self.state.set_current_field(index); - } - - pub fn current_cursor_pos(&self) -> usize { - self.state.current_cursor_pos() - } - - pub fn set_current_cursor_pos(&mut self, pos: usize) { - self.state.set_current_cursor_pos(pos); - } - - pub fn get_current_input(&self) -> &str { - self.state.get_current_input() - } - - pub fn get_current_input_mut(&mut self) -> &mut String { - self.state.get_current_input_mut() - } - - // === Delegates to FormEditor === - - pub fn mode(&self) -> AppMode { - self.editor.mode() - } - - pub fn cursor_position(&self) -> usize { - self.editor.cursor_position() - } - - pub fn handle_key_event( - &mut self, - key_event: crossterm::event::KeyEvent, - ) -> canvas::keymap::KeyEventOutcome { - self.editor.handle_key_event(key_event) - } -} diff --git a/client/src/pages/login/ui.rs b/client/src/pages/login/ui.rs deleted file mode 100644 index 69483c4..0000000 --- a/client/src/pages/login/ui.rs +++ /dev/null @@ -1,155 +0,0 @@ -// src/pages/login/ui.rs - -use crate::{ - config::colors::themes::Theme, - state::app::state::AppState, -}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect, Margin}, - style::{Style, Modifier, Color}, - widgets::{Block, BorderType, Borders, Paragraph}, - Frame, -}; -use canvas::{ - FormEditor, - render_canvas, - render_suggestions_dropdown, - DefaultCanvasTheme, -}; - -use crate::pages::login::LoginFormState; -use crate::dialog; - -pub fn render_login( - f: &mut Frame, - area: Rect, - theme: &Theme, - login_page: &LoginFormState, - app_state: &AppState, -) { - let login_state = &login_page.state; - let editor = &login_page.editor; - - // Main container - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(Style::default().fg(theme.border)) - .title(" Login ") - .style(Style::default().bg(theme.bg)); - - f.render_widget(block, area); - - let inner_area = area.inner(Margin { - horizontal: 1, - vertical: 1, - }); - - // Layout chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(4), // Form (2 fields + padding) - Constraint::Length(1), // Error message - Constraint::Length(3), // Buttons - ]) - .split(inner_area); - - let input_rect = render_canvas( - f, - chunks[0], - editor, - &DefaultCanvasTheme, - ); - - // --- ERROR MESSAGE --- - if let Some(err) = &login_state.error_message { - f.render_widget( - Paragraph::new(err.as_str()) - .style(Style::default().fg(Color::Red)) - .alignment(Alignment::Center), - chunks[1], - ); - } - - // --- BUTTONS (unchanged) --- - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[2]); - - // Login Button - let login_button_index = 0; - let login_active = login_page.focus_outside_canvas - && login_page.focused_button_index == login_button_index; - let mut login_style = Style::default().fg(theme.fg); - let mut login_border = Style::default().fg(theme.border); - if login_active { - login_style = login_style.fg(theme.highlight).add_modifier(Modifier::BOLD); - login_border = login_border.fg(theme.accent); - } - - f.render_widget( - Paragraph::new("Login") - .style(login_style) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(login_border), - ), - button_chunks[0], - ); - - // Return Button - let return_button_index = 1; - let return_active = login_page.focus_outside_canvas - && login_page.focused_button_index == return_button_index; - let mut return_style = Style::default().fg(theme.fg); - let mut return_border = Style::default().fg(theme.border); - if return_active { - return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD); - return_border = return_border.fg(theme.accent); - } - - f.render_widget( - Paragraph::new("Return") - .style(return_style) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(return_border), - ), - button_chunks[1], - ); - - // --- SUGGESTIONS DROPDOWN (if active) --- - if editor.mode() == canvas::AppMode::Edit { - if let Some(input_rect) = input_rect { - render_suggestions_dropdown( - f, - chunks[0], - input_rect, - &DefaultCanvasTheme, - editor, - ); - } - } - - // --- DIALOG --- - if app_state.ui.dialog.dialog_show { - dialog::render_dialog( - f, - f.area(), - theme, - &app_state.ui.dialog.dialog_title, - &app_state.ui.dialog.dialog_message, - &app_state.ui.dialog.dialog_buttons, - app_state.ui.dialog.dialog_active_button_index, - app_state.ui.dialog.is_loading, - ); - } -} diff --git a/client/src/pages/mod.rs b/client/src/pages/mod.rs deleted file mode 100644 index d0b1ed9..0000000 --- a/client/src/pages/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/pages/mod.rs - -pub mod routing; -pub mod intro; -pub mod login; -pub mod register; -pub mod forms; -pub mod admin; -pub mod admin_panel; diff --git a/client/src/pages/register/event.rs b/client/src/pages/register/event.rs deleted file mode 100644 index 21b210a..0000000 --- a/client/src/pages/register/event.rs +++ /dev/null @@ -1,72 +0,0 @@ -// src/pages/register/event.rs -use anyhow::Result; -use crossterm::event::{Event, KeyCode, KeyModifiers}; -use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode}; -use canvas::DataProvider; -use crate::{ - state::app::state::AppState, - pages::register::RegisterFormState, - modes::handlers::event::EventOutcome, -}; - -/// Handles all Register page-specific events. -/// Return a non-empty Ok(message) only when the page actually consumed the key, -/// otherwise return Ok("") to let global handling proceed. -pub fn handle_register_event( - event: Event, - app_state: &mut AppState, - register_page: &mut RegisterFormState, -)-> Result { - if let Event::Key(key_event) = event { - let key_code = key_event.code; - let modifiers = key_event.modifiers; - - // From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button - if register_page.focus_outside_canvas - && register_page.focused_button_index == 0 - && matches!(key_code, KeyCode::Up | KeyCode::Char('k')) - && modifiers.is_empty() - { - register_page.focus_outside_canvas = false; - register_page.editor.set_mode(CanvasMode::ReadOnly); - return Ok(EventOutcome::Ok(String::new())); - } - - // Focus handoff: inside canvas → buttons - if !register_page.focus_outside_canvas { - let last_idx = register_page.editor.data_provider().field_count().saturating_sub(1); - let at_last = register_page.editor.current_field() >= last_idx; - if register_page.editor.mode() == CanvasMode::ReadOnly - && at_last - && matches!( - (key_code, modifiers), - (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) - ) - { - register_page.focus_outside_canvas = true; - register_page.focused_button_index = 0; // focus "Register" button - register_page.editor.set_mode(CanvasMode::ReadOnly); - return Ok(EventOutcome::Ok("Focus moved to buttons".into())); - } - } - - // Forward to canvas if focus is inside - if !register_page.focus_outside_canvas { - match register_page.handle_key_event(key_event) { - KeyEventOutcome::Consumed(Some(msg)) => { - return Ok(EventOutcome::Ok(msg)); - } - KeyEventOutcome::Consumed(None) => { - return Ok(EventOutcome::Ok("Register input updated".into())); - } - KeyEventOutcome::Pending => { - return Ok(EventOutcome::Ok("Waiting for next key...".into())); - } - KeyEventOutcome::NotMatched => { - // fall through - } - } - } - } - Ok(EventOutcome::Ok(String::new())) -} diff --git a/client/src/pages/register/logic.rs b/client/src/pages/register/logic.rs deleted file mode 100644 index 2ad9743..0000000 --- a/client/src/pages/register/logic.rs +++ /dev/null @@ -1,157 +0,0 @@ -// src/pages/register/logic.rs - -use crate::services::auth::AuthClient; -use crate::state::app::state::AppState; -use crate::ui::handlers::context::DialogPurpose; -use crate::buffer::state::{AppView, BufferState}; -use common::proto::komp_ac::auth::AuthResponse; -use crate::pages::register::RegisterFormState; -use anyhow::Context; -use tokio::spawn; -use tokio::sync::mpsc; -use tracing::{info, error}; - -#[derive(Debug)] -pub enum RegisterResult { - Success(AuthResponse), - Failure(String), - ConnectionError(String), -} - -/// Clears the registration form fields. -pub async fn revert( - register_state: &mut RegisterFormState, - app_state: &mut AppState, -) -> String { - register_state.username_mut().clear(); - register_state.email_mut().clear(); - register_state.password_mut().clear(); - register_state.password_confirmation_mut().clear(); - register_state.role_mut().clear(); - register_state.set_error_message(None); - register_state.set_has_unsaved_changes(false); - register_state.set_current_field(0); // Reset focus to first field - register_state.set_current_cursor_pos(0); - - app_state.hide_dialog(); - "Registration form cleared".to_string() -} - -/// Clears the form and returns to the intro screen. -pub async fn back_to_login( - register_state: &mut RegisterFormState, - app_state: &mut AppState, - buffer_state: &mut BufferState, -) -> String { - // Clear fields first - let _ = revert(register_state, app_state).await; - - // Ensure dialog is hidden - app_state.hide_dialog(); - - // Navigation logic - buffer_state.close_active_buffer(); - buffer_state.update_history(AppView::Login); - - // Reset focus state - register_state.focus_outside_canvas = false; - register_state.focused_button_index = 0; - - "Returned to main menu".to_string() -} - -/// Validates input, shows loading, and spawns the registration task. -pub fn initiate_registration( - register_state: &mut RegisterFormState, - app_state: &mut AppState, - mut auth_client: AuthClient, - sender: mpsc::Sender, -) -> String { - register_state.sync_from_editor(); - let username = register_state.username().to_string(); - let email = register_state.email().to_string(); - let password = register_state.password().to_string(); - let password_confirmation = register_state.password_confirmation().to_string(); - let role = register_state.role().to_string(); - - // 1. Client-side validation - if username.trim().is_empty() { - app_state.show_dialog( - "Registration Failed", - "Username cannot be empty.", - vec!["OK".to_string()], - DialogPurpose::RegisterFailed, - ); - "Username cannot be empty.".to_string() - } else if !password.is_empty() && password != password_confirmation { - app_state.show_dialog( - "Registration Failed", - "Passwords do not match.", - vec!["OK".to_string()], - DialogPurpose::RegisterFailed, - ); - "Passwords do not match.".to_string() - } else { - // 2. Show Loading Dialog - app_state.show_loading_dialog("Registering", "Please wait..."); - - // 3. Spawn the registration task - spawn(async move { - let password_opt = if password.is_empty() { None } else { Some(password) }; - let password_conf_opt = - if password_confirmation.is_empty() { None } else { Some(password_confirmation) }; - let role_opt = if role.is_empty() { None } else { Some(role) }; - - let register_outcome = match auth_client - .register(username.clone(), email, password_opt, password_conf_opt, role_opt) - .await - .with_context(|| format!("Spawned register task failed for username: {}", username)) - { - Ok(response) => RegisterResult::Success(response), - Err(e) => RegisterResult::Failure(format!("{}", e)), - }; - - // Send result back to the main UI thread - if let Err(e) = sender.send(register_outcome).await { - error!("Failed to send registration result: {}", e); - } - }); - - // 4. Return immediately - "Registration initiated.".to_string() - } -} - -/// Handles the result received from the registration task. -/// Returns true if a redraw is needed. -pub fn handle_registration_result( - result: RegisterResult, - app_state: &mut AppState, - register_state: &mut RegisterFormState, -) -> bool { - match result { - RegisterResult::Success(response) => { - let success_message = format!( - "Registration Successful!\n\nUser ID: {}\nUsername: {}\nEmail: {}\nRole: {}", - response.id, response.username, response.email, response.role - ); - app_state.update_dialog_content( - &success_message, - vec!["OK".to_string()], - DialogPurpose::RegisterSuccess, - ); - info!(message = %success_message, "Registration successful"); - } - RegisterResult::Failure(err_msg) | RegisterResult::ConnectionError(err_msg) => { - app_state.update_dialog_content( - &err_msg, - vec!["OK".to_string()], - DialogPurpose::RegisterFailed, - ); - register_state.set_error_message(Some(err_msg.clone())); - error!(error = %err_msg, "Registration failed/connection error"); - } - } - register_state.set_has_unsaved_changes(false); // Clear flag after processing - true // Request redraw as dialog content changed -} diff --git a/client/src/pages/register/mod.rs b/client/src/pages/register/mod.rs deleted file mode 100644 index d95e14a..0000000 --- a/client/src/pages/register/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -// src/pages/register/mod.rs - -// pub mod state; -pub mod ui; -pub mod state; -pub mod logic; -pub mod suggestions; -pub mod event; - -// pub use state::*; -pub use ui::render_register; -pub use logic::*; -pub use state::*; -pub use event::*; diff --git a/client/src/pages/register/state.rs b/client/src/pages/register/state.rs deleted file mode 100644 index 6c87d6b..0000000 --- a/client/src/pages/register/state.rs +++ /dev/null @@ -1,370 +0,0 @@ -// src/pages/register/state.rs - -use canvas::{DataProvider, AppMode, FormEditor}; -use std::fmt; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use canvas::keymap::KeyEventOutcome; -use crate::pages::register::suggestions::role_suggestions_sync; - -/// Represents the state of the Registration form UI -#[derive(Debug, Clone)] -pub struct RegisterState { - pub username: String, - pub email: String, - pub password: String, - pub password_confirmation: String, - pub role: String, - pub error_message: Option, - pub current_field: usize, - pub current_cursor_pos: usize, - pub has_unsaved_changes: bool, - pub app_mode: canvas::AppMode, -} - -impl Default for RegisterState { - fn default() -> Self { - Self { - username: String::new(), - email: String::new(), - password: String::new(), - password_confirmation: String::new(), - role: String::new(), - error_message: None, - current_field: 0, - current_cursor_pos: 0, - has_unsaved_changes: false, - app_mode: canvas::AppMode::Edit, - } - } -} - -impl RegisterState { - pub fn new() -> Self { - Self { - app_mode: canvas::AppMode::Edit, - ..Default::default() - } - } - - pub fn current_field(&self) -> usize { - self.current_field - } - - pub fn current_cursor_pos(&self) -> usize { - self.current_cursor_pos - } - - pub fn set_current_field(&mut self, index: usize) { - if index < 5 { - self.current_field = index; - } - } - - pub fn set_current_cursor_pos(&mut self, pos: usize) { - self.current_cursor_pos = pos; - } - - pub fn get_current_input(&self) -> &str { - match self.current_field { - 0 => &self.username, - 1 => &self.email, - 2 => &self.password, - 3 => &self.password_confirmation, - 4 => &self.role, - _ => "", - } - } - - pub fn get_current_input_mut(&mut self, index: usize) -> &mut String { - match index { - 0 => &mut self.username, - 1 => &mut self.email, - 2 => &mut self.password, - 3 => &mut self.password_confirmation, - 4 => &mut self.role, - _ => panic!("Invalid current_field index in RegisterState"), - } - } - - pub fn current_mode(&self) -> AppMode { - self.app_mode - } - - pub fn has_unsaved_changes(&self) -> bool { - self.has_unsaved_changes - } - - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.has_unsaved_changes = changed; - } -} - -impl DataProvider for RegisterState { - fn field_count(&self) -> usize { 5 } - - fn field_name(&self, index: usize) -> &str { - match index { - 0 => "Username", - 1 => "Email (Optional)", - 2 => "Password (Optional)", - 3 => "Confirm Password", - 4 => "Role (Optional)", - _ => "", - } - } - - fn field_value(&self, index: usize) -> &str { - match index { - 0 => &self.username, - 1 => &self.email, - 2 => &self.password, - 3 => &self.password_confirmation, - 4 => &self.role, - _ => "", - } - } - - fn set_field_value(&mut self, index: usize, value: String) { - match index { - 0 => self.username = value, - 1 => self.email = value, - 2 => self.password = value, - 3 => self.password_confirmation = value, - 4 => self.role = value, - _ => {} - } - self.has_unsaved_changes = true; - } - - fn supports_suggestions(&self, field_index: usize) -> bool { - field_index == 4 // only Role field supports suggestions - } -} - -/// Wrapper that owns both the raw register state and its editor -pub struct RegisterFormState { - pub state: RegisterState, - pub editor: FormEditor, - pub focus_outside_canvas: bool, - pub focused_button_index: usize, -} - -impl Default for RegisterFormState { - fn default() -> Self { - Self::new() - } -} - -// manual Debug because FormEditor doesn’t implement Debug -impl fmt::Debug for RegisterFormState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RegisterFormState") - .field("state", &self.state) - .finish() - } -} - -impl RegisterFormState { - /// Sync the editor's data provider back into our state - pub fn sync_from_editor(&mut self) { - // The FormEditor holds the authoritative data - let dp = self.editor.data_provider(); - self.state = dp.clone(); // because RegisterState: Clone - } - - pub fn new() -> Self { - let state = RegisterState::default(); - let editor = FormEditor::new(state.clone()); - Self { - state, - editor, - focus_outside_canvas: false, - focused_button_index: 0, - } - } - - // === Delegates to RegisterState === - pub fn username(&self) -> &str { - &self.state.username - } - pub fn username_mut(&mut self) -> &mut String { - &mut self.state.username - } - - pub fn email(&self) -> &str { - &self.state.email - } - pub fn email_mut(&mut self) -> &mut String { - &mut self.state.email - } - - pub fn password(&self) -> &str { - &self.state.password - } - pub fn password_mut(&mut self) -> &mut String { - &mut self.state.password - } - - pub fn password_confirmation(&self) -> &str { - &self.state.password_confirmation - } - pub fn password_confirmation_mut(&mut self) -> &mut String { - &mut self.state.password_confirmation - } - - pub fn role(&self) -> &str { - &self.state.role - } - pub fn role_mut(&mut self) -> &mut String { - &mut self.state.role - } - - pub fn error_message(&self) -> Option<&String> { - self.state.error_message.as_ref() - } - pub fn set_error_message(&mut self, msg: Option) { - self.state.error_message = msg; - } - - pub fn has_unsaved_changes(&self) -> bool { - self.state.has_unsaved_changes - } - pub fn set_has_unsaved_changes(&mut self, changed: bool) { - self.state.has_unsaved_changes = changed; - } - - pub fn clear(&mut self) { - self.state.username.clear(); - self.state.email.clear(); - self.state.password.clear(); - self.state.password_confirmation.clear(); - self.state.role.clear(); - self.state.error_message = None; - self.state.has_unsaved_changes = false; - self.state.current_field = 0; - self.state.current_cursor_pos = 0; - } - - // === Delegates to cursor/input === - pub fn current_field(&self) -> usize { - self.state.current_field() - } - pub fn set_current_field(&mut self, index: usize) { - self.state.set_current_field(index); - } - pub fn current_cursor_pos(&self) -> usize { - self.state.current_cursor_pos() - } - pub fn set_current_cursor_pos(&mut self, pos: usize) { - self.state.set_current_cursor_pos(pos); - } - pub fn get_current_input(&self) -> &str { - self.state.get_current_input() - } - pub fn get_current_input_mut(&mut self) -> &mut String { - self.state.get_current_input_mut(self.state.current_field) - } - - // === Delegates to FormEditor === - pub fn mode(&self) -> canvas::AppMode { - self.editor.mode() - } - pub fn cursor_position(&self) -> usize { - self.editor.cursor_position() - } - - pub fn handle_key_event( - &mut self, - key_event: crossterm::event::KeyEvent, - ) -> canvas::keymap::KeyEventOutcome { - // Only customize behavior for the Role field (index 4) in Edit mode - let in_role_field = self.editor.current_field() == 4; - let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit; - - if in_role_field && in_edit_mode { - match key_event.code { - // Tab: open suggestions if inactive; otherwise cycle next - KeyCode::Tab => { - if !self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(4) { - let items = role_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(4, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } else { - // Cycle to next suggestion - self.editor.suggestions_next(); - } - return KeyEventOutcome::Consumed(None); - } - - // Shift+Tab (BackTab): cycle suggestions too (fallback to next) - KeyCode::BackTab => { - if self.editor.is_suggestions_active() { - // If your canvas exposes suggestions_prev(), use it here. - // Fallback: cycle next. - self.editor.suggestions_next(); - return KeyEventOutcome::Consumed(None); - } - } - - // Enter: if suggestions active — apply selected suggestion - KeyCode::Enter => { - if self.editor.is_suggestions_active() { - let _ = self.editor.apply_suggestion(); - return KeyEventOutcome::Consumed(None); - } - } - - // Esc: close suggestions if active - KeyCode::Esc => { - if self.editor.is_suggestions_active() { - self.editor.close_suggestions(); - return KeyEventOutcome::Consumed(None); - } - } - - // Character input: first let editor mutate text, then refilter if active - KeyCode::Char(_) => { - let outcome = self.editor.handle_key_event(key_event); - if self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(4) { - let items = role_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(4, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } - return outcome; - } - - // Backspace/Delete: mutate then refilter if active - KeyCode::Backspace | KeyCode::Delete => { - let outcome = self.editor.handle_key_event(key_event); - if self.editor.is_suggestions_active() { - if let Some(query) = self.editor.start_suggestions(4) { - let items = role_suggestions_sync(&query); - let applied = - self.editor.apply_suggestions_result(4, &query, items); - if applied { - self.editor.update_inline_completion(); - } - } - } - return outcome; - } - - _ => { /* fall through to default */ } - } - } - - // Default: let canvas handle it - self.editor.handle_key_event(key_event) - } -} diff --git a/client/src/pages/register/suggestions.rs b/client/src/pages/register/suggestions.rs deleted file mode 100644 index 0ec12cc..0000000 --- a/client/src/pages/register/suggestions.rs +++ /dev/null @@ -1,36 +0,0 @@ -// src/pages/register/suggestions.rs - -use anyhow::Result; -use async_trait::async_trait; -use canvas::{SuggestionItem, SuggestionsProvider}; - -// Keep the async provider if you want, but add this sync helper and shared data. -const ROLES: &[&str] = &["admin", "moderator", "accountant", "viewer"]; - -pub fn role_suggestions_sync(query: &str) -> Vec { - let q = query.to_lowercase(); - ROLES - .iter() - .filter(|r| q.is_empty() || r.to_lowercase().contains(&q)) - .map(|r| SuggestionItem { - display_text: (*r).to_string(), - value_to_store: (*r).to_string(), - }) - .collect() -} - -pub struct RoleSuggestionsProvider; - -#[async_trait] -impl SuggestionsProvider for RoleSuggestionsProvider { - async fn fetch_suggestions( - &mut self, - field_index: usize, - query: &str, - ) -> Result> { - if field_index != 4 { - return Ok(Vec::new()); - } - Ok(role_suggestions_sync(query)) - } -} diff --git a/client/src/pages/register/ui.rs b/client/src/pages/register/ui.rs deleted file mode 100644 index 0098ce5..0000000 --- a/client/src/pages/register/ui.rs +++ /dev/null @@ -1,157 +0,0 @@ -// src/pages/register/ui.rs - -use crate::{ - config::colors::themes::Theme, - state::app::state::AppState, -}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect, Margin}, - style::{Style, Modifier, Color}, - widgets::{Block, BorderType, Borders, Paragraph}, - Frame, -}; -use crate::dialog; -use crate::pages::register::RegisterFormState; -use crate::pages::register::suggestions::RoleSuggestionsProvider; -use tokio::runtime::Handle; -use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme}; -use canvas::SuggestionsProvider; - -pub fn render_register( - f: &mut Frame, - area: Rect, - theme: &Theme, - register_page: &RegisterFormState, - app_state: &AppState, -) { - let state = ®ister_page.state; - let editor = ®ister_page.editor; - - // Outer block - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(Style::default().fg(theme.border)) - .title(" Register ") - .style(Style::default().bg(theme.bg)); - - f.render_widget(block, area); - - let inner_area = area.inner(Margin { - horizontal: 1, - vertical: 1, - }); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Form (5 fields + padding) - Constraint::Length(1), // Help text line - Constraint::Length(1), // Error message - Constraint::Length(3), // Buttons - ]) - .split(inner_area); - - // Render the form canvas - let input_rect = render_canvas(f, chunks[0], editor, theme); - - - // --- HELP TEXT --- - let help_text = Paragraph::new("* are optional fields") - .style(Style::default().fg(theme.fg)) - .alignment(Alignment::Center); - f.render_widget(help_text, chunks[1]); - - // --- ERROR MESSAGE --- - if let Some(err) = &state.error_message { - f.render_widget( - Paragraph::new(err.as_str()) - .style(Style::default().fg(Color::Red)) - .alignment(Alignment::Center), - chunks[2], - ); - } - - // --- BUTTONS --- - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[3]); - - // Register Button - let register_button_index = 0; - let register_active = - register_page.focus_outside_canvas - && register_page.focused_button_index == register_button_index; - let mut register_style = Style::default().fg(theme.fg); - let mut register_border = Style::default().fg(theme.border); - if register_active { - register_style = register_style.fg(theme.highlight).add_modifier(Modifier::BOLD); - register_border = register_border.fg(theme.accent); - } - - f.render_widget( - Paragraph::new("Register") - .style(register_style) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(register_border), - ), - button_chunks[0], - ); - - // Return Button - let return_button_index = 1; - let return_active = - register_page.focus_outside_canvas - && register_page.focused_button_index == return_button_index; - let mut return_style = Style::default().fg(theme.fg); - let mut return_border = Style::default().fg(theme.border); - if return_active { - return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD); - return_border = return_border.fg(theme.accent); - } - - f.render_widget( - Paragraph::new("Return") - .style(return_style) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_style(return_border), - ), - button_chunks[1], - ); - - // --- DIALOG --- - if app_state.ui.dialog.dialog_show { - dialog::render_dialog( - f, - f.area(), - theme, - &app_state.ui.dialog.dialog_title, - &app_state.ui.dialog.dialog_message, - &app_state.ui.dialog.dialog_buttons, - app_state.ui.dialog.dialog_active_button_index, - app_state.ui.dialog.is_loading, - ); - } - - // Render suggestions dropdown if active (library GUI) - if editor.mode() == canvas::AppMode::Edit { - if let Some(input_rect) = input_rect { - render_suggestions_dropdown( - f, - f.area(), - input_rect, - &DefaultCanvasTheme, - editor, - ); - } - } -} diff --git a/client/src/pages/routing/mod.rs b/client/src/pages/routing/mod.rs deleted file mode 100644 index 44efd00..0000000 --- a/client/src/pages/routing/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// src/pages/routing/mod.rs - -pub mod router; - -pub use router::{Page, Router}; diff --git a/client/src/pages/routing/router.rs b/client/src/pages/routing/router.rs deleted file mode 100644 index 8542a4b..0000000 --- a/client/src/pages/routing/router.rs +++ /dev/null @@ -1,36 +0,0 @@ -// src/pages/routing/router.rs -use crate::state::pages::auth::AuthState; -use crate::pages::admin_panel::add_logic::state::AddLogicFormState; -use crate::pages::admin_panel::add_table::state::AddTableFormState; -use crate::pages::admin::AdminState; -use crate::pages::forms::FormState; -use crate::pages::login::LoginFormState; -use crate::pages::register::RegisterFormState; -use crate::pages::intro::IntroState; - -#[derive(Debug)] -pub enum Page { - Intro(IntroState), - Login(LoginFormState), - Register(RegisterFormState), - Admin(AdminState), - AddLogic(AddLogicFormState), - AddTable(AddTableFormState), - Form(String), -} - -pub struct Router { - pub current: Page, -} - -impl Router { - pub fn new() -> Self { - Self { - current: Page::Intro(IntroState::default()), - } - } - - pub fn navigate(&mut self, page: Page) { - self.current = page; - } -} diff --git a/client/src/search/event.rs b/client/src/search/event.rs deleted file mode 100644 index 8de2b1a..0000000 --- a/client/src/search/event.rs +++ /dev/null @@ -1,112 +0,0 @@ -// src/search/event.rs -use crate::state::app::state::AppState; -use crate::services::grpc_client::GrpcClient; -use common::proto::komp_ac::search::search_response::Hit; -use crossterm::event::KeyCode; -use tokio::sync::mpsc; -use tracing::{error, info}; -use std::collections::HashMap; -use anyhow::Result; - -pub async fn handle_search_palette_event( - key_event: crossterm::event::KeyEvent, - app_state: &mut AppState, - grpc_client: &mut GrpcClient, - search_result_sender: mpsc::UnboundedSender>, -) -> Result> { - let mut should_close = false; - let mut outcome_message = None; - let mut trigger_search = false; - - if let Some(search_state) = app_state.search_state.as_mut() { - match key_event.code { - KeyCode::Esc => { - should_close = true; - outcome_message = Some("Search cancelled".to_string()); - } - KeyCode::Enter => { - // Step 1: Extract the data we need while holding the borrow - let maybe_data = search_state - .results - .get(search_state.selected_index) - .map(|hit| (hit.id, hit.content_json.clone())); - - // Step 2: Process outside the borrow - if let Some((id, content_json)) = maybe_data { - if let Ok(data) = serde_json::from_str::>(&content_json) { - // Use current view path to access the active form - if let (Some(profile), Some(table)) = ( - app_state.current_view_profile_name.clone(), - app_state.current_view_table_name.clone(), - ) { - let path = format!("{}/{}", profile, table); - if let Some(fs) = app_state.form_state_for_path(&path) { - let detached_pos = fs.total_count + 2; - fs.update_from_response(&data, detached_pos); - } - } - should_close = true; - outcome_message = Some(format!("Loaded record ID {}", id)); - } - } - } - KeyCode::Up => search_state.previous_result(), - KeyCode::Down => search_state.next_result(), - KeyCode::Char(c) => { - search_state.input.insert(search_state.cursor_position, c); - search_state.cursor_position += 1; - trigger_search = true; - } - KeyCode::Backspace => { - if search_state.cursor_position > 0 { - search_state.cursor_position -= 1; - search_state.input.remove(search_state.cursor_position); - trigger_search = true; - } - } - KeyCode::Left => { - search_state.cursor_position = - search_state.cursor_position.saturating_sub(1); - } - KeyCode::Right => { - if search_state.cursor_position < search_state.input.len() { - search_state.cursor_position += 1; - } - } - _ => {} - } - } - - if trigger_search { - if let Some(search_state) = app_state.search_state.as_mut() { - search_state.is_loading = true; - search_state.results.clear(); - search_state.selected_index = 0; - - let query = search_state.input.clone(); - let table_name = search_state.table_name.clone(); - let sender = search_result_sender.clone(); - let mut grpc_client = grpc_client.clone(); - - info!("Spawning search task for query: '{}'", query); - tokio::spawn(async move { - match grpc_client.search_table(table_name, query).await { - Ok(response) => { - let _ = sender.send(response.hits); - } - Err(e) => { - error!("Search failed: {:?}", e); - let _ = sender.send(vec![]); - } - } - }); - } - } - - if should_close { - app_state.search_state = None; - app_state.ui.show_search_palette = false; - } - - Ok(outcome_message) -} diff --git a/client/src/search/grpc.rs b/client/src/search/grpc.rs deleted file mode 100644 index 6b9379f..0000000 --- a/client/src/search/grpc.rs +++ /dev/null @@ -1,31 +0,0 @@ -// src/search/grpc.rs - -use common::proto::komp_ac::search::{ - searcher_client::SearcherClient, SearchRequest, SearchResponse, -}; -use tonic::transport::Channel; -use anyhow::Result; - -/// Internal search gRPC wrapper -#[derive(Clone)] -pub struct SearchGrpc { - client: SearcherClient, -} - -impl SearchGrpc { - pub fn new(channel: Channel) -> Self { - Self { - client: SearcherClient::new(channel), - } - } - - pub async fn search_table( - &mut self, - table_name: String, - query: String, - ) -> Result { - let request = tonic::Request::new(SearchRequest { table_name, query }); - let response = self.client.search_table(request).await?; - Ok(response.into_inner()) - } -} diff --git a/client/src/search/mod.rs b/client/src/search/mod.rs deleted file mode 100644 index de37124..0000000 --- a/client/src/search/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/search/mod.rs - -pub mod state; -pub mod ui; -pub mod event; -pub mod grpc; - -pub use ui::*; -pub use grpc::SearchGrpc; diff --git a/client/src/search/state.rs b/client/src/search/state.rs deleted file mode 100644 index eb243f6..0000000 --- a/client/src/search/state.rs +++ /dev/null @@ -1,56 +0,0 @@ -// src/search/state.rs - -use common::proto::komp_ac::search::search_response::Hit; - -/// Holds the complete state for the search palette. -pub struct SearchState { - /// The name of the table being searched. - pub table_name: String, - /// The current text entered by the user. - pub input: String, - /// The position of the cursor within the input text. - pub cursor_position: usize, - /// The search results returned from the server. - pub results: Vec, - /// The index of the currently selected search result. - pub selected_index: usize, - /// A flag to indicate if a search is currently in progress. - pub is_loading: bool, -} - -impl SearchState { - /// Creates a new SearchState for a given table. - pub fn new(table_name: String) -> Self { - Self { - table_name, - input: String::new(), - cursor_position: 0, - results: Vec::new(), - selected_index: 0, - is_loading: false, - } - } - - /// Moves the selection to the next item, wrapping around if at the end. - pub fn next_result(&mut self) { - if !self.results.is_empty() { - let next = self.selected_index + 1; - self.selected_index = if next >= self.results.len() { - 0 // Wrap to the start - } else { - next - }; - } - } - - /// Moves the selection to the previous item, wrapping around if at the beginning. - pub fn previous_result(&mut self) { - if !self.results.is_empty() { - self.selected_index = if self.selected_index == 0 { - self.results.len() - 1 // Wrap to the end - } else { - self.selected_index - 1 - }; - } - } -} diff --git a/client/src/search/ui.rs b/client/src/search/ui.rs deleted file mode 100644 index 77861ab..0000000 --- a/client/src/search/ui.rs +++ /dev/null @@ -1,121 +0,0 @@ -// src/search/ui.rs - -use crate::config::colors::themes::Theme; -use crate::search::state::SearchState; -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, - Frame, -}; - -/// Renders the search palette dialog over the main UI. -pub fn render_search_palette( - f: &mut Frame, - area: Rect, - theme: &Theme, - state: &SearchState, -) { - // --- Dialog Area Calculation --- - let height = (area.height as f32 * 0.7).min(30.0) as u16; - let width = (area.width as f32 * 0.6).min(100.0) as u16; - let dialog_area = Rect { - x: area.x + (area.width - width) / 2, - y: area.y + (area.height - height) / 4, - width, - height, - }; - - f.render_widget(Clear, dialog_area); // Clear background - - let block = Block::default() - .title(format!(" Search in '{}' ", state.table_name)) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent)); - f.render_widget(block.clone(), dialog_area); - - // --- Inner Layout (Input + Results) --- - let inner_chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(3), // For input box - Constraint::Min(0), // For results list - ]) - .split(dialog_area); - - // --- Render Input Box --- - let input_block = Block::default() - .title("Query") - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.border)); - let input_text = Paragraph::new(state.input.as_str()) - .block(input_block) - .style(Style::default().fg(theme.fg)); - f.render_widget(input_text, inner_chunks[0]); - // Set cursor position - f.set_cursor_position(( - inner_chunks[0].x + state.cursor_position as u16 + 1, - inner_chunks[0].y + 1, - )); - - // --- Render Results List --- - if state.is_loading { - let loading_p = Paragraph::new("Searching...") - .style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC)); - f.render_widget(loading_p, inner_chunks[1]); - } else { - let list_items: Vec = state - .results - .iter() - .map(|hit| { - // Parse the JSON string to make it readable - let content_summary = match serde_json::from_str::< - serde_json::Value, - >(&hit.content_json) - { - Ok(json) => { - if let Some(obj) = json.as_object() { - // Create a summary from the first few non-null string values - obj.values() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .take(3) - .collect::>() - .join(" | ") - } else { - "Non-object JSON".to_string() - } - } - Err(_) => "Invalid JSON content".to_string(), - }; - - let line = Line::from(vec![ - Span::styled( - format!("{:<4.2} ", hit.score), - Style::default().fg(theme.accent), - ), - Span::raw(content_summary), - ]); - ListItem::new(line) - }) - .collect(); - - let results_list = List::new(list_items) - .block(Block::default().title("Results")) - .highlight_style( - Style::default() - .bg(theme.highlight) - .fg(theme.bg) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - // We need a mutable ListState to render the selection - let mut list_state = - ratatui::widgets::ListState::default().with_selected(Some(state.selected_index)); - - f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state); - } -} diff --git a/client/src/services/auth.rs b/client/src/services/auth.rs deleted file mode 100644 index 78fb448..0000000 --- a/client/src/services/auth.rs +++ /dev/null @@ -1,57 +0,0 @@ -// src/services/auth.rs -use tonic::transport::Channel; -use common::proto::komp_ac::auth::{ - auth_service_client::AuthServiceClient, - LoginRequest, LoginResponse, - RegisterRequest, AuthResponse, -}; -use anyhow::{Context, Result}; - -#[derive(Clone)] -pub struct AuthClient { - client: AuthServiceClient, -} - -impl AuthClient { - pub async fn new() -> Result { - // Kept for backward compatibility; opens a new connection. - let client = AuthServiceClient::connect("http://[::1]:50051") - .await - .context("Failed to connect to auth service")?; - Ok(Self { client }) - } - - /// Preferred: reuse an existing Channel (from GrpcClient). - pub async fn with_channel(channel: Channel) -> Result { - Ok(Self { - client: AuthServiceClient::new(channel), - }) - } - - /// Login user via gRPC. - pub async fn login(&mut self, identifier: String, password: String) -> Result { - let request = tonic::Request::new(LoginRequest { identifier, password }); - let response = self.client.login(request).await?.into_inner(); - Ok(response) - } - - /// Registers a new user via gRPC. - pub async fn register( - &mut self, - username: String, - email: String, - password: Option, - password_confirmation: Option, - role: Option, - ) -> Result { - let request = tonic::Request::new(RegisterRequest { - username, - email, - password: password.unwrap_or_default(), - password_confirmation: password_confirmation.unwrap_or_default(), - role: role.unwrap_or_default(), - }); - let response = self.client.register(request).await?.into_inner(); - Ok(response) - } -} diff --git a/client/src/services/grpc_client.rs b/client/src/services/grpc_client.rs deleted file mode 100644 index 5d35e9a..0000000 --- a/client/src/services/grpc_client.rs +++ /dev/null @@ -1,298 +0,0 @@ -// src/services/grpc_client.rs - -use common::proto::komp_ac::common::Empty; -use common::proto::komp_ac::table_structure::table_structure_service_client::TableStructureServiceClient; -use common::proto::komp_ac::table_structure::{ - GetTableStructureRequest, TableStructureResponse, -}; -use common::proto::komp_ac::table_definition::{ - table_definition_client::TableDefinitionClient, - PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse, -}; -use common::proto::komp_ac::table_script::{ - table_script_client::TableScriptClient, - PostTableScriptRequest, TableScriptResponse, -}; -use common::proto::komp_ac::tables_data::{ - tables_data_client::TablesDataClient, - GetTableDataByPositionRequest, - GetTableDataRequest, // ADD THIS - GetTableDataResponse, - DeleteTableDataRequest, // ADD THIS - DeleteTableDataResponse, // ADD THIS - GetTableDataCountRequest, - PostTableDataRequest, PostTableDataResponse, PutTableDataRequest, - PutTableDataResponse, -}; -use crate::search::SearchGrpc; -use common::proto::komp_ac::search::SearchResponse; -use common::proto::komp_ac::table_validation::{ - table_validation_service_client::TableValidationServiceClient, - GetTableValidationRequest, - TableValidationResponse, - CountMode as PbCountMode, - FieldValidation as PbFieldValidation, - CharacterLimits as PbCharacterLimits, -}; -use anyhow::{Context, Result}; -use std::collections::HashMap; -use tonic::transport::{Channel, Endpoint}; -use prost_types::Value; -use std::time::Duration; - -#[derive(Clone)] -pub struct GrpcClient { - channel: Channel, - table_structure_client: TableStructureServiceClient, - table_definition_client: TableDefinitionClient, - table_script_client: TableScriptClient, - tables_data_client: TablesDataClient, - search_client: SearchGrpc, - table_validation_client: TableValidationServiceClient, -} - -impl GrpcClient { - pub async fn new() -> Result { - let endpoint = Endpoint::from_static("http://[::1]:50051") - .connect_timeout(Duration::from_secs(5)) - .tcp_keepalive(Some(Duration::from_secs(30))) - .keep_alive_while_idle(true) - .http2_keep_alive_interval(Duration::from_secs(15)) - .keep_alive_timeout(Duration::from_secs(5)); - - let channel = endpoint - .connect() - .await - .context("Failed to create gRPC channel")?; - - let table_structure_client = - TableStructureServiceClient::new(channel.clone()); - let table_definition_client = - TableDefinitionClient::new(channel.clone()); - let table_script_client = TableScriptClient::new(channel.clone()); - let tables_data_client = TablesDataClient::new(channel.clone()); - let search_client = SearchGrpc::new(channel.clone()); - let table_validation_client = - TableValidationServiceClient::new(channel.clone()); - - Ok(Self { - channel, - table_structure_client, - table_definition_client, - table_script_client, - tables_data_client, - search_client, - table_validation_client, - }) - } - - // Expose the shared channel so other typed clients can reuse it. - pub fn channel(&self) -> Channel { - self.channel.clone() - } - - // Fetch validation rules for a table. Absence of a field in response = no validation. - pub async fn get_table_validation( - &mut self, - profile_name: String, - table_name: String, - ) -> Result { - let req = GetTableValidationRequest { - profile_name, - table_name, - }; - let resp = self - .table_validation_client - .get_table_validation(tonic::Request::new(req)) - .await - .context("gRPC GetTableValidation call failed")?; - Ok(resp.into_inner()) - } - - pub async fn get_table_structure( - &mut self, - profile_name: String, - table_name: String, - ) -> Result { - let grpc_request = GetTableStructureRequest { - profile_name, - table_name, - }; - let request = tonic::Request::new(grpc_request); - let response = self - .table_structure_client - .get_table_structure(request) - .await - .context("gRPC GetTableStructure call failed")?; - Ok(response.into_inner()) - } - - pub async fn get_profile_tree( - &mut self, - ) -> Result { - let request = tonic::Request::new(Empty::default()); - let response = self - .table_definition_client - .get_profile_tree(request) - .await - .context("gRPC GetProfileTree call failed")?; - Ok(response.into_inner()) - } - - pub async fn post_table_definition( - &mut self, - request: PostTableDefinitionRequest, - ) -> Result { - let tonic_request = tonic::Request::new(request); - let response = self - .table_definition_client - .post_table_definition(tonic_request) - .await - .context("gRPC PostTableDefinition call failed")?; - Ok(response.into_inner()) - } - - pub async fn post_table_script( - &mut self, - request: PostTableScriptRequest, - ) -> Result { - let tonic_request = tonic::Request::new(request); - let response = self - .table_script_client - .post_table_script(tonic_request) - .await - .context("gRPC PostTableScript call failed")?; - Ok(response.into_inner()) - } - - // Existing TablesData methods - pub async fn get_table_data_count( - &mut self, - profile_name: String, - table_name: String, - ) -> Result { - 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 { - 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()) - } - - // ADD THIS: Missing get_table_data method - pub async fn get_table_data( - &mut self, - profile_name: String, - table_name: String, - id: i64, - ) -> Result { - let grpc_request = GetTableDataRequest { - profile_name, - table_name, - id, - }; - let request = tonic::Request::new(grpc_request); - let response = self - .tables_data_client - .get_table_data(request) - .await - .context("gRPC GetTableData call failed")?; - Ok(response.into_inner()) - } - - // ADD THIS: Missing delete_table_data method - pub async fn delete_table_data( - &mut self, - profile_name: String, - table_name: String, - record_id: i64, - ) -> Result { - let grpc_request = DeleteTableDataRequest { - profile_name, - table_name, - record_id, - }; - let request = tonic::Request::new(grpc_request); - let response = self - .tables_data_client - .delete_table_data(request) - .await - .context("gRPC DeleteTableData call failed")?; - Ok(response.into_inner()) - } - - pub async fn post_table_data( - &mut self, - profile_name: String, - table_name: String, - data: HashMap, - ) -> Result { - 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, - ) -> Result { - 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()) - } - - pub async fn search_table( - &mut self, - table_name: String, - query: String, - ) -> Result { - self.search_client.search_table(table_name, query).await - } -} diff --git a/client/src/services/mod.rs b/client/src/services/mod.rs deleted file mode 100644 index a094b4a..0000000 --- a/client/src/services/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// services/mod.rs - -pub mod grpc_client; -pub mod auth; -pub mod ui_service; - -pub use grpc_client::*; -pub use ui_service::*; -pub use auth::*; diff --git a/client/src/services/ui_service.rs b/client/src/services/ui_service.rs deleted file mode 100644 index 582925c..0000000 --- a/client/src/services/ui_service.rs +++ /dev/null @@ -1,375 +0,0 @@ -// src/services/ui_service.rs - -use crate::services::grpc_client::GrpcClient; -use crate::state::app::state::AppState; -use crate::pages::admin_panel::add_logic::state::AddLogicState; -use crate::pages::forms::logic::SaveOutcome; -use crate::utils::columns::filter_user_columns; -use crate::pages::forms::{FieldDefinition, FormState}; -use common::proto::komp_ac::table_validation::CountMode as PbCountMode; -use canvas::validation::limits::CountMode; -use anyhow::{anyhow, Context, Result}; -use std::sync::Arc; - -pub struct UiService; - -impl UiService { - pub async fn load_table_view( - grpc_client: &mut GrpcClient, - app_state: &mut AppState, - profile_name: &str, - table_name: &str, - ) -> Result { - // 1. & 2. Fetch and Cache Schema - UNCHANGED - let table_structure = grpc_client - .get_table_structure(profile_name.to_string(), table_name.to_string()) - .await - .context(format!( - "Failed to get table structure for {}.{}", - profile_name, table_name - ))?; - let cache_key = format!("{}.{}", profile_name, table_name); - app_state - .schema_cache - .insert(cache_key, Arc::new(table_structure.clone())); - tracing::info!("Schema for '{}.{}' cached.", profile_name, table_name); - - // --- START: FINAL, SIMPLIFIED, CORRECT LOGIC --- - - // 3a. Create definitions for REGULAR fields first. - let mut fields: Vec = table_structure - .columns - .iter() - .filter(|col| { - !col.is_primary_key - && col.name != "deleted" - && col.name != "created_at" - && !col.name.ends_with("_id") // Filter out ALL potential links - }) - .map(|col| FieldDefinition { - display_name: col.name.clone(), - data_key: col.name.clone(), - is_link: false, - link_target_table: None, - }) - .collect(); - - // 3b. Now, find and APPEND definitions for LINK fields based on the `_id` convention. - let link_fields: Vec = table_structure - .columns - .iter() - .filter(|col| col.name.ends_with("_id")) // Find all foreign key columns - .map(|col| { - // The table we link to is derived from the column name. - // e.g., "test_diacritics_id" -> "test_diacritics" - let target_table_base = col - .name - .strip_suffix("_id") - .unwrap_or(&col.name); - - // Find the full table name from the profile tree for display. - // e.g., "test_diacritics" -> "2025_test_diacritics" - let full_target_table_name = app_state - .profile_tree - .profiles - .iter() - .find(|p| p.name == profile_name) - .and_then(|p| p.tables.iter().find(|t| t.name.ends_with(target_table_base))) - .map_or(target_table_base.to_string(), |t| t.name.clone()); - - FieldDefinition { - display_name: full_target_table_name.clone(), - data_key: col.name.clone(), // The actual FK column name - is_link: true, - link_target_table: Some(full_target_table_name), - } - }) - .collect(); - - fields.extend(link_fields); // Append the link fields to the end - - // --- END: FINAL, SIMPLIFIED, CORRECT LOGIC --- - - Ok(FormState::new( - profile_name.to_string(), - table_name.to_string(), - fields, - )) - } - - pub async fn initialize_add_logic_table_data( - grpc_client: &mut GrpcClient, - add_logic_state: &mut AddLogicState, - profile_tree: &common::proto::komp_ac::table_definition::ProfileTreeResponse, - ) -> Result { - let profile_name_clone_opt = Some(add_logic_state.profile_name.clone()); - let table_name_opt_clone = add_logic_state.selected_table_name.clone(); - - // Collect table names from SAME profile only - let same_profile_table_names: Vec = profile_tree.profiles - .iter() - .find(|profile| profile.name == add_logic_state.profile_name) - .map(|profile| profile.tables.iter().map(|table| table.name.clone()).collect()) - .unwrap_or_default(); - - // Set same profile table names for autocomplete - add_logic_state.set_same_profile_table_names(same_profile_table_names.clone()); - - if let (Some(profile_name_clone), Some(table_name_clone)) = (profile_name_clone_opt, table_name_opt_clone) { - match grpc_client.get_table_structure(profile_name_clone.clone(), table_name_clone.clone()).await { - Ok(response) => { - let column_names: Vec = response.columns - .into_iter() - .map(|col| col.name) - .collect(); - - add_logic_state.set_table_columns(column_names.clone()); - - Ok(format!( - "Loaded {} columns for table '{}' and {} tables from profile '{}'", - column_names.len(), - table_name_clone, - same_profile_table_names.len(), - add_logic_state.profile_name - )) - } - Err(e) => { - tracing::warn!( - "Failed to fetch table structure for {}.{}: {}", - profile_name_clone, - table_name_clone, - e - ); - Ok(format!( - "Warning: Could not load table structure for '{}'. Autocomplete will use {} tables from profile '{}'.", - table_name_clone, - same_profile_table_names.len(), - add_logic_state.profile_name - )) - } - } - } else { - Ok(format!( - "No table selected for Add Logic. Loaded {} tables from profile '{}' for autocomplete.", - same_profile_table_names.len(), - add_logic_state.profile_name - )) - } - } - - /// Fetches columns for a specific table (used for table.column autocomplete) - pub async fn fetch_columns_for_table( - grpc_client: &mut GrpcClient, - profile_name: &str, - table_name: &str, - ) -> Result> { - match grpc_client.get_table_structure(profile_name.to_string(), table_name.to_string()).await { - Ok(response) => { - let column_names: Vec = response.columns - .into_iter() - .map(|col| col.name) - .collect(); - Ok(filter_user_columns(column_names)) - } - Err(e) => { - tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e); - Err(e.into()) - } - } - } - - // TODO REFACTOR (maybe) - pub async fn initialize_app_state_and_form( - grpc_client: &mut GrpcClient, - app_state: &mut AppState, - ) -> Result<(String, String, Vec)> { - let profile_tree = grpc_client - .get_profile_tree() - .await - .context("Failed to get profile tree")?; - - app_state.profile_tree = profile_tree; - - // Find first profile that contains tables - let (initial_profile_name, initial_table_name) = app_state - .profile_tree - .profiles - .iter() - .find(|profile| !profile.tables.is_empty()) - .and_then(|profile| { - profile.tables.first().map(|table| { - (profile.name.clone(), table.name.clone()) - }) - }) - .ok_or_else(|| anyhow!("No profiles with tables found. Create a table first."))?; - - app_state.set_current_view_table( - initial_profile_name.clone(), - initial_table_name.clone(), - ); - - let form_state = Self::load_table_view( - grpc_client, - app_state, - &initial_profile_name, - &initial_table_name, - ) - .await?; - - let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect(); - - Ok((initial_profile_name, initial_table_name, field_names)) - } - - pub async fn fetch_and_set_table_count( - grpc_client: &mut GrpcClient, - form_state: &mut FormState, - ) -> 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; - - if total_count > 0 { - form_state.current_position = total_count; - } else { - form_state.current_position = 1; - } - Ok(()) - } - - pub async fn load_table_data_by_position( - grpc_client: &mut GrpcClient, - form_state: &mut FormState, - ) -> Result { - if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) { - form_state.reset_to_empty(); - 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 { - 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) => { - // FIX: Pass the current position as the second argument - form_state.update_from_response(&response.data, form_state.current_position); - Ok(format!( - "Loaded entry {}/{} for table {}.{}", - form_state.current_position, - form_state.total_count, - form_state.profile_name, - form_state.table_name - )) - } - Err(e) => { - tracing::error!( - "Error loading entry {} for table {}.{}: {}", - form_state.current_position, - form_state.profile_name, - form_state.table_name, - e - ); - Err(anyhow::anyhow!( - "Error loading entry {}: {}", - form_state.current_position, - e - )) - } - } - } - - pub async fn handle_save_outcome( - save_outcome: SaveOutcome, - _grpc_client: &mut GrpcClient, - _app_state: &mut AppState, - form_state: &mut FormState, - ) -> Result<()> { - match save_outcome { - SaveOutcome::CreatedNew(new_id) => { - form_state.id = new_id; - } - SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => { - // No action needed - } - } - Ok(()) - } - - /// Fetch and apply "Validation 1" (character limits) rules for this form. - pub async fn apply_validation1_for_form( - grpc_client: &mut GrpcClient, - app_state: &mut AppState, - path: &str, - ) -> Result<()> { - let (profile, table) = path - .split_once('/') - .context("Invalid form path for validation")?; - - let resp = grpc_client - .get_table_validation(profile.to_string(), table.to_string()) - .await - .context("Failed to fetch table validation")?; - - if let Some(fs) = app_state.form_state_for_path(path) { - let mut rules: Vec> = - vec![None; fs.fields.len()]; - - for f in resp.fields { - if let Some(idx) = fs.fields.iter().position(|fd| fd.data_key == f.data_key) { - if let Some(limits) = f.limits { - let has_any = - limits.min != 0 || limits.max != 0 || limits.warn_at.is_some(); - if has_any { - let cm = match PbCountMode::from_i32(limits.count_mode) { - Some(PbCountMode::Unspecified) | None => CountMode::Characters, // protobuf default → fallback - Some(PbCountMode::Chars) => CountMode::Characters, - Some(PbCountMode::Bytes) => CountMode::Bytes, - Some(PbCountMode::DisplayWidth) => CountMode::DisplayWidth, - }; - - let min = if limits.min == 0 { None } else { Some(limits.min as usize) }; - let max = if limits.max == 0 { None } else { Some(limits.max as usize) }; - let warn_at = limits.warn_at.map(|w| w as usize); - - rules[idx] = Some(crate::pages::forms::state::CharLimitsRule { - min, - max, - warn_at, - count_mode: cm, - }); - } - } - } - } - fs.set_character_limits_rules(rules); - } - - if let Some(editor) = app_state.editor_for_path(path) { - editor.set_validation_enabled(true); - } - - Ok(()) - } -} diff --git a/client/src/sidebar/logic.rs b/client/src/sidebar/logic.rs deleted file mode 100644 index 89f15ae..0000000 --- a/client/src/sidebar/logic.rs +++ /dev/null @@ -1,21 +0,0 @@ -// src/sidebar/state.rs -use crossterm::event::{KeyCode, KeyModifiers}; -use crate::config::binds::config::Config; -use crate::state::app::state::UiState; - -pub fn toggle_sidebar( - ui_state: &mut UiState, - config: &Config, - key: KeyCode, - modifiers: KeyModifiers, -) -> bool { - if let Some(action) = - config.get_action_for_key_in_mode(&config.keybindings.common, key, modifiers) - { - if action == "toggle_sidebar" { - ui_state.show_sidebar = !ui_state.show_sidebar; - return true; - } - } - false -} diff --git a/client/src/sidebar/mod.rs b/client/src/sidebar/mod.rs deleted file mode 100644 index 54bab47..0000000 --- a/client/src/sidebar/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/sidebar/mod.rs - -pub mod ui; -pub mod logic; - -pub use ui::{calculate_sidebar_layout, render_sidebar}; -pub use logic::toggle_sidebar; diff --git a/client/src/sidebar/ui.rs b/client/src/sidebar/ui.rs deleted file mode 100644 index f0358b0..0000000 --- a/client/src/sidebar/ui.rs +++ /dev/null @@ -1,150 +0,0 @@ -// src/sidebar/ui.rs -use ratatui::{ - widgets::{Block, List, ListItem}, - layout::{Rect, Direction, Layout, Constraint}, - style::Style, - Frame, -}; -use crate::config::colors::themes::Theme; -use common::proto::komp_ac::table_definition::{ProfileTreeResponse}; -use ratatui::text::{Span, Line}; -use crate::components::utils::text::truncate_string; - -// Reduced sidebar width -const SIDEBAR_WIDTH: u16 = 20; - -// --- Icons --- -const ICON_PROFILE: &str = "📁"; -const ICON_TABLE: &str = "📄"; - -pub fn calculate_sidebar_layout(show_sidebar: bool, main_content_area: Rect) -> (Option, Rect) { - if show_sidebar { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(SIDEBAR_WIDTH), - Constraint::Min(0), - ]) - .split(main_content_area); - (Some(chunks[0]), chunks[1]) - } else { - (None, main_content_area) - } -} - -pub fn render_sidebar( - f: &mut Frame, - area: Rect, - theme: &Theme, - profile_tree: &ProfileTreeResponse, - selected_profile: &Option, -) { - let sidebar_block = Block::default().style(Style::default().bg(theme.bg)); - let mut items = Vec::new(); - let profile_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(3); - let table_name_available_width = (SIDEBAR_WIDTH as usize).saturating_sub(5); - - if let Some(profile_name) = selected_profile { - // Find the selected profile in the tree - if let Some(profile) = profile_tree - .profiles - .iter() - .find(|p| &p.name == profile_name) - { - // Add profile name as header - items.push(ListItem::new(Line::from(vec![ - Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)), - Span::styled( - truncate_string(&profile.name, profile_name_available_width), - Style::default().fg(theme.highlight) - ), - ]))); - - // List tables for the selected profile - for table in &profile.tables { - // Get table name without year prefix to save space - let display_name = if table.name.starts_with("2025_") { - &table.name[5..] // Skip "2025_" prefix - } else { - &table.name - }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" "), // Indentation - Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)), - Span::styled( - truncate_string(display_name, table_name_available_width), - theme.fg - ), - ]))); - } - } - } else { - // Show full profile tree when no profile is selected (compact version) - for (profile_idx, profile) in profile_tree.profiles.iter().enumerate() { - // Profile header - more compact - items.push(ListItem::new(Line::from(vec![ - Span::styled(format!("{} ", ICON_PROFILE), Style::default().fg(theme.accent)), - Span::styled( - &profile.name, - Style::default().fg(theme.highlight) - ), - ]))); - // Tables with compact prefixes - for (table_idx, table) in profile.tables.iter().enumerate() { - let is_last_table = table_idx == profile.tables.len() - 1; - let is_last_profile = profile_idx == profile_tree.profiles.len() - 1; - - // Shorter prefix characters - let prefix = match (is_last_profile, is_last_table) { - (true, true) => " └", - (true, false) => " ├", - (false, true) => "│└", - (false, false) => "│├", - }; - - // Get table name without year prefix to save space - let display_name = if table.name.starts_with("2025_") { - &table.name[5..] // Skip "2025_" prefix - } else { - &table.name - }; - - // Adjust available width if dependency arrow is shown - let current_table_available_width = if !table.depends_on.is_empty() { - table_name_available_width.saturating_sub(1) - } else { - table_name_available_width - }; - - let line = vec![ - Span::styled(prefix, Style::default().fg(theme.fg)), - Span::styled(format!("{} ", ICON_TABLE), Style::default().fg(theme.secondary)), - Span::styled(truncate_string(display_name, current_table_available_width), Style::default().fg(theme.fg)), - ]; - - items.push(ListItem::new(Line::from(line))); - } - - // Compact separator between profiles - if profile_idx < profile_tree.profiles.len() - 1 { - items.push(ListItem::new(Line::from( - Span::styled("│", Style::default().fg(theme.secondary)) - ))); - } - } - - if profile_tree.profiles.is_empty() { - items.push(ListItem::new(Span::styled( - "No profiles", - Style::default().fg(theme.secondary) - ))); - } - } - - let list = List::new(items) - .block(sidebar_block) - .highlight_style(Style::default().fg(theme.highlight)) - .highlight_symbol(">"); - - f.render_widget(list, area); -} diff --git a/client/src/state/app.rs b/client/src/state/app.rs deleted file mode 100644 index 2551055..0000000 --- a/client/src/state/app.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/state/app.rs - -pub mod state; diff --git a/client/src/state/app/state.rs b/client/src/state/app/state.rs deleted file mode 100644 index da3ca8a..0000000 --- a/client/src/state/app/state.rs +++ /dev/null @@ -1,169 +0,0 @@ -// src/state/app/state.rs - -use anyhow::Result; -use common::proto::komp_ac::table_definition::ProfileTreeResponse; -// NEW: Import the types we need for the cache -use common::proto::komp_ac::table_structure::TableStructureResponse; -use crate::modes::handlers::mode_manager::AppMode; -use crate::search::state::SearchState; -use crate::ui::handlers::context::DialogPurpose; -use crate::config::binds::Config; -use crate::pages::forms::FormState; -use canvas::FormEditor; -use crate::dialog::DialogState; -use std::collections::HashMap; -use std::env; -use std::sync::Arc; -#[cfg(feature = "ui-debug")] -use std::time::Instant; - -pub struct UiState { - pub show_sidebar: bool, - pub show_buffer_list: bool, - pub show_intro: bool, - pub show_admin: bool, - pub show_add_table: bool, - pub show_add_logic: bool, - pub show_form: bool, - pub show_login: bool, - pub show_register: bool, - pub show_search_palette: bool, - pub dialog: DialogState, -} - -#[cfg(feature = "ui-debug")] -#[derive(Debug, Clone)] -pub struct DebugState { - pub displayed_message: String, - pub is_error: bool, - pub display_start_time: Instant, -} - -pub struct AppState { - // Core editor state - pub current_dir: String, - pub profile_tree: ProfileTreeResponse, - pub selected_profile: Option, - pub current_mode: AppMode, - pub current_view_profile_name: Option, - pub current_view_table_name: Option, - - // NEW: The "Rulebook" cache. We use Arc for efficient sharing. - pub schema_cache: HashMap>, - - pub pending_table_structure_fetch: Option<(String, String)>, - - pub search_state: Option, - - // UI preferences - pub ui: UiState, - - pub form_editor: HashMap>, // key = "profile/table" - #[cfg(feature = "ui-debug")] - pub debug_state: Option, -} - -impl AppState { - pub fn new() -> Result { - let current_dir = env::current_dir()?.to_string_lossy().to_string(); - Ok(AppState { - current_dir, - profile_tree: ProfileTreeResponse::default(), - selected_profile: None, - current_view_profile_name: None, - current_view_table_name: None, - current_mode: AppMode::General, - schema_cache: HashMap::new(), // NEW: Initialize the cache - pending_table_structure_fetch: None, - search_state: None, - ui: UiState::default(), - form_editor: HashMap::new(), - - #[cfg(feature = "ui-debug")] - debug_state: None, - }) - } - - // --- ALL YOUR EXISTING METHODS ARE UNTOUCHED --- - - pub fn update_mode(&mut self, mode: AppMode) { - self.current_mode = mode; - } - - pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) { - self.current_view_profile_name = Some(profile_name); - self.current_view_table_name = Some(table_name); - } - - /// Returns true if the current view's editor is in Edit mode. - /// Uses current_view_profile_name/current_view_table_name to build the path. - pub fn is_canvas_edit_mode(&self) -> bool { - if let (Some(profile), Some(table)) = - (self.current_view_profile_name.as_ref(), self.current_view_table_name.as_ref()) - { - let path = format!("{}/{}", profile, table); - if let Some(editor) = self.form_editor.get(&path) { - return matches!(editor.mode(), canvas::AppMode::Edit); - } - } - false - } - - pub fn is_canvas_edit_mode_at(&self, path: &str) -> bool { - self.form_editor - .get(path) - .map(|e| matches!(e.mode(), canvas::AppMode::Edit)) - .unwrap_or(false) - } - - // Mutable editor accessor - pub fn editor_for_path(&mut self, path: &str) -> Option<&mut FormEditor> { - self.form_editor.get_mut(path) - } - - // Mutable FormState accessor - pub fn form_state_for_path(&mut self, path: &str) -> Option<&mut FormState> { - self.form_editor - .get_mut(path) - .map(|e| e.data_provider_mut()) - } - - // Immutable editor accessor - pub fn editor_for_path_ref(&self, path: &str) -> Option<&FormEditor> { - self.form_editor.get(path) - } - - // Immutable FormState accessor - pub fn form_state_for_path_ref(&self, path: &str) -> Option<&FormState> { - self.form_editor.get(path).map(|e| e.data_provider()) - } - - pub fn ensure_form_editor(&mut self, path: &str, config: &Config, loader: F) - where - F: FnOnce() -> FormState, - { - if !self.form_editor.contains_key(path) { - let mut editor = FormEditor::new(loader()); - editor.set_keymap(config.build_canvas_keymap()); - self.form_editor.insert(path.to_string(), editor); - } - } -} - -impl Default for UiState { - fn default() -> Self { - Self { - show_sidebar: false, - show_intro: true, - show_admin: false, - show_add_table: false, - show_add_logic: false, - show_form: false, - show_login: false, - show_register: false, - show_buffer_list: true, - show_search_palette: false, - dialog: DialogState::default(), - } - } -} diff --git a/client/src/state/mod.rs b/client/src/state/mod.rs deleted file mode 100644 index e70cddc..0000000 --- a/client/src/state/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/state/mod.rs -pub mod app; -pub mod pages; diff --git a/client/src/state/pages.rs b/client/src/state/pages.rs deleted file mode 100644 index 548a524..0000000 --- a/client/src/state/pages.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/state/pages.rs - -pub mod auth; diff --git a/client/src/state/pages/auth.rs b/client/src/state/pages/auth.rs deleted file mode 100644 index 3ac3ac2..0000000 --- a/client/src/state/pages/auth.rs +++ /dev/null @@ -1,50 +0,0 @@ -// src/state/pages/auth.rs - -use canvas::{DataProvider, AppMode}; - -/// Strongly typed user roles -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UserRole { - Admin, - Moderator, - Accountant, - Viewer, - Unknown(String), // fallback for unexpected roles -} - -impl UserRole { - pub fn from_str(s: &str) -> Self { - match s.trim().to_lowercase().as_str() { - "admin" => UserRole::Admin, - "moderator" => UserRole::Moderator, - "accountant" => UserRole::Accountant, - "viewer" => UserRole::Viewer, - other => UserRole::Unknown(other.to_string()), - } - } - - pub fn as_str(&self) -> &str { - match self { - UserRole::Admin => "admin", - UserRole::Moderator => "moderator", - UserRole::Accountant => "accountant", - UserRole::Viewer => "viewer", - UserRole::Unknown(s) => s.as_str(), - } - } -} - -/// Represents the authenticated session state -#[derive(Default)] -pub struct AuthState { - pub auth_token: Option, - pub user_id: Option, - pub role: Option, - pub decoded_username: Option, -} - -impl AuthState { - pub fn new() -> Self { - Self::default() - } -} diff --git a/client/src/tui/functions.rs b/client/src/tui/functions.rs deleted file mode 100644 index d75cb82..0000000 --- a/client/src/tui/functions.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/tui/functions.rs - -pub mod common; diff --git a/client/src/tui/functions/common.rs b/client/src/tui/functions/common.rs deleted file mode 100644 index 7786e41..0000000 --- a/client/src/tui/functions/common.rs +++ /dev/null @@ -1,3 +0,0 @@ -// src/tui/functions/common.rs - -pub mod logout; diff --git a/client/src/tui/functions/common/logout.rs b/client/src/tui/functions/common/logout.rs deleted file mode 100644 index ec9cbb1..0000000 --- a/client/src/tui/functions/common/logout.rs +++ /dev/null @@ -1,42 +0,0 @@ -// src/tui/functions/common/logout.rs -use crate::config::storage::delete_auth_data; -use crate::state::pages::auth::AuthState; -use crate::state::app::state::AppState; -use crate::buffer::state::{AppView, BufferState}; -use crate::ui::handlers::context::DialogPurpose; -use tracing::{error, info}; - -pub fn logout( - auth_state: &mut AuthState, - app_state: &mut AppState, - buffer_state: &mut BufferState, -) -> String { - // Clear auth state in memory - auth_state.auth_token = None; - auth_state.user_id = None; - auth_state.role = None; - auth_state.decoded_username = None; - - // Delete stored auth data - if let Err(e) = delete_auth_data() { - error!("Failed to delete stored auth data: {}", e); - } - - // Navigate to intro screen - buffer_state.history = vec![AppView::Intro]; - buffer_state.active_index = 0; - - // Hide any open dialogs - app_state.hide_dialog(); - - // Show logout confirmation dialog - app_state.show_dialog( - "Logged Out", - "You have been successfully logged out.", - vec!["OK".to_string()], - DialogPurpose::LoginSuccess, - ); - - info!("User logged out successfully."); - "Logged out successfully".to_string() -} diff --git a/client/src/tui/mod.rs b/client/src/tui/mod.rs deleted file mode 100644 index 5ee3836..0000000 --- a/client/src/tui/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// src/tui/mod.rs -pub mod terminal; -pub mod functions; - -pub use functions::*; diff --git a/client/src/tui/terminal.rs b/client/src/tui/terminal.rs deleted file mode 100644 index e66b9a6..0000000 --- a/client/src/tui/terminal.rs +++ /dev/null @@ -1,7 +0,0 @@ -// src/tui/terminal.rs - -pub mod core; -pub mod event_reader; - -pub use core::TerminalCore; -pub use event_reader::EventReader; diff --git a/client/src/tui/terminal/core.rs b/client/src/tui/terminal/core.rs deleted file mode 100644 index 8ff76e2..0000000 --- a/client/src/tui/terminal/core.rs +++ /dev/null @@ -1,97 +0,0 @@ -// src/tui/terminal/core.rs - -use crossterm::{ - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - cursor::{SetCursorStyle, EnableBlinking, Show, Hide, MoveTo}, -}; -use crossterm::ExecutableCommand; -use ratatui::{backend::CrosstermBackend, Terminal}; -use std::io::{self, stdout, Write}; -use anyhow::Result; - -pub struct TerminalCore { - terminal: Terminal>, -} - -impl TerminalCore { - pub fn new() -> Result { - enable_raw_mode()?; - let mut stdout = stdout(); - execute!( - stdout, - EnterAlternateScreen, - SetCursorStyle::SteadyBlock, - EnableBlinking - )?; - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - Ok(Self { terminal }) - } - - pub fn draw(&mut self, f: F) -> Result<()> - where - F: FnOnce(&mut ratatui::Frame), - { - self.terminal.draw(f)?; - Ok(()) - } - - pub fn cleanup(&mut self) -> Result<()> { - let backend = self.terminal.backend_mut(); - execute!( - backend, - Show, - SetCursorStyle::DefaultUserShape, - LeaveAlternateScreen - )?; - disable_raw_mode()?; - backend.flush()?; - execute!( - backend, - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - MoveTo(0, 0) - )?; - Ok(()) - } - - pub fn set_cursor_style( - &mut self, - style: SetCursorStyle, - ) -> Result<()> { - execute!( - self.terminal.backend_mut(), - style, - EnableBlinking, - )?; - Ok(()) - } - - pub fn show_cursor(&mut self) -> Result<()> { - execute!( - self.terminal.backend_mut(), - Show - )?; - Ok(()) - } - - pub fn hide_cursor(&mut self) -> Result<()> { - execute!( - self.terminal.backend_mut(), - Hide - )?; - Ok(()) - } - - /// Move the cursor to a specific (x, y) position on screen. - pub fn set_cursor_position(&mut self, x: u16, y: u16) -> io::Result<()> { - self.terminal.backend_mut().execute(MoveTo(x, y))?; - Ok(()) - } -} - -impl Drop for TerminalCore { - fn drop(&mut self) { - let _ = self.cleanup(); - } -} diff --git a/client/src/tui/terminal/event_reader.rs b/client/src/tui/terminal/event_reader.rs deleted file mode 100644 index 6e6c676..0000000 --- a/client/src/tui/terminal/event_reader.rs +++ /dev/null @@ -1,16 +0,0 @@ -// src/tui/terminal/event_reader.rs - -use crossterm::event::{self, Event}; -use anyhow::Result; - -pub struct EventReader; - -impl EventReader { - pub fn new() -> Self { - Self - } - - pub fn read_event(&self) -> Result { - Ok(event::read()?) - } -} diff --git a/client/src/ui/docs/ui_redraws.md b/client/src/ui/docs/ui_redraws.md deleted file mode 100644 index 4d3628b..0000000 --- a/client/src/ui/docs/ui_redraws.md +++ /dev/null @@ -1,34 +0,0 @@ -# UI Redraw Logic (`needs_redraw` Flag) - -## Problem - -The main UI loop in `client/src/ui/handlers/ui.rs` uses `crossterm_event::poll` with a short timeout to remain responsive to both user input and asynchronous operations (like login results arriving via channels). However, calling `terminal.draw()` unconditionally in every loop iteration caused constant UI refreshes and high CPU usage, even when idle. - -## Solution - -A boolean flag, `needs_redraw`, was introduced in the main loop scope to control when the UI is actually redrawn. - -## Mechanism - -1. **Initialization:** `needs_redraw` is initialized to `true` before the loop starts to ensure the initial UI state is drawn. -2. **Conditional Drawing:** The `terminal.draw(...)` call is wrapped in an `if needs_redraw { ... }` block. -3. **Flag Reset:** Immediately after a successful `terminal.draw(...)` call, `needs_redraw` is set back to `false`. -4. **Triggering Redraws:** The `needs_redraw` flag is explicitly set to `true` only when a redraw is actually required. - -## When `needs_redraw` Must Be Set to `true` - -To ensure the UI stays up-to-date without unnecessary refreshes, `needs_redraw = true;` **must** be set in the following situations: - -1. **After Handling User Input:** When `crossterm_event::poll` returns `true`, indicating a keyboard/mouse event was received and processed by `event_handler.handle_event`. -2. **During Active Loading States:** If an asynchronous operation is in progress and a visual indicator (like a loading dialog) is active (e.g., checking `if app_state.ui.dialog.is_loading`). This keeps the loading state visible while waiting for the result. -3. **After Processing Async Results:** When the result of an asynchronous operation (e.g., received from an `mpsc::channel` like `login_result_receiver`) is processed and the application state is updated (e.g., dialog content changed, data updated). -4. **After Internal State Changes:** If any logic *outside* the direct event handling block modifies state that needs to be visually reflected (e.g., the position change logic loading new data into the form). - -## Rationale - -This approach balances UI responsiveness for asynchronous tasks and user input with CPU efficiency by avoiding redraws when the application state is static. - -## Maintenance Note - -When adding new asynchronous operations or internal logic that modifies UI-relevant state outside the main event handler, developers **must remember** to set `needs_redraw = true` at the appropriate point after the state change to ensure the UI updates correctly. Failure to do so can result in a stale UI. - diff --git a/client/src/ui/handlers.rs b/client/src/ui/handlers.rs deleted file mode 100644 index 4121a2f..0000000 --- a/client/src/ui/handlers.rs +++ /dev/null @@ -1,8 +0,0 @@ -// src/client/ui/handlers.rs - -pub mod ui; -pub mod render; -pub mod context; - -pub use ui::run_ui; -pub use context::*; diff --git a/client/src/ui/handlers/context.rs b/client/src/ui/handlers/context.rs deleted file mode 100644 index 2ad95da..0000000 --- a/client/src/ui/handlers/context.rs +++ /dev/null @@ -1,23 +0,0 @@ -// src/ui/handlers/context.rs - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UiContext { - Intro, - Login, - Register, - Admin, - Dialog, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DialogPurpose { - LoginSuccess, - LoginFailed, - RegisterSuccess, - RegisterFailed, - ConfirmDeleteColumns, - SaveTableSuccess, - SaveLogicSuccess, - // TODO in the future: - // ConfirmQuit, -} diff --git a/client/src/ui/handlers/render.rs b/client/src/ui/handlers/render.rs deleted file mode 100644 index 5e21805..0000000 --- a/client/src/ui/handlers/render.rs +++ /dev/null @@ -1,204 +0,0 @@ -// src/ui/handlers/render.rs - -use crate::components::render_background; -use crate::pages::admin_panel::add_logic::ui::render_add_logic; -use crate::pages::admin_panel::add_table::ui::render_add_table; -use crate::pages::login::render_login; -use crate::pages::register::render_register; -use crate::pages::intro::render_intro; -use crate::bottom_panel::{ - command_line::render_command_line, - status_line::render_status_line, - find_file_palette, -}; -use crate::sidebar::{calculate_sidebar_layout, render_sidebar}; -use crate::buffer::render_buffer_list; -use crate::search::render_search_palette; -use crate::config::colors::themes::Theme; -use crate::modes::general::command_navigation::NavigationState; -use crate::buffer::state::BufferState; -use crate::state::app::state::AppState; -use crate::state::pages::auth::AuthState; -use crate::bottom_panel::layout::{bottom_panel_constraints, render_bottom_panel}; -use canvas::FormEditor; -use ratatui::{ - layout::{Constraint, Direction, Layout}, - Frame, -}; -use crate::pages::routing::{Router, Page}; -use crate::dialog::render_dialog; -use crate::pages::forms::render_form_page; - -pub fn render_ui( - f: &mut Frame, - router: &mut Router, - buffer_state: &BufferState, - theme: &Theme, - event_handler_command_input: &str, - event_handler_command_mode_active: bool, - event_handler_command_message: &str, - navigation_state: &NavigationState, - current_dir: &str, - current_fps: f64, - app_state: &AppState, - auth_state: &AuthState, -) { - render_background(f, f.area(), theme); - - // Layout: optional buffer list + main content + bottom panel - 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_panel_constraints( - app_state, - navigation_state, - event_handler_command_mode_active, - )); - - let root_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(main_layout_constraints) - .split(f.area()); - - let mut chunk_idx = 0; - let buffer_list_area = if app_state.ui.show_buffer_list { - let area = Some(root_chunks[chunk_idx]); - chunk_idx += 1; - area - } else { - None - }; - - let main_content_area = root_chunks[chunk_idx]; - chunk_idx += 1; - - // Page rendering is now fully router-driven - match &mut router.current { - Page::Intro(state) => render_intro(f, state, main_content_area, theme), - Page::Login(page) => render_login( - f, - main_content_area, - theme, - page, - app_state, - ), - Page::Register(state) => render_register( - f, - main_content_area, - theme, - state, - app_state, - ), - Page::Admin(state) => crate::pages::admin::main::ui::render_admin_panel( - f, - app_state, - auth_state, - state, - main_content_area, - theme, - &app_state.profile_tree, - &app_state.selected_profile, - ), - Page::AddLogic(state) => render_add_logic( - f, - main_content_area, - theme, - app_state, - state, - ), - Page::AddTable(state) => render_add_table( - f, - main_content_area, - theme, - app_state, - state, - ), - Page::Form(path) => { - let (sidebar_area, form_actual_area) = - calculate_sidebar_layout(app_state.ui.show_sidebar, main_content_area); - if let Some(sidebar_rect) = sidebar_area { - render_sidebar( - f, - sidebar_rect, - theme, - &app_state.profile_tree, - &app_state.selected_profile, - ); - } - let available_width = form_actual_area.width; - let form_render_area = if available_width >= 80 { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)]) - .split(form_actual_area)[1] - } else { - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(0), - Constraint::Length(available_width), - Constraint::Min(0), - ]) - .split(form_actual_area)[1] - }; - - if let Some(editor) = app_state.editor_for_path_ref(path) { - let (total_count, current_position) = - if let Some(fs) = app_state.form_state_for_path_ref(path) { - (fs.total_count, fs.current_position) - } else { - (0, 1) - }; - let table_name = path.split('/').nth(1).unwrap_or(""); - - render_form_page( - f, - form_render_area, - editor, - table_name, - theme, - total_count, - current_position, - ); - } - } - } - - // Global overlays (not tied to a page) - if let Some(area) = buffer_list_area { - render_buffer_list(f, area, theme, buffer_state, app_state); - } - - if app_state.ui.show_search_palette { - if let Some(search_state) = &app_state.search_state { - render_search_palette(f, f.area(), theme, search_state); - } - } else if app_state.ui.dialog.dialog_show { - render_dialog( - f, - f.area(), - theme, - &app_state.ui.dialog.dialog_title, - &app_state.ui.dialog.dialog_message, - &app_state.ui.dialog.dialog_buttons, - app_state.ui.dialog.dialog_active_button_index, - app_state.ui.dialog.is_loading, - ); - } - - render_bottom_panel( - f, - &root_chunks, - &mut chunk_idx, - current_dir, - theme, - current_fps, - app_state, - router, - navigation_state, - event_handler_command_input, - event_handler_command_mode_active, - event_handler_command_message, - ); -} diff --git a/client/src/ui/handlers/ui.rs b/client/src/ui/handlers/ui.rs deleted file mode 100644 index bdedbbc..0000000 --- a/client/src/ui/handlers/ui.rs +++ /dev/null @@ -1,758 +0,0 @@ -// src/ui/handlers/ui.rs - -use crate::config::binds::config::Config; -use crate::config::colors::themes::Theme; -use crate::services::grpc_client::GrpcClient; -use crate::services::ui_service::UiService; -use crate::config::storage::storage::load_auth_data; -use crate::modes::common::commands::CommandHandler; -use crate::modes::handlers::event::{EventHandler, EventOutcome}; -use crate::modes::handlers::mode_manager::{AppMode, ModeManager}; -use crate::state::pages::auth::AuthState; -use crate::state::pages::auth::UserRole; -use crate::pages::login::LoginFormState; -use crate::pages::register::RegisterFormState; -use crate::pages::admin_panel::add_table; -use crate::pages::admin_panel::add_logic; -use crate::pages::admin::AdminState; -use crate::pages::admin::AdminFocus; -use crate::pages::admin::admin; -use crate::pages::intro::IntroState; -use crate::pages::forms::{FormState, FieldDefinition}; -use crate::pages::forms; -use crate::pages::routing::{Router, Page}; -use crate::buffer::state::BufferState; -use crate::buffer::state::AppView; -use crate::state::app::state::AppState; -use crate::tui::terminal::{EventReader, TerminalCore}; -use crate::ui::handlers::render::render_ui; -use crate::input::leader::leader_has_any_start; -use crate::pages::login; -use crate::pages::register; -use crate::pages::login::LoginResult; -use crate::pages::login::LoginState; -use crate::pages::register::RegisterResult; -use crate::ui::handlers::context::DialogPurpose; -use crate::utils::columns::filter_user_columns; -use canvas::keymap::KeyEventOutcome; -use canvas::CursorManager; -use canvas::FormEditor; -use anyhow::{Context, Result}; -use crossterm::cursor::{SetCursorStyle, MoveTo}; -use crossterm::event as crossterm_event; -use crossterm::ExecutableCommand; -use tracing::{error, info, warn}; -use tokio::sync::mpsc; -use std::time::Instant; -use std::time::Duration; -#[cfg(feature = "ui-debug")] -use crate::state::app::state::DebugState; -#[cfg(feature = "ui-debug")] -use crate::utils::debug_logger::pop_next_debug_message; - -// Rest of the file remains the same... -pub async fn run_ui() -> Result<()> { - let config = Config::load().context("Failed to load configuration")?; - let theme = Theme::from_str(&config.colors.theme); - let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?; - let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?; - let mut command_handler = CommandHandler::new(); - - let (login_result_sender, mut login_result_receiver) = mpsc::channel::(1); - let (register_result_sender, mut register_result_receiver) = mpsc::channel::(1); - let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::>(1); - let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::>(1); - - let mut event_handler = EventHandler::new( - login_result_sender.clone(), - register_result_sender.clone(), - save_table_result_sender.clone(), - save_logic_result_sender.clone(), - grpc_client.clone(), - ) - .await - .context("Failed to create event handler")?; - let event_reader = EventReader::new(); - - let mut auth_state = AuthState::default(); - let mut login_state = LoginFormState::new(); - login_state.editor.set_keymap(config.build_canvas_keymap()); - let mut register_state = RegisterFormState::default(); - register_state.editor.set_keymap(config.build_canvas_keymap()); - let mut intro_state = IntroState::default(); - let mut admin_state = AdminState::default(); - let mut router = Router::new(); - let mut buffer_state = BufferState::default(); - let mut app_state = AppState::new().context("Failed to create initial app state")?; - - let mut auto_logged_in = false; - match load_auth_data() { - Ok(Some(stored_data)) => { - auth_state.auth_token = Some(stored_data.access_token); - auth_state.user_id = Some(stored_data.user_id); - auth_state.role = Some(UserRole::from_str(&stored_data.role)); - auth_state.decoded_username = Some(stored_data.username); - auto_logged_in = true; - info!("Auth data loaded from file. User is auto-logged in."); - } - Ok(None) => { - info!("No stored auth data found. User will see intro/login."); - } - Err(e) => { - error!("Failed to load auth data: {}", e); - } - } - - let (initial_profile, initial_table, initial_columns_from_service) = - UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state) - .await - .context("Failed to initialize app state and form")?; - - let initial_field_defs: Vec = filter_user_columns(initial_columns_from_service) - .into_iter() - .map(|col_name| FieldDefinition { - display_name: col_name.clone(), - data_key: col_name, - is_link: false, - link_target_table: None, - }) - .collect(); - - // Replace local form_state with app_state.form_editor - let path = format!("{}/{}", initial_profile, initial_table); - app_state.ensure_form_editor(&path, &config, || { - FormState::new(initial_profile.clone(), initial_table.clone(), initial_field_defs) - }); - #[cfg(feature = "validation")] - UiService::apply_validation1_for_form(&mut grpc_client, &mut app_state, &path) - .await - .ok(); - buffer_state.update_history(AppView::Form(path.clone())); - router.navigate(Page::Form(path.clone())); - - // Fetch initial count using app_state accessor - if let Some(form_state) = app_state.form_state_for_path(&path) { - UiService::fetch_and_set_table_count(&mut grpc_client, form_state) - .await - .context(format!( - "Failed to fetch initial count for table {}.{}", - initial_profile, initial_table - ))?; - - if form_state.total_count > 0 { - if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, form_state).await { - event_handler.command_message = format!("Error loading initial data: {}", e); - } - } else { - form_state.reset_to_empty(); - } - } - - if auto_logged_in { - let path = format!("{}/{}", initial_profile, initial_table); - buffer_state.history = vec![AppView::Form(path.clone())]; - router.navigate(Page::Form(path)); - buffer_state.active_index = 0; - info!("Initial view set to Form due to auto-login."); - } - - let mut last_frame_time = Instant::now(); - let mut current_fps = 0.0; - 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(); - let mut table_just_switched = false; - - loop { - let position_before_event = if let Page::Form(path) = &router.current { - app_state.form_state_for_path(path).map(|fs| fs.current_position).unwrap_or(1) - } else { - 1 - }; - let mut event_processed = false; - - // --- CHANNEL RECEIVERS --- - - // For main search palette - match event_handler.search_result_receiver.try_recv() { - Ok(hits) => { - info!("--- 4. Main loop received message from channel. ---"); - if let Some(search_state) = app_state.search_state.as_mut() { - search_state.results = hits; - search_state.is_loading = false; - } - needs_redraw = true; - } - Err(mpsc::error::TryRecvError::Empty) => { - } - Err(mpsc::error::TryRecvError::Disconnected) => { - error!("Search result channel disconnected!"); - } - } - - // --- ADDED: For live form autocomplete --- - match event_handler.autocomplete_result_receiver.try_recv() { - Ok(hits) => { - if let Page::Form(path) = &router.current { - if let Some(form_state) = app_state.form_state_for_path(path) { - if form_state.autocomplete_active { - form_state.autocomplete_suggestions = hits; - form_state.autocomplete_loading = false; - if !form_state.autocomplete_suggestions.is_empty() { - form_state.selected_suggestion_index = Some(0); - } else { - form_state.selected_suggestion_index = None; - } - event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len()); - } - } - } - needs_redraw = true; - } - Err(mpsc::error::TryRecvError::Empty) => {} - Err(mpsc::error::TryRecvError::Disconnected) => { - error!("Autocomplete result channel disconnected!"); - } - } - - if app_state.ui.show_search_palette { - needs_redraw = true; - } - if crossterm_event::poll(std::time::Duration::from_millis(1))? { - let event = event_reader.read_event().context("Failed to read terminal event")?; - event_processed = true; - - // Decouple Command Line and palettes from canvas: - // Only forward keys to Form canvas when: - // - not in command mode - // - no search/palette active - // - focus is inside the canvas - if let crossterm_event::Event::Key(key_event) = &event { - let overlay_active = event_handler.command_mode - || app_state.ui.show_search_palette - || event_handler.navigation_state.active; - if !overlay_active { - let inside_canvas = match &router.current { - Page::Form(_) => true, - Page::Login(state) => !state.focus_outside_canvas, - Page::Register(state) => !state.focus_outside_canvas, - Page::AddTable(state) => !state.focus_outside_canvas, - Page::AddLogic(state) => !state.focus_outside_canvas, - _ => false, - }; - - if inside_canvas { - // Do NOT forward to canvas while a leader is active or about to start. - // This prevents the canvas from stealing the second/third key (b/d/r). - let leader_in_progress = event_handler.input_engine.has_active_sequence(); - let is_space = matches!(key_event.code, crossterm_event::KeyCode::Char(' ')); - let can_start_leader = leader_has_any_start(&config); - let form_in_edit_mode = match &router.current { - Page::Form(path) => app_state - .editor_for_path_ref(path) - .map(|e| e.mode() == canvas::AppMode::Edit) - .unwrap_or(false), - _ => false, - }; - - let defer_to_engine_for_leader = leader_in_progress - || (is_space && can_start_leader && !form_in_edit_mode); - - if defer_to_engine_for_leader { - info!( - "Skipping canvas pre-handle: leader sequence active or starting" - ); - } else { - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path(path) { - match editor.handle_key_event(*key_event) { - KeyEventOutcome::Consumed(Some(msg)) => { - event_handler.command_message = msg; - needs_redraw = true; - continue; - } - KeyEventOutcome::Consumed(None) => { - needs_redraw = true; - continue; - } - KeyEventOutcome::Pending => { - needs_redraw = true; - continue; - } - KeyEventOutcome::NotMatched => { - // fall through to client-level handling - } - } - } - } - } - } - } - } - - // Call handle_event directly - let event_outcome_result = event_handler.handle_event( - event, - &config, - &mut terminal, - &mut command_handler, - &mut auth_state, - &mut buffer_state, - &mut app_state, - &mut router, - ).await; - let mut should_exit = false; - match event_outcome_result { - Ok(outcome) => match outcome { - EventOutcome::Ok(message) => { - if !message.is_empty() { - event_handler.command_message = message; - } - } - EventOutcome::Exit(message) => { - event_handler.command_message = message; - should_exit = true; - } - EventOutcome::DataSaved(save_outcome, message) => { - event_handler.command_message = message; - if let Page::Form(path) = &router.current { - if let Some(mut temp_form_state) = app_state.form_state_for_path(path).cloned() { - if let Err(e) = UiService::handle_save_outcome( - save_outcome, - &mut grpc_client, - &mut app_state, - &mut temp_form_state, - ).await { - event_handler.command_message = - format!("Error handling save outcome: {}", e); - } - // Update app_state with changes - if let Some(form_state) = app_state.form_state_for_path(path) { - *form_state = temp_form_state; - } - } - } - } - EventOutcome::ButtonSelected { .. } => {} - EventOutcome::TableSelected { path } => { - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() == 2 { - let profile_name = parts[0].to_string(); - let table_name = parts[1].to_string(); - - app_state.set_current_view_table(profile_name, table_name); - buffer_state.update_history(AppView::Form(path.clone())); - event_handler.command_message = format!("Loading table: {}", path); - } else { - event_handler.command_message = format!("Invalid table path: {}", path); - } - } - }, - Err(e) => { - event_handler.command_message = format!("Error: {}", e); - } - } - if should_exit { - return Ok(()); - } - } - - match login_result_receiver.try_recv() { - Ok(result) => { - // Apply result to the active router Login page if present, - // otherwise update the local copy. - let updated = if let Page::Login(page) = &mut router.current { - login::handle_login_result( - result, - &mut app_state, - &mut auth_state, - page, - ) - } else { - login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) - }; - if updated { needs_redraw = true; } - } - Err(mpsc::error::TryRecvError::Empty) => {} - Err(mpsc::error::TryRecvError::Disconnected) => { - error!("Login result channel disconnected unexpectedly."); - } - } - - match register_result_receiver.try_recv() { - Ok(result) => { - if register::handle_registration_result(result, &mut app_state, &mut register_state) { - needs_redraw = true; - } - } - Err(mpsc::error::TryRecvError::Empty) => {} - Err(mpsc::error::TryRecvError::Disconnected) => { - error!("Register result channel disconnected unexpectedly."); - } - } - - match save_table_result_receiver.try_recv() { - Ok(result) => { - app_state.hide_dialog(); - match result { - Ok(ref success_message) => { - app_state.show_dialog( - "Save Successful", - success_message, - vec!["OK".to_string()], - DialogPurpose::SaveTableSuccess, - ); - if let Page::AddTable(page) = &mut router.current { - page.state.has_unsaved_changes = false; - } - } - Err(e) => { - event_handler.command_message = format!("Save failed: {}", e); - } - } - needs_redraw = true; - } - Err(mpsc::error::TryRecvError::Empty) => {} - Err(mpsc::error::TryRecvError::Disconnected) => { - error!("Save table result channel disconnected unexpectedly."); - } - } - - if let Some(active_view) = buffer_state.get_active_view() { - match active_view { - AppView::Intro => { - // Keep external intro_state in sync with the live Router state - if let Page::Intro(current) = &router.current { - intro_state = current.clone(); - } - // Navigate with the up-to-date state - router.navigate(Page::Intro(intro_state.clone())); - } - AppView::Login => { - // Do not re-create the page every frame. If we're already on Login, - // keep it. If we just switched into Login, create it once and - // inject the keymap. - if let Page::Login(_) = &router.current { - // Already on login page; keep existing state - } else { - let mut page = LoginFormState::new(); - page.editor.set_keymap(config.build_canvas_keymap()); - router.navigate(Page::Login(page)); - } - } - AppView::Register => { - if let Page::Register(_) = &router.current { - // already on register page - } else { - let mut page = RegisterFormState::new(); - page.editor.set_keymap(config.build_canvas_keymap()); - router.navigate(Page::Register(page)); - } - } - AppView::Admin => { - if let Page::Admin(current) = &router.current { - admin_state = current.clone(); - } - info!("Auth role at render: {:?}", auth_state.role); - - // Use the admin loader instead of inline logic - if let Err(e) = admin::loader::refresh_admin_state(&mut grpc_client, &mut app_state, &mut admin_state).await { - error!("Failed to refresh admin state: {}", e); - event_handler.command_message = format!("Error refreshing admin data: {}", e); - } - - router.navigate(Page::Admin(admin_state.clone())); - } - AppView::AddTable => { - if let Page::AddTable(page) = &mut router.current { - // Ensure keymap is set once (same as AddLogic) - page.editor.set_keymap(config.build_canvas_keymap()); - } else { - // Page is created by admin navigation (Button2). No-op here. - } - } - AppView::AddLogic => { - if let Page::AddLogic(page) = &mut router.current { - // Ensure keymap is set once - page.editor.set_keymap(config.build_canvas_keymap()); - } - } - AppView::Form(path) => { - // Keep current_view_* consistent with the active buffer path - if let Some((profile, table)) = path.split_once('/') { - app_state.set_current_view_table( - profile.to_string(), - table.to_string(), - ); - } - router.navigate(Page::Form(path.clone())); - } - AppView::Scratch => {} - } - } - - if let Page::Form(_current_path) = &router.current { - 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; - - // DELEGATE to the forms loader - match forms::loader::ensure_form_loaded_and_count( - &mut grpc_client, - &mut app_state, - &config, - prof_name, - tbl_name, - ).await { - Ok(()) => { - app_state.hide_dialog(); - prev_view_profile_name = current_view_profile; - prev_view_table_name = current_view_table; - table_just_switched = true; - // Apply character-limit validation for the new form - #[cfg(feature = "validation")] - if let (Some(prof), Some(tbl)) = ( - app_state.current_view_profile_name.as_ref(), - app_state.current_view_table_name.as_ref(), - ) { - let p = format!("{}/{}", prof, tbl); - UiService::apply_validation1_for_form( - &mut grpc_client, - &mut app_state, - &p, - ) - .await - .ok(); - } - } - Err(e) => { - app_state.update_dialog_content( - &format!("Error loading table: {}", e), - vec!["OK".to_string()], - DialogPurpose::LoginFailed, - ); - // Reset to previous state on error - app_state.current_view_profile_name = prev_view_profile_name.clone(); - app_state.current_view_table_name = prev_view_table_name.clone(); - } - } - } - needs_redraw = true; - } - } - - let needs_redraw_from_fetch = add_logic::loader::process_pending_table_structure_fetch( - &mut app_state, - &mut router, - &mut grpc_client, - &mut event_handler.command_message, - ).await.unwrap_or(false); - - if needs_redraw_from_fetch { - needs_redraw = true; - } - - if let Page::AddLogic(state) = &mut router.current { - let needs_redraw_from_columns = add_logic::loader::maybe_fetch_columns_for_awaiting_table( - &mut grpc_client, - state, - &mut event_handler.command_message, - ).await.unwrap_or(false); - - if needs_redraw_from_columns { - needs_redraw = true; - } - } - - let current_position = if let Page::Form(path) = &router.current { - app_state.form_state_for_path(path).map(|fs| fs.current_position).unwrap_or(1) - } else { - 1 - }; - let position_changed = current_position != position_before_event; - let mut position_logic_needs_redraw = false; - - if let Page::Form(path) = &router.current { - if !table_just_switched { - if position_changed && !app_state.is_canvas_edit_mode_at(path) { - position_logic_needs_redraw = true; - - if let Some(form_state) = app_state.form_state_for_path(path) { - if form_state.current_position > form_state.total_count { - form_state.reset_to_empty(); - event_handler.command_message = format!( - "New entry for {}.{}", - form_state.profile_name, - form_state.table_name - ); - } else { - match UiService::load_table_data_by_position(&mut grpc_client, form_state).await { - Ok(load_message) => { - if event_handler.command_message.is_empty() - || !load_message.starts_with("Error") - { - event_handler.command_message = load_message; - } - } - Err(e) => { - event_handler.command_message = - format!("Error loading data: {}", e); - } - } - } - - 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 = if current_input_len_after_load > 0 { - current_input_len_after_load.saturating_sub(1) - } else { - 0 - }; - form_state.current_cursor_pos = - event_handler.ideal_cursor_column.min(max_cursor_pos); - } - } else if !position_changed && !app_state.is_canvas_edit_mode_at(path) { - if let Some(form_state) = app_state.form_state_for_path(path) { - 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); - } - } - } - } else if let Page::Register(state) = &mut router.current { - if !app_state.is_canvas_edit_mode() { - let current_input = state.get_current_input(); - let max_cursor_pos = - if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; - state.set_current_cursor_pos(event_handler.ideal_cursor_column.min(max_cursor_pos)); - } - } else if let Page::Login(state) = &mut router.current { - if !app_state.is_canvas_edit_mode() { - let current_input = state.get_current_input(); - let max_cursor_pos = - if !current_input.is_empty() { current_input.len() - 1 } else { 0 }; - state.set_current_cursor_pos(event_handler.ideal_cursor_column.min(max_cursor_pos)); - } - } - - if position_logic_needs_redraw { - needs_redraw = true; - } - - if app_state.ui.dialog.is_loading { - needs_redraw = true; - } - - #[cfg(feature = "ui-debug")] - { - let can_display_next = match &app_state.debug_state { - Some(current) => current.display_start_time.elapsed() >= Duration::from_secs(2), - None => true, - }; - - if can_display_next { - if let Some((new_message, is_error)) = pop_next_debug_message() { - app_state.debug_state = Some(DebugState { - displayed_message: new_message, - is_error, - display_start_time: Instant::now(), - }); - } - } - } - - if event_processed || needs_redraw || position_changed { - let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &router); - - match current_mode { - AppMode::General => { - let outside_canvas = match &router.current { - Page::Login(state) => state.focus_outside_canvas, - Page::Register(state) => state.focus_outside_canvas, - Page::AddTable(state) => state.focus_outside_canvas, - Page::AddLogic(state) => state.focus_outside_canvas, - _ => false, // Form and Admin don’t use this flag - }; - - if outside_canvas { - // Outside canvas → app decides - terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; - terminal.show_cursor()?; - } else { - // Inside canvas → let canvas handle it - if let Page::Form(path) = &router.current { - if let Some(editor) = app_state.editor_for_path(path) { - let _ = CursorManager::update_for_mode(editor.mode()); - } - } - if let Page::Login(page) = &router.current { - let _ = CursorManager::update_for_mode(page.editor.mode()); - } - if let Page::Register(page) = &router.current { - let _ = CursorManager::update_for_mode(page.editor.mode()); - } - if let Page::AddTable(page) = &router.current { - let _ = CursorManager::update_for_mode(page.editor.mode()); - } - if let Page::AddLogic(page) = &router.current { - let _ = CursorManager::update_for_mode(page.editor.mode()); - } - } - } - AppMode::Command => { - // Command line overlay → app decides - terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; - terminal.show_cursor()?; - } - } - - let current_dir = app_state.current_dir.clone(); - terminal - .draw(|f| { - render_ui( - f, - &mut router, - &buffer_state, - &theme, - &event_handler.command_input, - event_handler.command_mode, - &event_handler.command_message, - &event_handler.navigation_state, - ¤t_dir, - current_fps, - &app_state, - &auth_state, - ); - }) - .context("Terminal draw call failed")?; - needs_redraw = false; - } - - let now = Instant::now(); - let frame_duration = now.duration_since(last_frame_time); - last_frame_time = now; - if frame_duration.as_secs_f64() > 1e-6 { - current_fps = 1.0 / frame_duration.as_secs_f64(); - } - - table_just_switched = false; - } -} diff --git a/client/src/ui/mod.rs b/client/src/ui/mod.rs deleted file mode 100644 index d548bc7..0000000 --- a/client/src/ui/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// src/client/ui/mod.rs -pub mod models; -pub mod handlers; - -pub use handlers::*; diff --git a/client/src/ui/models.rs b/client/src/ui/models.rs deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/utils/columns.rs b/client/src/utils/columns.rs deleted file mode 100644 index 72989a3..0000000 --- a/client/src/utils/columns.rs +++ /dev/null @@ -1,14 +0,0 @@ -// src/utils/columns.rs -pub fn is_system_column(column_name: &str) -> bool { - match column_name { - "id" | "deleted" | "created_at" => true, - name if name.ends_with("_id") => true, - _ => false, - } -} - -pub fn filter_user_columns(all_columns: Vec) -> Vec { - all_columns.into_iter() - .filter(|col| !is_system_column(col)) - .collect() -} diff --git a/client/src/utils/data_converter.rs b/client/src/utils/data_converter.rs deleted file mode 100644 index 0f51078..0000000 --- a/client/src/utils/data_converter.rs +++ /dev/null @@ -1,50 +0,0 @@ -// src/utils/data_converter.rs - -use common::proto::komp_ac::table_structure::TableStructureResponse; -use prost_types::{value::Kind, NullValue, Value}; -use std::collections::HashMap; - -pub fn convert_and_validate_data( - data: &HashMap, - schema: &TableStructureResponse, -) -> Result, String> { - let type_map: HashMap<_, _> = schema - .columns - .iter() - .map(|col| (col.name.as_str(), col.data_type.as_str())) - .collect(); - - data.iter() - .map(|(key, str_value)| { - let expected_type = type_map.get(key.as_str()).unwrap_or(&"TEXT"); - - let kind = if str_value.is_empty() { - // TODO: Use the correct enum variant - Kind::NullValue(NullValue::NullValue.into()) - } else { - // Attempt to parse the string based on the expected type - match *expected_type { - "BOOL" => match str_value.to_lowercase().parse::() { - Ok(v) => Kind::BoolValue(v), - Err(_) => return Err(format!("Invalid boolean for '{}': must be 'true' or 'false'", key)), - }, - "INT8" | "INT4" | "INT2" | "SERIAL" | "BIGSERIAL" => { - match str_value.parse::() { - Ok(v) => Kind::NumberValue(v), - Err(_) => return Err(format!("Invalid number for '{}': must be a whole number", key)), - } - } - "NUMERIC" | "FLOAT4" | "FLOAT8" => match str_value.parse::() { - Ok(v) => Kind::NumberValue(v), - Err(_) => return Err(format!("Invalid decimal for '{}': must be a number", key)), - }, - "TIMESTAMPTZ" | "DATE" | "TIME" | "TEXT" | "VARCHAR" | "UUID" => { - Kind::StringValue(str_value.clone()) - } - _ => Kind::StringValue(str_value.clone()), - } - }; - Ok((key.clone(), Value { kind: Some(kind) })) - }) - .collect() -} diff --git a/client/src/utils/debug_logger.rs b/client/src/utils/debug_logger.rs deleted file mode 100644 index 5d96d18..0000000 --- a/client/src/utils/debug_logger.rs +++ /dev/null @@ -1,78 +0,0 @@ -// client/src/utils/debug_logger.rs -use lazy_static::lazy_static; -use std::collections::VecDeque; // <-- FIX: Import VecDeque -use std::io::{self, Write}; -use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex -use std::fs::OpenOptions; -use std::thread; -use std::time::Duration; - -lazy_static! { - static ref UI_DEBUG_BUFFER: Arc>> = - Arc::new(Mutex::new(VecDeque::from([(String::from("Logger initialized..."), false)]))); -} - -#[derive(Clone)] -pub struct UiDebugWriter; - -impl Default for UiDebugWriter { - fn default() -> Self { - Self::new() - } -} - -impl UiDebugWriter { - pub fn new() -> Self { - Self - } -} - -impl io::Write for UiDebugWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - let mut buffer = UI_DEBUG_BUFFER.lock().unwrap(); - let message = String::from_utf8_lossy(buf).trim().to_string(); - let is_error = message.starts_with("ERROR"); - - // Keep in memory for UI - buffer.push_back((message.clone(), is_error)); - - // ALSO log directly to file (non-blocking best effort) - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open("ui_debug.log") - { - let _ = writeln!(file, "{message}"); - } - - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -// A public function to pop the next message from the front of the queue. -pub fn pop_next_debug_message() -> Option<(String, bool)> { - UI_DEBUG_BUFFER.lock().unwrap().pop_front() -} - -/// spawn a background thread that keeps draining UI_DEBUG_BUFFER -/// and writes messages into ui_debug.log continuously -pub fn spawn_file_logger() { - thread::spawn(|| loop { - // pop one message if present - if let Some((msg, _)) = pop_next_debug_message() { - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open("ui_debug.log") - { - let _ = writeln!(file, "{msg}"); - } - } - // small sleep to avoid burning CPU - thread::sleep(Duration::from_millis(50)); - }); -} diff --git a/client/src/utils/mod.rs b/client/src/utils/mod.rs deleted file mode 100644 index 9b22312..0000000 --- a/client/src/utils/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// src/utils/mod.rs - -pub mod columns; -pub mod debug_logger; -pub mod data_converter; - -pub use columns::*; -pub use debug_logger::*; -pub use data_converter::*; diff --git a/client/tests/form/gui/form_tests.rs b/client/tests/form/gui/form_tests.rs deleted file mode 100644 index 03e2383..0000000 --- a/client/tests/form/gui/form_tests.rs +++ /dev/null @@ -1,262 +0,0 @@ -// client/tests/form_tests.rs -use rstest::{fixture, rstest}; -use std::collections::HashMap; -use client::state::pages::form::{FormState, FieldDefinition}; -use canvas::canvas::CanvasState; - -#[fixture] -fn test_form_state() -> FormState { - let fields = vec![ - FieldDefinition { - display_name: "Company".to_string(), - data_key: "firma".to_string(), - is_link: false, - link_target_table: None, - }, - FieldDefinition { - display_name: "Phone".to_string(), - data_key: "telefon".to_string(), - is_link: false, - link_target_table: None, - }, - FieldDefinition { - display_name: "Email".to_string(), - data_key: "email".to_string(), - is_link: false, - link_target_table: None, - }, - ]; - - FormState::new("test_profile".to_string(), "test_table".to_string(), fields) -} - -#[fixture] -fn test_form_data() -> HashMap { - let mut data = HashMap::new(); - data.insert("firma".to_string(), "Test Company".to_string()); - data.insert("telefon".to_string(), "+421123456789".to_string()); - data.insert("email".to_string(), "test@example.com".to_string()); - data -} - -#[rstest] -fn test_form_state_creation(test_form_state: FormState) { - assert_eq!(test_form_state.profile_name, "test_profile"); - assert_eq!(test_form_state.table_name, "test_table"); - assert_eq!(test_form_state.fields.len(), 3); - assert_eq!(test_form_state.current_field(), 0); - assert!(!test_form_state.has_unsaved_changes()); -} - -#[rstest] -fn test_form_field_navigation(mut test_form_state: FormState) { - // Test initial field - assert_eq!(test_form_state.current_field(), 0); - - // Test navigation to next field - test_form_state.set_current_field(1); - assert_eq!(test_form_state.current_field(), 1); - - // Test navigation to last field - test_form_state.set_current_field(2); - assert_eq!(test_form_state.current_field(), 2); - - // Test invalid field (should not crash) - test_form_state.set_current_field(999); - assert_eq!(test_form_state.current_field(), 2); // Should stay at valid field -} - -#[rstest] -fn test_form_data_entry(mut test_form_state: FormState) { - // Test entering data in first field - *test_form_state.get_current_input_mut() = "Test Company".to_string(); - test_form_state.set_has_unsaved_changes(true); - - assert_eq!(test_form_state.get_current_input(), "Test Company"); - assert!(test_form_state.has_unsaved_changes()); -} - -#[rstest] -fn test_form_field_switching_with_data(mut test_form_state: FormState) { - // Enter data in first field - *test_form_state.get_current_input_mut() = "Company Name".to_string(); - - // Switch to second field - test_form_state.set_current_field(1); - *test_form_state.get_current_input_mut() = "+421123456789".to_string(); - - // Switch back to first field - test_form_state.set_current_field(0); - assert_eq!(test_form_state.get_current_input(), "Company Name"); - - // Switch to second field again - test_form_state.set_current_field(1); - assert_eq!(test_form_state.get_current_input(), "+421123456789"); -} - -#[rstest] -fn test_form_reset_functionality(mut test_form_state: FormState) { - // Add some data - test_form_state.set_current_field(0); - *test_form_state.get_current_input_mut() = "Test Company".to_string(); - test_form_state.set_current_field(1); - *test_form_state.get_current_input_mut() = "+421123456789".to_string(); - test_form_state.set_has_unsaved_changes(true); - test_form_state.id = 123; - test_form_state.current_position = 5; - - // Reset the form - test_form_state.reset_to_empty(); - - // Verify reset - assert_eq!(test_form_state.id, 0); - assert!(!test_form_state.has_unsaved_changes()); - assert_eq!(test_form_state.current_field(), 0); - - // Check all fields are empty - for i in 0..test_form_state.fields.len() { - test_form_state.set_current_field(i); - assert!(test_form_state.get_current_input().is_empty()); - } -} - -#[rstest] -fn test_form_update_from_response(mut test_form_state: FormState, test_form_data: HashMap) { - let position = 3; - - // Update form with response data - test_form_state.update_from_response(&test_form_data, position); - - // Verify data was loaded - assert_eq!(test_form_state.current_position, position); - assert!(!test_form_state.has_unsaved_changes()); - assert_eq!(test_form_state.current_field(), 0); - - // Check field values - test_form_state.set_current_field(0); - assert_eq!(test_form_state.get_current_input(), "Test Company"); - - test_form_state.set_current_field(1); - assert_eq!(test_form_state.get_current_input(), "+421123456789"); - - test_form_state.set_current_field(2); - assert_eq!(test_form_state.get_current_input(), "test@example.com"); -} - -#[rstest] -fn test_form_cursor_position(mut test_form_state: FormState) { - // Test initial cursor position - assert_eq!(test_form_state.current_cursor_pos(), 0); - - // Add some text - *test_form_state.get_current_input_mut() = "Test Company".to_string(); - - // Test cursor positioning - test_form_state.set_current_cursor_pos(5); - assert_eq!(test_form_state.current_cursor_pos(), 5); - - // Test cursor bounds - test_form_state.set_current_cursor_pos(999); - // Should be clamped to text length - assert!(test_form_state.current_cursor_pos() <= "Test Company".len()); -} - -#[rstest] -fn test_form_field_display_names(test_form_state: FormState) { - let field_names = test_form_state.fields(); - - assert_eq!(field_names.len(), 3); - assert_eq!(field_names[0], "Company"); - assert_eq!(field_names[1], "Phone"); - assert_eq!(field_names[2], "Email"); -} - -#[rstest] -fn test_form_inputs_vector(mut test_form_state: FormState) { - // Add data to fields - test_form_state.set_current_field(0); - *test_form_state.get_current_input_mut() = "Company A".to_string(); - - test_form_state.set_current_field(1); - *test_form_state.get_current_input_mut() = "123456789".to_string(); - - test_form_state.set_current_field(2); - *test_form_state.get_current_input_mut() = "test@test.com".to_string(); - - // Get inputs vector - let inputs = test_form_state.inputs(); - - assert_eq!(inputs.len(), 3); - assert_eq!(inputs[0], "Company A"); - assert_eq!(inputs[1], "123456789"); - assert_eq!(inputs[2], "test@test.com"); -} - -#[rstest] -fn test_form_position_management(mut test_form_state: FormState) { - // Test initial position - assert_eq!(test_form_state.current_position, 1); - assert_eq!(test_form_state.total_count, 0); - - // Set some values - test_form_state.total_count = 10; - test_form_state.current_position = 5; - - assert_eq!(test_form_state.current_position, 5); - assert_eq!(test_form_state.total_count, 10); - - // Test reset affects position - test_form_state.reset_to_empty(); - assert_eq!(test_form_state.current_position, 11); // total_count + 1 -} - -#[rstest] -fn test_form_autocomplete_state(mut test_form_state: FormState) { - // Test initial autocomplete state - assert!(!test_form_state.autocomplete_active); - assert!(test_form_state.autocomplete_suggestions.is_empty()); - assert!(test_form_state.selected_suggestion_index.is_none()); - - // Test deactivating autocomplete - test_form_state.autocomplete_active = true; - test_form_state.deactivate_autocomplete(); - - assert!(!test_form_state.autocomplete_active); - assert!(test_form_state.autocomplete_suggestions.is_empty()); - assert!(test_form_state.selected_suggestion_index.is_none()); - assert!(!test_form_state.autocomplete_loading); -} - -#[rstest] -fn test_form_empty_data_handling(mut test_form_state: FormState) { - let empty_data = HashMap::new(); - - // Update with empty data - test_form_state.update_from_response(&empty_data, 1); - - // All fields should be empty - for i in 0..test_form_state.fields.len() { - test_form_state.set_current_field(i); - assert!(test_form_state.get_current_input().is_empty()); - } -} - -#[rstest] -fn test_form_partial_data_handling(mut test_form_state: FormState) { - let mut partial_data = HashMap::new(); - partial_data.insert("firma".to_string(), "Partial Company".to_string()); - // Intentionally missing telefon and email - - test_form_state.update_from_response(&partial_data, 1); - - // First field should have data - test_form_state.set_current_field(0); - assert_eq!(test_form_state.get_current_input(), "Partial Company"); - - // Other fields should be empty - test_form_state.set_current_field(1); - assert!(test_form_state.get_current_input().is_empty()); - - test_form_state.set_current_field(2); - assert!(test_form_state.get_current_input().is_empty()); -} diff --git a/client/tests/form/gui/mod.rs b/client/tests/form/gui/mod.rs deleted file mode 100644 index 1f4fbaa..0000000 --- a/client/tests/form/gui/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod form_tests; diff --git a/client/tests/form/mod.rs b/client/tests/form/mod.rs deleted file mode 100644 index 0d35e92..0000000 --- a/client/tests/form/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod gui; -pub mod requests; diff --git a/client/tests/form/requests/form_request_tests.rs b/client/tests/form/requests/form_request_tests.rs deleted file mode 100644 index 8e25d2b..0000000 --- a/client/tests/form/requests/form_request_tests.rs +++ /dev/null @@ -1,1019 +0,0 @@ -// client/tests/form_request_tests.rs -pub use rstest::{fixture, rstest}; -pub use client::services::grpc_client::GrpcClient; -pub use client::state::pages::form::FormState; -pub use canvas::canvas::CanvasState; -pub use prost_types::Value; -pub use prost_types::value::Kind; -pub use std::collections::HashMap; -pub use tonic::Status; -pub use tokio::time::{timeout, Duration}; -pub use std::time::{SystemTime, UNIX_EPOCH}; - -// ======================================================================== -// HELPER FUNCTIONS AND UTILITIES -// ======================================================================== - -/// Generate unique identifiers for test isolation using timestamp -fn generate_unique_id() -> String { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - // Ensure we always get a 12-character hex string by padding with zeros - format!("{:012x}", timestamp % 1_000_000_000_000u128) -} - -/// Helper function to create string value -fn create_string_value(s: &str) -> Value { - Value { - kind: Some(Kind::StringValue(s.to_string())) - } -} - -/// Helper function to create number value -fn create_number_value(n: f64) -> Value { - Value { - kind: Some(Kind::NumberValue(n)) - } -} - -/// Helper function to create boolean value -fn create_bool_value(b: bool) -> Value { - Value { - kind: Some(Kind::BoolValue(b)) - } -} - -/// Helper function to create null value -fn create_null_value() -> Value { - Value { - kind: Some(Kind::NullValue(0)) - } -} - -/// Check if backend is available -async fn is_backend_available() -> bool { - if std::env::var("SKIP_BACKEND_TESTS").is_ok() { - return false; - } - - match GrpcClient::new().await { - Ok(_) => true, - Err(_) => false, - } -} - -/// Skip test if backend is not available -macro_rules! skip_if_backend_unavailable { - () => { - if !is_backend_available().await { - println!("Backend unavailable - skipping test"); - return; - } - }; -} - -// ======================================================================== -// TEST CONTEXT AND FIXTURES -// ======================================================================== - -#[derive(Clone)] -struct FormTestContext { - client: GrpcClient, - profile_name: String, - table_name: String, -} - -impl FormTestContext { - /// Create test form data for insertion - fn create_test_form_data(&self) -> HashMap { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Test Company Ltd")); - data.insert("telefon".to_string(), create_string_value("+421123456789")); - data.insert("email".to_string(), create_string_value("test@company.com")); - data.insert("kz".to_string(), create_string_value("KZ123")); - data.insert("ulica".to_string(), create_string_value("Test Street 123")); - data.insert("mesto".to_string(), create_string_value("Test City")); - data - } - - /// Create minimal valid form data - fn create_minimal_form_data(&self) -> HashMap { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Minimal Company")); - data - } - - /// Create form data with invalid fields - fn create_invalid_form_data(&self) -> HashMap { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Test Company")); - data.insert("nonexistent_field".to_string(), create_string_value("Invalid")); - data - } - - /// Create form data with type mismatches - fn create_type_mismatch_data(&self) -> HashMap { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Test Company")); - data.insert("age".to_string(), create_string_value("thirty")); // String for number field - data - } -} - -#[fixture] -async fn form_test_context() -> FormTestContext { - let client = GrpcClient::new() - .await - .expect("Failed to create gRPC client for test"); - - let unique_id = generate_unique_id(); - let profile_name = format!("test_profile_{}", unique_id); - let table_name = format!("test_table_{}", unique_id); - - FormTestContext { - client, - profile_name, - table_name, - } -} - -#[fixture] -async fn populated_test_context() -> FormTestContext { - let mut context = form_test_context().await; - - // Pre-populate with test data if backend is available - if is_backend_available().await { - let test_data = context.create_test_form_data(); - let _ = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data, - ).await; - } - - context -} - -#[rstest] -#[tokio::test] -async fn test_post_table_data_success(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let test_data = context.create_test_form_data(); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data.clone(), - ).await; - - match result { - Ok(response) => { - assert!(response.success, "POST operation should succeed"); - assert!(response.inserted_id > 0, "Should return valid inserted ID"); - assert!(!response.message.is_empty(), "Should return non-empty message"); - println!("POST successful: ID {}, Message: {}", response.inserted_id, response.message); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - if status.code() == tonic::Code::Unavailable { - println!("Backend unavailable - test cannot run"); - return; - } - } - panic!("POST request failed unexpectedly: {}", e); - } - } -} - -#[rstest] -#[tokio::test] -async fn test_post_table_data_minimal_data(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let minimal_data = context.create_minimal_form_data(); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - minimal_data, - ).await; - - assert!(result.is_ok(), "POST with minimal data should succeed"); - let response = result.unwrap(); - assert!(response.success); - assert!(response.inserted_id > 0); -} - -#[rstest] -#[tokio::test] -async fn test_get_table_data_count_success(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let result = context.client.get_table_data_count( - context.profile_name.clone(), - context.table_name.clone(), - ).await; - - match result { - Ok(count) => { - assert!(count >= 0, "Count should be non-negative"); - println!("GET count successful: {}", count); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - if status.code() == tonic::Code::Unavailable { - println!("Backend unavailable - test cannot run"); - return; - } - } - panic!("GET count request failed unexpectedly: {}", e); - } - } -} - -#[rstest] -#[tokio::test] -async fn test_get_table_data_by_id_with_existing_record(#[future] populated_test_context: FormTestContext) { - let mut context = populated_test_context.await; - skip_if_backend_unavailable!(); - - // First create a record - let test_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data.clone(), - ).await; - - if let Ok(post_response) = post_result { - let created_id = post_response.inserted_id; - - // Now try to get it - let get_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - match get_result { - Ok(response) => { - assert!(!response.data.is_empty(), "Should return data fields"); - println!("GET by ID successful: {} fields", response.data.len()); - - // Verify some data matches - if let Some(firma_value) = response.data.get("firma") { - assert_eq!(firma_value, "Test Company Ltd"); - } - } - Err(e) => panic!("GET by ID failed unexpectedly: {}", e), - } - } else { - println!("Could not create test record, skipping GET test"); - } -} - -#[rstest] -#[tokio::test] -async fn test_get_table_data_by_nonexistent_id(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let nonexistent_id = 99999; - let result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - nonexistent_id, - ).await; - - assert!(result.is_err(), "GET should fail for nonexistent ID"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound); - } -} - -#[rstest] -#[tokio::test] -async fn test_put_table_data_success(#[future] populated_test_context: FormTestContext) { - let mut context = populated_test_context.await; - skip_if_backend_unavailable!(); - - // First create a record - let test_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data, - ).await; - - if let Ok(post_response) = post_result { - let created_id = post_response.inserted_id; - - // Update the record - let mut update_data = HashMap::new(); - update_data.insert("firma".to_string(), create_string_value("Updated Company Name")); - update_data.insert("telefon".to_string(), create_string_value("+421987654321")); - - let put_result = context.client.put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - update_data, - ).await; - - match put_result { - Ok(response) => { - assert!(response.success, "PUT operation should succeed"); - assert_eq!(response.updated_id, created_id, "Should return correct updated ID"); - println!("PUT successful: ID {}, Message: {}", response.updated_id, response.message); - } - Err(e) => panic!("PUT request failed unexpectedly: {}", e), - } - } else { - println!("Could not create test record, skipping PUT test"); - } -} - -#[rstest] -#[tokio::test] -async fn test_put_table_data_nonexistent_id(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let nonexistent_id = 99999; - let mut update_data = HashMap::new(); - update_data.insert("firma".to_string(), create_string_value("Updated Company")); - - let result = context.client.put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - nonexistent_id, - update_data, - ).await; - - assert!(result.is_err(), "PUT should fail for nonexistent ID"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound); - } -} - -#[rstest] -#[tokio::test] -async fn test_delete_table_data_success(#[future] populated_test_context: FormTestContext) { - let mut context = populated_test_context.await; - skip_if_backend_unavailable!(); - - // First create a record - let test_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data, - ).await; - - if let Ok(post_response) = post_result { - let created_id = post_response.inserted_id; - - let delete_result = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - match delete_result { - Ok(response) => { - assert!(response.success, "DELETE operation should succeed"); - println!("DELETE successful for ID {}", created_id); - } - Err(e) => panic!("DELETE request failed unexpectedly: {}", e), - } - } else { - println!("Could not create test record, skipping DELETE test"); - } -} - -#[rstest] -#[tokio::test] -async fn test_delete_table_data_nonexistent_id(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let nonexistent_id = 99999; - let result = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - nonexistent_id, - ).await; - - // DELETE should succeed even for nonexistent IDs (idempotent operation) - assert!(result.is_ok(), "DELETE should not fail for nonexistent ID"); - let response = result.unwrap(); - assert!(response.success, "DELETE should report success even for nonexistent ID"); -} - -// ======================================================================== -// ERROR HANDLING AND VALIDATION TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_invalid_profile_and_table_errors(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let bogus_profile = "profile_does_not_exist".to_string(); - let bogus_table = "table_does_not_exist".to_string(); - - let result = context.client.get_table_data_count(bogus_profile, bogus_table).await; - - assert!(result.is_err(), "Expected error for non-existent profile/table"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound, "Expected NotFound for non-existent profile"); - } -} - -#[rstest] -#[tokio::test] -async fn test_invalid_column_validation(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let invalid_data = context.create_invalid_form_data(); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - invalid_data, - ).await; - - assert!(result.is_err(), "Expected error for undefined column"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument); - assert!(status.message().contains("Invalid column") || - status.message().contains("nonexistent")); - } -} - -#[rstest] -#[tokio::test] -async fn test_data_type_validation(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let type_mismatch_data = context.create_type_mismatch_data(); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - type_mismatch_data, - ).await; - - assert!(result.is_err(), "Expected error for wrong data type"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument); - } -} - -#[rstest] -#[tokio::test] -async fn test_empty_data_validation(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let empty_data = HashMap::new(); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - empty_data, - ).await; - - assert!(result.is_err(), "Expected error for empty data"); - if let Some(status) = result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument); - } -} - -// ======================================================================== -// SOFT DELETE BEHAVIOR TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_soft_delete_behavior_comprehensive(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a record - let test_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data, - ).await; - - if let Ok(post_response) = post_result { - let record_id = post_response.inserted_id; - println!("Created record with ID {}", record_id); - - // 2. Verify count before deletion - let count_before = context.client.get_table_data_count( - context.profile_name.clone(), - context.table_name.clone() - ).await.unwrap_or(0); - assert!(count_before >= 1, "Count should be at least 1 after creation"); - - // 3. Soft-delete the record - let delete_result = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - - assert!(delete_result.is_ok(), "Delete operation should succeed"); - println!("Soft-deleted record {}", record_id); - - // 4. Verify count decreased after deletion - let count_after = context.client.get_table_data_count( - context.profile_name.clone(), - context.table_name.clone() - ).await.unwrap_or(0); - assert_eq!(count_after, count_before - 1, "Count should decrease by 1 after soft delete"); - - // 5. Try to GET the soft-deleted record - let get_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - - assert!(get_result.is_err(), "Should not be able to GET a soft-deleted record"); - if let Some(status) = get_result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound); - } - println!("Correctly failed to GET soft-deleted record"); - } else { - println!("Could not create test record for soft delete test"); - } -} - -// ======================================================================== -// POSITIONAL RETRIEVAL TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_positional_retrieval_comprehensive(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create multiple records - let test_names = ["Alice Corp", "Bob Industries", "Charlie Ltd"]; - let mut created_ids = Vec::new(); - - for (i, name) in test_names.iter().enumerate() { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(name)); - data.insert("kz".to_string(), create_string_value(&format!("KZ{}", i + 1))); - - if let Ok(response) = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await { - created_ids.push(response.inserted_id); - } - } - - if created_ids.len() < 3 { - println!("Could not create enough test records for positional test"); - return; - } - - println!("Created {} records with IDs: {:?}", created_ids.len(), created_ids); - - // 2. Test valid positional retrieval - for i in 0..3 { - let position = (i + 1) as i32; - let result = context.client.get_table_data_by_position( - context.profile_name.clone(), - context.table_name.clone(), - position, - ).await; - - match result { - Ok(response) => { - assert!(!response.data.is_empty(), "Position {} should return data", position); - if let Some(firma_value) = response.data.get("firma") { - assert!(test_names.contains(&firma_value.as_str()), - "Returned firma '{}' should be one of our test names", firma_value); - } - println!("Successfully retrieved record at position {}", position); - } - Err(e) => { - println!("Failed to get record at position {}: {}", position, e); - } - } - } - - // 3. Test out-of-bounds position - let oob_position = 100; - let result_oob = context.client.get_table_data_by_position( - context.profile_name.clone(), - context.table_name.clone(), - oob_position, - ).await; - - assert!(result_oob.is_err(), "Should fail for out-of-bounds position"); - if let Some(status) = result_oob.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound); - } - - // 4. Test invalid position (≤ 0) - let invalid_positions = [0, -1, -5]; - for invalid_pos in invalid_positions { - let result_invalid = context.client.get_table_data_by_position( - context.profile_name.clone(), - context.table_name.clone(), - invalid_pos, - ).await; - - assert!(result_invalid.is_err(), "Should fail for invalid position {}", invalid_pos); - if let Some(status) = result_invalid.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument); - } - } -} - -// ======================================================================== -// WORKFLOW AND INTEGRATION TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_complete_crud_workflow(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let test_data = context.create_test_form_data(); - - // 1. CREATE - Post data - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data.clone(), - ).await; - - let created_id = match post_result { - Ok(response) => { - assert!(response.success, "POST should succeed"); - println!("Workflow: Created record with ID {}", response.inserted_id); - response.inserted_id - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - if status.code() == tonic::Code::Unavailable { - println!("Workflow test skipped - backend not available"); - return; - } - } - panic!("Workflow POST failed unexpectedly: {}", e); - } - }; - - // 2. READ - Get the created data - let get_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - let get_response = get_result.expect("Workflow GET should succeed"); - if let Some(firma_value) = get_response.data.get("firma") { - assert_eq!(firma_value, "Test Company Ltd", "Retrieved data should match created data"); - } - println!("Workflow: Verified created data"); - - // 3. UPDATE - Modify the data - let mut update_data = HashMap::new(); - update_data.insert("firma".to_string(), create_string_value("Updated in Workflow")); - update_data.insert("telefon".to_string(), create_string_value("+421999888777")); - - let put_result = context.client.put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - update_data, - ).await; - - let put_response = put_result.expect("Workflow PUT should succeed"); - assert!(put_response.success, "PUT should succeed"); - assert_eq!(put_response.updated_id, created_id, "PUT should return correct ID"); - println!("Workflow: Updated record"); - - // 4. VERIFY UPDATE - Get updated data - let get_updated_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - let get_updated_response = get_updated_result.expect("Workflow GET after update should succeed"); - if let Some(firma_value) = get_updated_response.data.get("firma") { - assert_eq!(firma_value, "Updated in Workflow", "Data should be updated"); - } - println!("Workflow: Verified updated data"); - - // 5. DELETE - Remove the data - let delete_result = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - let delete_response = delete_result.expect("Workflow DELETE should succeed"); - assert!(delete_response.success, "DELETE should succeed"); - println!("Workflow: Deleted record"); - - // 6. VERIFY DELETE - Ensure data is gone - let get_deleted_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await; - - assert!(get_deleted_result.is_err(), "Should not be able to GET deleted record"); - if let Some(status) = get_deleted_result.unwrap_err().downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound); - } - println!("Workflow: Verified record deletion"); - - println!("Complete CRUD workflow test successful for ID {}", created_id); -} - -// ======================================================================== -// FORM STATE INTEGRATION TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_form_state_integration(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Create a form state - let mut form_state = FormState::new( - context.profile_name.clone(), - context.table_name.clone(), - vec![], // columns would be populated in real use - ); - - // Test count update - let count_result = context.client.get_table_data_count( - context.profile_name.clone(), - context.table_name.clone(), - ).await; - - if let Ok(count) = count_result { - form_state.total_count = count; - assert_eq!(form_state.total_count, count, "Form state count should match backend"); - println!("Form state updated with count: {}", form_state.total_count); - } - - // Create a test record for form state testing - let test_data = context.create_test_form_data(); - if let Ok(post_response) = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - test_data, - ).await { - let created_id = post_response.inserted_id; - - // Test form state update from backend response - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ).await { - form_state.update_from_response(&get_response.data, created_id as u64); - assert_eq!(form_state.current_position, created_id as u64, "Form state position should match"); - assert!(!form_state.has_unsaved_changes(), "Form state should not have unsaved changes after update"); - println!("Form state successfully updated from backend data"); - } - } else { - println!("Could not create test record for form state test"); - } -} - -// ======================================================================== -// CONCURRENT OPERATIONS TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_concurrent_post_operations(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Create multiple concurrent POST operations using tokio::spawn - let mut handles = Vec::new(); - - for i in 0..5 { - let context_clone = context.clone(); - let handle = tokio::spawn(async move { - let mut data = context_clone.create_test_form_data(); - data.insert("firma".to_string(), create_string_value(&format!("Concurrent Company {}", i))); - data.insert("kz".to_string(), create_string_value(&format!("CONC{}", i))); - - let mut client = context_clone.client; - client.post_table_data( - context_clone.profile_name.clone(), - context_clone.table_name.clone(), - data, - ).await - }); - handles.push(handle); - } - - // Wait for all tasks to complete - let mut success_count = 0; - for (i, handle) in handles.into_iter().enumerate() { - match handle.await { - Ok(Ok(response)) => { - assert!(response.success, "Concurrent POST {} should succeed", i); - success_count += 1; - } - Ok(Err(_)) => { - println!("Concurrent POST {} failed (may be expected if backend issues)", i); - } - Err(e) => { - println!("Concurrent task {} panicked: {}", i, e); - } - } - } - - println!("Concurrent operations: {}/{} succeeded", success_count, 5); - assert!(success_count > 0, "At least some concurrent operations should succeed"); -} - -// ======================================================================== -// PERFORMANCE AND STRESS TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_rapid_sequential_operations(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let start_time = std::time::Instant::now(); - let operation_count = 10; - let mut successful_operations = 0; - - for i in 0..operation_count { - let mut data = context.create_test_form_data(); - data.insert("firma".to_string(), create_string_value(&format!("Rapid Company {}", i))); - data.insert("kz".to_string(), create_string_value(&format!("RAP{}", i))); - - if let Ok(response) = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await { - assert!(response.success, "Rapid operation {} should succeed", i); - successful_operations += 1; - } - } - - let duration = start_time.elapsed(); - println!("{} rapid operations took: {:?}", operation_count, duration); - println!("Success rate: {}/{}", successful_operations, operation_count); - - assert!(successful_operations > 0, "At least some rapid operations should succeed"); - assert!(duration.as_secs() < 30, "Rapid operations should complete in reasonable time"); -} - -// ======================================================================== -// CONNECTION AND CLIENT TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_grpc_client_connection() { - if std::env::var("SKIP_BACKEND_TESTS").is_ok() { - println!("Connection test skipped due to SKIP_BACKEND_TESTS"); - return; - } - - let client_result = GrpcClient::new().await; - match client_result { - Ok(_) => println!("gRPC client connection test passed"), - Err(e) => { - println!("gRPC client connection failed (expected if backend not running): {}", e); - // Don't panic - this is expected when backend is not available - } - } -} - -#[rstest] -#[tokio::test] -async fn test_client_timeout_handling(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test that operations complete within reasonable timeouts - let timeout_duration = Duration::from_secs(10); - - let count_result = timeout( - timeout_duration, - context.client.get_table_data_count( - context.profile_name.clone(), - context.table_name.clone(), - ) - ).await; - - match count_result { - Ok(Ok(count)) => { - println!("Count operation completed within timeout: {}", count); - } - Ok(Err(e)) => { - println!("Count operation failed: {}", e); - } - Err(_) => { - panic!("Count operation timed out after {:?}", timeout_duration); - } - } -} - -// ======================================================================== -// DATA EDGE CASES TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_special_characters_and_unicode(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let special_strings = vec![ - "José María González", - "Москва", - "北京市", - "🚀 Tech Company 🌟", - "Quote\"Test'Apostrophe", - "Price: $1,000.50 (50% off!)", - ]; - - for (i, test_string) in special_strings.iter().enumerate() { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(test_string)); - data.insert("kz".to_string(), create_string_value(&format!("UNI{}", i))); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - if let Ok(response) = result { - assert!(response.success, "Should handle special characters: '{}'", test_string); - println!("Successfully handled special string: '{}'", test_string); - } else { - println!("Failed to handle special string: '{}' (may be expected)", test_string); - } - } -} - -#[rstest] -#[tokio::test] -async fn test_null_and_empty_values(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Null Test Company")); - data.insert("telefon".to_string(), create_null_value()); - data.insert("email".to_string(), create_string_value("")); - data.insert("ulica".to_string(), create_string_value(" ")); // Whitespace only - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - if let Ok(response) = result { - assert!(response.success, "Should handle null and empty values"); - println!("Successfully handled null and empty values"); - } else { - println!("Failed to handle null and empty values (may be expected based on validation)"); - } -} - -include!("form_request_tests2.rs"); -include!("form_request_tests3.rs"); diff --git a/client/tests/form/requests/form_request_tests2.rs b/client/tests/form/requests/form_request_tests2.rs deleted file mode 100644 index 0dd9507..0000000 --- a/client/tests/form/requests/form_request_tests2.rs +++ /dev/null @@ -1,267 +0,0 @@ -// ======================================================================== -// ROBUST WORKFLOW AND INTEGRATION TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_partial_update_preserves_other_fields( - #[future] populated_test_context: FormTestContext, -) { - let mut context = populated_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a record with multiple fields - let mut initial_data = context.create_test_form_data(); - let original_email = "preserve.this@email.com"; - initial_data.insert( - "email".to_string(), - create_string_value(original_email), - ); - - let post_res = context - .client - .post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - initial_data, - ) - .await - .expect("Setup: Failed to create record for partial update test"); - let created_id = post_res.inserted_id; - println!("Partial Update Test: Created record ID {}", created_id); - - // 2. Update only ONE field - let mut partial_update = HashMap::new(); - let updated_firma = "Partially Updated Inc."; - partial_update.insert( - "firma".to_string(), - create_string_value(updated_firma), - ); - - context - .client - .put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - partial_update, - ) - .await - .expect("Partial update failed"); - println!("Partial Update Test: Updated only 'firma' field"); - - // 3. Get the record back and verify ALL fields - let get_res = context - .client - .get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ) - .await - .expect("Failed to get record after partial update"); - - let final_data = get_res.data; - assert_eq!( - final_data.get("firma").unwrap(), - updated_firma, - "The 'firma' field should be updated" - ); - assert_eq!( - final_data.get("email").unwrap(), - original_email, - "The 'email' field should have been preserved" - ); - println!("Partial Update Test: Verified other fields were preserved. OK."); -} - -#[rstest] -#[tokio::test] -async fn test_data_edge_cases_and_unicode( - #[future] form_test_context: FormTestContext, -) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let edge_case_strings = vec![ - ("Unicode", "José María González, Москва, 北京市"), - ("Emoji", "🚀 Tech Company 🌟"), - ("Quotes", "Quote\"Test'Apostrophe"), - ("Symbols", "Price: $1,000.50 (50% off!)"), - ("Empty", ""), - ("Whitespace", " "), - ]; - - for (case_name, test_string) in edge_case_strings { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(test_string)); - data.insert( - "kz".to_string(), - create_string_value(&format!("EDGE-{}", case_name)), - ); - - let post_res = context - .client - .post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ) - .await - .expect(&format!("POST should succeed for case: {}", case_name)); - let created_id = post_res.inserted_id; - - let get_res = context - .client - .get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - created_id, - ) - .await - .expect(&format!( - "GET should succeed for case: {}", - case_name - )); - - assert_eq!( - get_res.data.get("firma").unwrap(), - test_string, - "Data should be identical after round-trip for case: {}", - case_name - ); - println!("Edge Case Test: '{}' passed.", case_name); - } -} - -#[rstest] -#[tokio::test] -async fn test_numeric_and_null_edge_cases( - #[future] form_test_context: FormTestContext, -) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Test NULL value - let mut null_data = HashMap::new(); - null_data.insert( - "firma".to_string(), - create_string_value("Company With Null Phone"), - ); - null_data.insert("telefon".to_string(), create_null_value()); - let post_res_null = context - .client - .post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - null_data, - ) - .await - .expect("POST with NULL value should succeed"); - let get_res_null = context - .client - .get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - post_res_null.inserted_id, - ) - .await - .unwrap(); - // Depending on DB, NULL may come back as empty string or be absent. - // The important part is that the operation doesn't fail. - assert!( - get_res_null.data.get("telefon").unwrap_or(&"".to_string()).is_empty(), - "NULL value should result in an empty or absent field" - ); - println!("Edge Case Test: NULL value handled correctly. OK."); - - // 2. Test Zero value for a numeric field (assuming 'age' is numeric) - let mut zero_data = HashMap::new(); - zero_data.insert( - "firma".to_string(), - create_string_value("Newborn Company"), - ); - // Assuming 'age' is a field in your actual table definition - // zero_data.insert("age".to_string(), create_number_value(0.0)); - // let post_res_zero = context.client.post_table_data(...).await.expect("POST with zero should succeed"); - // ... then get and verify it's "0" - println!("Edge Case Test: Zero value test skipped (uncomment if 'age' field exists)."); -} - -#[rstest] -#[tokio::test] -async fn test_concurrent_updates_on_same_record( - #[future] populated_test_context: FormTestContext, -) { - let mut context = populated_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a single record to be updated by all tasks - let initial_data = context.create_minimal_form_data(); - let post_res = context - .client - .post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - initial_data, - ) - .await - .expect("Setup: Failed to create record for concurrency test"); - let record_id = post_res.inserted_id; - println!("Concurrency Test: Target record ID is {}", record_id); - - // 2. Spawn multiple concurrent UPDATE operations - let mut handles = Vec::new(); - let num_concurrent_tasks = 5; - let mut final_values = Vec::new(); - - for i in 0..num_concurrent_tasks { - let mut client_clone = context.client.clone(); - let profile_name = context.profile_name.clone(); - let table_name = context.table_name.clone(); - let final_value = format!("Concurrent Update {}", i); - final_values.push(final_value.clone()); - - let handle = tokio::spawn(async move { - let mut update_data = HashMap::new(); - update_data.insert( - "firma".to_string(), - create_string_value(&final_value), - ); - client_clone - .put_table_data(profile_name, table_name, record_id, update_data) - .await - }); - handles.push(handle); - } - - // 3. Wait for all tasks to complete and check for panics - let results = futures::future::join_all(handles).await; - assert!( - results.iter().all(|r| r.is_ok()), - "No concurrent task should panic" - ); - println!("Concurrency Test: All update tasks completed without panicking."); - - // 4. Get the final state of the record - let final_get_res = context - .client - .get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ) - .await - .expect("Should be able to get the record after concurrent updates"); - - let final_firma = final_get_res.data.get("firma").unwrap(); - assert!( - final_values.contains(final_firma), - "The final state '{}' must be one of the states set by the tasks", - final_firma - ); - println!( - "Concurrency Test: Final state is '{}', which is a valid outcome. OK.", - final_firma - ); -} diff --git a/client/tests/form/requests/form_request_tests3.rs b/client/tests/form/requests/form_request_tests3.rs deleted file mode 100644 index 9c84b13..0000000 --- a/client/tests/form/requests/form_request_tests3.rs +++ /dev/null @@ -1,727 +0,0 @@ -// form_request_tests3.rs - Comprehensive and Robust Testing - -// ======================================================================== -// STEEL SCRIPT VALIDATION TESTS (HIGHEST PRIORITY) -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_steel_script_validation_success(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test with data that should pass script validation - // Assuming there's a script that validates 'kz' field to start with "KZ" and be 5 chars - let mut valid_data = HashMap::new(); - valid_data.insert("firma".to_string(), create_string_value("Script Test Company")); - valid_data.insert("kz".to_string(), create_string_value("KZ123")); - valid_data.insert("telefon".to_string(), create_string_value("+421123456789")); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - valid_data, - ).await; - - match result { - Ok(response) => { - assert!(response.success, "Valid data should pass script validation"); - println!("Script Validation Test: Valid data passed - ID {}", response.inserted_id); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - if status.code() == tonic::Code::Unavailable { - println!("Script validation test skipped - backend not available"); - return; - } - // If there are no scripts configured, this might still work - println!("Script validation test: {}", status.message()); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_steel_script_validation_failure(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test with data that should fail script validation - let invalid_script_data = vec![ - ("TooShort", "KZ12"), // Too short - ("TooLong", "KZ12345"), // Too long - ("WrongPrefix", "AB123"), // Wrong prefix - ("NoPrefix", "12345"), // No prefix - ("Empty", ""), // Empty - ]; - - for (test_case, invalid_kz) in invalid_script_data { - let mut invalid_data = HashMap::new(); - invalid_data.insert("firma".to_string(), create_string_value("Script Fail Company")); - invalid_data.insert("kz".to_string(), create_string_value(invalid_kz)); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - invalid_data, - ).await; - - match result { - Ok(_) => { - println!("Script Validation Test: {} passed (no validation script configured)", test_case); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument, - "Script validation failure should return InvalidArgument for case: {}", test_case); - println!("Script Validation Test: {} correctly failed - {}", test_case, status.message()); - } - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_steel_script_validation_on_update(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a valid record first - let mut initial_data = HashMap::new(); - initial_data.insert("firma".to_string(), create_string_value("Update Script Test")); - initial_data.insert("kz".to_string(), create_string_value("KZ123")); - - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - initial_data, - ).await; - - if let Ok(post_response) = post_result { - let record_id = post_response.inserted_id; - - // 2. Try to update with invalid data - let mut invalid_update = HashMap::new(); - invalid_update.insert("kz".to_string(), create_string_value("INVALID")); - - let update_result = context.client.put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - invalid_update, - ).await; - - match update_result { - Ok(_) => { - println!("Script Validation on Update: No validation script configured for updates"); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument, - "Update with invalid data should fail script validation"); - println!("Script Validation on Update: Correctly rejected invalid update"); - } - } - } - } -} - -// ======================================================================== -// COMPREHENSIVE DATA TYPE TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_boolean_data_type(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test valid boolean values - let boolean_test_cases = vec![ - ("true", true), - ("false", false), - ]; - - for (case_name, bool_value) in boolean_test_cases { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Boolean Test Company")); - // Assuming there's a boolean field called 'active' - data.insert("active".to_string(), create_bool_value(bool_value)); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(response) => { - println!("Boolean Test: {} value succeeded", case_name); - - // Verify the value round-trip - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - response.inserted_id, - ).await { - if let Some(retrieved_value) = get_response.data.get("active") { - println!("Boolean Test: {} round-trip value: {}", case_name, retrieved_value); - } - } - } - Err(e) => { - println!("Boolean Test: {} failed (field may not exist): {}", case_name, e); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_numeric_data_types(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test various numeric values - let numeric_test_cases = vec![ - ("Zero", 0.0), - ("Positive", 123.45), - ("Negative", -67.89), - ("Large", 999999.99), - ("SmallDecimal", 0.01), - ]; - - for (case_name, numeric_value) in numeric_test_cases { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Numeric Test Company")); - // Assuming there's a numeric field called 'price' or 'amount' - data.insert("amount".to_string(), create_number_value(numeric_value)); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(response) => { - println!("Numeric Test: {} ({}) succeeded", case_name, numeric_value); - - // Verify round-trip - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - response.inserted_id, - ).await { - if let Some(retrieved_value) = get_response.data.get("amount") { - println!("Numeric Test: {} round-trip value: {}", case_name, retrieved_value); - } - } - } - Err(e) => { - println!("Numeric Test: {} failed (field may not exist): {}", case_name, e); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_timestamp_data_type(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test various timestamp formats - let timestamp_test_cases = vec![ - ("ISO8601", "2024-01-15T10:30:00Z"), - ("WithTimezone", "2024-01-15T10:30:00+01:00"), - ("WithMilliseconds", "2024-01-15T10:30:00.123Z"), - ]; - - for (case_name, timestamp_str) in timestamp_test_cases { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Timestamp Test Company")); - // Assuming there's a timestamp field called 'created_at' - data.insert("created_at".to_string(), create_string_value(timestamp_str)); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(response) => { - println!("Timestamp Test: {} succeeded", case_name); - - // Verify round-trip - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - response.inserted_id, - ).await { - if let Some(retrieved_value) = get_response.data.get("created_at") { - println!("Timestamp Test: {} round-trip value: {}", case_name, retrieved_value); - } - } - } - Err(e) => { - println!("Timestamp Test: {} failed (field may not exist): {}", case_name, e); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_invalid_data_types(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test invalid data type combinations - let invalid_type_cases = vec![ - ("StringForNumber", "amount", create_string_value("not-a-number")), - ("NumberForBoolean", "active", create_number_value(123.0)), - ("StringForBoolean", "active", create_string_value("maybe")), - ("InvalidTimestamp", "created_at", create_string_value("not-a-date")), - ]; - - for (case_name, field_name, invalid_value) in invalid_type_cases { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Invalid Type Test")); - data.insert(field_name.to_string(), invalid_value); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(_) => { - println!("Invalid Type Test: {} passed (no type validation or field doesn't exist)", case_name); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - assert_eq!(status.code(), tonic::Code::InvalidArgument, - "Invalid data type should return InvalidArgument for case: {}", case_name); - println!("Invalid Type Test: {} correctly rejected - {}", case_name, status.message()); - } - } - } - } -} - -// ======================================================================== -// FOREIGN KEY RELATIONSHIP TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_foreign_key_valid_relationship(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a parent record first (e.g., company) - let mut parent_data = HashMap::new(); - parent_data.insert("firma".to_string(), create_string_value("Parent Company")); - - let parent_result = context.client.post_table_data( - context.profile_name.clone(), - "companies".to_string(), // Assuming companies table exists - parent_data, - ).await; - - if let Ok(parent_response) = parent_result { - let parent_id = parent_response.inserted_id; - - // 2. Create a child record that references the parent - let mut child_data = HashMap::new(); - child_data.insert("name".to_string(), create_string_value("Child Record")); - child_data.insert("company_id".to_string(), create_number_value(parent_id as f64)); - - let child_result = context.client.post_table_data( - context.profile_name.clone(), - "contacts".to_string(), // Assuming contacts table exists - child_data, - ).await; - - match child_result { - Ok(child_response) => { - assert!(child_response.success, "Valid foreign key relationship should succeed"); - println!("Foreign Key Test: Valid relationship created - Parent ID: {}, Child ID: {}", - parent_id, child_response.inserted_id); - } - Err(e) => { - println!("Foreign Key Test: Failed (tables may not exist or no FK constraint): {}", e); - } - } - } else { - println!("Foreign Key Test: Could not create parent record"); - } -} - -#[rstest] -#[tokio::test] -async fn test_foreign_key_invalid_relationship(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Try to create a child record with non-existent parent ID - let mut invalid_child_data = HashMap::new(); - invalid_child_data.insert("name".to_string(), create_string_value("Orphan Record")); - invalid_child_data.insert("company_id".to_string(), create_number_value(99999.0)); // Non-existent ID - - let result = context.client.post_table_data( - context.profile_name.clone(), - "contacts".to_string(), - invalid_child_data, - ).await; - - match result { - Ok(_) => { - println!("Foreign Key Test: Invalid relationship passed (no FK constraint configured)"); - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - // Could be InvalidArgument or NotFound depending on implementation - assert!(matches!(status.code(), tonic::Code::InvalidArgument | tonic::Code::NotFound), - "Invalid foreign key should return InvalidArgument or NotFound"); - println!("Foreign Key Test: Invalid relationship correctly rejected - {}", status.message()); - } - } - } -} - -// ======================================================================== -// DELETED RECORD INTERACTION TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_update_deleted_record_behavior(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create a record - let initial_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - initial_data, - ).await; - - if let Ok(post_response) = post_result { - let record_id = post_response.inserted_id; - println!("Deleted Record Test: Created record ID {}", record_id); - - // 2. Delete the record (soft delete) - let delete_result = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - - assert!(delete_result.is_ok(), "Delete should succeed"); - println!("Deleted Record Test: Soft-deleted record {}", record_id); - - // 3. Try to UPDATE the deleted record - let mut update_data = HashMap::new(); - update_data.insert("firma".to_string(), create_string_value("Updated Deleted Record")); - - let update_result = context.client.put_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - update_data, - ).await; - - match update_result { - Ok(_) => { - // This might be a bug - updating deleted records should probably fail - println!("Deleted Record Test: UPDATE on deleted record succeeded (potential bug?)"); - - // Check if the record is still considered deleted - let get_result = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - - if get_result.is_err() { - println!("Deleted Record Test: Record still appears deleted after update"); - } else { - println!("Deleted Record Test: Record appears to be undeleted after update"); - } - } - Err(e) => { - if let Some(status) = e.downcast_ref::() { - assert_eq!(status.code(), tonic::Code::NotFound, - "UPDATE on deleted record should return NotFound"); - println!("Deleted Record Test: UPDATE correctly rejected on deleted record"); - } - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_delete_already_deleted_record(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // 1. Create and delete a record - let initial_data = context.create_test_form_data(); - let post_result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - initial_data, - ).await; - - if let Ok(post_response) = post_result { - let record_id = post_response.inserted_id; - - // First deletion - let delete_result1 = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - assert!(delete_result1.is_ok(), "First delete should succeed"); - - // Second deletion (idempotent) - let delete_result2 = context.client.delete_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await; - - assert!(delete_result2.is_ok(), "Second delete should succeed (idempotent)"); - if let Ok(response) = delete_result2 { - assert!(response.success, "Delete should report success even for already-deleted record"); - } - println!("Double Delete Test: Both deletions succeeded (idempotent behavior)"); - } -} - -// ======================================================================== -// VALIDATION AND BOUNDARY TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_large_data_handling(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test with very large string values - let large_string = "A".repeat(10000); // 10KB string - let very_large_string = "B".repeat(100000); // 100KB string - - let test_cases = vec![ - ("Large", large_string), - ("VeryLarge", very_large_string), - ]; - - for (case_name, large_value) in test_cases { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(&large_value)); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(response) => { - println!("Large Data Test: {} string handled successfully", case_name); - - // Verify round-trip - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - response.inserted_id, - ).await { - if let Some(retrieved_value) = get_response.data.get("firma") { - assert_eq!(retrieved_value.len(), large_value.len(), - "Large string should survive round-trip for case: {}", case_name); - } - } - } - Err(e) => { - println!("Large Data Test: {} failed (may hit size limits): {}", case_name, e); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_sql_injection_attempts(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test potential SQL injection strings - let injection_attempts = vec![ - ("SingleQuote", "'; DROP TABLE users; --"), - ("DoubleQuote", "\"; DROP TABLE users; --"), - ("Union", "' UNION SELECT * FROM users --"), - ("Comment", "/* malicious comment */"), - ("Semicolon", "; DELETE FROM users;"), - ]; - - for (case_name, injection_string) in injection_attempts { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(injection_string)); - data.insert("kz".to_string(), create_string_value("KZ123")); - - let result = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await; - - match result { - Ok(response) => { - println!("SQL Injection Test: {} handled safely (parameterized queries)", case_name); - - // Verify the malicious string was stored as-is (not executed) - if let Ok(get_response) = context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - response.inserted_id, - ).await { - if let Some(retrieved_value) = get_response.data.get("firma") { - assert_eq!(retrieved_value, injection_string, - "Injection string should be stored literally for case: {}", case_name); - } - } - } - Err(e) => { - println!("SQL Injection Test: {} rejected: {}", case_name, e); - } - } - } -} - -#[rstest] -#[tokio::test] -async fn test_concurrent_operations_with_same_data(#[future] form_test_context: FormTestContext) { - let context = form_test_context.await; - skip_if_backend_unavailable!(); - - // Test multiple concurrent operations with identical data - let mut handles = Vec::new(); - let num_tasks = 10; - - for i in 0..num_tasks { - let mut context_clone = context.clone(); - let handle = tokio::spawn(async move { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value("Concurrent Identical")); - data.insert("kz".to_string(), create_string_value(&format!("SAME{:02}", i))); - - context_clone.client.post_table_data( - context_clone.profile_name, - context_clone.table_name, - data, - ).await - }); - handles.push(handle); - } - - // Wait for all to complete - let mut success_count = 0; - let mut inserted_ids = Vec::new(); - - for (i, handle) in handles.into_iter().enumerate() { - match handle.await { - Ok(Ok(response)) => { - success_count += 1; - inserted_ids.push(response.inserted_id); - println!("Concurrent Identical Data: Task {} succeeded with ID {}", i, response.inserted_id); - } - Ok(Err(e)) => { - println!("Concurrent Identical Data: Task {} failed: {}", i, e); - } - Err(e) => { - println!("Concurrent Identical Data: Task {} panicked: {}", i, e); - } - } - } - - assert!(success_count > 0, "At least some concurrent operations should succeed"); - - // Verify all IDs are unique - let unique_ids: std::collections::HashSet<_> = inserted_ids.iter().collect(); - assert_eq!(unique_ids.len(), inserted_ids.len(), "All inserted IDs should be unique"); - - println!("Concurrent Identical Data: {}/{} operations succeeded with unique IDs", - success_count, num_tasks); -} - -// ======================================================================== -// PERFORMANCE AND STRESS TESTS -// ======================================================================== - -#[rstest] -#[tokio::test] -async fn test_bulk_operations_performance(#[future] form_test_context: FormTestContext) { - let mut context = form_test_context.await; - skip_if_backend_unavailable!(); - - let operation_count = 50; - let start_time = std::time::Instant::now(); - - let mut successful_operations = 0; - let mut created_ids = Vec::new(); - - // Bulk create - for i in 0..operation_count { - let mut data = HashMap::new(); - data.insert("firma".to_string(), create_string_value(&format!("Bulk Company {}", i))); - data.insert("kz".to_string(), create_string_value(&format!("BLK{:02}", i))); - - if let Ok(response) = context.client.post_table_data( - context.profile_name.clone(), - context.table_name.clone(), - data, - ).await { - successful_operations += 1; - created_ids.push(response.inserted_id); - } - } - - let create_duration = start_time.elapsed(); - println!("Bulk Performance: Created {} records in {:?}", successful_operations, create_duration); - - // Bulk read - let read_start = std::time::Instant::now(); - let mut successful_reads = 0; - - for &record_id in &created_ids { - if context.client.get_table_data( - context.profile_name.clone(), - context.table_name.clone(), - record_id, - ).await.is_ok() { - successful_reads += 1; - } - } - - let read_duration = read_start.elapsed(); - println!("Bulk Performance: Read {} records in {:?}", successful_reads, read_duration); - - // Performance assertions - assert!(successful_operations > operation_count * 8 / 10, - "At least 80% of operations should succeed"); - assert!(create_duration.as_secs() < 60, - "Bulk operations should complete in reasonable time"); - - println!("Bulk Performance Test: {}/{} creates, {}/{} reads successful", - successful_operations, operation_count, successful_reads, created_ids.len()); -} diff --git a/client/tests/form/requests/mod.rs b/client/tests/form/requests/mod.rs deleted file mode 100644 index 89aa474..0000000 --- a/client/tests/form/requests/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod form_request_tests; diff --git a/client/tests/input/engine_leader_e2e.rs b/client/tests/input/engine_leader_e2e.rs deleted file mode 100644 index 4c981b6..0000000 --- a/client/tests/input/engine_leader_e2e.rs +++ /dev/null @@ -1,39 +0,0 @@ -use client::config::binds::config::Config; -use client::input::engine::{InputEngine, InputContext, InputOutcome}; -use client::modes::handlers::mode_manager::AppMode; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -fn ctx() -> InputContext { - InputContext { - app_mode: AppMode::General, - overlay_active: false, - allow_navigation_capture: true, - } -} - -fn key(c: char) -> KeyEvent { - KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()) -} - -#[test] -fn engine_collects_space_b_r() { - let toml_str = r#" - [keybindings] - revert = ["space+b+r"] - "#; - let config: Config = toml::from_str(toml_str).unwrap(); - - let mut eng = InputEngine::new(400, 5_000); - - // space -> Pending (leader started) - let out1 = eng.process_key(key(' '), &ctx(), &config); - assert!(matches!(out1, InputOutcome::Pending)); - - // b -> Pending (prefix) - let out2 = eng.process_key(key('b'), &ctx(), &config); - assert!(matches!(out2, InputOutcome::Pending)); - - // r -> Action(revert) - let out3 = eng.process_key(key('r'), &ctx(), &config); - assert!(matches!(out3, InputOutcome::Action(_))); -} diff --git a/client/tests/input/leader_sequences.rs b/client/tests/input/leader_sequences.rs deleted file mode 100644 index c6d5506..0000000 --- a/client/tests/input/leader_sequences.rs +++ /dev/null @@ -1,25 +0,0 @@ -use client::config::binds::config::Config; -use client::input::leader::leader_match_action; -use client::config::binds::key_sequences::parse_binding; -use crossterm::event::KeyCode; - -#[test] -fn test_space_b_d_binding() { - // Minimal fake config TOML - let toml_str = r#" - [keybindings] - close_buffer = ["space+b+d"] - "#; - let config: Config = toml::from_str(toml_str).unwrap(); - - let seq = vec![KeyCode::Char(' '), KeyCode::Char('b'), KeyCode::Char('d')]; - let action = leader_match_action(&config, &seq); - assert_eq!(action, Some("close_buffer")); -} - -#[test] -fn parses_space_b_r() { - let seq = parse_binding("space+b+r"); - let codes: Vec = seq.iter().map(|p| p.code).collect(); - assert_eq!(codes, vec![KeyCode::Char(' '), KeyCode::Char('b'), KeyCode::Char('r')]); -} diff --git a/client/tests/input/mod.rs b/client/tests/input/mod.rs deleted file mode 100644 index ae85126..0000000 --- a/client/tests/input/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// tests/input/mod.rs - -pub mod engine_leader_e2e; -pub mod leader_sequences; diff --git a/client/tests/mod.rs b/client/tests/mod.rs deleted file mode 100644 index 3203166..0000000 --- a/client/tests/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// tests/mod.rs - -// pub mod form; -pub mod input;