Compare commits

...

15 Commits

Author SHA1 Message Date
filipriec
7a8f18b116 cargo fix 2025-05-26 22:28:58 +02:00
filipriec
d255e4abb6 proper postiion of the cursor when using sql 2025-05-26 20:53:05 +02:00
filipriec
b770240f0d better autocomplete 2025-05-26 20:43:58 +02:00
filipriec
43b064673b autocomplete is now powerful 2025-05-26 20:22:47 +02:00
filipriec
bf2726c151 tablenames added properly well 2025-05-26 19:51:48 +02:00
filipriec
f3cd921c76 we are suggesting properly table column names now 2025-05-26 19:42:23 +02:00
filipriec
913f6b6b64 broken autocomplete in the add_logic, but its usable, we are keeping it as is, there is nothing more we can do 2025-05-26 16:37:01 +02:00
filipriec
3463a52960 working autocomplete, need more fixes soon 2025-05-26 11:54:28 +02:00
filipriec
116db3566f intro buffer can be killed now also 2025-05-25 22:37:27 +02:00
filipriec
32210a5f7c killing of the buffer now works amazingly well 2025-05-25 22:24:26 +02:00
filipriec
d8f9372bbd killing buffers 2025-05-25 22:02:18 +02:00
filipriec
6e1997fd9d storage in the system is now storing log in details properly well 2025-05-25 21:33:24 +02:00
filipriec
4e7213d1aa automcomplete running and working now 2025-05-25 19:26:30 +02:00
filipriec
5afb427bb4 neccessary hardcode changes to fix the last changes introducing bug. general solution soon 2025-05-25 19:16:42 +02:00
filipriec
685361a11a server table structure response is now generalized 2025-05-25 18:57:13 +02:00
34 changed files with 1469 additions and 1064 deletions

1
Cargo.lock generated
View File

