Compare commits

..

5 Commits

Author SHA1 Message Date
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
25 changed files with 839 additions and 889 deletions

1
Cargo.lock generated
View File

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

View File

@@ -16,6 +16,7 @@ lazy_static = "1.5.0"
prost = "0.13.5" prost = "0.13.5"
ratatui = { version = "0.29.0", features = ["crossterm"] } ratatui = { version = "0.29.0", features = ["crossterm"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41" time = "0.3.41"
tokio = { version = "1.44.2", features = ["full", "macros"] } tokio = { version = "1.44.2", features = ["full", "macros"] }
toml = "0.8.20" toml = "0.8.20"

View File

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

View File

@@ -12,16 +12,17 @@ use ratatui::{
Frame, Frame,
}; };
use crate::components::handlers::canvas::render_canvas; 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; use crate::config::binds::config::EditorKeybindingMode;
use crate::modes::handlers::mode_manager::AppMode; // For checking AppMode::Edit
pub fn render_add_logic( pub fn render_add_logic(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
app_state: &AppState, app_state: &AppState,
add_logic_state: &mut AddLogicState, add_logic_state: &mut AddLogicState, // Changed to &mut
is_edit_mode: bool, // Used for border/title hints in InsideScriptContent is_edit_mode: bool, // This is the general edit mode from EventHandler
highlight_state: &HighlightState, highlight_state: &HighlightState,
) { ) {
let main_block = Block::default() let main_block = Block::default()
@@ -41,20 +42,18 @@ pub fn render_add_logic(
let border_style = Style::default().fg(border_style_color); let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default()); 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)); editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
let script_title_hint = match add_logic_state.editor_keybinding_mode { let script_title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => { EditorKeybindingMode::Vim => {
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state); 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 // Vim mode status is relevant regardless of the general `is_edit_mode`
format!("Script {}", vim_mode_status) format!("Script {}", vim_mode_status)
} else {
format!("Script {}", vim_mode_status)
}
} }
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode { // For default/emacs, the general `is_edit_mode` (passed to this function)
// indicates if the text area itself is in an "editing" state.
if is_edit_mode { // This `is_edit_mode` refers to the text area's active editing.
"Script (Editing)".to_string() "Script (Editing)".to_string()
} else { } else {
"Script".to_string() "Script".to_string()
@@ -79,7 +78,7 @@ pub fn render_add_logic(
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // Top info 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::Min(5), // Script preview
Constraint::Length(3), // Buttons Constraint::Length(3), // Buttons
]) ])
@@ -123,10 +122,11 @@ pub fn render_add_logic(
| AddLogicFocus::InputTargetColumn | AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription | AddLogicFocus::InputDescription
); );
render_canvas( // Call render_canvas and get the active_field_rect
let active_field_rect = render_canvas(
f, f,
canvas_area, canvas_area,
add_logic_state, add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(), &add_logic_state.fields(),
&add_logic_state.current_field(), &add_logic_state.current_field(),
&add_logic_state.inputs(), &add_logic_state.inputs(),
@@ -135,6 +135,26 @@ pub fn render_add_logic(
highlight_state, 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 // Script content preview
{ {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
@@ -143,10 +163,8 @@ pub fn render_add_logic(
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview; let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
if is_script_preview_focused { 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)); editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else { } else {
// When script PREVIEW is NOT focused, use an underscore cursor.
let underscore_cursor_style = Style::default() let underscore_cursor_style = Style::default()
.add_modifier(Modifier::UNDERLINED) .add_modifier(Modifier::UNDERLINED)
.fg(theme.secondary); .fg(theme.secondary);
@@ -154,16 +172,12 @@ pub fn render_add_logic(
} }
let border_style_color = if is_script_preview_focused { let border_style_color = if is_script_preview_focused {
theme.highlight // Green highlight when focused and ready to select theme.highlight
} else { } else {
theme.secondary theme.secondary
}; };
let title_text = if is_script_preview_focused { let title_text = "Script Preview"; // Title doesn't need to change based on focus here
"Script Preview"
} else {
"Script Preview"
};
let title_style = if is_script_preview_focused { let title_style = if is_script_preview_focused {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD) Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
@@ -182,8 +196,8 @@ pub fn render_add_logic(
} }
// Buttons // Buttons
let get_button_style = |button_focus: AddLogicFocus, current_focus| { let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
let is_focused = current_focus == button_focus; let is_focused = current_focus_state == button_focus;
let base_style = Style::default().fg(if is_focused { let base_style = Style::default().fg(if is_focused {
theme.highlight theme.highlight
} else { } else {
@@ -196,11 +210,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 { if is_focused {
Style::default().fg(theme.highlight) Style::default().fg(current_theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(current_theme.secondary)
} }
}; };

View File

@@ -2,3 +2,4 @@
pub mod binds; pub mod binds;
pub mod colors; 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

@@ -1,277 +1,135 @@
// src/functions/modes/edit/add_logic_e.rs // 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 crate::state::pages::canvas_state::CanvasState;
use crossterm::event::{KeyCode, KeyEvent};
use anyhow::Result; 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( pub async fn execute_edit_action(
action: &str, action: &str,
key: KeyEvent, key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState, // Changed state: &mut AddLogicState,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
) -> Result<String> { ) -> Result<String> {
let mut message = String::new();
match action { 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" => { "next_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed let current_field = state.current_field();
if num_fields > 0 { let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
let current_field = state.current_field(); state.set_current_field(next_field);
let last_field_index = num_fields - 1; *ideal_cursor_column = state.current_cursor_pos();
if current_field < last_field_index { // Prevent cycling message = format!("Focus on field {}", state.fields()[next_field]);
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())
} }
"prev_field" => { "prev_field" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed let current_field = state.current_field();
if num_fields > 0 { let prev_field = if current_field == 0 {
let current_field = state.current_field(); AddLogicState::INPUT_FIELD_COUNT - 1
if current_field > 0 { // Prevent cycling } else {
state.set_current_field(current_field - 1); current_field - 1
} };
let current_input = state.get_current_input(); state.set_current_field(prev_field);
let max_pos = current_input.len(); *ideal_cursor_column = state.current_cursor_pos();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_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" => { "move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1); let current_pos = state.current_cursor_pos();
state.set_current_cursor_pos(new_pos); if current_pos > 0 {
*ideal_cursor_column = new_pos; let new_pos = current_pos - 1;
Ok("".to_string()) state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
} }
"move_right" => { "move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos(); 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; let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; *ideal_cursor_column = new_pos;
} }
Ok("".to_string())
} }
"move_up" => { // In edit mode, up/down usually means prev/next field "insert_char" => {
let current_field = state.current_field(); if let KeyCode::Char(c) = key.code {
if current_field > 0 { let current_pos = state.current_cursor_pos();
let new_field = current_field - 1; state.get_current_input_mut().insert(current_pos, c);
state.set_current_field(new_field); let new_pos = current_pos + 1;
let current_input = state.get_current_input(); state.set_current_cursor_pos(new_pos);
let max_pos = current_input.len(); *ideal_cursor_column = new_pos;
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); state.set_has_unsaved_changes(true);
} if state.current_field() == 1 {
Ok("".to_string()) state.update_target_column_suggestions();
}
"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));
} }
} }
Ok("".to_string())
} }
"move_line_start" => { "suggestion_down" => {
state.set_current_cursor_pos(0); if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
*ideal_cursor_column = 0; let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
Ok("".to_string()) let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
} state.selected_target_column_suggestion_index = Some(next_selection);
"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));
} }
Ok("".to_string())
} }
"move_last_line" => { "suggestion_up" => {
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
if num_fields > 0 { let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let new_field = num_fields - 1; let prev_selection = if current_selection == 0 {
state.set_current_field(new_field); state.target_column_suggestions.len() - 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())
}
"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)
} else { } else {
new_pos current_selection - 1
}; };
let max_valid_index = current_input.len(); // Allow cursor at end state.selected_target_column_suggestion_index = Some(prev_selection);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
} }
Ok("".to_string())
} }
"move_word_prev" => { "select_suggestion" => {
let current_input = state.get_current_input(); if state.in_target_column_suggestion_mode {
if !current_input.is_empty() { let mut selected_suggestion_text: Option<String> = None;
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)),
}
}
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