@@ -409,6 +409,7 @@ dependencies = [
"prost",
"ratatui",
"serde",
"serde_json",
"time",
"tokio",
"toml",

View File

@@ -16,6 +16,7 @@ lazy_static = "1.5.0"
prost = "0.13.5"
ratatui = { version = "0.29.0", features = ["crossterm"] }
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 = "0.8.20"

View File

@@ -4,6 +4,7 @@
enter_command_mode = [":", "ctrl+;"]
next_buffer = ["ctrl+l"]
previous_buffer = ["ctrl+h"]
close_buffer = ["ctrl+k"]
[keybindings.general]
move_up = ["k", "Up"]

View File

@@ -12,7 +12,7 @@ use ratatui::{
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::dialog;
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
pub fn render_add_logic(
@@ -21,7 +21,7 @@ pub fn render_add_logic(
theme: &Theme,
app_state: &AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: bool, // Used for border/title hints in InsideScriptContent
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let main_block = Block::default()
@@ -41,17 +41,12 @@ pub fn render_add_logic(
let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default());
// Explicitly set to tui-textarea's default "active" editing cursor style
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);
if is_edit_mode { // is_edit_mode here refers to Vim's Insert mode
format!("Script {}", vim_mode_status)
} else {
format!("Script {}", vim_mode_status)
}
format!("Script {}", vim_mode_status)
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode {
@@ -71,7 +66,48 @@ pub fn render_add_logic(
.border_style(border_style),
);
f.render_widget(&*editor_ref, inner_area);
return;
// 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
@@ -79,7 +115,7 @@ pub fn render_add_logic(
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Top info
Constraint::Length(9), // Canvas
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
])
@@ -123,10 +159,11 @@ pub fn render_add_logic(
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
render_canvas(
// Call render_canvas and get the active_field_rect
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state,
add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
@@ -135,6 +172,26 @@ pub fn render_add_logic(
highlight_state,
);
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
let selected = add_logic_state.get_selected_suggestion_index();
if !suggestions.is_empty() { // Only render if there are suggestions to show
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
suggestions,
selected,
);
}
}
}
}
// Script content preview
{
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
@@ -143,10 +200,8 @@ pub fn render_add_logic(
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
if is_script_preview_focused {
// When script PREVIEW is focused, use tui-textarea's default "active" cursor (block-like).
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else {
// When script PREVIEW is NOT focused, use an underscore cursor.
let underscore_cursor_style = Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(theme.secondary);
@@ -154,16 +209,12 @@ pub fn render_add_logic(
}
let border_style_color = if is_script_preview_focused {
theme.highlight // Green highlight when focused and ready to select
theme.highlight
} else {
theme.secondary
};
let title_text = if is_script_preview_focused {
"Script Preview"
} else {
"Script Preview"
};
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)
@@ -182,8 +233,8 @@ pub fn render_add_logic(
}
// Buttons
let get_button_style = |button_focus: AddLogicFocus, current_focus| {
let is_focused = current_focus == button_focus;
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 {
@@ -196,11 +247,11 @@ pub fn render_add_logic(
}
};
let get_button_border_style = |is_focused: bool, theme: &Theme| {
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
if is_focused {
Style::default().fg(theme.highlight)
Style::default().fg(current_theme.highlight)
} else {
Style::default().fg(theme.secondary)
Style::default().fg(current_theme.secondary)
}
};

View File

@@ -2,7 +2,7 @@
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, Scrolling};
use tui_textarea::{Input, Key, TextArea, CursorMove};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -2,6 +2,7 @@
use crate::config::colors::themes::Theme;
use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; // Add this import
use ratatui::{
layout::{Alignment, Rect},
style::Style,
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
area: Rect,
theme: &Theme,
buffer_state: &BufferState,
app_state: &AppState, // Add this parameter
) {
// --- Style Definitions ---
let active_style = Style::default()
@@ -37,6 +39,9 @@ pub fn render_buffer_list(
let mut spans = Vec::new();
let mut current_width = 0;
// TODO: Replace with actual table name from server response
let current_table_name = Some("2025_customer");
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 {
@@ -44,7 +49,7 @@ pub fn render_buffer_list(
}
let is_active = original_index == buffer_state.active_index;
let buffer_name = view.display_name();
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());

View File

@@ -2,3 +2,4 @@
pub mod binds;
pub mod colors;
pub mod storage;

View File

@@ -0,0 +1,4 @@
// src/config/storage.rs
pub mod storage;
pub use storage::*;

View File

@@ -0,0 +1,101 @@
// 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 = "multieko2_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<PathBuf> {
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<Option<StoredAuthData>> {
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::<StoredAuthData>(&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(())
}

View File

@@ -7,7 +7,7 @@ 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,
AppView::Form | AppView::Scratch => 3,
}
}

View File

@@ -1,277 +1,135 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::canvas_state::CanvasState;
use crossterm::event::{KeyCode, KeyEvent};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
// can be kept as they are generic.
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() { CharType::Whitespace }
else if c.is_alphanumeric() { CharType::Alphanumeric }
else { CharType::Punctuation }
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len { return len.saturating_sub(1); }
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { pos -= 1; }
if pos > 0 { pos - 1 } else { 0 }
}
/// Executes edit actions for the AddLogic view canvas.
pub async fn execute_edit_action(
action: &str,
key: KeyEvent,
state: &mut AddLogicState, // Changed
key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let mut message = String::new();
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key.".to_string());
}
Ok("".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index { // Prevent cycling
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
"prev_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let current_field = state.current_field();
if current_field > 0 { // Prevent cycling
state.set_current_field(current_field - 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
let current_field = state.current_field();
let prev_field = if current_field == 0 {
AddLogicState::INPUT_FIELD_COUNT - 1
} else {
current_field - 1
};
state.set_current_field(prev_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[prev_field]);
}
"delete_char_forward" => {
let current_pos = state.current_cursor_pos();
let current_input_mut = state.get_current_input_mut();
if current_pos < current_input_mut.len() {
current_input_mut.remove(current_pos);
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"delete_char_backward" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.get_current_input_mut().remove(new_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let input_len = state.get_current_input().len();
if current_pos < input_len {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => { // In edit mode, up/down usually means prev/next field
let current_field = state.current_field();
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_down" => { // In edit mode, up/down usually means prev/next field
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let current_pos = state.current_cursor_pos();
state.get_current_input_mut().insert(current_pos, c);
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 {
state.update_target_column_suggestions();
}
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
if AddLogicState::INPUT_FIELD_COUNT > 0 { // Changed
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
"suggestion_down" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
state.selected_target_column_suggestion_index = Some(next_selection);
}
Ok("".to_string())
}
"move_last_line" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos && current_pos < current_input.len() { // Ensure not to go past end
find_word_end(current_input, current_pos + 1)
"suggestion_up" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let prev_selection = if current_selection == 0 {
state.target_column_suggestions.len() - 1
} else {
new_pos
current_selection - 1
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
state.selected_target_column_suggestion_index = Some(prev_selection);
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"exit_edit_mode" | "save" | "revert" => {
Ok("Action handled by main loop".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}
"select_suggestion" => {
if state.in_target_column_suggestion_mode {
let mut selected_suggestion_text: Option<String> = None;
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
selected_suggestion_text = Some(suggestion.clone());
}
}
if let Some(suggestion_text) = selected_suggestion_text {
state.target_column_input = suggestion_text.clone();
state.target_column_cursor_pos = state.target_column_input.len();
*ideal_cursor_column = state.target_column_cursor_pos;
state.set_has_unsaved_changes(true);
message = format!("Selected column: '{}'", suggestion_text);
}
state.in_target_column_suggestion_mode = false;
state.show_target_column_suggestions = false;
state.selected_target_column_suggestion_index = None;
state.update_target_column_suggestions();
} else {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
}
_ => {}
}
Ok(message)
}

View File

@@ -11,6 +11,8 @@ use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result;
use crate::components::common::text_editor::TextEditor;
use crate::services::ui_service::UiService;
use tui_textarea::CursorMove; // Ensure this import is present
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
@@ -22,31 +24,231 @@ pub fn handle_add_logic_navigation(
is_edit_mode: &mut bool,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
save_logic_sender: SaveLogicResultSender,
_save_logic_sender: SaveLogicResultSender, // Marked as unused
command_message: &mut String,
) -> bool {
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
// Handle ONLY Escape to exit fullscreen mode
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
// First escape: try to go to Vim Normal mode
// === AUTOCOMPLETE HANDLING ===
if add_logic_state.script_editor_autocomplete_active {
match key_event.code {
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
add_logic_state.script_editor_filter_text.push(c);
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
return true;
}
KeyCode::Backspace => {
if !add_logic_state.script_editor_filter_text.is_empty() {
add_logic_state.script_editor_filter_text.pop();
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
"Autocomplete: @".to_string()
} else {
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
};
} else {
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
} else {
false
};
if should_deactivate {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
}
return true;
}
KeyCode::Tab | KeyCode::Down => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
add_logic_state.script_editor_selected_suggestion_index = Some(next);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
}
return true;
}
KeyCode::Up => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
add_logic_state.script_editor_suggestions.len() - 1
} else {
current - 1
};
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
}
return true;
}
KeyCode::Enter => {
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
let trigger_pos = add_logic_state.script_editor_trigger_position;
let filter_len = add_logic_state.script_editor_filter_text.len();
add_logic_state.deactivate_script_editor_autocomplete();
add_logic_state.has_unsaved_changes = true;
if let Some(pos) = trigger_pos {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
if suggestion == "sql" {
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
editor_borrow.insert_str("('')");
// Move cursor back twice to be between the single quotes
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
*command_message = "Inserted: @sql('')".to_string();
} else {
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
if is_table_selection {
editor_borrow.insert_str(".");
let new_cursor = editor_borrow.cursor();
drop(editor_borrow); // Release borrow before calling add_logic_state methods
add_logic_state.script_editor_trigger_position = Some(new_cursor);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
let profile_name = add_logic_state.profile_name.clone();
let table_name_for_fetch = suggestion.clone();
let mut client_clone = grpc_client.clone();
tokio::spawn(async move {
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
Ok(_columns) => {
// Result handled by main UI loop
}
Err(e) => {
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
}
}
});
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
} else {
*command_message = format!("Inserted: {}", suggestion);
}
}
}
return true;
}
}
add_logic_state.deactivate_script_editor_autocomplete();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
KeyCode::Esc => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
_ => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
}
}
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
let should_trigger = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => *is_edit_mode,
_ => true,
};
if should_trigger {
let cursor_before = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
add_logic_state.script_editor_trigger_position = Some(cursor_before);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.update_script_editor_suggestions();
add_logic_state.has_unsaved_changes = true;
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
return true;
}
}
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
*is_edit_mode = false;
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
}
} else {
// Second escape: exit fullscreen
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
@@ -58,7 +260,6 @@ pub fn handle_add_logic_navigation(
*is_edit_mode = false;
*command_message = "Exited script edit. Esc again to exit script.".to_string();
} else {
// Exit fullscreen
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
@@ -69,27 +270,24 @@ pub fn handle_add_logic_navigation(
return true;
}
// ALL OTHER KEYS: Pass directly to textarea without any interference
let changed = TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
if changed {
add_logic_state.has_unsaved_changes = true;
let changed = {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
)
};
if changed {
add_logic_state.has_unsaved_changes = true;
}
// Update edit mode status for Vim
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
}
return true; // Always consume the event in fullscreen mode
return true;
}
// === END FULLSCREEN ISOLATION ===
// Regular navigation logic for non-fullscreen elements
let action = config.get_general_action(key_event.code, key_event.modifiers);
let current_focus = add_logic_state.current_focus;
let mut handled = true;
@@ -97,14 +295,11 @@ pub fn handle_add_logic_navigation(
match action.as_deref() {
Some("exit_table_scroll") => {
// This shouldn't happen since we handle InsideScriptContent above
handled = false;
}
Some("move_up") => {
match current_focus {
AddLogicFocus::InputLogicName => {
// Stay at top
}
AddLogicFocus::InputLogicName => {}
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
@@ -123,9 +318,7 @@ pub fn handle_add_logic_navigation(
},
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {
// Stay at bottom
}
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
@@ -135,14 +328,14 @@ pub fn handle_add_logic_navigation(
{ new_focus = AddLogicFocus::ScriptContentPreview; }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => { /* Stay at last */ }
AddLogicFocus::CancelButton => { }
_ => handled = false,
}
}
Some("previous_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ /* Stay at first */ }
{ }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
@@ -175,8 +368,8 @@ pub fn handle_add_logic_navigation(
match current_focus {
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InsideScriptContent;
*is_edit_mode = false; // Start in preview mode
app_state.ui.focus_outside_canvas = false; // Script is like canvas
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
_ => "Enter/Ctrl+E to edit",
@@ -215,24 +408,33 @@ pub fn handle_add_logic_navigation(
if handled && current_focus != new_focus {
add_logic_state.current_focus = new_focus;
// Set edit mode and canvas focus based on new focus
let new_is_canvas_input_focus = matches!(new_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
);
if new_is_canvas_input_focus {
// Entering canvas - start in readonly mode
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
} else {
// Outside canvas
app_state.ui.focus_outside_canvas = true;
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
*is_edit_mode = false;
}
}
}
handled
}
fn replace_autocomplete_text(
editor: &mut tui_textarea::TextArea,
trigger_pos: (usize, usize),
filter_len: usize,
replacement: &str,
) {
// use tui_textarea::CursorMove; // Already imported at the top of the module
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
for _ in 0..filter_len {
editor.delete_next_char();
}
editor.insert_str(replacement);
}

View File

@@ -234,31 +234,34 @@ pub fn handle_admin_navigation(
admin_state.add_logic_state = AddLogicState {
profile_name: profile.name.clone(),
selected_table_name: Some(table.name.clone()),
// selected_table_id: table.id, // If you have table IDs
selected_table_id: Some(table.id), // If you have table IDs
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
current_focus: AddLogicFocus::default(), // Reset focus for the new screen
current_focus: AddLogicFocus::default(),
..AddLogicState::default()
};
buffer_state.update_history(AppView::AddLogic); // Switch view
app_state.ui.focus_outside_canvas = false; // Ensure canvas focus
// Store table info for later fetching
app_state.pending_table_structure_fetch = Some((
profile.name.clone(),
table.name.clone()
));
buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
*command_message = format!(
"Opening Add Logic for table '{}' in profile '{}'...",
table.name, profile.name
);
} else {
// This case should ideally not be reached if indices are managed correctly
*command_message = "Error: Selected table data not found.".to_string();
}
} else {
// Profile is selected, but table is not
*command_message = "Select a table first!".to_string();
}
} else {
// This case should ideally not be reached if p_idx is valid
*command_message = "Error: Selected profile data not found.".to_string();
}
} else {
// Profile is not selected
*command_message = "Select a profile first!".to_string();
}
handled = true;

View File

@@ -5,26 +5,27 @@ use crate::state::pages::{
auth::{LoginState, RegisterState},
canvas_state::CanvasState,
};
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::form::FormState;
use crate::state::pages::add_table::AddTableState;
use crate::state::pages::form::FormState; // <<< ADD THIS LINE
// AddLogicState is already imported
// AddTableState is already imported
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::state::app::state::AppState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyEvent; // Removed KeyCode, KeyModifiers as they were unused
use tracing::debug;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
Message(String), // Return a message, stay in Edit mode
ExitEditMode, // Signal to exit Edit mode
Message(String),
ExitEditMode,
}
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
form_state: &mut FormState, // Now FormState is in scope
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
@@ -34,17 +35,20 @@ pub async fn handle_edit_event(
grpc_client: &mut GrpcClient,
app_state: &AppState,
) -> Result<EditEventOutcome> {
// Global command mode check (should ideally be handled before calling this function)
// --- Global command mode check ---
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global,
&config.keybindings.global, // Assuming command mode can be entered globally
key.code,
key.modifiers,
) {
// This check might be redundant if EventHandler already prevents entering Edit mode
// when command_mode is true. However, it's a safeguard.
return Ok(EditEventOutcome::Message(
"Command mode entry handled globally.".to_string(),
"Cannot enter command mode from edit mode here.".to_string(),
));
}
// --- Common actions (save, revert) ---
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key.code,
@@ -52,261 +56,197 @@ pub async fn handle_edit_event(
).as_deref() {
if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action(
action,
login_state,
grpc_client,
current_position,
total_count,
)
.await?
auth_e::execute_common_action(action, login_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_register {
auth_e::execute_common_action(
action,
register_state,
grpc_client,
current_position,
total_count,
)
.await?
auth_e::execute_common_action(action, register_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_add_table {
format!(
"Action '{}' not fully implemented for Add Table view here.",
action
)
// TODO: Implement common actions for AddTable if needed
format!("Action '{}' not implemented for Add Table in edit mode.", action)
} else if app_state.ui.show_add_logic {
format!(
"Action '{}' not fully implemented for Add Logic view here.",
action
)
} else {
let outcome = form_e::execute_common_action(
action,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
// TODO: Implement common actions for AddLogic if needed
format!("Action '{}' not implemented for Add Logic in edit mode.", action)
} else { // Assuming Form view
let outcome = form_e::execute_common_action(action, form_state, grpc_client, current_position, total_count).await?;
match outcome {
EventOutcome::Ok(msg) => msg,
EventOutcome::DataSaved(_, msg) => msg,
_ => format!(
"Unexpected outcome from common action: {:?}",
outcome
),
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
_ => format!("Unexpected outcome from common action: {:?}", outcome),
}
};
return Ok(EditEventOutcome::Message(message_string));
}
}
// Edit-specific actions
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
.as_deref() {
// Handle enter_decider first
if action == "enter_decider" {
// --- Edit-specific actions ---
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers).as_deref() {
// --- Handle "enter_decider" (Enter key) ---
if action_str == "enter_decider" {
let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode
&& register_state.current_field() == 4 {
&& register_state.current_field() == 4 { // Role field
"select_suggestion"
} else if app_state.ui.show_add_logic
&& admin_state.add_logic_state.in_target_column_suggestion_mode
&& admin_state.add_logic_state.current_field() == 1 { // Target Column field
"select_suggestion"
} else {
"next_field"
"next_field" // Default action for Enter
};
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
effective_action,
key,
login_state,
ideal_cursor_column,
)
.await?
auth_e::execute_edit_action(effective_action, key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
effective_action,
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
add_table_e::execute_edit_action(effective_action, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(
effective_action,
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
effective_action,
key,
register_state,
ideal_cursor_column,
)
.await?
} else {
form_e::execute_edit_action(
effective_action,
key,
form_state,
ideal_cursor_column,
)
.await?
auth_e::execute_edit_action(effective_action, key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action(effective_action, key, form_state, ideal_cursor_column).await?
};
return Ok(EditEventOutcome::Message(msg));
}
if action == "exit" {
// --- Handle "exit" (Escape key) ---
if action_str == "exit" {
if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action(
"exit_suggestion_mode",
key,
register_state,
ideal_cursor_column,
)
.await?;
let msg = auth_e::execute_edit_action("exit_suggestion_mode", key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
} else if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
return Ok(EditEventOutcome::Message("Exited column suggestions".to_string()));
} else {
return Ok(EditEventOutcome::ExitEditMode);
}
}
// Special handling for role field suggestions (Register view only)
if app_state.ui.show_register && register_state.current_field() == 4 {
if !register_state.in_suggestion_mode
&& key.code == KeyCode::Tab
&& key.modifiers == KeyModifiers::NONE
{
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
register_state.selected_suggestion_index = Some(0);
return Ok(EditEventOutcome::Message(
"Suggestions shown".to_string(),
));
} else {
return Ok(EditEventOutcome::Message(
"No suggestions available".to_string(),
));
// --- Autocomplete for AddLogicState Target Column ---
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 { // Target Column field
if action_str == "suggestion_down" { // "Tab" is mapped to suggestion_down
if !admin_state.add_logic_state.in_target_column_suggestion_mode {
// Attempt to open suggestions
if let Some(profile_name) = admin_state.add_logic_state.profile_name.clone().into() {
if let Some(table_name) = admin_state.add_logic_state.selected_table_name.clone() {
debug!("Fetching table structure for autocomplete: Profile='{}', Table='{}'", profile_name, table_name);
match grpc_client.get_table_structure(profile_name, table_name).await {
Ok(ts_response) => {
admin_state.add_logic_state.table_columns_for_suggestions =
ts_response.columns.into_iter().map(|c| c.name).collect();
admin_state.add_logic_state.update_target_column_suggestions();
if !admin_state.add_logic_state.target_column_suggestions.is_empty() {
admin_state.add_logic_state.in_target_column_suggestion_mode = true;
// update_target_column_suggestions handles initial selection
return Ok(EditEventOutcome::Message("Column suggestions shown".to_string()));
} else {
return Ok(EditEventOutcome::Message("No column suggestions for current input".to_string()));
}
}
Err(e) => {
debug!("Error fetching table structure: {}", e);
admin_state.add_logic_state.table_columns_for_suggestions.clear(); // Clear old data on error
admin_state.add_logic_state.update_target_column_suggestions();
return Ok(EditEventOutcome::Message(format!("Error fetching columns: {}", e)));
}
}
} else {
return Ok(EditEventOutcome::Message("No table selected for column suggestions".to_string()));
}
} else { // Should not happen if AddLogic is properly initialized
return Ok(EditEventOutcome::Message("Profile name missing for column suggestions".to_string()));
}
} else { // Already in suggestion mode, navigate down
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
if register_state.in_suggestion_mode
&& matches!(
action,
"suggestion_down" | "suggestion_up"
)
{
let msg = auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
)
.await?;
} else if admin_state.add_logic_state.in_target_column_suggestion_mode && action_str == "suggestion_up" {
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// Execute other edit actions based on the current view
// --- Autocomplete for RegisterState Role Field ---
if app_state.ui.show_register && register_state.current_field() == 4 { // Role field
if !register_state.in_suggestion_mode && action_str == "suggestion_down" { // Tab
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
// update_role_suggestions should handle initial selection
return Ok(EditEventOutcome::Message("Role suggestions shown".to_string()));
} else {
// If Tab doesn't open suggestions, it might fall through to "next_field"
// or you might want specific behavior. For now, let it fall through.
}
}
if register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up") {
let msg = auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Dispatch other edit actions ---
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
action,
key,
login_state,
ideal_cursor_column,
)
.await?
auth_e::execute_edit_action(action_str, key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
action,
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
add_table_e::execute_edit_action(action_str, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(
action,
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
// If not a suggestion action handled above for AddLogic
if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
)
.await?
} else {
form_e::execute_edit_action(
action,
key,
form_state,
ideal_cursor_column,
)
.await?
if !(register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
} else { // Form view
form_e::execute_edit_action(action_str, key, form_state, ideal_cursor_column).await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- Character insertion ---
// If character insertion happens while in suggestion mode, exit suggestion mode first.
let mut exited_suggestion_mode_for_typing = false;
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
}
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(
"insert_char",
key,
login_state,
ideal_cursor_column,
)
.await?
let mut char_insert_msg = if app_state.ui.show_login {
auth_e::execute_edit_action("insert_char", key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
add_table_e::execute_edit_action("insert_char", key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(
"insert_char",
key,
register_state,
ideal_cursor_column,
)
.await?
} else {
form_e::execute_edit_action(
"insert_char",
key,
form_state,
ideal_cursor_column,
)
.await?
auth_e::execute_edit_action("insert_char", key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action("insert_char", key, form_state, ideal_cursor_column).await?
};
// After character insertion, update suggestions if applicable
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
// If we just exited suggestion mode by typing, don't immediately show them again unless Tab is pressed.
// However, update_role_suggestions will set show_role_suggestions if matches are found.
// This is fine, as the render logic checks in_suggestion_mode.
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 {
admin_state.add_logic_state.update_target_column_suggestions();
}
return Ok(EditEventOutcome::Message(msg));
if exited_suggestion_mode_for_typing && char_insert_msg.is_empty() {
char_insert_msg = "Suggestions hidden".to_string();
}
Ok(EditEventOutcome::Message(char_insert_msg))
}

View File

@@ -121,10 +121,7 @@ impl EventHandler {
else if ui.show_admin { AppView::Admin }
else if ui.show_add_logic { AppView::AddLogic }
else if ui.show_add_table { AppView::AddTable }
else if ui.show_form {
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
else if ui.show_form { AppView::Form } // Remove the dynamic name part
else { AppView::Scratch }
};
buffer_state.update_history(current_view);
@@ -161,6 +158,7 @@ impl EventHandler {
return Ok(EventOutcome::Ok(message));
}
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global, key_code, modifiers
@@ -176,6 +174,16 @@ impl EventHandler {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
}
}
//"close_buffer" => {
// let message = buffer_state.close_buffer_with_intro_fallback();
// return Ok(EventOutcome::Ok(message));
//}
"close_buffer" => {
// TODO: Replace with actual table name from server response
let current_table_name = Some("2025_customer"); // Your hardcoded table name
let message = buffer_state.close_buffer_with_intro_fallback(current_table_name);
return Ok(EventOutcome::Ok(message));
}
_ => {}
}
}

View File

@@ -5,7 +5,8 @@ use common::proto::multieko2::adresar::adresar_client::AdresarClient;
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
use common::proto::multieko2::table_structure::TableStructureResponse;
// Import the new request type for table structure
use common::proto::multieko2::table_structure::{TableStructureResponse, GetTableStructureRequest};
use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient,
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse,
@@ -63,9 +64,20 @@ impl GrpcClient {
Ok(response)
}
pub async fn get_table_structure(&mut self) -> Result<TableStructureResponse> {
let request = tonic::Request::new(Empty::default());
let response = self.table_structure_client.get_adresar_table_structure(request).await?;
// Updated get_table_structure method
pub async fn get_table_structure(
&mut self,
profile_name: String,
table_name: String,
) -> Result<TableStructureResponse> {
// Create the new request type
let grpc_request = GetTableStructureRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
// Call the new gRPC method
let response = self.table_structure_client.get_table_structure(request).await?;
Ok(response.into_inner())
}
@@ -87,4 +99,3 @@ impl GrpcClient {
Ok(response.into_inner())
}
}

View File

@@ -3,12 +3,94 @@
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::app::state::AppState;
use anyhow::{Context, Result};
pub struct UiService;
impl UiService {
pub async fn initialize_add_logic_table_data(
grpc_client: &mut GrpcClient,
add_logic_state: &mut AddLogicState,
profile_tree: &common::proto::multieko2::table_definition::ProfileTreeResponse,
) -> Result<String> {
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<String> = 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<String> = 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<Vec<String>> {
match grpc_client.get_table_structure(profile_name.to_string(), table_name.to_string()).await {
Ok(response) => {
let column_names: Vec<String> = response.columns
.into_iter()
.map(|col| col.name)
.collect();
Ok(column_names)
}
Err(e) => {
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
Err(e.into())
}
}
}
pub async fn initialize_app_state(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
@@ -17,8 +99,15 @@ impl UiService {
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree;
// Fetch table structure
let table_structure = grpc_client.get_table_structure().await?;
// TODO for general tables and not hardcoded
let default_profile_name = "default".to_string();
let default_table_name = "2025_customer".to_string();
// Fetch table structure for the default table
let table_structure = grpc_client
.get_table_structure(default_profile_name, default_table_name)
.await
.context("Failed to get initial table structure")?;
// Extract the column names from the response
let column_names: Vec<String> = table_structure

View File

@@ -1,6 +1,5 @@
// src/state/app/buffer.rs
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView {
Intro,
@@ -9,12 +8,13 @@ pub enum AppView {
Admin,
AddTable,
AddLogic,
Form(String),
Form,
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",
@@ -23,13 +23,24 @@ impl AppView {
AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table",
AppView::AddLogic => "Add_Logic",
AppView::Form(name) => name.as_str(),
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 => {
current_table_name
.unwrap_or("Data Form")
.to_string()
}
_ => self.display_name().to_string(),
}
}
}
/// Holds the state related to buffer management (navigation history).
#[derive(Debug, Clone)]
pub struct BufferState {
pub history: Vec<AppView>,
@@ -39,23 +50,17 @@ pub struct BufferState {
impl Default for BufferState {
fn default() -> Self {
Self {
history: vec![AppView::Intro], // Start with Intro view
history: vec![AppView::Intro],
active_index: 0,
}
}
}
impl BufferState {
/// Updates the buffer history and active index.
/// If the view already exists, it sets it as active.
/// Otherwise, it adds the new view and makes it active.
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;
}
Some(pos) => self.active_index = pos,
None => {
self.history.push(view.clone());
self.active_index = self.history.len() - 1;
@@ -63,34 +68,52 @@ impl BufferState {
}
}
/// Gets the currently active view.
pub fn get_active_view(&self) -> Option<&AppView> {
self.history.get(self.active_index)
}
/// Removes the currently active buffer from the history, unless it's the Intro buffer.
/// Sets the new active buffer to the one preceding the closed one.
/// # Returns
/// * `true` if a non-Intro buffer was closed.
/// * `false` if the active buffer was Intro or only Intro remained.
pub fn close_active_buffer(&mut self) -> bool {
let current_index = self.active_index;
// Rule 1: Cannot close Intro buffer.
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
if self.history.is_empty() {
self.history.push(AppView::Intro);
self.active_index = 0;
return false;
}
// Rule 2: Cannot close if only Intro would remain (or already remains).
// This check implicitly covers the case where len <= 1.
if self.history.len() <= 1 {
return false;
}
let current_index = self.active_index;
self.history.remove(current_index);
self.active_index = current_index.saturating_sub(1).min(self.history.len() - 1);
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)
}
}
}

View File

@@ -3,7 +3,7 @@
use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::{DialogPurpose, UiContext};
use crate::ui::handlers::context::DialogPurpose;
use anyhow::Result;
pub struct DialogState {
@@ -39,6 +39,7 @@ pub struct AppState {
pub selected_profile: Option<String>,
pub current_mode: AppMode,
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
// UI preferences
pub ui: UiState,
@@ -57,6 +58,7 @@ impl AppState {
selected_profile: None,
current_mode: AppMode::General,
focused_button_index: 0,
pending_table_structure_fetch: None,
ui: UiState::default(),
})
}

View File

@@ -12,8 +12,8 @@ pub enum AddLogicFocus {
InputLogicName,
InputTargetColumn,
InputDescription,
ScriptContentPreview, // Like ColumnsTable - can be highlighted/selected
InsideScriptContent, // Like InsideColumnsTable - full editing mode
ScriptContentPreview,
InsideScriptContent,
SaveButton,
CancelButton,
}
@@ -35,6 +35,25 @@ pub struct AddLogicState {
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<String>, // All columns for the table
pub target_column_suggestions: Vec<String>, // Filtered suggestions
pub show_target_column_suggestions: bool,
pub selected_target_column_suggestion_index: Option<usize>,
pub in_target_column_suggestion_mode: bool,
// Script Editor Autocomplete
pub script_editor_autocomplete_active: bool,
pub script_editor_suggestions: Vec<String>,
pub script_editor_selected_suggestion_index: Option<usize>,
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
pub all_table_names: Vec<String>,
pub script_editor_filter_text: String,
// New fields for same-profile table names and column autocomplete
pub same_profile_table_names: Vec<String>, // Tables from same profile only
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
}
impl AddLogicState {
@@ -56,10 +75,156 @@ impl AddLogicState {
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,
}
}
pub const INPUT_FIELD_COUNT: usize = 3;
/// 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(&current_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<String>) {
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<String>) {
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<String>) {
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<String>) {
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();
}
}
impl Default for AddLogicState {
@@ -122,21 +287,20 @@ impl CanvasState for AddLogicState {
}
fn set_current_field(&mut self, index: usize) {
self.current_focus = match index {
0 => {
self.last_canvas_field = 0;
AddLogicFocus::InputLogicName
},
1 => {
self.last_canvas_field = 1;
AddLogicFocus::InputTargetColumn
},
2 => {
self.last_canvas_field = 2;
AddLogicFocus::InputDescription
},
_ => self.current_focus,
let new_focus = match index {
0 => AddLogicFocus::InputLogicName,
1 => AddLogicFocus::InputTargetColumn,
2 => AddLogicFocus::InputDescription,
_ => return,
};
if self.current_focus != new_focus {
if self.current_focus == AddLogicFocus::InputTargetColumn {
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
}
self.current_focus = new_focus;
self.last_canvas_field = index;
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
@@ -145,12 +309,10 @@ impl CanvasState for AddLogicState {
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
}
AddLogicFocus::InputTargetColumn => {
self.target_column_cursor_pos =
pos.min(self.target_column_input.len());
self.target_column_cursor_pos = pos.min(self.target_column_input.len());
}
AddLogicFocus::InputDescription => {
self.description_cursor_pos =
pos.min(self.description_input.len());
self.description_cursor_pos = pos.min(self.description_input.len());
}
_ => {}
}
@@ -161,10 +323,24 @@ impl CanvasState for AddLogicState {
}
fn get_suggestions(&self) -> Option<&[String]> {
None
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
Some(&self.target_column_suggestions)
} else {
None
}
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
self.selected_target_column_suggestion_index
} else {
None
}
}
}

View File

@@ -2,5 +2,6 @@
pub mod form;
pub mod login;
pub mod logout;
pub mod register;
pub mod add_table;

View File

@@ -1,6 +1,6 @@
// src/tui/functions/common/add_table.rs
use crate::state::pages::add_table::{
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition, LinkDefinition,
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
};
use crate::services::GrpcClient;
use anyhow::{anyhow, Result};

View File

@@ -5,6 +5,7 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::DialogPurpose;
use common::proto::multieko2::auth::LoginResponse;
@@ -200,6 +201,20 @@ pub fn handle_login_result(
auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
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);
// Continue anyway - user is still logged in for this session
}
// --- END NEW ---
let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
response.username, response.user_id, response.role

View File

@@ -0,0 +1,47 @@
// 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::state::app::buffer::{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);
// Continue anyway - user is logged out in memory
}
// Navigate to intro screen
buffer_state.history = vec![AppView::Intro];
buffer_state.active_index = 0;
// Reset UI state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_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, // Reuse or create a new purpose
);
info!("User logged out successfully.");
"Logged out successfully".to_string()
}

View File

@@ -13,10 +13,7 @@ pub fn handle_intro_selection(
index: usize,
) {
let target_view = match index {
0 => {
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
0 => AppView::Form,
1 => AppView::Admin,
2 => AppView::Login,
3 => AppView::Register,

View File

@@ -201,7 +201,7 @@ pub fn render_ui(
// Render buffer list if enabled and area is available
if let Some(area) = buffer_list_area {
if app_state.ui.show_buffer_list {
render_buffer_list(f, area, theme, buffer_state);
render_buffer_list(f, area, theme, buffer_state, app_state);
}
}
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);

View File

@@ -4,6 +4,7 @@ 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};
@@ -22,16 +23,16 @@ use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult;
use crate::tui::functions::common::add_table::handle_save_table_action;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::{DialogPurpose, UiContext};
// Removed: use crate::tui::functions::common::add_table::handle_save_table_action;
// Removed: use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::DialogPurpose; // UiContext removed if not used directly
use crate::tui::functions::common::login;
use crate::tui::functions::common::register;
use std::time::Instant;
use anyhow::{Context, Result};
use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event;
use tracing::{error, info};
use tracing::{error, info, warn}; // Added warn
use tokio::sync::mpsc;
@@ -49,7 +50,7 @@ pub async fn run_ui() -> Result<()> {
mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) =
mpsc::channel::<Result<String>>(1);
let (save_logic_result_sender, mut save_logic_result_receiver) =
let (save_logic_result_sender, _save_logic_result_receiver) = // Prefixed and removed mut
mpsc::channel::<Result<String>>(1);
let mut event_handler = EventHandler::new(
@@ -68,17 +69,40 @@ pub async fn run_ui() -> Result<()> {
let mut buffer_state = BufferState::default();
let mut app_state = AppState::new().context("Failed to create initial app state")?;
// Initialize app state with profile tree and table structure
// --- DATA: Load auth data from file at startup ---
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(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);
}
}
// --- END DATA ---
let column_names =
UiService::initialize_app_state(&mut grpc_client, &mut app_state)
.await.context("Failed to initialize app state from UI service")?;
let mut form_state = FormState::new(column_names);
// Fetch the total count of Adresar entries
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?;
form_state.reset_to_empty();
// --- FPS Calculation State ---
if auto_logged_in {
buffer_state.history = vec![AppView::Form];
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;
@@ -86,7 +110,6 @@ pub async fn run_ui() -> Result<()> {
loop {
// --- Synchronize UI View from Active Buffer ---
if let Some(active_view) = buffer_state.get_active_view() {
// Reset all flags first
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
app_state.ui.show_register = false;
@@ -109,38 +132,72 @@ pub async fn run_ui() -> Result<()> {
event_handler.command_message = format!("Error refreshing admin data: {}", e);
}
}
app_state.ui.show_admin = true; // <<< RESTORE THIS
let profile_names = app_state.profile_tree.profiles.iter() // <<< RESTORE THIS
.map(|p| p.name.clone()) // <<< RESTORE THIS
.collect(); // <<< RESTORE THIS
app_state.ui.show_admin = true;
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
// Only reset to ProfilesPane if not already in a specific admin sub-focus
if admin_state.current_focus == AdminFocus::default() ||
!matches!(admin_state.current_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) {
admin_state.current_focus = AdminFocus::ProfilesPane;
}
// Pre-select first profile item for visual consistency, but '>' won't show until 'select'
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
}
}
AppView::AddTable => app_state.ui.show_add_table = true,
AppView::AddLogic => app_state.ui.show_add_logic = true,
AppView::Form(_) => app_state.ui.show_form = true,
AppView::Scratch => {} // Or show a scratchpad component
AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {}
}
}
// --- End Synchronization ---
// --- Handle Pending Table Structure Fetches ---
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic {
// Ensure admin_state.add_logic_state matches the pending fetch
if admin_state.add_logic_state.profile_name == profile_name &&
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
info!("Fetching table structure for {}.{}", profile_name, table_name);
let fetch_message = UiService::initialize_add_logic_table_data(
&mut grpc_client,
&mut admin_state.add_logic_state,
&app_state.profile_tree, // Pass the profile tree
).await.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e)
});
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
info!("{}", fetch_message);
} else {
event_handler.command_message = fetch_message;
}
needs_redraw = true;
} else {
error!(
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
profile_name, table_name,
admin_state.add_logic_state.profile_name,
admin_state.add_logic_state.selected_table_name
);
}
} else {
warn!(
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
profile_name, table_name
);
}
}
// --- 3. Draw UI ---
// Draw the current state *first*. This ensures the loading dialog
// set in the *previous* iteration gets rendered before the pending
// action check below.
if needs_redraw {
if needs_redraw {
terminal.draw(|f| {
render_ui(
f,
@@ -152,7 +209,7 @@ pub async fn run_ui() -> Result<()> {
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode, // Use event_handler's state
event_handler.is_edit_mode,
&event_handler.highlight_state,
app_state.total_count,
app_state.current_position,
@@ -167,8 +224,30 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = false;
}
// --- Handle Pending Column Autocomplete for Table Selection ---
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
if app_state.ui.show_add_logic {
let profile_name = admin_state.add_logic_state.profile_name.clone();
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
}
Err(e) => {
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
}
}
needs_redraw = true;
}
}
// --- Cursor Visibility Logic ---
// (Keep existing cursor logic here - depends on state drawn above)
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
@@ -189,8 +268,6 @@ pub async fn run_ui() -> Result<()> {
let total_count = app_state.total_count;
let mut current_position = app_state.current_position;
let position_before_event = current_position;
// --- Determine if redraw is needed based on active login ---
// Always redraw if the loading dialog is currently showing.
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
@@ -198,10 +275,9 @@ pub async fn run_ui() -> Result<()> {
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false;
// Poll for events *after* drawing and checking pending actions
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; // Mark that we received and will process an event
event_processed = true;
event_outcome_result = event_handler.handle_event(
event,
&config,
@@ -224,9 +300,6 @@ pub async fn run_ui() -> Result<()> {
if event_processed {
needs_redraw = true;
}
// Update position based on handler's modification
// This happens *after* the event is handled
app_state.current_position = current_position;
// --- Check for Login Results from Channel ---
@@ -239,7 +312,6 @@ pub async fn run_ui() -> Result<()> {
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
// Optionally show an error dialog here
}
}
@@ -258,7 +330,7 @@ pub async fn run_ui() -> Result<()> {
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog(); // Hide loading indicator
app_state.hide_dialog();
match result {
Ok(ref success_message) => {
app_state.show_dialog(
@@ -271,12 +343,11 @@ pub async fn run_ui() -> Result<()> {
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
// Optionally show an error dialog instead of just command message
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {} // No message
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
@@ -286,19 +357,15 @@ pub async fn run_ui() -> Result<()> {
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(message) => {
if !message.is_empty() {
// Update command message only if event handling produced one
// Avoid overwriting messages potentially set by pending actions
// event_handler.command_message = message;
}
EventOutcome::Ok(_message) => {
// Message is often set directly in event_handler.command_message
}
EventOutcome::Exit(message) => {
event_handler.command_message = message;
should_exit = true;
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message; // Show save status
event_handler.command_message = message;
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
@@ -312,119 +379,87 @@ pub async fn run_ui() -> Result<()> {
}
}
EventOutcome::ButtonSelected { context: _, index: _ } => {
// This case should ideally be fully handled within handle_event
// If initiate_login was called, it returned early.
// If not, the message was set and returned via Ok(message).
// Log if necessary, but likely no action needed here.
// log::warn!("ButtonSelected outcome reached main loop unexpectedly.");
// Handled within event_handler or specific navigation modules
}
},
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
}
} // --- End Consequence Handling ---
}
// --- End Consequence Handling ---
// --- Position Change Handling (after outcome processing and pending actions) ---
// --- Position Change Handling ---
let position_changed = app_state.current_position != position_before_event;
let current_total_count = app_state.total_count;
let current_total_count = app_state.total_count; // Use current total_count
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form {
if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1 // Limit to last character in readonly mode
} else {
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
position_logic_needs_redraw = true;
// Ensure position never exceeds total_count + 1
if app_state.current_position > current_total_count + 1 {
app_state.current_position = current_total_count + 1;
}
if app_state.current_position > current_total_count {
// New entry - reset form
form_state.reset_to_empty();
form_state.current_field = 0;
} else if app_state.current_position >= 1
&& app_state.current_position <= current_total_count
{
// Existing entry - load data
let current_position_to_load = app_state.current_position; // Use a copy
} else if app_state.current_position >= 1 && app_state.current_position <= current_total_count {
let current_position_to_load = app_state.current_position;
let load_message = UiService::load_adresar_by_position(
&mut grpc_client,
&mut app_state, // Pass app_state mutably if needed by the service
&mut app_state,
&mut form_state,
current_position_to_load,
)
.await.with_context(|| format!("Failed to load adresar by position: {}", current_position_to_load))?;
let current_input = form_state.get_current_input();
let max_cursor_pos = if !event_handler.is_edit_mode
&& !current_input.is_empty()
{
current_input.len() - 1 // In readonly mode, limit to last character
let current_input_after_load = form_state.get_current_input();
let max_cursor_pos_after_load = if !event_handler.is_edit_mode && !current_input_after_load.is_empty() {
current_input_after_load.len() - 1
} else {
current_input.len()
current_input_after_load.len()
};
form_state.current_cursor_pos = event_handler
.ideal_cursor_column
.min(max_cursor_pos);
// Don't overwrite message from handle_event if load_message is simple success
if !load_message.starts_with("Loaded entry")
|| event_handler.command_message.is_empty()
{
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_after_load);
if !load_message.starts_with("Loaded entry") || event_handler.command_message.is_empty() {
event_handler.command_message = load_message;
}
} else {
// Invalid position (e.g., 0) - reset to first entry or new entry mode
app_state.current_position =
1.min(current_total_count + 1); // Go to 1 or new entry if empty
if app_state.current_position > total_count {
} else { // current_position is 0 or invalid
app_state.current_position = 1.min(current_total_count + 1);
if app_state.current_position > current_total_count { // Handles empty db case
form_state.reset_to_empty();
form_state.current_field = 0;
}
// If db is not empty, this will trigger load in next iteration if position changed to 1
}
} else if !position_changed && !event_handler.is_edit_mode {
// If position didn't change but we are in read-only, just adjust cursor
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_register {
if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_login {
if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
}
if position_logic_needs_redraw {
needs_redraw = true;
}
// --- End Position Change Handling ---
// Check exit condition *after* all processing for the iteration
if should_exit {
return Ok(());
}
@@ -433,9 +468,8 @@ pub async fn run_ui() -> Result<()> {
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 {
if frame_duration.as_secs_f64() > 1e-6 { // Avoid division by zero
current_fps = 1.0 / frame_duration.as_secs_f64();
}
} // End main loop
}

View File

@@ -4,18 +4,22 @@ package multieko2.table_structure;
import "common.proto";
message GetTableStructureRequest {
string profile_name = 1; // e.g., "default"
string table_name = 2; // e.g., "2025_adresar6"
}
message TableStructureResponse {
repeated TableColumn columns = 1;
}
message TableColumn {
string name = 1;
string data_type = 2;
string data_type = 2; // e.g., "TEXT", "BIGINT", "VARCHAR(255)", "TIMESTAMPTZ"
bool is_nullable = 3;
bool is_primary_key = 4;
}
service TableStructureService {
rpc GetAdresarTableStructure (common.Empty) returns (TableStructureResponse);
rpc GetUctovnictvoTableStructure (common.Empty) returns (TableStructureResponse);
rpc GetTableStructure (GetTableStructureRequest) returns (TableStructureResponse);
}

Binary file not shown.

View File

@@ -1,5 +1,14 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetTableStructureRequest {
/// e.g., "default"
#[prost(string, tag = "1")]
pub profile_name: ::prost::alloc::string::String,
/// e.g., "2025_adresar6"
#[prost(string, tag = "2")]
pub table_name: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TableStructureResponse {
#[prost(message, repeated, tag = "1")]
pub columns: ::prost::alloc::vec::Vec<TableColumn>,
@@ -8,6 +17,7 @@ pub struct TableStructureResponse {
pub struct TableColumn {
#[prost(string, tag = "1")]
pub name: ::prost::alloc::string::String,
/// e.g., "TEXT", "BIGINT", "VARCHAR(255)", "TIMESTAMPTZ"
#[prost(string, tag = "2")]
pub data_type: ::prost::alloc::string::String,
#[prost(bool, tag = "3")]
@@ -106,9 +116,9 @@ pub mod table_structure_service_client {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn get_adresar_table_structure(
pub async fn get_table_structure(
&mut self,
request: impl tonic::IntoRequest<super::super::common::Empty>,
request: impl tonic::IntoRequest<super::GetTableStructureRequest>,
) -> std::result::Result<
tonic::Response<super::TableStructureResponse>,
tonic::Status,
@@ -123,43 +133,14 @@ pub mod table_structure_service_client {
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/multieko2.table_structure.TableStructureService/GetAdresarTableStructure",
"/multieko2.table_structure.TableStructureService/GetTableStructure",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"multieko2.table_structure.TableStructureService",
"GetAdresarTableStructure",
),
);
self.inner.unary(req, path, codec).await
}
pub async fn get_uctovnictvo_table_structure(
&mut self,
request: impl tonic::IntoRequest<super::super::common::Empty>,
) -> std::result::Result<
tonic::Response<super::TableStructureResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/multieko2.table_structure.TableStructureService/GetUctovnictvoTableStructure",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"multieko2.table_structure.TableStructureService",
"GetUctovnictvoTableStructure",
"GetTableStructure",
),
);
self.inner.unary(req, path, codec).await
@@ -179,16 +160,9 @@ pub mod table_structure_service_server {
/// Generated trait containing gRPC methods that should be implemented for use with TableStructureServiceServer.
#[async_trait]
pub trait TableStructureService: std::marker::Send + std::marker::Sync + 'static {
async fn get_adresar_table_structure(
async fn get_table_structure(
&self,
request: tonic::Request<super::super::common::Empty>,
) -> std::result::Result<
tonic::Response<super::TableStructureResponse>,
tonic::Status,
>;
async fn get_uctovnictvo_table_structure(
&self,
request: tonic::Request<super::super::common::Empty>,
request: tonic::Request<super::GetTableStructureRequest>,
) -> std::result::Result<
tonic::Response<super::TableStructureResponse>,
tonic::Status,
@@ -271,15 +245,13 @@ pub mod table_structure_service_server {
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/multieko2.table_structure.TableStructureService/GetAdresarTableStructure" => {
"/multieko2.table_structure.TableStructureService/GetTableStructure" => {
#[allow(non_camel_case_types)]
struct GetAdresarTableStructureSvc<T: TableStructureService>(
pub Arc<T>,
);
struct GetTableStructureSvc<T: TableStructureService>(pub Arc<T>);
impl<
T: TableStructureService,
> tonic::server::UnaryService<super::super::common::Empty>
for GetAdresarTableStructureSvc<T> {
> tonic::server::UnaryService<super::GetTableStructureRequest>
for GetTableStructureSvc<T> {
type Response = super::TableStructureResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
@@ -287,11 +259,11 @@ pub mod table_structure_service_server {
>;
fn call(
&mut self,
request: tonic::Request<super::super::common::Empty>,
request: tonic::Request<super::GetTableStructureRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as TableStructureService>::get_adresar_table_structure(
<T as TableStructureService>::get_table_structure(
&inner,
request,
)
@@ -306,58 +278,7 @@ pub mod table_structure_service_server {
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetAdresarTableStructureSvc(inner);
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/multieko2.table_structure.TableStructureService/GetUctovnictvoTableStructure" => {
#[allow(non_camel_case_types)]
struct GetUctovnictvoTableStructureSvc<T: TableStructureService>(
pub Arc<T>,
);
impl<
T: TableStructureService,
> tonic::server::UnaryService<super::super::common::Empty>
for GetUctovnictvoTableStructureSvc<T> {
type Response = super::TableStructureResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::super::common::Empty>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as TableStructureService>::get_uctovnictvo_table_structure(
&inner,
request,
)
.await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetUctovnictvoTableStructureSvc(inner);
let method = GetTableStructureSvc(inner);
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(

View File

@@ -1,11 +1,12 @@
// src/server/services/table_structure_service.rs
use tonic::{Request, Response, Status};
// Correct the import path for the TableStructureService trait
use common::proto::multieko2::table_structure::table_structure_service_server::TableStructureService;
use common::proto::multieko2::table_structure::TableStructureResponse;
use common::proto::multieko2::common::Empty;
use crate::table_structure::handlers::{
get_adresar_table_structure, get_uctovnictvo_table_structure,
use common::proto::multieko2::table_structure::{
GetTableStructureRequest,
TableStructureResponse,
};
use crate::table_structure::handlers::get_table_structure;
use sqlx::PgPool;
#[derive(Debug)]
@@ -13,22 +14,21 @@ pub struct TableStructureHandler {
pub db_pool: PgPool,
}
#[tonic::async_trait]
impl TableStructureService for TableStructureHandler {
async fn get_adresar_table_structure(
&self,
request: Request<Empty>,
) -> Result<Response<TableStructureResponse>, Status> {
let response = get_adresar_table_structure(&self.db_pool, request.into_inner())
.await?;
Ok(Response::new(response))
impl TableStructureHandler {
pub fn new(db_pool: PgPool) -> Self {
Self { db_pool }
}
}
async fn get_uctovnictvo_table_structure(
#[tonic::async_trait]
impl TableStructureService for TableStructureHandler { // This line should now be correct
async fn get_table_structure(
&self,
request: Request<Empty>,
request: Request<GetTableStructureRequest>,
) -> Result<Response<TableStructureResponse>, Status> {
let response = get_uctovnictvo_table_structure(&self.db_pool, request.into_inner()).await?;
let req_payload = request.into_inner();
let response =
get_table_structure(&self.db_pool, req_payload).await?;
Ok(Response::new(response))
}
}

View File

@@ -1,83 +1,39 @@
Adresar response:
grpcurl -plaintext \
-proto proto/table_structure.proto \
-import-path proto \
grpcurl -plaintext \
-d '{
"profile_name": "default",
"table_name": "2025_customer"
}' \
localhost:50051 \
multieko2.table_structure.TableStructureService/GetAdresarTableStructure
multieko2.table_structure.TableStructureService/GetTableStructure
{
"columns": [
{
"name": "firma",
"dataType": "TEXT"
"name": "id",
"dataType": "INT8",
"isPrimaryKey": true
},
{
"name": "kz",
"name": "deleted",
"dataType": "BOOL"
},
{
"name": "full_name",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "drc",
"dataType": "TEXT",
"name": "email",
"dataType": "VARCHAR(255)",
"isNullable": true
},
{
"name": "ulica",
"dataType": "TEXT",
"name": "loyalty_status",
"dataType": "BOOL",
"isNullable": true
},
{
"name": "psc",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "mesto",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "stat",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "banka",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "ucet",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "skladm",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "ico",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "kontakt",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "telefon",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "skladu",
"dataType": "TEXT",
"isNullable": true
},
{
"name": "fax",
"dataType": "TEXT",
"name": "created_at",
"dataType": "TIMESTAMPTZ",
"isNullable": true
}
]

View File

@@ -1,4 +1,4 @@
// src/table_structure/handlers.rs
pub mod table_structure;
pub use table_structure::{get_adresar_table_structure, get_uctovnictvo_table_structure};
pub use table_structure::get_table_structure;

View File

@@ -1,181 +1,134 @@
// src/table_structure/handlers/table_structure.rs
use tonic::Status;
use sqlx::PgPool;
use common::proto::multieko2::{
table_structure::{TableStructureResponse, TableColumn},
common::Empty
use common::proto::multieko2::table_structure::{
GetTableStructureRequest, TableColumn, TableStructureResponse,
};
use sqlx::{PgPool, Row};
use tonic::Status;
pub async fn get_adresar_table_structure(
_db_pool: &PgPool,
_request: Empty,
) -> Result<TableStructureResponse, Status> {
let columns = vec![
TableColumn {
name: "firma".to_string(),
data_type: "TEXT".to_string(),
is_nullable: false,
is_primary_key: false,
},
TableColumn {
name: "kz".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "drc".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "ulica".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "psc".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "mesto".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "stat".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "banka".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "ucet".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "skladm".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "ico".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "kontakt".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "telefon".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "skladu".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "fax".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
];
Ok(TableStructureResponse { columns })
// Helper struct to map query results
#[derive(sqlx::FromRow, Debug)]
struct DbColumnInfo {
column_name: String,
formatted_data_type: String,
is_nullable: bool,
is_primary_key: bool,
}
pub async fn get_uctovnictvo_table_structure(
_db_pool: &PgPool,
_request: Empty,
pub async fn get_table_structure(
db_pool: &PgPool,
request: GetTableStructureRequest,
) -> Result<TableStructureResponse, Status> {
let columns = vec![
TableColumn {
name: "adresar_id".to_string(),
data_type: "BIGINT".to_string(),
is_nullable: false,
is_primary_key: false,
},
TableColumn {
name: "c_dokladu".to_string(),
data_type: "TEXT".to_string(),
is_nullable: false,
is_primary_key: false,
},
TableColumn {
name: "datum".to_string(),
data_type: "DATE".to_string(),
is_nullable: false,
is_primary_key: false,
},
TableColumn {
name: "c_faktury".to_string(),
data_type: "TEXT".to_string(),
is_nullable: false,
is_primary_key: false,
},
TableColumn {
name: "obsah".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "stredisko".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "c_uctu".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "md".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "identif".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "poznanka".to_string(),
data_type: "TEXT".to_string(),
is_nullable: true,
is_primary_key: false,
},
TableColumn {
name: "firma".to_string(),
data_type: "TEXT".to_string(),
is_nullable: false,
is_primary_key: false,
},
];
let profile_name = request.profile_name;
let table_name = request.table_name; // This should be the full table name, e.g., "2025_adresar6"
let table_schema = "public"; // Assuming tables are in the 'public' schema
// 1. Validate Profile
let profile = sqlx::query!(
"SELECT id FROM profiles WHERE name = $1",
profile_name
)
.fetch_optional(db_pool)
.await
.map_err(|e| {
Status::internal(format!(
"Failed to query profile '{}': {}",
profile_name, e
))
})?;
let profile_id = match profile {
Some(p) => p.id,
None => {
return Err(Status::not_found(format!(
"Profile '{}' not found",
profile_name
)));
}
};
// 2. Validate Table within Profile
sqlx::query!(
"SELECT id FROM table_definitions WHERE profile_id = $1 AND table_name = $2",
profile_id,
table_name
)
.fetch_optional(db_pool)
.await
.map_err(|e| Status::internal(format!("Failed to query table_definitions: {}", e)))?
.ok_or_else(|| Status::not_found(format!(
"Table '{}' not found in profile '{}'",
table_name,
profile_name
)))?;
// 3. Query information_schema for column details
let query_str = r#"
SELECT
c.column_name,
CASE
WHEN c.udt_name = 'varchar' AND c.character_maximum_length IS NOT NULL THEN
'VARCHAR(' || c.character_maximum_length || ')'
WHEN c.udt_name = 'bpchar' AND c.character_maximum_length IS NOT NULL THEN
'CHAR(' || c.character_maximum_length || ')'
WHEN c.udt_name = 'numeric' AND c.numeric_precision IS NOT NULL AND c.numeric_scale IS NOT NULL THEN
'NUMERIC(' || c.numeric_precision || ',' || c.numeric_scale || ')'
WHEN c.udt_name = 'numeric' AND c.numeric_precision IS NOT NULL THEN
'NUMERIC(' || c.numeric_precision || ')'
WHEN STARTS_WITH(c.udt_name, '_') THEN
UPPER(SUBSTRING(c.udt_name FROM 2)) || '[]'
ELSE
UPPER(c.udt_name)
END AS formatted_data_type,
c.is_nullable = 'YES' AS is_nullable,
EXISTS (
SELECT 1
FROM information_schema.key_column_usage kcu
JOIN information_schema.table_constraints tc
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
AND kcu.table_name = tc.table_name
WHERE tc.table_schema = c.table_schema
AND tc.table_name = c.table_name
AND tc.constraint_type = 'PRIMARY KEY'
AND kcu.column_name = c.column_name
) AS is_primary_key
FROM
information_schema.columns c
WHERE
c.table_schema = $1
AND c.table_name = $2
ORDER BY
c.ordinal_position;
"#;
let db_columns = sqlx::query_as::<_, DbColumnInfo>(query_str)
.bind(table_schema)
.bind(&table_name) // Use the validated table_name
.fetch_all(db_pool)
.await
.map_err(|e| {
Status::internal(format!(
"Failed to query column information for table '{}': {}",
table_name, e
))
})?;
if db_columns.is_empty() {
// This could mean the table exists in table_definitions but not in information_schema,
// or it has no columns. The latter is unlikely for a created table.
// Depending on desired behavior, you could return an error or an empty list.
// For now, returning an empty list if the table was validated.
}
let columns = db_columns
.into_iter()
.map(|db_col| TableColumn {
name: db_col.column_name,
data_type: db_col.formatted_data_type,
is_nullable: db_col.is_nullable,
is_primary_key: db_col.is_primary_key,
})
.collect();
Ok(TableStructureResponse { columns })
}