@@ -5,26 +5,27 @@ use crate::state::pages::{
auth::{LoginState, RegisterState}, auth::{LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
}; };
use crate::state::pages::add_logic::AddLogicState; use crate::state::pages::form::FormState; // <<< ADD THIS LINE
use crate::state::pages::form::FormState; // AddLogicState is already imported
use crate::state::pages::add_table::AddTableState; // AddTableState is already imported
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e}; use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use anyhow::Result; 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome { pub enum EditEventOutcome {
Message(String), // Return a message, stay in Edit mode Message(String),
ExitEditMode, // Signal to exit Edit mode ExitEditMode,
} }
pub async fn handle_edit_event( pub async fn handle_edit_event(
key: KeyEvent, key: KeyEvent,
config: &Config, config: &Config,
form_state: &mut FormState, form_state: &mut FormState, // Now FormState is in scope
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
admin_state: &mut AdminState, admin_state: &mut AdminState,
@@ -34,17 +35,20 @@ pub async fn handle_edit_event(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &AppState, app_state: &AppState,
) -> Result<EditEventOutcome> { ) -> 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( 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.code,
key.modifiers, 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( 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( if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common, &config.keybindings.common,
key.code, key.code,
@@ -52,261 +56,197 @@ pub async fn handle_edit_event(
).as_deref() { ).as_deref() {
if matches!(action, "save" | "revert") { if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login { let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action( auth_e::execute_common_action(action, login_state, grpc_client, current_position, total_count).await?
action,
login_state,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_e::execute_common_action( auth_e::execute_common_action(action, register_state, grpc_client, current_position, total_count).await?
action,
register_state,
grpc_client,
current_position,
total_count,
)
.await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
format!( // TODO: Implement common actions for AddTable if needed
"Action '{}' not fully implemented for Add Table view here.", format!("Action '{}' not implemented for Add Table in edit mode.", action)
action
)
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
format!( // TODO: Implement common actions for AddLogic if needed
"Action '{}' not fully implemented for Add Logic view here.", format!("Action '{}' not implemented for Add Logic in edit mode.", action)
action } else { // Assuming Form view
) let outcome = form_e::execute_common_action(action, form_state, grpc_client, current_position, total_count).await?;
} else {
let outcome = form_e::execute_common_action(
action,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
match outcome { match outcome {
EventOutcome::Ok(msg) => msg, EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
EventOutcome::DataSaved(_, msg) => msg, _ => format!("Unexpected outcome from common action: {:?}", outcome),
_ => format!(
"Unexpected outcome from common action: {:?}",
outcome
),
} }
}; };
return Ok(EditEventOutcome::Message(message_string)); return Ok(EditEventOutcome::Message(message_string));
} }
} }
// Edit-specific actions // --- Edit-specific actions ---
if let Some(action) = if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers).as_deref() {
config.get_edit_action_for_key(key.code, key.modifiers) // --- Handle "enter_decider" (Enter key) ---
.as_deref() { if action_str == "enter_decider" {
// Handle enter_decider first
if action == "enter_decider" {
let effective_action = if app_state.ui.show_register let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode && 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" "select_suggestion"
} else { } else {
"next_field" "next_field" // Default action for Enter
}; };
let msg = if app_state.ui.show_login { let msg = if app_state.ui.show_login {
auth_e::execute_edit_action( auth_e::execute_edit_action(effective_action, key, login_state, ideal_cursor_column).await?
effective_action,
key,
login_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_e::execute_edit_action( add_table_e::execute_edit_action(effective_action, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
effective_action,
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action( add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
effective_action,
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_e::execute_edit_action( auth_e::execute_edit_action(effective_action, key, register_state, ideal_cursor_column).await?
effective_action, } else { // Form view
key, form_e::execute_edit_action(effective_action, key, form_state, ideal_cursor_column).await?
register_state,
ideal_cursor_column,
)
.await?
} else {
form_e::execute_edit_action(
effective_action,
key,
form_state,
ideal_cursor_column,
)
.await?
}; };
return Ok(EditEventOutcome::Message(msg)); 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 { if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action( let msg = auth_e::execute_edit_action("exit_suggestion_mode", key, register_state, ideal_cursor_column).await?;
"exit_suggestion_mode",
key,
register_state,
ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg)); 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 { } else {
return Ok(EditEventOutcome::ExitEditMode); return Ok(EditEventOutcome::ExitEditMode);
} }
} }
// Special handling for role field suggestions (Register view only) // --- Autocomplete for AddLogicState Target Column ---
if app_state.ui.show_register && register_state.current_field() == 4 { if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 { // Target Column field
if !register_state.in_suggestion_mode if action_str == "suggestion_down" { // "Tab" is mapped to suggestion_down
&& key.code == KeyCode::Tab if !admin_state.add_logic_state.in_target_column_suggestion_mode {
&& key.modifiers == KeyModifiers::NONE // Attempt to open suggestions
{ if let Some(profile_name) = admin_state.add_logic_state.profile_name.clone().into() {
register_state.update_role_suggestions(); if let Some(table_name) = admin_state.add_logic_state.selected_table_name.clone() {
if !register_state.role_suggestions.is_empty() { debug!("Fetching table structure for autocomplete: Profile='{}', Table='{}'", profile_name, table_name);
register_state.in_suggestion_mode = true; match grpc_client.get_table_structure(profile_name, table_name).await {
register_state.selected_suggestion_index = Some(0); Ok(ts_response) => {
return Ok(EditEventOutcome::Message( admin_state.add_logic_state.table_columns_for_suggestions =
"Suggestions shown".to_string(), ts_response.columns.into_iter().map(|c| c.name).collect();
)); admin_state.add_logic_state.update_target_column_suggestions();
} else { if !admin_state.add_logic_state.target_column_suggestions.is_empty() {
return Ok(EditEventOutcome::Message( admin_state.add_logic_state.in_target_column_suggestion_mode = true;
"No suggestions available".to_string(), // 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));
} }
} } else if admin_state.add_logic_state.in_target_column_suggestion_mode && action_str == "suggestion_up" {
if register_state.in_suggestion_mode let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
&& matches!(
action,
"suggestion_down" | "suggestion_up"
)
{
let msg = auth_e::execute_edit_action(
action,
key,
register_state,
ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg)); 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 { let msg = if app_state.ui.show_login {
auth_e::execute_edit_action( auth_e::execute_edit_action(action_str, key, login_state, ideal_cursor_column).await?
action,
key,
login_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_e::execute_edit_action( add_table_e::execute_edit_action(action_str, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
action,
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action( // If not a suggestion action handled above for AddLogic
action, if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
key, add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
&mut admin_state.add_logic_state, } else { String::new() /* Already handled */ }
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_e::execute_edit_action( if !(register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
action, auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?
key, } else { String::new() /* Already handled */ }
register_state, } else { // Form view
ideal_cursor_column, form_e::execute_edit_action(action_str, key, form_state, ideal_cursor_column).await?
)
.await?
} else {
form_e::execute_edit_action(
action,
key,
form_state,
ideal_cursor_column,
)
.await?
}; };
return Ok(EditEventOutcome::Message(msg)); return Ok(EditEventOutcome::Message(msg));
} }
// --- Character insertion --- // --- 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 { if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false; register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false; register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None; 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 { let mut char_insert_msg = if app_state.ui.show_login {
auth_e::execute_edit_action( auth_e::execute_edit_action("insert_char", key, login_state, ideal_cursor_column).await?
"insert_char",
key,
login_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_e::execute_edit_action( add_table_e::execute_edit_action("insert_char", key, &mut admin_state.add_table_state, ideal_cursor_column).await?
"insert_char",
key,
&mut admin_state.add_table_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action( add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
"insert_char",
key,
&mut admin_state.add_logic_state,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_e::execute_edit_action( auth_e::execute_edit_action("insert_char", key, register_state, ideal_cursor_column).await?
"insert_char", } else { // Form view
key, form_e::execute_edit_action("insert_char", key, form_state, ideal_cursor_column).await?
register_state,
ideal_cursor_column,
)
.await?
} else {
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 { if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions(); 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

@@ -161,6 +161,7 @@ impl EventHandler {
return Ok(EventOutcome::Ok(message)); return Ok(EventOutcome::Ok(message));
} }
if !matches!(current_mode, AppMode::Edit | AppMode::Command) { if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode( if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global, key_code, modifiers &config.keybindings.global, key_code, modifiers
@@ -176,6 +177,10 @@ impl EventHandler {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string())); return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
} }
} }
"close_buffer" => {
let message = buffer_state.close_buffer_with_intro_fallback();
return Ok(EventOutcome::Ok(message));
}
_ => {} _ => {}
} }
} }

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::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty}; use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient; use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
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::{ use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient, table_definition_client::TableDefinitionClient,
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse, ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse,
@@ -63,9 +64,20 @@ impl GrpcClient {
Ok(response) Ok(response)
} }
pub async fn get_table_structure(&mut self) -> Result<TableStructureResponse> { // Updated get_table_structure method
let request = tonic::Request::new(Empty::default()); pub async fn get_table_structure(
let response = self.table_structure_client.get_adresar_table_structure(request).await?; &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()) Ok(response.into_inner())
} }
@@ -87,4 +99,3 @@ impl GrpcClient {
Ok(response.into_inner()) Ok(response.into_inner())
} }
} }

View File

@@ -17,8 +17,15 @@ impl UiService {
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?; let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree; app_state.profile_tree = profile_tree;
// Fetch table structure // TODO for general tables and not hardcoded
let table_structure = grpc_client.get_table_structure().await?; 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 // Extract the column names from the response
let column_names: Vec<String> = table_structure let column_names: Vec<String> = table_structure

View File

@@ -1,6 +1,5 @@
// src/state/app/buffer.rs // src/state/app/buffer.rs
/// Represents the distinct views or "buffers" the user can navigate.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView { pub enum AppView {
Intro, Intro,
@@ -14,7 +13,6 @@ pub enum AppView {
} }
impl AppView { impl AppView {
/// Returns the display name for the view.
pub fn display_name(&self) -> &str { pub fn display_name(&self) -> &str {
match self { match self {
AppView::Intro => "Intro", AppView::Intro => "Intro",
@@ -29,7 +27,6 @@ impl AppView {
} }
} }
/// Holds the state related to buffer management (navigation history).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BufferState { pub struct BufferState {
pub history: Vec<AppView>, pub history: Vec<AppView>,
@@ -39,23 +36,17 @@ pub struct BufferState {
impl Default for BufferState { impl Default for BufferState {
fn default() -> Self { fn default() -> Self {
Self { Self {
history: vec![AppView::Intro], // Start with Intro view history: vec![AppView::Intro],
active_index: 0, active_index: 0,
} }
} }
} }
impl BufferState { 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) { pub fn update_history(&mut self, view: AppView) {
let existing_pos = self.history.iter().position(|v| v == &view); let existing_pos = self.history.iter().position(|v| v == &view);
match existing_pos { match existing_pos {
Some(pos) => { Some(pos) => self.active_index = pos,
self.active_index = pos;
}
None => { None => {
self.history.push(view.clone()); self.history.push(view.clone());
self.active_index = self.history.len() - 1; self.active_index = self.history.len() - 1;
@@ -63,34 +54,51 @@ impl BufferState {
} }
} }
/// Gets the currently active view.
pub fn get_active_view(&self) -> Option<&AppView> { pub fn get_active_view(&self) -> Option<&AppView> {
self.history.get(self.active_index) 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 { pub fn close_active_buffer(&mut self) -> bool {
let current_index = self.active_index; if self.history.is_empty() {
self.history.push(AppView::Intro);
self.active_index = 0;
return false;
}
// Rule 1: Cannot close Intro buffer. let current_index = self.active_index;
if matches!(self.history.get(current_index), Some(AppView::Intro)) { if matches!(self.history.get(current_index), Some(AppView::Intro)) {
return false; 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;
}
self.history.remove(current_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 true
} }
}
pub fn close_buffer_with_intro_fallback(&mut self) -> String {
let current_view_cloned = self.get_active_view().cloned();
if let Some(AppView::Intro) = current_view_cloned {
return "Cannot close intro buffer".to_string();
}
let closed_name = current_view_cloned
.as_ref()
.map(|v| v.display_name().to_string())
.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!("Cannot close buffer: {}", closed_name)
}
}
}

View File

@@ -12,8 +12,8 @@ pub enum AddLogicFocus {
InputLogicName, InputLogicName,
InputTargetColumn, InputTargetColumn,
InputDescription, InputDescription,
ScriptContentPreview, // Like ColumnsTable - can be highlighted/selected ScriptContentPreview,
InsideScriptContent, // Like InsideColumnsTable - full editing mode InsideScriptContent,
SaveButton, SaveButton,
CancelButton, CancelButton,
} }
@@ -35,6 +35,13 @@ pub struct AddLogicState {
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
pub editor_keybinding_mode: EditorKeybindingMode, pub editor_keybinding_mode: EditorKeybindingMode,
pub vim_state: VimState, 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,
} }
impl AddLogicState { impl AddLogicState {
@@ -56,10 +63,57 @@ impl AddLogicState {
has_unsaved_changes: false, has_unsaved_changes: false,
editor_keybinding_mode: editor_config.keybinding_mode.clone(), editor_keybinding_mode: editor_config.keybinding_mode.clone(),
vim_state: VimState::default(), 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,
} }
} }
pub const INPUT_FIELD_COUNT: usize = 3; 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 suggestions are shown, ensure a selection (usually the first)
// or maintain current if it's still valid.
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);
}
// If the previously selected item is no longer in the filtered list, reset.
// This is a bit more complex to check perfectly without iterating again.
// For now, just ensuring it's within bounds is a good start.
// A more robust way would be to check if the string at selected_idx still matches.
} else {
self.selected_target_column_suggestion_index = Some(0);
}
} else {
self.selected_target_column_suggestion_index = None;
}
}
} }
impl Default for AddLogicState { impl Default for AddLogicState {
@@ -122,21 +176,21 @@ impl CanvasState for AddLogicState {
} }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
self.current_focus = match index { let new_focus = match index {
0 => { 0 => AddLogicFocus::InputLogicName,
self.last_canvas_field = 0; 1 => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputLogicName 2 => AddLogicFocus::InputDescription,
}, _ => return, // Or handle error/default
1 => {
self.last_canvas_field = 1;
AddLogicFocus::InputTargetColumn
},
2 => {
self.last_canvas_field = 2;
AddLogicFocus::InputDescription
},
_ => self.current_focus,
}; };
if self.current_focus != new_focus {
// If changing field, exit suggestion mode for target column
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) { fn set_current_cursor_pos(&mut self, pos: usize) {
@@ -161,10 +215,24 @@ impl CanvasState for AddLogicState {
} }
fn get_suggestions(&self) -> Option<&[String]> { fn get_suggestions(&self) -> Option<&[String]> {
None if self.current_field() == 1 // Target Column field index
&& 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> { fn get_selected_suggestion_index(&self) -> Option<usize> {
None if self.current_field() == 1 // Target Column field index
&& 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 form;
pub mod login; pub mod login;
pub mod logout;
pub mod register; pub mod register;
pub mod add_table; pub mod add_table;

View File

@@ -5,6 +5,7 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState}; 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::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::DialogPurpose; use crate::ui::handlers::context::DialogPurpose;
use common::proto::multieko2::auth::LoginResponse; use common::proto::multieko2::auth::LoginResponse;
@@ -200,6 +201,20 @@ pub fn handle_login_result(
auth_state.role = Some(response.role.clone()); auth_state.role = Some(response.role.clone());
auth_state.decoded_username = Some(response.username.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!( let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}", "Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
response.username, response.user_id, response.role 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

@@ -4,6 +4,7 @@ use crate::config::binds::config::Config;
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::services::ui_service::UiService; use crate::services::ui_service::UiService;
use crate::config::storage::storage::load_auth_data;
use crate::modes::common::commands::CommandHandler; use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome}; use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager}; use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
@@ -68,6 +69,28 @@ pub async fn run_ui() -> Result<()> {
let mut buffer_state = BufferState::default(); let mut buffer_state = BufferState::default();
let mut app_state = AppState::new().context("Failed to create initial app state")?; let mut app_state = AppState::new().context("Failed to create initial app state")?;
// --- DATA: Load auth data from file at startup ---
let mut auto_logged_in = false;
match load_auth_data() {
Ok(Some(stored_data)) => {
// TODO: Optionally validate token with server here
// For now, assume valid if successfully loaded
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 ---
// Initialize app state with profile tree and table structure // Initialize app state with profile tree and table structure
let column_names = let column_names =
UiService::initialize_app_state(&mut grpc_client, &mut app_state) UiService::initialize_app_state(&mut grpc_client, &mut app_state)
@@ -78,6 +101,16 @@ pub async fn run_ui() -> Result<()> {
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?; UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?;
form_state.reset_to_empty(); form_state.reset_to_empty();
// --- DATA2: Adjust initial view based on auth status ---
if auto_logged_in {
// User is auto-logged in, go to main app view
buffer_state.history = vec![AppView::Form("Adresar".to_string())];
buffer_state.active_index = 0;
info!("Initial view set to Form due to auto-login.");
}
// If not auto-logged in, BufferState default (Intro) will be used
// --- END DATA2 ---
// --- FPS Calculation State --- // --- FPS Calculation State ---
let mut last_frame_time = Instant::now(); let mut last_frame_time = Instant::now();
let mut current_fps = 0.0; let mut current_fps = 0.0;

View File

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

Binary file not shown.

View File

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

View File

@@ -1,11 +1,12 @@
// src/server/services/table_structure_service.rs // src/server/services/table_structure_service.rs
use tonic::{Request, Response, Status}; 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::table_structure_service_server::TableStructureService;
use common::proto::multieko2::table_structure::TableStructureResponse; use common::proto::multieko2::table_structure::{
use common::proto::multieko2::common::Empty; GetTableStructureRequest,
use crate::table_structure::handlers::{ TableStructureResponse,
get_adresar_table_structure, get_uctovnictvo_table_structure,
}; };
use crate::table_structure::handlers::get_table_structure;
use sqlx::PgPool; use sqlx::PgPool;
#[derive(Debug)] #[derive(Debug)]
@@ -13,22 +14,21 @@ pub struct TableStructureHandler {
pub db_pool: PgPool, pub db_pool: PgPool,
} }
#[tonic::async_trait] impl TableStructureHandler {
impl TableStructureService for TableStructureHandler { pub fn new(db_pool: PgPool) -> Self {
async fn get_adresar_table_structure( Self { db_pool }
&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))
} }
}
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, &self,
request: Request<Empty>, request: Request<GetTableStructureRequest>,
) -> Result<Response<TableStructureResponse>, Status> { ) -> 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)) Ok(Response::new(response))
} }
} }

View File

@@ -1,83 +1,39 @@
Adresar response: grpcurl -plaintext \
grpcurl -plaintext \ -d '{
-proto proto/table_structure.proto \ "profile_name": "default",
-import-path proto \ "table_name": "2025_customer"
}' \
localhost:50051 \ localhost:50051 \
multieko2.table_structure.TableStructureService/GetAdresarTableStructure multieko2.table_structure.TableStructureService/GetTableStructure
{ {
"columns": [ "columns": [
{ {
"name": "firma", "name": "id",
"dataType": "TEXT" "dataType": "INT8",
"isPrimaryKey": true
}, },
{ {
"name": "kz", "name": "deleted",
"dataType": "BOOL"
},
{
"name": "full_name",
"dataType": "TEXT", "dataType": "TEXT",
"isNullable": true "isNullable": true
}, },
{ {
"name": "drc", "name": "email",
"dataType": "TEXT", "dataType": "VARCHAR(255)",
"isNullable": true "isNullable": true
}, },
{ {
"name": "ulica", "name": "loyalty_status",
"dataType": "TEXT", "dataType": "BOOL",
"isNullable": true "isNullable": true
}, },
{ {
"name": "psc", "name": "created_at",
"dataType": "TEXT", "dataType": "TIMESTAMPTZ",
"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",
"isNullable": true "isNullable": true
} }
] ]

View File

@@ -1,4 +1,4 @@
// src/table_structure/handlers.rs // src/table_structure/handlers.rs
pub mod table_structure; 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 // src/table_structure/handlers/table_structure.rs
use tonic::Status; use common::proto::multieko2::table_structure::{
use sqlx::PgPool; GetTableStructureRequest, TableColumn, TableStructureResponse,
use common::proto::multieko2::{
table_structure::{TableStructureResponse, TableColumn},
common::Empty
}; };
use sqlx::{PgPool, Row};
use tonic::Status;
pub async fn get_adresar_table_structure( // Helper struct to map query results
_db_pool: &PgPool, #[derive(sqlx::FromRow, Debug)]
_request: Empty, struct DbColumnInfo {
) -> Result<TableStructureResponse, Status> { column_name: String,
let columns = vec![ formatted_data_type: String,
TableColumn { is_nullable: bool,
name: "firma".to_string(), is_primary_key: bool,
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 })
} }
pub async fn get_uctovnictvo_table_structure( pub async fn get_table_structure(
_db_pool: &PgPool, db_pool: &PgPool,
_request: Empty, request: GetTableStructureRequest,
) -> Result<TableStructureResponse, Status> { ) -> Result<TableStructureResponse, Status> {
let columns = vec![ let profile_name = request.profile_name;
TableColumn { let table_name = request.table_name; // This should be the full table name, e.g., "2025_adresar6"
name: "adresar_id".to_string(), let table_schema = "public"; // Assuming tables are in the 'public' schema
data_type: "BIGINT".to_string(),
is_nullable: false, // 1. Validate Profile
is_primary_key: false, let profile = sqlx::query!(
}, "SELECT id FROM profiles WHERE name = $1",
TableColumn { profile_name
name: "c_dokladu".to_string(), )
data_type: "TEXT".to_string(), .fetch_optional(db_pool)
is_nullable: false, .await
is_primary_key: false, .map_err(|e| {
}, Status::internal(format!(
TableColumn { "Failed to query profile '{}': {}",
name: "datum".to_string(), profile_name, e
data_type: "DATE".to_string(), ))
is_nullable: false, })?;
is_primary_key: false,
}, let profile_id = match profile {
TableColumn { Some(p) => p.id,
name: "c_faktury".to_string(), None => {
data_type: "TEXT".to_string(), return Err(Status::not_found(format!(
is_nullable: false, "Profile '{}' not found",
is_primary_key: false, profile_name
}, )));
TableColumn { }
name: "obsah".to_string(), };
data_type: "TEXT".to_string(),
is_nullable: true, // 2. Validate Table within Profile
is_primary_key: false, sqlx::query!(
}, "SELECT id FROM table_definitions WHERE profile_id = $1 AND table_name = $2",
TableColumn { profile_id,
name: "stredisko".to_string(), table_name
data_type: "TEXT".to_string(), )
is_nullable: true, .fetch_optional(db_pool)
is_primary_key: false, .await
}, .map_err(|e| Status::internal(format!("Failed to query table_definitions: {}", e)))?
TableColumn { .ok_or_else(|| Status::not_found(format!(
name: "c_uctu".to_string(), "Table '{}' not found in profile '{}'",
data_type: "TEXT".to_string(), table_name,
is_nullable: true, profile_name
is_primary_key: false, )))?;
},
TableColumn { // 3. Query information_schema for column details
name: "md".to_string(), let query_str = r#"
data_type: "TEXT".to_string(), SELECT
is_nullable: true, c.column_name,
is_primary_key: false, CASE
}, WHEN c.udt_name = 'varchar' AND c.character_maximum_length IS NOT NULL THEN
TableColumn { 'VARCHAR(' || c.character_maximum_length || ')'
name: "identif".to_string(), WHEN c.udt_name = 'bpchar' AND c.character_maximum_length IS NOT NULL THEN
data_type: "TEXT".to_string(), 'CHAR(' || c.character_maximum_length || ')'
is_nullable: true, WHEN c.udt_name = 'numeric' AND c.numeric_precision IS NOT NULL AND c.numeric_scale IS NOT NULL THEN
is_primary_key: false, 'NUMERIC(' || c.numeric_precision || ',' || c.numeric_scale || ')'
}, WHEN c.udt_name = 'numeric' AND c.numeric_precision IS NOT NULL THEN
TableColumn { 'NUMERIC(' || c.numeric_precision || ')'
name: "poznanka".to_string(), WHEN STARTS_WITH(c.udt_name, '_') THEN
data_type: "TEXT".to_string(), UPPER(SUBSTRING(c.udt_name FROM 2)) || '[]'
is_nullable: true, ELSE
is_primary_key: false, UPPER(c.udt_name)
}, END AS formatted_data_type,
TableColumn { c.is_nullable = 'YES' AS is_nullable,
name: "firma".to_string(), EXISTS (
data_type: "TEXT".to_string(), SELECT 1
is_nullable: false, FROM information_schema.key_column_usage kcu
is_primary_key: false, 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 }) Ok(TableStructureResponse { columns })
} }