Compare commits

..

49 Commits

Author SHA1 Message Date
filipriec
6b5cbe854b now working with the gen schema in the database 2025-06-02 12:39:23 +02:00
filipriec
59ed52814e compiled, needs other fixes 2025-06-02 12:08:16 +02:00
filipriec
3488ab4f6b hardcoded adresar to general form 2025-06-02 10:32:39 +02:00
filipriec
6e2fc5349b code cleanup 2025-05-31 23:02:09 +02:00
filipriec
ea88c2686d tabbing now adds / if there is nothing to tab to 2025-05-30 23:43:49 +02:00
filipriec
3df4baec92 tabbing now works perfectly well 2025-05-30 23:36:53 +02:00
filipriec
ff74e1aaa1 it works amazingly well now, we can select the table name via command line 2025-05-30 22:46:32 +02:00
filipriec
b0c865ab76 workig suggestion menu 2025-05-29 19:46:58 +02:00
filipriec
3dbc086f10 overriding overflows by using empty spaces as letters 2025-05-29 19:32:48 +02:00
filipriec
e9b4b34fb4 fixed height of the find file 2025-05-29 19:02:02 +02:00
filipriec
668eeee197 navigation in the menu but needs refactoring 2025-05-29 16:11:41 +02:00
filipriec
799d8471c9 open menu in command mode now implemented 2025-05-28 19:09:55 +02:00
filipriec
f77c16dec9 temp fix, before implementing C-x C-f 2025-05-28 15:53:33 +02:00
filipriec
45026cac6a table schema is gen now 2025-05-28 15:40:17 +02:00
filipriec
edf6ab5bca gen schema being created 2025-05-28 13:10:08 +02:00
filipriec
462b1f14e2 generated tables are now in gen schema, breaking change, needs crucial fixes NOW 2025-05-27 22:21:40 +02:00
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
filipriec
bd7c97ca91 required table to access logic 2025-05-25 17:53:06 +02:00
filipriec
81235c67dc add script now has a proper way of doing things 2025-05-25 15:46:06 +02:00
filipriec
65e8e03224 better and better add script 2025-05-25 15:27:41 +02:00
filipriec
85eb3adec7 logic is being implemented properly well 2025-05-25 15:09:38 +02:00
filipriec
5d0f958a68 working cursor tracking in the add_table 2025-05-25 14:08:50 +02:00
filipriec
b82f50b76b movement in the add_table now fixed 2025-05-25 12:40:19 +02:00
filipriec
0ab11a9bf9 add table movement adjustements 2025-05-25 12:27:30 +02:00
filipriec
d28c310704 forbid jump from the last to the first and vice versa 2025-05-25 11:39:25 +02:00
filipriec
2e1d7fdf2b admin panel fixed completely 2025-05-25 11:26:19 +02:00
filipriec
82e96f7b86 vim or default mode workin properly now 2025-05-24 19:25:35 +02:00
filipriec
7229e2abbd weird highlight is gone 2025-05-24 18:53:06 +02:00
filipriec
4e35043da0 vim keybinings are now working properly well 2025-05-24 18:41:41 +02:00
filipriec
56fe1c2ccc text area working now perfectly well 2025-05-24 16:34:59 +02:00
filipriec
a874edf2a1 text area on focus is now big 2025-05-24 14:24:19 +02:00
filipriec
9d55ec3e43 h and l movements are now working 2025-05-23 16:59:12 +02:00
filipriec
05580ac978 better and better 2025-05-23 15:40:16 +02:00
filipriec
667eb4809d compiled but the readonly and edit mode is not working 2025-05-23 15:22:21 +02:00
filipriec
58fdaa8298 add logic, not working tho 2025-05-23 13:34:49 +02:00
77 changed files with 5582 additions and 2391 deletions

83
Cargo.lock generated
View File

@@ -402,19 +402,21 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"common", "common",
"crossterm 0.29.0", "crossterm",
"dirs 6.0.0", "dirs 6.0.0",
"dotenvy", "dotenvy",
"lazy_static", "lazy_static",
"prost", "prost",
"ratatui", "ratatui",
"serde", "serde",
"serde_json",
"time", "time",
"tokio", "tokio",
"toml", "toml",
"tonic", "tonic",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tui-textarea",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]
@@ -484,15 +486,6 @@ version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2459fc9262a1aa204eb4b5764ad4f189caec88aea9634389c0a25f8be7f6265e" checksum = "2459fc9262a1aa204eb4b5764ad4f189caec88aea9634389c0a25f8be7f6265e"
[[package]]
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -620,24 +613,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 1.0.5",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]] [[package]]
name = "crossterm_winapi" name = "crossterm_winapi"
version = "0.9.1" version = "0.9.1"
@@ -726,27 +701,6 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -812,15 +766,6 @@ dependencies = [
"syn 2.0.100", "syn 2.0.100",
] ]
[[package]]
name = "document-features"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@@ -1681,12 +1626,6 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
[[package]]
name = "litrs"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.12" version = "0.4.12"
@@ -2354,7 +2293,7 @@ dependencies = [
"bitflags", "bitflags",
"cassowary", "cassowary",
"compact_str", "compact_str",
"crossterm 0.28.1", "crossterm",
"indoc", "indoc",
"instability", "instability",
"itertools 0.13.0", "itertools 0.13.0",
@@ -3606,6 +3545,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a"
[[package]]
name = "tui-textarea"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"ratatui",
"regex",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "typed-arena" name = "typed-arena"
version = "2.0.2" version = "2.0.2"
@@ -3899,7 +3850,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]

View File

@@ -9,18 +9,20 @@ anyhow = "1.0.98"
async-trait = "0.1.88" async-trait = "0.1.88"
common = { path = "../common" } common = { path = "../common" }
crossterm = "0.29.0" crossterm = "0.28.1"
dirs = "6.0.0" dirs = "6.0.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
lazy_static = "1.5.0" lazy_static = "1.5.0"
prost = "0.13.5" prost = "0.13.5"
ratatui = "0.29.0" 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"
tonic = "0.13.0" tonic = "0.13.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
unicode-width = "0.2.0" unicode-width = "0.2.0"

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"]
@@ -82,6 +83,10 @@ quit = ["q"]
force_quit = ["q!"] force_quit = ["q!"]
save_and_quit = ["wq"] save_and_quit = ["wq"]
revert = ["r"] revert = ["r"]
find_file_palette_toggle = ["ff"]
[editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors] [colors]
theme = "dark" theme = "dark"

View File

@@ -2,7 +2,9 @@
pub mod admin_panel; pub mod admin_panel;
pub mod admin_panel_admin; pub mod admin_panel_admin;
pub mod add_table; pub mod add_table;
pub mod add_logic;
pub use admin_panel::*; pub use admin_panel::*;
pub use admin_panel_admin::*; pub use admin_panel_admin::*;
pub use add_table::*; pub use add_table::*;
pub use add_logic::*;

View File

@@ -0,0 +1,313 @@
// src/components/admin/add_logic.rs
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
use crate::state::pages::canvas_state::CanvasState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
pub fn render_add_logic(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let main_block = Block::default()
.title(" Add New Logic Script ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.bg));
let inner_area = main_block.inner(area);
f.render_widget(main_block, area);
// Handle full-screen script editing
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default());
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
let script_title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
format!("Script {}", vim_mode_status)
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode {
"Script (Editing)".to_string()
} else {
"Script".to_string()
}
}
};
editor_ref.set_block(
Block::default()
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style),
);
f.render_widget(&*editor_ref, inner_area);
// Drop the editor borrow before accessing autocomplete state
drop(editor_ref);
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
// Get the current cursor position from textarea
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
};
let (cursor_line, cursor_col) = current_cursor;
// Account for TextArea's block borders (1 for each side)
let block_offset_x = 1;
let block_offset_y = 1;
// Position autocomplete at current cursor position
// Add 1 to column to position dropdown right after the cursor
let autocomplete_x = cursor_col + 1;
let autocomplete_y = cursor_line;
let input_rect = Rect {
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
width: 1, // Minimum width for positioning
height: 1,
};
// Render autocomplete dropdown
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
&add_logic_state.script_editor_suggestions,
add_logic_state.script_editor_selected_suggestion_index,
);
}
return; // Exit early for fullscreen mode
}
// Regular layout with preview
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Top info
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
Constraint::Min(5), // Script preview
Constraint::Length(3), // Buttons
])
.split(inner_area);
let top_info_area = main_chunks[0];
let canvas_area = main_chunks[1];
let script_content_area = main_chunks[2];
let buttons_area = main_chunks[3];
// Top info
let profile_text = Paragraph::new(vec![
Line::from(Span::styled(
format!("Profile: {}", add_logic_state.profile_name),
Style::default().fg(theme.fg),
)),
Line::from(Span::styled(
format!(
"Table: {}",
add_logic_state
.selected_table_name
.clone()
.unwrap_or_else(|| add_logic_state.selected_table_id
.map(|id| format!("ID {}", id))
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
),
Style::default().fg(theme.fg),
)),
])
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.secondary)),
);
f.render_widget(profile_text, top_info_area);
// Canvas
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
// Call render_canvas and get the active_field_rect
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
theme,
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
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();
editor_ref.set_cursor_line_style(Style::default());
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
if is_script_preview_focused {
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
} else {
let underscore_cursor_style = Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(theme.secondary);
editor_ref.set_cursor_style(underscore_cursor_style);
}
let border_style_color = if is_script_preview_focused {
theme.highlight
} else {
theme.secondary
};
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
let title_style = if is_script_preview_focused {
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
editor_ref.set_block(
Block::default()
.title(Span::styled(title_text, title_style))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_style_color)),
);
f.render_widget(&*editor_ref, script_content_area);
}
// Buttons
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
let is_focused = current_focus_state == button_focus;
let base_style = Style::default().fg(if is_focused {
theme.highlight
} else {
theme.secondary
});
if is_focused {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
}
};
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
if is_focused {
Style::default().fg(current_theme.highlight)
} else {
Style::default().fg(current_theme.secondary)
}
};
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(buttons_area);
let save_button = Paragraph::new(" Save Logic ")
.style(get_button_style(
AddLogicFocus::SaveButton,
add_logic_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::SaveButton,
theme,
)),
);
f.render_widget(save_button, button_chunks[0]);
let cancel_button = Paragraph::new(" Cancel ")
.style(get_button_style(
AddLogicFocus::CancelButton,
add_logic_state.current_focus,
))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_button_border_style(
add_logic_state.current_focus == AddLogicFocus::CancelButton,
theme,
)),
);
f.render_widget(cancel_button, button_chunks[1]);
// Dialog
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -6,12 +6,11 @@ use crate::state::app::state::AppState;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style, style::Style,
text::{Line, Span, Text}, // Added Text text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph}, widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
Frame, Frame,
}; };
/// Renders the view specific to admin users with a three-pane layout.
pub fn render_admin_panel_admin( pub fn render_admin_panel_admin(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
@@ -19,15 +18,13 @@ pub fn render_admin_panel_admin(
admin_state: &mut AdminState, admin_state: &mut AdminState,
theme: &Theme, theme: &Theme,
) { ) {
// Split vertically: Top for panes, Bottom for buttons
let main_chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) // 1 line for buttons .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(area); .split(area);
let panes_area = main_chunks[0]; let panes_area = main_chunks[0];
let buttons_area = main_chunks[1]; let buttons_area = main_chunks[1];
// Split the top area into three panes: Profiles | Tables | Dependencies
let pane_chunks = Layout::default() let pane_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
@@ -35,207 +32,148 @@ pub fn render_admin_panel_admin(
Constraint::Percentage(40), // Tables Constraint::Percentage(40), // Tables
Constraint::Percentage(35), // Dependencies Constraint::Percentage(35), // Dependencies
].as_ref()) ].as_ref())
.split(panes_area); // Use the whole area directly .split(panes_area);
let profiles_pane = pane_chunks[0]; let profiles_pane = pane_chunks[0];
let tables_pane = pane_chunks[1]; let tables_pane = pane_chunks[1];
let deps_pane = pane_chunks[2]; let deps_pane = pane_chunks[2];
// --- Profiles Pane (Left) --- // --- Profiles Pane (Left) ---
let profile_focus = admin_state.current_focus == AdminFocus::Profiles; let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
let profile_border_style = if profile_focus { let profile_border_style = if profile_pane_has_focus {
Style::default().fg(theme.highlight) Style::default().fg(theme.highlight)
} else { } else {
Style::default().fg(theme.border) Style::default().fg(theme.border)
}; };
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
// Block for the profiles pane let profiles_inner_area = profiles_block.inner(profiles_pane);
let profiles_block = Block::default() f.render_widget(profiles_block, profiles_pane);
.title(" Profiles ") let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
.borders(Borders::ALL) let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
.border_type(BorderType::Rounded) let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
.border_style(profile_border_style); let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
let profiles_inner_area = profiles_block.inner(profiles_pane); // Get inner area for list let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
f.render_widget(profiles_block, profiles_pane); // Render the block itself else if is_persistently_selected { Style::default().fg(theme.accent) }
else { Style::default().fg(theme.fg) };
// Create profile list items ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
let profile_list_items: Vec<ListItem> = app_state }).collect();
.profile_tree
.profiles
.iter()
.enumerate()
.map(|(idx, profile)| {
// Check persistent selection for prefix, navigation state for style/highlight
let is_selected = admin_state.selected_profile_index == Some(idx);
let prefix = if is_selected { "[*] " } else { "[ ] " };
let style = if is_selected { // Style based on selection too
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
ListItem::new(Line::from(vec![
Span::styled(prefix, style),
Span::styled(&profile.name, style)
]))
})
.collect();
// Build and render profile list inside the block's inner area
let profile_list = List::new(profile_list_items) let profile_list = List::new(profile_list_items)
// Highlight style depends only on Profiles focus .highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
.highlight_style(if profile_focus { .highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
} else {
Style::default()
})
.highlight_symbol(if profile_focus { "> " } else { " " });
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state); f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
// --- Tables Pane (Middle) --- // --- Tables Pane (Middle) ---
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList); let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
let table_border_style = if table_pane_has_focus { let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
Style::default().fg(theme.highlight)
let profile_to_display_tables_for_idx: Option<usize>;
if admin_state.current_focus == AdminFocus::InsideProfilesList {
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
} else { } else {
Style::default().fg(theme.border) profile_to_display_tables_for_idx = admin_state.selected_profile_index
}; .or_else(|| admin_state.profile_list_state.selected());
}
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
.map_or("None Selected", |p| p.name.as_str());
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
let tables_inner_area = tables_block.inner(tables_pane);
f.render_widget(tables_block, tables_pane);
// Get selected profile information let table_list_items_for_display: Vec<ListItem> =
let navigated_profile_idx = admin_state.profile_list_state.selected(); // Use nav state for display if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
let selected_profile_name = app_state .and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
.profile_tree profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
.profiles let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
.get(navigated_profile_idx.unwrap_or(usize::MAX)) // Use nav state for title profile_to_display_tables_for_idx == admin_state.selected_profile_index;
.map_or("None", |p| &p.name); let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
admin_state.current_focus == AdminFocus::InsideTablesList;
// Block for the tables pane let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
let tables_block = Block::default() let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
.title(format!(" Tables (Profile: {}) ", selected_profile_name)) else if is_table_persistently_selected { Style::default().fg(theme.accent) }
.borders(Borders::ALL) else { Style::default().fg(theme.fg) };
.border_type(BorderType::Rounded) ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
.border_style(table_border_style); }).collect()
let tables_inner_area = tables_block.inner(tables_pane); // Get inner area for list
f.render_widget(tables_block, tables_pane); // Render the block itself
// Create table list items and get dependencies for the selected table
let (table_list_items, selected_table_deps): (Vec<ListItem>, Vec<String>) = if let Some(
profile, // Get profile based on NAVIGATED profile index
) = navigated_profile_idx.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
let items: Vec<ListItem> = profile
.tables
.iter()
.enumerate()
.map(|(idx, table)| { // Renamed i to idx for clarity
// Check persistent selection for prefix, navigation state for style/highlight
let is_selected = admin_state.selected_table_index == Some(idx); // Use persistent state for [*]
let is_navigated = admin_state.table_list_state.selected() == Some(idx); // Use nav state for highlight/>
let prefix = if is_selected { "[*] " } else { "[ ] " };
let style = if is_navigated { // Style based on navigation highlight
Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
ListItem::new(Line::from(vec![
Span::styled(prefix, style),
Span::styled(&table.name, style),
]))
})
.collect();
// Get dependencies only for the PERSISTENTLY selected table in the PERSISTENTLY selected profile
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
let deps = chosen_profile_idx // Start with the chosen profile index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) // Get the chosen profile
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Get the chosen table using its index
.map_or(Vec::new(), |t| t.depends_on.clone()); // If found, clone its depends_on, otherwise return empty Vec
(items, deps)
} else {
// Default when no profile is selected
(vec![ListItem::new("Select a profile to see tables")], vec![])
};
// Build and render table list inside the block's inner area
let table_list = List::new(table_list_items)
// Highlight style only applies when focus is *inside* the list
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList {
Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
} else { } else {
Style::default() vec![ListItem::new("Select a profile to see tables")]
}) };
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { "" }); let table_list = List::new(table_list_items_for_display)
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state); f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
// --- Dependencies Pane (Right) ---
// Get name based on PERSISTENT selections
let chosen_profile_idx = admin_state.selected_profile_index; // Use persistent profile selection
let selected_table_name = chosen_profile_idx
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx))) // Use persistent table selection
.map_or("N/A", |t| &t.name); // Get name of the selected table
// Block for the dependencies pane // --- Dependencies Pane (Right) ---
let mut deps_pane_title_table_name = "N/A".to_string();
let dependencies_to_display: Vec<String>;
if admin_state.current_focus == AdminFocus::InsideTablesList {
// If navigating tables, show dependencies for the '>' highlighted table.
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
deps_pane_title_table_name = navigated_table.name.clone();
dependencies_to_display = navigated_table.depends_on.clone();
} else {
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
}
} else {
dependencies_to_display = Vec::new(); // No table navigated with '>'
}
} else {
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
}
} else {
dependencies_to_display = Vec::new(); // No profile active for table display
}
} else {
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
deps_pane_title_table_name = selected_table.name.clone();
dependencies_to_display = selected_table.depends_on.clone();
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
} else { dependencies_to_display = Vec::new(); }
}
let deps_block = Block::default() let deps_block = Block::default()
.title(format!(" Dependencies (Table: {}) ", selected_table_name)) .title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border)); // No focus highlight for deps pane .border_style(Style::default().fg(theme.border));
let deps_inner_area = deps_block.inner(deps_pane); // Get inner area for content let deps_inner_area = deps_block.inner(deps_pane);
f.render_widget(deps_block, deps_pane); // Render the block itself f.render_widget(deps_block, deps_pane);
// Prepare content for the dependencies paragraph
let mut deps_content = Text::default(); let mut deps_content = Text::default();
deps_content.lines.push(Line::from(Span::styled( deps_content.lines.push(Line::from(Span::styled(
"Depends On:", "Depends On:",
Style::default().fg(theme.accent), // Use accent color for the label Style::default().fg(theme.accent),
))); )));
if !selected_table_deps.is_empty() { if !dependencies_to_display.is_empty() {
for dep in selected_table_deps { for dep in dependencies_to_display {
// List each dependency
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg))); deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
} }
} else { } else {
// Indicate if there are no dependencies
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary))); deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
} }
// Build and render dependencies paragraph inside the block's inner area
let deps_paragraph = Paragraph::new(deps_content); let deps_paragraph = Paragraph::new(deps_content);
f.render_widget(deps_paragraph, deps_inner_area); f.render_widget(deps_paragraph, deps_inner_area);
// --- Buttons Row --- // --- Buttons Row ---
let button_chunks = Layout::default() let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
].as_ref())
.split(buttons_area);
let btn_base_style = Style::default().fg(theme.secondary); let btn_base_style = Style::default().fg(theme.secondary);
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
// Define the helper closure to get style based on focus let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
let get_btn_style = |button_focus: AdminFocus| { let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
if admin_state.current_focus == button_focus { let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
// Apply highlight style if this button is focused
btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED)
} else {
btn_base_style // Use base style otherwise
}
};
let btn1 = Paragraph::new("Add Logic")
.style(get_btn_style(AdminFocus::Button1))
.alignment(Alignment::Center);
let btn2 = Paragraph::new("Add Table")
.style(get_btn_style(AdminFocus::Button2))
.alignment(Alignment::Center);
let btn3 = Paragraph::new("Change Table")
.style(get_btn_style(AdminFocus::Button3))
.alignment(Alignment::Center);
f.render_widget(btn1, button_chunks[0]); f.render_widget(btn1, button_chunks[0]);
f.render_widget(btn2, button_chunks[1]); f.render_widget(btn2, button_chunks[1]);
f.render_widget(btn3, button_chunks[2]); f.render_widget(btn3, button_chunks[2]);

View File

@@ -1,12 +1,16 @@
// src/components/common.rs // src/components/common.rs
pub mod command_line; pub mod command_line;
pub mod status_line; pub mod status_line;
pub mod text_editor;
pub mod background; pub mod background;
pub mod dialog; pub mod dialog;
pub mod autocomplete; pub mod autocomplete;
pub mod find_file_palette;
pub use command_line::*; pub use command_line::*;
pub use status_line::*; pub use status_line::*;
pub use text_editor::*;
pub use background::*; pub use background::*;
pub use dialog::*; pub use dialog::*;
pub use autocomplete::*; pub use autocomplete::*;
pub use find_file_palette::*;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,331 @@
// src/components/common/text_editor.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use ratatui::style::{Color, Style, Modifier};
use tui_textarea::{Input, Key, TextArea, CursorMove};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimMode {
Normal,
Insert,
Visual,
Operator(char),
}
impl VimMode {
pub fn cursor_style(&self) -> Style {
let color = match self {
Self::Normal => Color::Reset,
Self::Insert => Color::LightBlue,
Self::Visual => Color::LightYellow,
Self::Operator(_) => Color::LightGreen,
};
Style::default().fg(color).add_modifier(Modifier::REVERSED)
}
}
impl fmt::Display for VimMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
Self::Normal => write!(f, "NORMAL"),
Self::Insert => write!(f, "INSERT"),
Self::Visual => write!(f, "VISUAL"),
Self::Operator(c) => write!(f, "OPERATOR({})", c),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Transition {
Nop,
Mode(VimMode),
Pending(Input),
}
#[derive(Debug, Clone)]
pub struct VimState {
pub mode: VimMode,
pub pending: Input,
}
impl Default for VimState {
fn default() -> Self {
Self {
mode: VimMode::Normal,
pending: Input::default(),
}
}
}
impl VimState {
pub fn new(mode: VimMode) -> Self {
Self {
mode,
pending: Input::default(),
}
}
fn with_pending(self, pending: Input) -> Self {
Self {
mode: self.mode,
pending,
}
}
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
if input.key == Key::Null {
return Transition::Nop;
}
match self.mode {
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
match input {
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
Input { key: Key::Char('e'), ctrl: false, .. } => {
textarea.move_cursor(CursorMove::WordEnd);
if matches!(self.mode, VimMode::Operator(_)) {
textarea.move_cursor(CursorMove::Forward);
}
}
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
Input { key: Key::Char('D'), .. } => {
textarea.delete_line_by_end();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('C'), .. } => {
textarea.delete_line_by_end();
textarea.cancel_selection();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('p'), .. } => {
textarea.paste();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('u'), ctrl: false, .. } => {
textarea.undo();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('r'), ctrl: true, .. } => {
textarea.redo();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('x'), .. } => {
textarea.delete_next_char();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('i'), .. } => {
textarea.cancel_selection();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('a'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Forward);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('A'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::End);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('o'), .. } => {
textarea.move_cursor(CursorMove::End);
textarea.insert_newline();
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('O'), .. } => {
textarea.move_cursor(CursorMove::Head);
textarea.insert_newline();
textarea.move_cursor(CursorMove::Up);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('I'), .. } => {
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Head);
return Transition::Mode(VimMode::Insert);
}
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.start_selection();
return Transition::Mode(VimMode::Visual);
}
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
textarea.move_cursor(CursorMove::End);
return Transition::Mode(VimMode::Visual);
}
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.cancel_selection();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
self.pending,
Input { key: Key::Char('g'), ctrl: false, .. }
) => {
textarea.move_cursor(CursorMove::Top)
}
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
let cursor = textarea.cursor();
textarea.move_cursor(CursorMove::Down);
if cursor == textarea.cursor() {
textarea.move_cursor(CursorMove::End);
}
}
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
textarea.start_selection();
return Transition::Mode(VimMode::Operator(op));
}
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.copy();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.cut();
return Transition::Mode(VimMode::Normal);
}
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
textarea.move_cursor(CursorMove::Forward);
textarea.cut();
return Transition::Mode(VimMode::Insert);
}
// Arrow keys work in normal mode
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
input => return Transition::Pending(input),
}
// Handle the pending operator
match self.mode {
VimMode::Operator('y') => {
textarea.copy();
Transition::Mode(VimMode::Normal)
}
VimMode::Operator('d') => {
textarea.cut();
Transition::Mode(VimMode::Normal)
}
VimMode::Operator('c') => {
textarea.cut();
Transition::Mode(VimMode::Insert)
}
_ => Transition::Nop,
}
}
VimMode::Insert => match input {
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
Transition::Mode(VimMode::Normal)
}
input => {
textarea.input(input);
Transition::Mode(VimMode::Insert)
}
},
}
}
}
pub struct TextEditor;
impl TextEditor {
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
let mut textarea = TextArea::default();
if editor_config.show_line_numbers {
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
}
textarea.set_tab_length(editor_config.tab_width);
textarea
}
pub fn handle_input(
textarea: &mut TextArea<'static>,
key_event: KeyEvent,
keybinding_mode: &EditorKeybindingMode,
vim_state: &mut VimState,
) -> bool {
match keybinding_mode {
EditorKeybindingMode::Vim => {
Self::handle_vim_input(textarea, key_event, vim_state)
}
_ => {
let tui_input: Input = key_event.into();
textarea.input(tui_input)
}
}
}
fn handle_vim_input(
textarea: &mut TextArea<'static>,
key_event: KeyEvent,
vim_state: &mut VimState,
) -> bool {
let input = Self::convert_key_event_to_input(key_event);
*vim_state = match vim_state.transition(input, textarea) {
Transition::Mode(mode) if vim_state.mode != mode => {
// Update cursor style based on mode
textarea.set_cursor_style(mode.cursor_style());
VimState::new(mode)
}
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
Transition::Pending(input) => vim_state.clone().with_pending(input),
};
true // Always consider input as handled in vim mode
}
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
let key = match key_event.code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Enter => Key::Enter,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Backspace => Key::Backspace,
KeyCode::Delete => Key::Delete,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Tab => Key::Tab,
KeyCode::Esc => Key::Esc,
_ => Key::Null,
};
Input {
key,
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
alt: key_event.modifiers.contains(KeyModifiers::ALT),
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
}
}
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
vim_state.mode.to_string()
}
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
matches!(vim_state.mode, VimMode::Insert)
}
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
matches!(vim_state.mode, VimMode::Normal)
}
}

View File

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

View File

@@ -2,6 +2,7 @@
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::buffer::BufferState; use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; // Add this import
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::Style, style::Style,
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
area: Rect, area: Rect,
theme: &Theme, theme: &Theme,
buffer_state: &BufferState, buffer_state: &BufferState,
app_state: &AppState, // Add this parameter
) { ) {
// --- Style Definitions --- // --- Style Definitions ---
let active_style = Style::default() let active_style = Style::default()
@@ -37,6 +39,9 @@ pub fn render_buffer_list(
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut current_width = 0; 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() { for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer // Filter: Only process views matching the active layer
if get_view_layer(view) != 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 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 buffer_text = format!(" {} ", buffer_name);
let text_width = UnicodeWidthStr::width(buffer_text.as_str()); let text_width = UnicodeWidthStr::width(buffer_text.as_str());

View File

@@ -1,11 +1,57 @@
// src/config/binds/config.rs // src/config/binds/config.rs
use serde::Deserialize; use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
// NEW: Editor Keybinding Mode Enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EditorKeybindingMode {
#[serde(rename = "default")]
Default,
#[serde(rename = "vim")]
Vim,
#[serde(rename = "emacs")]
Emacs,
}
impl Default for EditorKeybindingMode {
fn default() -> Self {
EditorKeybindingMode::Default
}
}
// NEW: Editor Configuration Struct
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorConfig {
#[serde(default)]
pub keybinding_mode: EditorKeybindingMode,
#[serde(default = "default_show_line_numbers")]
pub show_line_numbers: bool,
#[serde(default = "default_tab_width")]
pub tab_width: u8,
}
fn default_show_line_numbers() -> bool {
true
}
fn default_tab_width() -> u8 {
4
}
impl Default for EditorConfig {
fn default() -> Self {
EditorConfig {
keybinding_mode: EditorKeybindingMode::default(),
show_line_numbers: default_show_line_numbers(),
tab_width: default_tab_width(),
}
}
}
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
pub struct ColorsConfig { pub struct ColorsConfig {
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
@@ -22,9 +68,14 @@ pub struct Config {
pub keybindings: ModeKeybindings, pub keybindings: ModeKeybindings,
#[serde(default)] #[serde(default)]
pub colors: ColorsConfig, pub colors: ColorsConfig,
// NEW: Add editor configuration
#[serde(default)]
pub editor: EditorConfig,
} }
#[derive(Debug, Deserialize)] // ... (rest of your Config struct and impl Config remains the same)
// Make sure ModeKeybindings is also deserializable if it's not already
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
pub struct ModeKeybindings { pub struct ModeKeybindings {
#[serde(default)] #[serde(default)]
pub general: HashMap<String, Vec<String>>, pub general: HashMap<String, Vec<String>>,
@@ -49,11 +100,11 @@ impl Config {
let config_path = Path::new(manifest_dir).join("config.toml"); let config_path = Path::new(manifest_dir).join("config.toml");
let config_str = std::fs::read_to_string(&config_path) let config_str = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file at {:?}", config_path))?; .with_context(|| format!("Failed to read config file at {:?}", config_path))?;
let config: Config = toml::from_str(&config_str)?; let config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
Ok(config) Ok(config)
} }
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers) self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))

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

@@ -6,8 +6,8 @@ use crate::state::app::buffer::AppView;
pub fn get_view_layer(view: &AppView) -> u8 { pub fn get_view_layer(view: &AppView) -> u8 {
match view { match view {
AppView::Intro => 1, AppView::Intro => 1,
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable => 2, AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form(_) | AppView::Scratch => 3, AppView::Form | AppView::Scratch => 3,
} }
} }

View File

@@ -3,3 +3,4 @@
pub mod form_e; pub mod form_e;
pub mod auth_e; pub mod auth_e;
pub mod add_table_e; pub mod add_table_e;
pub mod add_logic_e;

View File

@@ -0,0 +1,135 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let mut message = String::new();
match action {
"next_field" => {
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 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(); }
}
}
"move_left" => {
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_pos = state.current_cursor_pos();
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;
}
}
"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();
}
}
}
"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);
}
}
"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 {
current_selection - 1
};
state.selected_target_column_suggestion_index = Some(prev_selection);
}
}
"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

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

View File

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

View File

@@ -2,3 +2,4 @@
pub mod admin_nav; pub mod admin_nav;
pub mod add_table_nav; pub mod add_table_nav;
pub mod add_logic_nav;

View File

@@ -0,0 +1,440 @@
// src/functions/modes/navigation/add_logic_nav.rs
use crate::config::binds::config::{Config, EditorKeybindingMode};
use crate::state::{
app::state::AppState,
pages::add_logic::{AddLogicFocus, AddLogicState},
app::buffer::AppView,
app::buffer::BufferState,
};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
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>>;
pub fn handle_add_logic_navigation(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: &mut bool,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
_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 {
// === 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 {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
_ => {
if *is_edit_mode {
*is_edit_mode = false;
*command_message = "Exited script edit. Esc again to exit script.".to_string();
} else {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
}
return 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;
}
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
}
return true;
}
let action = config.get_general_action(key_event.code, key_event.modifiers);
let current_focus = add_logic_state.current_focus;
let mut handled = true;
let mut new_focus = current_focus;
match action.as_deref() {
Some("exit_table_scroll") => {
handled = false;
}
Some("move_up") => {
match current_focus {
AddLogicFocus::InputLogicName => {}
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("move_down") => {
match current_focus {
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => {
add_logic_state.last_canvas_field = 2;
new_focus = AddLogicFocus::ScriptContentPreview;
},
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
Some("next_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ new_focus = AddLogicFocus::ScriptContentPreview; }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => { }
_ => handled = false,
}
}
Some("previous_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("next_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
_ => current_focus,
};
}
Some("prev_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
_ => current_focus,
};
}
Some("select") => {
match current_focus {
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InsideScriptContent;
*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",
};
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
}
AddLogicFocus::SaveButton => {
*command_message = "Save logic action".to_string();
}
AddLogicFocus::CancelButton => {
buffer_state.update_history(AppView::Admin);
app_state.ui.show_add_logic = false;
*command_message = "Cancelled Add Logic".to_string();
*is_edit_mode = false;
}
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => handled = false,
}
}
Some("toggle_edit_mode") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => {
*command_message = "Cannot toggle edit mode here.".to_string();
}
}
}
_ => handled = false,
}
if handled && current_focus != new_focus {
add_logic_state.current_focus = new_focus;
let new_is_canvas_input_focus = matches!(new_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
);
if new_is_canvas_input_focus {
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
} else {
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

@@ -12,11 +12,32 @@ use crate::services::GrpcClient;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use anyhow::Result; use anyhow::Result;
// Define a type for the save result channel
pub type SaveTableResultSender = mpsc::Sender<Result<String>>; pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
/// Handles navigation events specifically for the Add Table view. fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
/// Returns true if the event was handled, false otherwise. if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index > 0 { table_state.select(Some(index - 1)); true }
else { false }
}
None => { table_state.select(Some(0)); true }
}
}
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
else { false }
}
None => { table_state.select(Some(0)); true }
}
}
pub fn handle_add_table_navigation( pub fn handle_add_table_navigation(
key: KeyEvent, key: KeyEvent,
config: &Config, config: &Config,
@@ -28,61 +49,52 @@ pub fn handle_add_table_navigation(
) -> bool { ) -> bool {
let action = config.get_general_action(key.code, key.modifiers); let action = config.get_general_action(key.code, key.modifiers);
let current_focus = add_table_state.current_focus; let current_focus = add_table_state.current_focus;
let mut handled = true; // Assume handled unless logic determines otherwise let mut handled = true;
let mut new_focus = current_focus; // Initialize new_focus let mut new_focus = current_focus;
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
*command_message = "Press Esc to exit table item navigation first.".to_string();
return true;
}
}
match action.as_deref() { match action.as_deref() {
// --- Handle Exiting Table Scroll Mode ---
Some("exit_table_scroll") => { Some("exit_table_scroll") => {
match current_focus { match current_focus {
AddTableFocus::InsideColumnsTable => { AddTableFocus::InsideColumnsTable => {
add_table_state.column_table_state.select(None); add_table_state.column_table_state.select(None);
new_focus = AddTableFocus::ColumnsTable; new_focus = AddTableFocus::ColumnsTable;
*command_message = "Exited Columns Table".to_string(); // *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
} }
AddTableFocus::InsideIndexesTable => { AddTableFocus::InsideIndexesTable => {
add_table_state.index_table_state.select(None); add_table_state.index_table_state.select(None);
new_focus = AddTableFocus::IndexesTable; new_focus = AddTableFocus::IndexesTable;
*command_message = "Exited Indexes Table".to_string(); // *command_message = "Exited Indexes Table".to_string();
} }
AddTableFocus::InsideLinksTable => { AddTableFocus::InsideLinksTable => {
add_table_state.link_table_state.select(None); add_table_state.link_table_state.select(None);
new_focus = AddTableFocus::LinksTable; new_focus = AddTableFocus::LinksTable;
*command_message = "Exited Links Table".to_string(); // *command_message = "Exited Links Table".to_string();
}
_ => {
// Action triggered but not applicable in this focus state
handled = false;
} }
_ => handled = false,
} }
// If handled (i.e., focus changed), handled remains true.
// If not handled, handled becomes false.
} }
// --- Vertical Navigation (Up/Down) ---
Some("move_up") => { Some("move_up") => {
match current_focus { match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::CancelButton, AddTableFocus::InputTableName => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At top of form.".to_string(); // Remove message
}
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName, AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName, AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType, AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
// Navigate between blocks when focus is on the table block itself AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton, // Move up to right pane
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable, AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable, AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
// Scroll inside the table when focus is internal AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
AddTableFocus::InsideColumnsTable => { AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
// Stay inside the table, don't change new_focus
}
AddTableFocus::InsideIndexesTable => {
navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len());
// Stay inside the table
}
AddTableFocus::InsideLinksTable => {
navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len());
// Stay inside the table
}
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable, AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton, AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton, AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
@@ -92,306 +104,102 @@ pub fn handle_add_table_navigation(
match current_focus { match current_focus {
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName, AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType, AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
AddTableFocus::InputColumnType => new_focus = AddTableFocus::AddColumnButton, AddTableFocus::InputColumnType => {
add_table_state.last_canvas_field = 2;
new_focus = AddTableFocus::AddColumnButton;
},
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable, AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
// Navigate between blocks when focus is on the table block itself
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable, AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable, AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton, // Move down to right pane AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
// Scroll inside the table when focus is internal AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
AddTableFocus::InsideColumnsTable => { AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
// Stay inside the table
}
AddTableFocus::InsideIndexesTable => {
navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len());
// Stay inside the table
}
AddTableFocus::InsideLinksTable => {
navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len());
// Stay inside the table
}
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton, AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton, AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::InputTableName, AddTableFocus::CancelButton => {
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
// *command_message = "At bottom of form.".to_string(); // Remove message
}
} }
} }
Some("next_option") => { // This logic should already be non-wrapping
// --- Horizontal Navigation (Left/Right) --- match current_focus {
Some("next_option") => { // 'l' or Right: Move from Left Pane to Right Pane AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
// Horizontal nav within bottom buttons { new_focus = AddTableFocus::AddColumnButton; }
if current_focus == AddTableFocus::SaveButton { AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
new_focus = AddTableFocus::DeleteSelectedButton; AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
} else if current_focus == AddTableFocus::DeleteSelectedButton { AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
new_focus = AddTableFocus::CancelButton; AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
} AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
_ => handled = false,
}
} }
Some("previous_option") => { // 'h' or Left: Move from Right Pane to Left Pane Some("previous_option") => { // This logic should already be non-wrapping
// Horizontal nav within bottom buttons match current_focus {
if current_focus == AddTableFocus::CancelButton { AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
new_focus = AddTableFocus::DeleteSelectedButton; { /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
} else if current_focus == AddTableFocus::DeleteSelectedButton { AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
new_focus = AddTableFocus::SaveButton; AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
} AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
_ => handled = false,
}
} }
Some("next_field") => {
// --- Tab / Shift+Tab Navigation (Keep as vertical cycle) ---
Some("next_field") => { // Tab
new_focus = match current_focus { new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
// Treat Inside* same as block focus for tabbing out
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable,
AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable,
AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton,
AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton,
AddTableFocus::CancelButton => AddTableFocus::InputTableName, // Wrap
}; };
} }
Some("prev_field") => { // Shift+Tab Some("prev_field") => {
new_focus = match current_focus { new_focus = match current_focus {
AddTableFocus::InputTableName => AddTableFocus::CancelButton, // Wrap AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
// Treat Inside* same as block focus for tabbing out
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton,
AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable,
AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
AddTableFocus::SaveButton => AddTableFocus::LinksTable,
AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton,
AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
}; };
} }
// --- Selection ---
Some("select") => { Some("select") => {
match current_focus { match current_focus {
// --- Enter/Exit Table Focus --- AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
AddTableFocus::ColumnsTable => { AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
new_focus = AddTableFocus::InsideColumnsTable; AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
// Select first item if none selected when entering AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
add_table_state.column_table_state.select(Some(0)); AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
} AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
*command_message = "Entered Columns Table (Scroll with Up/Down, Select to exit)".to_string(); AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
} AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
AddTableFocus::IndexesTable => { AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
new_focus = AddTableFocus::InsideIndexesTable; _ => { handled = false; }
if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() {
add_table_state.index_table_state.select(Some(0));
}
*command_message = "Entered Indexes Table (Scroll with Up/Down, Select to exit)".to_string();
}
AddTableFocus::LinksTable => {
new_focus = AddTableFocus::InsideLinksTable;
if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() {
add_table_state.link_table_state.select(Some(0));
}
*command_message = "Entered Links Table (Scroll with Up/Down, Select to toggle/exit)".to_string();
}
AddTableFocus::InsideColumnsTable => {
// Toggle selection when pressing select *inside* the columns table
if let Some(index) = add_table_state.column_table_state.selected() {
if let Some(col) = add_table_state.columns.get_mut(index) {
col.selected = !col.selected;
add_table_state.has_unsaved_changes = true;
*command_message = format!(
"Toggled selection for column: {} to {}",
col.name, col.selected
);
}
} else {
*command_message = "No column highlighted to toggle selection".to_string();
}
}
AddTableFocus::InsideIndexesTable => {
// Select does nothing here anymore, only Esc exits.
if let Some(index) = add_table_state.index_table_state.selected() {
if let Some(idx_def) = add_table_state.indexes.get_mut(index) {
idx_def.selected = !idx_def.selected;
add_table_state.has_unsaved_changes = true;
*command_message = format!(
"Toggled selection for index: {} to {}",
idx_def.name, idx_def.selected
);
} else {
*command_message = "Error: Selected index out of bounds".to_string();
}
} else {
*command_message = "No index selected (Press Esc to exit scroll mode)".to_string();
}
}
AddTableFocus::InsideLinksTable => {
// Toggle selection when pressing select *inside* the links table
if let Some(index) = add_table_state.link_table_state.selected() {
if let Some(link) = add_table_state.links.get_mut(index) {
link.selected = !link.selected; // Toggle the selected state
add_table_state.has_unsaved_changes = true; // Mark changes
*command_message = format!(
"Toggled selection for link: {} to {}",
link.linked_table_name, link.selected
);
} else {
*command_message = "Error: Selected link index out of bounds".to_string();
}
} else {
*command_message = "No link selected to toggle".to_string();
}
// Stay inside the links table after toggling
new_focus = AddTableFocus::InsideLinksTable;
// Alternative: Exit after toggle:
// new_focus = AddTableFocus::LinksTable;
// *command_message = format!("{} - Exited Links Table", command_message);
}
// --- Other Select Actions ---
AddTableFocus::AddColumnButton => {
if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) {
new_focus = focus_after_add;
}
}
AddTableFocus::SaveButton => {
// --- Initiate Async Save ---
if add_table_state.table_name.is_empty() {
*command_message = "Cannot save: Table name is empty.".to_string();
} else if add_table_state.columns.is_empty() {
*command_message = "Cannot save: No columns defined.".to_string();
} else {
*command_message = "Saving table...".to_string();
app_state.show_loading_dialog("Saving", "Please wait...");
let mut client_clone = grpc_client.clone();
let state_clone = add_table_state.clone();
let sender_clone = save_result_sender.clone();
tokio::spawn(async move {
let result = handle_save_table_action(&mut client_clone, &state_clone).await;
let _ = sender_clone.send(result).await; // Send result back
});
}
// --- End Initiate Async Save ---
}
AddTableFocus::DeleteSelectedButton => {
// --- Show Confirmation Dialog ---
// Collect tuples of (index, name, type) for selected columns
let columns_to_delete: Vec<(usize, String, String)> = add_table_state
.columns
.iter()
.enumerate() // Get index along with the column
.filter(|(_index, col)| col.selected) // Filter based on selection
.map(|(index, col)| (index, col.name.clone(), col.data_type.clone())) // Map to (index, name, type)
.collect();
if columns_to_delete.is_empty() {
*command_message = "No columns selected for deletion.".to_string();
} else {
// Format the message to include index, name, and type
let column_details: String = columns_to_delete
.iter()
// Add 1 to index for 1-based numbering for user display
.map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype))
.collect::<Vec<String>>()
.join("\n");
// Use the formatted column_details string in the message
let message = format!(
"Delete the following columns?\n\n{}",
column_details
);
let buttons = vec!["Confirm".to_string(), "Cancel".to_string()];
app_state.show_dialog(
"Confirm Deletion",
&message,
buttons,
DialogPurpose::ConfirmDeleteColumns,
);
}
}
AddTableFocus::CancelButton => {
*command_message = "Action: Cancel Add Table".to_string();
// TODO: Implement logic
}
_ => { // Input fields
*command_message = format!("Select on {:?}", current_focus);
handled = false; // Let main loop handle edit mode toggle maybe
}
} }
// Keep handled = true for select actions unless specifically set to false
} }
// --- Other General Keys ---
Some("toggle_sidebar") | Some("toggle_buffer_list") => {
handled = false;
}
// --- No matching action ---
_ => handled = false, _ => handled = false,
} }
// Update focus state if it changed and was handled
if handled && current_focus != new_focus { if handled && current_focus != new_focus {
add_table_state.current_focus = new_focus; add_table_state.current_focus = new_focus;
// Avoid overwriting specific messages set during 'select' handling // Minimal change: Command message update logic can be simplified or removed if not desired
if command_message.is_empty() || command_message.starts_with("Focus set to") { // For now, let's keep it minimal and only update if it was truly a focus change,
*command_message = format!("Focus set to {:?}", add_table_state.current_focus); // and not a boundary message.
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
} }
// Check if the *new* focus target is one of the canvas input fields
let new_is_canvas_input_focus = matches!(new_focus, let new_is_canvas_input_focus = matches!(new_focus,
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
); );
// Focus is outside canvas if it's not an input field
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus; app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
} else if !handled {
// command_message.clear(); // Optional: Clear message if not handled here
} }
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
// or can be cleared if that's the desired default. For minimal change, we leave it.
handled handled
} }
// Helper function for navigating up within a table state
// Returns true if navigation happened within the table, false if it reached the top
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index > 0 {
table_state.select(Some(index - 1));
true // Navigation happened
} else {
false // Was at the top
}
}
None => { // No item selected, select the last one
table_state.select(Some(item_count - 1));
true // Navigation happened (selection set)
}
}
}
// Helper function for navigating down within a table state
// Returns true if navigation happened within the table, false if it reached the bottom
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
if item_count == 0 { return false; }
let current_selection = table_state.selected();
match current_selection {
Some(index) => {
if index < item_count - 1 {
table_state.select(Some(index + 1));
true // Navigation happened
} else {
false // Was at the bottom
}
}
None => { // No item selected, select the first one
table_state.select(Some(0));
true // Navigation happened (selection set)
}
}
}

View File

@@ -1,26 +1,20 @@
// src/functions/modes/navigation/admin_nav.rs // src/functions/modes/navigation/admin_nav.rs
use crate::state::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::state::{ use crate::state::app::buffer::{BufferState, AppView};
app::state::AppState,
pages::admin::{AdminFocus, AdminState},
};
use crossterm::event::KeyEvent;
use crate::state::app::buffer::AppView;
use crate::state::app::buffer::BufferState;
use crate::state::pages::add_table::{AddTableState, LinkDefinition}; use crate::state::pages::add_table::{AddTableState, LinkDefinition};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
// --- Helper functions for ListState navigation (similar to TableState) --- // Helper functions list_select_next and list_select_previous remain the same
fn list_select_next(list_state: &mut ListState, item_count: usize) { fn list_select_next(list_state: &mut ListState, item_count: usize) {
if item_count == 0 { if item_count == 0 {
list_state.select(None); list_state.select(None);
return; return;
} }
let i = match list_state.selected() { let i = match list_state.selected() {
Some(i) => { Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
if i >= item_count - 1 { 0 } else { i + 1 }
}
None => 0, None => 0,
}; };
list_state.select(Some(i)); list_state.select(Some(i));
@@ -32,260 +26,326 @@ fn list_select_previous(list_state: &mut ListState, item_count: usize) {
return; return;
} }
let i = match list_state.selected() { let i = match list_state.selected() {
Some(i) => { Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
if i == 0 { item_count - 1 } else { i - 1 } None => if item_count > 0 { item_count - 1 } else { 0 },
}
None => item_count - 1, // Select last if nothing was selected
}; };
list_state.select(Some(i)); list_state.select(Some(i));
} }
/// Handles navigation events specifically for the Admin Panel view.
/// Returns true if the event was handled, false otherwise.
pub fn handle_admin_navigation( pub fn handle_admin_navigation(
key: KeyEvent, key: crossterm::event::KeyEvent,
config: &Config, config: &Config,
app_state: &mut AppState, app_state: &mut AppState,
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
command_message: &mut String, command_message: &mut String,
) -> bool { ) -> bool {
let action = config.get_general_action(key.code, key.modifiers).map(String::from); // Clone action string let action = config.get_general_action(key.code, key.modifiers).map(String::from);
let current_focus = admin_state.current_focus; let current_focus = admin_state.current_focus;
let profile_count = app_state.profile_tree.profiles.len(); let profile_count = app_state.profile_tree.profiles.len();
let mut new_focus = current_focus; // Start with current focus let mut handled = false;
let mut handled = true; // Assume handled unless logic says otherwise
match action.as_deref() { match current_focus {
// --- Vertical Navigation (Up/Down) --- AdminFocus::ProfilesPane => {
Some("move_up") => { match action.as_deref() {
match current_focus { Some("select") => {
AdminFocus::Profiles => { admin_state.current_focus = AdminFocus::InsideProfilesList;
if profile_count > 0 { if !app_state.profile_tree.profiles.is_empty() {
admin_state.previous_profile(profile_count); if admin_state.profile_list_state.selected().is_none() {
*command_message = "Navigated profiles list".to_string(); admin_state.profile_list_state.select(Some(0));
}
}
AdminFocus::Tables => {
// Do nothing when focus is on the Tables pane itself
*command_message = "Press Enter to select and scroll tables".to_string();
}
AdminFocus::InsideTablesList => { // Scroll inside
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) { // Use nav or persistent selection
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
} }
} }
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
handled = true;
} }
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {} Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
Some("previous_option") | Some("move_up") => {
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
*command_message = "At first focusable pane.".to_string();
handled = true;
}
_ => handled = false,
} }
} }
Some("move_down") => {
match current_focus { AdminFocus::InsideProfilesList => {
AdminFocus::Profiles => { match action.as_deref() {
Some("move_up") => {
if profile_count > 0 { if profile_count > 0 {
// Updates navigation state, resets table state list_select_previous(&mut admin_state.profile_list_state, profile_count);
admin_state.next_profile(profile_count); *command_message = "".to_string();
*command_message = "Navigated profiles list".to_string(); handled = true;
} }
} }
AdminFocus::Tables => { Some("move_down") => {
// Do nothing when focus is on the Tables pane itself if profile_count > 0 {
*command_message = "Press Enter to select and scroll tables".to_string(); list_select_next(&mut admin_state.profile_list_state, profile_count);
} *command_message = "".to_string();
AdminFocus::InsideTablesList => { // Scroll inside handled = true;
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) { // Use nav or persistent selection
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
}
} }
} }
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3 => {} Some("select") => {
} admin_state.selected_profile_index = admin_state.profile_list_state.selected();
} admin_state.selected_table_index = None; // Deselect table when profile changes
// --- Horizontal Navigation (Focus Change) --- if let Some(profile_idx) = admin_state.selected_profile_index {
Some("next_option") | Some("previous_option") => { if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
let old_focus = admin_state.current_focus;
let is_next = action.as_deref() == Some("next_option");
admin_state.current_focus = match old_focus {
AdminFocus::Profiles => if is_next { AdminFocus::Tables } else { AdminFocus::Button3 }, // P -> T (l) or P -> B3 (h)
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles }, // T -> B1 (l) or T -> P (h)
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables }, // B1 -> B2 (l) or B1 -> T (h)
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 }, // B2 -> B3 (l) or B2 -> B1 (h)
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 }, // B3 -> P (l) or B3 -> B2 (h)
// Prevent horizontal nav when inside lists
AdminFocus::InsideTablesList => old_focus,
};
let new_focus = admin_state.current_focus;
*command_message = format!("Focus set to {:?}", new_focus);
// Auto-select first item only when moving from Profiles to Tables via 'l'
if old_focus == AdminFocus::Profiles && new_focus == AdminFocus::Tables && is_next {
if let Some(profile_idx) = admin_state.profile_list_state.selected() {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0));
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
}
// Clear table nav selection if moving away from Tables
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables {
admin_state.table_list_state.select(None);
}
// Clear profile nav selection if moving away from Profiles
if old_focus == AdminFocus::Profiles && new_focus != AdminFocus::Profiles {
// Maybe keep profile nav highlight? Let's try clearing it.
// admin_state.profile_list_state.select(None); // Optional: clear profile nav highlight
}
}
// --- Selection ---
Some("select") => {
match current_focus {
AdminFocus::Profiles => {
// --- Perform persistent selection ---
// Set the persistent selection to the currently navigated item
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
admin_state.selected_profile_index = Some(nav_idx);
// Move focus to Tables (like pressing 'l')
new_focus = AdminFocus::Tables;
// Select the first table for navigation highlight
admin_state.table_list_state.select(None); // Clear table nav first
admin_state.selected_table_index = None; // Clear persistent table selection
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
if !profile.tables.is_empty() { if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
} else {
admin_state.table_list_state.select(None);
} }
*command_message = format!("Selected profile: {}", app_state.profile_tree.profiles[nav_idx].name);
} }
} else { } else {
*command_message = "No profile selected".to_string(); admin_state.table_list_state.select(None);
} }
*command_message = format!(
"Profile '{}' set as active.",
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
);
handled = true;
} }
AdminFocus::Tables => { Some("exit_table_scroll") => {
// --- Enter InsideTablesList focus --- admin_state.current_focus = AdminFocus::ProfilesPane;
new_focus = AdminFocus::InsideTablesList; *command_message = "Focus: Profiles Pane".to_string();
// Select first item if none selected when entering handled = true;
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) { }
_ => handled = false,
}
}
AdminFocus::Tables => {
match action.as_deref() {
Some("select") => {
admin_state.current_focus = AdminFocus::InsideTablesList;
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(profile_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
if admin_state.table_list_state.selected().is_none() {
admin_state.table_list_state.select(Some(0));
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
}
} else {
admin_state.table_list_state.select(None);
*command_message = "Select a profile first to view its tables.".to_string();
}
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
} else if admin_state.table_list_state.selected().is_none() {
if current_profile_idx.is_none() {
*command_message = "No profile selected to view tables.".to_string();
} else {
*command_message = "No tables in selected profile.".to_string();
}
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
}
handled = true;
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::ProfilesPane;
*command_message = "Focus: Profiles Pane".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::InsideTablesList => {
match action.as_deref() {
Some("move_up") => {
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(p_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if admin_state.table_list_state.selected().is_none() && !profile.tables.is_empty() { if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
*command_message = "".to_string();
handled = true;
} else {
*command_message = "No tables to navigate.".to_string();
handled = true;
} }
} }
} else {
*command_message = "No active profile for tables.".to_string();
handled = true;
} }
*command_message = "Entered Tables List (Select item with Enter, Exit with Esc)".to_string();
} }
AdminFocus::InsideTablesList => { Some("move_down") => {
// --- Perform persistent selection --- let current_profile_idx = admin_state.selected_profile_index
// Set the persistent selection to the currently navigated item .or_else(|| admin_state.profile_list_state.selected());
if let Some(nav_idx) = admin_state.table_list_state.selected() { if let Some(p_idx) = current_profile_idx {
admin_state.selected_table_index = Some(nav_idx); // Set persistent selection if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
// Get table name for message if !profile.tables.is_empty() {
let table_name = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) list_select_next(&mut admin_state.table_list_state, profile.tables.len());
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx)) *command_message = "".to_string();
.and_then(|p| p.tables.get(nav_idx).map(|t| t.name.clone())) handled = true;
.unwrap_or_else(|| "N/A".to_string()); } else {
*command_message = format!("Selected table: {}", table_name); *command_message = "No tables to navigate.".to_string();
} else { handled = true;
*command_message = "No table highlighted".to_string(); }
} }
// Stay inside } else {
*command_message = "No active profile for tables.".to_string();
handled = true;
}
} }
Some("select") => { // This is for persistently selecting a table with [*]
AdminFocus::Button1 => { admin_state.selected_table_index = admin_state.table_list_state.selected();
*command_message = "Action: Add Logic (Not Implemented)".to_string(); let table_name = admin_state.selected_profile_index
// TODO: Trigger action for Button 1 .and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
.map_or("N/A", |t| t.name.as_str());
*command_message = format!("Table '{}' set as active.", table_name);
handled = true;
} }
AdminFocus::Button2 => { Some("exit_table_scroll") => {
// --- Prepare AddTableState based on persistent selections --- admin_state.current_focus = AdminFocus::Tables;
if let Some(p_idx) = admin_state.selected_profile_index { *command_message = "Focus: Tables Pane".to_string();
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) { handled = true;
let selected_profile_name = profile.name.clone();
// Populate links from the selected profile's tables
let available_links: Vec<LinkDefinition> = profile
.tables
.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false, // Default
selected: false, // Default
})
.collect();
// Create and populate the new AddTableState
let new_add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links, // Assign populated links
..AddTableState::default()
};
// Assign the prepared state
admin_state.add_table_state = new_add_table_state;
// Switch view
buffer_state.update_history(AppView::AddTable);
app_state.ui.focus_outside_canvas = false;
*command_message = format!(
"Navigating to Add Table for profile '{}'...",
admin_state.add_table_state.profile_name
);
} else {
*command_message = "Error: Selected profile index out of bounds.".to_string();
}
} else {
*command_message = "Please select a profile ([*]) first.".to_string();
}
// --- End preparation ---
} }
AdminFocus::Button3 => { _ => handled = false,
}
}
AdminFocus::Button1 => { // Add Logic Button
match action.as_deref() {
Some("select") => { // Typically "Enter" key
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if let Some(t_idx) = admin_state.selected_table_index {
if let Some(table) = profile.tables.get(t_idx) {
// Both profile and table are selected, proceed
admin_state.add_logic_state = AddLogicState {
profile_name: profile.name.clone(),
selected_table_name: Some(table.name.clone()),
selected_table_id: Some(table.id), // If you have table IDs
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
current_focus: AddLogicFocus::default(),
..AddLogicState::default()
};
// 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 {
*command_message = "Error: Selected table data not found.".to_string();
}
} else {
*command_message = "Select a table first!".to_string();
}
} else {
*command_message = "Error: Selected profile data not found.".to_string();
}
} else {
*command_message = "Select a profile first!".to_string();
}
handled = true;
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button2 => { // Add Table Button
match action.as_deref() {
Some("select") => {
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
let selected_profile_name = profile.name.clone();
// Prepare links from the selected profile's existing tables
let available_links: Vec<LinkDefinition> = profile.tables.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false, // Default, can be changed in AddTable screen
selected: false,
}).collect();
admin_state.add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links,
..AddTableState::default() // Reset other fields
};
buffer_state.update_history(AppView::AddTable);
app_state.ui.focus_outside_canvas = false;
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
handled = true;
} else {
*command_message = "Error: Selected profile index out of bounds.".to_string();
handled = true;
}
} else {
*command_message = "Please select a profile ([*]) first to add a table.".to_string();
handled = true;
}
}
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Button1;
*command_message = "Focus: Add Logic Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
admin_state.current_focus = AdminFocus::Button3;
*command_message = "Focus: Change Table Button".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button3 => { // Change Table Button
match action.as_deref() {
Some("select") => {
// Future: Logic to load selected table into AddTableState for editing
*command_message = "Action: Change Table (Not Implemented)".to_string(); *command_message = "Action: Change Table (Not Implemented)".to_string();
// TODO: Trigger action for Button 3 handled = true;
} }
Some("previous_option") | Some("move_up") => {
admin_state.current_focus = AdminFocus::Button2;
*command_message = "Focus: Add Table Button".to_string();
handled = true;
}
Some("next_option") | Some("move_down") => {
// No wrap-around: Stay on Button3 if trying to go "after" it
*command_message = "At last focusable button.".to_string();
handled = true;
}
_ => handled = false,
} }
} }
// --- Handle Exiting Inside Mode ---
Some("exit_table_scroll") => { // Assuming you have this action bound (e.g., to Esc)
match current_focus {
AdminFocus::InsideTablesList => {
new_focus = AdminFocus::Tables;
admin_state.table_list_state.select(None); // Clear nav highlight on exit
*command_message = "Exited Tables List".to_string();
}
_ => handled = false, // Not applicable
}
}
// --- Other General Keys (Ignore for admin nav) ---
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
// These are handled globally or not applicable here.
handled = false;
}
// --- No matching action ---
_ => handled = false, // Event not handled by admin navigation
} }
handled
// Update focus state if it changed and was handled
if handled && current_focus != new_focus {
admin_state.current_focus = new_focus;
// Avoid overwriting specific messages set during 'select' or 'exit' handling
if command_message.is_empty() || command_message.starts_with("Focus set to") {
*command_message = format!("Focus set to {:?}", admin_state.current_focus);
}
} else if !handled {
// command_message.clear(); // Optional: Clear message if not handled here
}
handled // Return whether the event was handled by this function
} }

View File

@@ -3,3 +3,4 @@
pub mod auth_ro; pub mod auth_ro;
pub mod form_ro; pub mod form_ro;
pub mod add_table_ro; pub mod add_table_ro;
pub mod add_logic_ro;

View File

@@ -0,0 +1,235 @@
// src/functions/modes/read_only/add_logic_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use anyhow::Result;
// 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 prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddLogic view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState,
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
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_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
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_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
// Move focus outside canvas when moving down from the last field
// FIX: Go to ScriptContentPreview instead of SaveButton
app_state.ui.focus_outside_canvas = true;
state.last_canvas_field = 2;
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
*command_message = "Focus moved to script preview".to_string();
}
Ok(command_message.clone())
}
// ... (rest of the actions remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_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().saturating_sub(1));
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().saturating_sub(1) {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
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" => {
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())
}
"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();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
}
_ => {
key_sequence_tracker.reset();
command_message.clear();
Ok(format!("Unknown read-only action: {}", action))
},
}
}

View File

@@ -1,7 +1,7 @@
// src/functions/modes/read_only/add_table_ro.rs // src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_table::AddTableState;
use crate::state::pages::canvas_state::CanvasState; // Use trait for common actions use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use anyhow::Result; use anyhow::Result;
@@ -80,41 +80,36 @@ pub async fn execute_action(
"move_up" => { "move_up" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT; let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); } if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field(); // Gets the index (0, 1, or 2) let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 { if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0 // This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1; let new_field = current_field - 1;
state.set_current_field(new_field); state.set_current_field(new_field);
// ... (rest of the logic to set cursor position) ... let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
*command_message = "".to_string(); // Clear message for successful internal navigation
} else { } else {
// --- THIS IS WHERE THE FIX GOES ---
// current_field is 0 (InputTableName), and user pressed Up. // current_field is 0 (InputTableName), and user pressed Up.
// We need to move focus *outside* the canvas. // Forbid moving up. Do not change focus or cursor.
*command_message = "At top of form.".to_string();
// Set the flag to indicate focus is leaving the canvas
app_state.ui.focus_outside_canvas = true;
// Decide which element gets focus. Based on your layout and the
// downward navigation (CancelButton wraps to InputTableName),
// moving up from InputTableName should likely go to CancelButton.
state.current_focus = crate::state::pages::add_table::AddTableFocus::CancelButton;
// Reset the sequence tracker as the action is complete
key_sequence_tracker.reset();
// Return a message indicating the focus change
return Ok("Focus moved above canvas".to_string());
// --- END FIX ---
} }
// If we moved within the canvas (e.g., 1 -> 0), return empty string Ok(command_message.clone())
Ok("".to_string())
} }
"move_down" => { "move_down" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT; let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); } if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field(); let current_field = state.current_field();
let last_field_index = num_fields - 1; let last_field_index = num_fields - 1;
@@ -125,16 +120,19 @@ pub async fn execute_action(
let max_cursor_pos = current_input.len(); // Allow cursor at end let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos); let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
*command_message = "".to_string();
} else { } else {
// Move focus outside canvas when moving down from the last field // Move focus outside canvas when moving down from the last field
app_state.ui.focus_outside_canvas = true; app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton) // Set focus to the first element outside canvas (AddColumnButton)
state.current_focus = crate::state::pages::add_table::AddTableFocus::AddColumnButton; state.current_focus =
key_sequence_tracker.reset(); crate::state::pages::add_table::AddTableFocus::AddColumnButton;
return Ok("Focus moved below canvas".to_string()); *command_message = "Focus moved below canvas".to_string();
} }
Ok("".to_string()) Ok(command_message.clone())
} }
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
"move_first_line" => { "move_first_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 { if AddTableState::INPUT_FIELD_COUNT > 0 {
@@ -145,7 +143,8 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column *ideal_cursor_column = new_pos; // Update ideal column
} }
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_last_line" => { "move_last_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
@@ -159,14 +158,16 @@ pub async fn execute_action(
state.set_current_cursor_pos(new_pos); state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column *ideal_cursor_column = new_pos; // Update ideal column
} }
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_left" => { "move_left" => {
let current_pos = state.current_cursor_pos(); let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1); let new_pos = current_pos.saturating_sub(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()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_right" => { "move_right" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
@@ -177,68 +178,90 @@ pub async fn execute_action(
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()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_next" => { "move_word_next" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos); state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos; *ideal_cursor_column = final_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_end" => { "move_word_end" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos(); let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos); let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char // If find_word_end returns current_pos, try starting search from next char
let final_pos = if new_pos == current_pos && current_pos < current_input.len() { let final_pos =
find_word_end(current_input, current_pos + 1) if new_pos == current_pos && current_pos < current_input.len() {
} else { find_word_end(current_input, current_pos + 1)
new_pos } else {
}; new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index); let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos); state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos; *ideal_cursor_column = clamped_pos;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_prev" => { "move_word_prev" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
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()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_word_end_prev" => { "move_word_end_prev" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
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()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_line_start" => { "move_line_start" => {
state.set_current_cursor_pos(0); state.set_current_cursor_pos(0);
*ideal_cursor_column = 0; *ideal_cursor_column = 0;
Ok("".to_string()) *command_message = "".to_string();
Ok(command_message.clone())
} }
"move_line_end" => { "move_line_end" => {
let current_input = state.get_current_input(); let current_input = state.get_current_input();
let new_pos = current_input.len(); // Allow cursor at end let new_pos = current_input.len(); // Allow cursor at end
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()) *command_message = "".to_string();
Ok(command_message.clone())
} }
// Actions handled by main event loop (mode changes) // Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => { "enter_edit_mode_before" | "enter_edit_mode_after"
key_sequence_tracker.reset(); | "enter_command_mode" | "exit_highlight_mode" => {
Ok("Mode change handled by main loop".to_string()) key_sequence_tracker.reset();
// These actions are primarily mode changes handled by the main event loop.
// The message here might be overridden by the main loop's message for mode change.
*command_message = "Mode change initiated".to_string();
Ok(command_message.clone())
} }
_ => { _ => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
command_message.clear(); // Clear message for unhandled actions *command_message =
Ok(format!("Unknown read-only action: {}", action)) format!("Unknown read-only action: {}", action);
}, Ok(command_message.clone())
}
} }
} }

View File

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

View File

@@ -5,45 +5,50 @@ use crate::state::pages::{
auth::{LoginState, RegisterState}, auth::{LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
}; };
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState; // <<< ADD THIS LINE
use crate::state::pages::add_table::AddTableState; // AddLogicState is already imported
// AddTableState is already imported
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{auth_e, form_e}; use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::functions::modes::edit::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,
add_table_state: &mut AddTableState, admin_state: &mut AdminState,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
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,
@@ -51,232 +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 {
) // TODO: Implement common actions for AddLogic if needed
} else { format!("Action '{}' not implemented for Add Logic in edit mode.", action)
let outcome = form_e::execute_common_action( } else { // Assuming Form view
action, let outcome = form_e::execute_common_action(action, form_state, grpc_client).await?;
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 let effective_action = if app_state.ui.show_register
if action == "enter_decider" { && register_state.in_suggestion_mode
let effective_action = if app_state.ui.show_register && register_state.current_field() == 4 { // Role field
&& register_state.in_suggestion_mode "select_suggestion"
&& register_state.current_field() == 4 { } 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, } else if app_state.ui.show_add_logic {
key, add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
add_table_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, } else if app_state.ui.show_add_logic {
key, // If not a suggestion action handled above for AddLogic
add_table_state, if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
ideal_cursor_column, add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
) } else { String::new() /* Already handled */ }
.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", } else if app_state.ui.show_add_logic {
key, add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
add_table_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

@@ -6,9 +6,10 @@ use crate::services::grpc_client::GrpcClient;
use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState}; use crate::state::pages::{canvas_state::CanvasState, auth::RegisterState};
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::functions::modes::read_only::{auth_ro, form_ro, add_table_ro}; use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use anyhow::Result; use anyhow::Result;
@@ -20,6 +21,7 @@ pub async fn handle_read_only_event(
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
@@ -37,6 +39,7 @@ pub async fn handle_read_only_event(
if config.is_enter_edit_mode_after(key.code, key.modifiers) { if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor // Determine target state to adjust cursor
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state }
else if app_state.ui.show_add_logic { add_logic_state }
else if app_state.ui.show_register { register_state } else if app_state.ui.show_register { register_state }
else if app_state.ui.show_add_table { add_table_state } else if app_state.ui.show_add_table { add_table_state }
else { form_state }; else { form_state };
@@ -87,6 +90,15 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register{ } else if app_state.ui.show_register{
auth_ro::execute_action( auth_ro::execute_action(
action, action,
@@ -147,6 +159,15 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_ro::execute_action( auth_ro::execute_action(
action, action,
@@ -206,6 +227,15 @@ pub async fn handle_read_only_event(
key_sequence_tracker, key_sequence_tracker,
command_message, command_message,
).await? ).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
auth_ro::execute_action( auth_ro::execute_action(
action, action,

View File

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

View File

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

View File

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

View File

@@ -139,6 +139,16 @@ pub async fn handle_dialog_event(
_ => { /* Handle unexpected index */ } _ => { /* Handle unexpected index */ }
} }
} }
DialogPurpose::SaveLogicSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin);
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
} }
} }
_ => {} // Ignore other general actions when dialog is shown _ => {} // Ignore other general actions when dialog is shown

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
// src/modes/handlers/mode_manager.rs // src/modes/handlers/mode_manager.rs
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventHandler; use crate::modes::handlers::event::EventHandler;
use crate::state::pages::add_logic::AddLogicFocus;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::state::pages::admin::AdminState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode { pub enum AppMode {
@@ -16,7 +18,15 @@ pub struct ModeManager;
impl ModeManager { impl ModeManager {
// Determine current mode based on app state // Determine current mode based on app state
pub fn derive_mode(app_state: &AppState, event_handler: &EventHandler) -> AppMode { pub fn derive_mode(
app_state: &AppState,
event_handler: &EventHandler,
admin_state: &AdminState,
) -> AppMode {
if event_handler.navigation_state.active {
return AppMode::General;
}
if event_handler.command_mode { if event_handler.command_mode {
return AppMode::Command; return AppMode::Command;
} }
@@ -25,16 +35,28 @@ impl ModeManager {
return AppMode::Highlight; return AppMode::Highlight;
} }
if app_state.ui.focus_outside_canvas {
return AppMode::General;
}
let is_canvas_view = app_state.ui.show_login let is_canvas_view = app_state.ui.show_login
|| app_state.ui.show_register || app_state.ui.show_register
|| app_state.ui.show_form || app_state.ui.show_form
|| app_state.ui.show_add_table; || app_state.ui.show_add_table
|| app_state.ui.show_add_logic;
if is_canvas_view { if app_state.ui.show_add_logic {
// Specific logic for AddLogic view
match admin_state.add_logic_state.current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
// These are canvas inputs
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
_ => AppMode::General,
}
} else if app_state.ui.show_add_table {
if app_state.ui.focus_outside_canvas { if app_state.ui.focus_outside_canvas {
AppMode::General AppMode::General
} else { } else {
@@ -44,20 +66,30 @@ impl ModeManager {
AppMode::ReadOnly AppMode::ReadOnly
} }
} }
} else if is_canvas_view {
if app_state.ui.focus_outside_canvas {
AppMode::General
} else {
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
} else { } else {
AppMode::General AppMode::General
} }
} }
// Mode transition rules // Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool { pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode !matches!(current_mode, AppMode::Edit)
} }
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool { pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly matches!(current_mode, AppMode::ReadOnly)
} }
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool { pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight) matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
} }

View File

@@ -6,7 +6,7 @@ use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient; use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::auth::{LoginState, RegisterState}; use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::add_table::AddTableState; use crate::state::pages::admin::AdminState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::modes::handlers::event::EventOutcome; use crate::modes::handlers::event::EventOutcome;
use crate::modes::read_only; use crate::modes::read_only;
@@ -23,7 +23,7 @@ pub async fn handle_highlight_event(
form_state: &mut FormState, form_state: &mut FormState,
login_state: &mut LoginState, login_state: &mut LoginState,
register_state: &mut RegisterState, register_state: &mut RegisterState,
add_table_state: &mut AddTableState, admin_state: &mut AdminState,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64, current_position: &mut u64,
total_count: u64, total_count: u64,
@@ -41,7 +41,8 @@ pub async fn handle_highlight_event(
form_state, form_state,
login_state, login_state,
register_state, register_state,
add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker, key_sequence_tracker,
current_position, current_position,
total_count, total_count,

View File

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

View File

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

View File

@@ -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,
@@ -8,12 +7,14 @@ pub enum AppView {
Register, Register,
Admin, Admin,
AddTable, AddTable,
Form(String), AddLogic,
Form,
Scratch, Scratch,
} }
impl AppView { impl AppView {
/// Returns the display name for the view. /// Returns the display name for the view.
/// For Form, pass the current table name to get dynamic naming.
pub fn display_name(&self) -> &str { pub fn display_name(&self) -> &str {
match self { match self {
AppView::Intro => "Intro", AppView::Intro => "Intro",
@@ -21,13 +22,25 @@ impl AppView {
AppView::Register => "Register", AppView::Register => "Register",
AppView::Admin => "Admin_Panel", AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table", AppView::AddTable => "Add_Table",
AppView::Form(name) => name.as_str(), AppView::AddLogic => "Add_Logic",
AppView::Form => "Form",
AppView::Scratch => "*scratch*", 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)] #[derive(Debug, Clone)]
pub struct BufferState { pub struct BufferState {
pub history: Vec<AppView>, pub history: Vec<AppView>,
@@ -37,23 +50,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;
@@ -61,34 +68,52 @@ 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);
// Rule 1: Cannot close Intro buffer. self.active_index = 0;
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). let current_index = self.active_index;
// 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, 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 std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse; use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode; use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::{DialogPurpose, UiContext}; use crate::ui::handlers::context::DialogPurpose;
use anyhow::Result; use anyhow::Result;
pub struct DialogState { pub struct DialogState {
@@ -22,6 +22,7 @@ pub struct UiState {
pub show_intro: bool, pub show_intro: bool,
pub show_admin: bool, pub show_admin: bool,
pub show_add_table: bool, pub show_add_table: bool,
pub show_add_logic: bool,
pub show_form: bool, pub show_form: bool,
pub show_login: bool, pub show_login: bool,
pub show_register: bool, pub show_register: bool,
@@ -32,12 +33,14 @@ pub struct UiState {
pub struct AppState { pub struct AppState {
// Core editor state // Core editor state
pub current_dir: String, pub current_dir: String,
pub total_count: u64,
pub current_position: u64,
pub profile_tree: ProfileTreeResponse, pub profile_tree: ProfileTreeResponse,
pub selected_profile: Option<String>, pub selected_profile: Option<String>,
pub current_mode: AppMode, pub current_mode: AppMode,
pub current_view_profile_name: Option<String>,
pub current_view_table_name: Option<String>,
pub focused_button_index: usize, pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
// UI preferences // UI preferences
pub ui: UiState, pub ui: UiState,
@@ -50,28 +53,25 @@ impl AppState {
.to_string(); .to_string();
Ok(AppState { Ok(AppState {
current_dir, current_dir,
total_count: 0,
current_position: 0,
profile_tree: ProfileTreeResponse::default(), profile_tree: ProfileTreeResponse::default(),
selected_profile: None, selected_profile: None,
current_view_profile_name: None,
current_view_table_name: None,
current_mode: AppMode::General, current_mode: AppMode::General,
focused_button_index: 0, focused_button_index: 0,
pending_table_structure_fetch: None,
ui: UiState::default(), ui: UiState::default(),
}) })
} }
// Existing methods remain unchanged
pub fn update_total_count(&mut self, total_count: u64) {
self.total_count = total_count;
}
pub fn update_current_position(&mut self, current_position: u64) {
self.current_position = current_position;
}
pub fn update_mode(&mut self, mode: AppMode) { pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode; self.current_mode = mode;
} }
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
self.current_view_profile_name = Some(profile_name);
self.current_view_table_name = Some(table_name);
}
// Add dialog helper methods // Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons. /// Shows a dialog with the given title, message, and buttons.
@@ -170,6 +170,7 @@ impl Default for UiState {
show_intro: true, show_intro: true,
show_admin: false, show_admin: false,
show_add_table: false, show_add_table: false,
show_add_logic: false,
show_form: false, show_form: false,
show_login: false, show_login: false,
show_register: false, show_register: false,

View File

@@ -5,4 +5,5 @@ pub mod auth;
pub mod admin; pub mod admin;
pub mod intro; pub mod intro;
pub mod add_table; pub mod add_table;
pub mod add_logic;
pub mod canvas_state; pub mod canvas_state;

View File

@@ -0,0 +1,346 @@
// src/state/pages/add_logic.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::state::pages::canvas_state::CanvasState;
use crate::components::common::text_editor::{TextEditor, VimState};
use std::cell::RefCell;
use std::rc::Rc;
use tui_textarea::TextArea;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddLogicFocus {
#[default]
InputLogicName,
InputTargetColumn,
InputDescription,
ScriptContentPreview,
InsideScriptContent,
SaveButton,
CancelButton,
}
#[derive(Clone, Debug)]
pub struct AddLogicState {
pub profile_name: String,
pub selected_table_id: Option<i64>,
pub selected_table_name: Option<String>,
pub logic_name_input: String,
pub target_column_input: String,
pub script_content_editor: Rc<RefCell<TextArea<'static>>>,
pub description_input: String,
pub current_focus: AddLogicFocus,
pub last_canvas_field: usize,
pub logic_name_cursor_pos: usize,
pub target_column_cursor_pos: usize,
pub description_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub editor_keybinding_mode: EditorKeybindingMode,
pub vim_state: VimState,
// New fields for Target Column Autocomplete
pub table_columns_for_suggestions: Vec<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 {
pub fn new(editor_config: &EditorConfig) -> Self {
let editor = TextEditor::new_textarea(editor_config);
AddLogicState {
profile_name: "default".to_string(),
selected_table_id: None,
selected_table_name: None,
logic_name_input: String::new(),
target_column_input: String::new(),
script_content_editor: Rc::new(RefCell::new(editor)),
description_input: String::new(),
current_focus: AddLogicFocus::InputLogicName,
last_canvas_field: 2,
logic_name_cursor_pos: 0,
target_column_cursor_pos: 0,
description_cursor_pos: 0,
has_unsaved_changes: false,
editor_keybinding_mode: editor_config.keybinding_mode.clone(),
vim_state: VimState::default(),
table_columns_for_suggestions: Vec::new(),
target_column_suggestions: Vec::new(),
show_target_column_suggestions: false,
selected_target_column_suggestion_index: None,
in_target_column_suggestion_mode: false,
script_editor_autocomplete_active: false,
script_editor_suggestions: Vec::new(),
script_editor_selected_suggestion_index: None,
script_editor_trigger_position: None,
all_table_names: Vec::new(),
script_editor_filter_text: String::new(),
same_profile_table_names: Vec::new(),
script_editor_awaiting_column_autocomplete: None,
}
}
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 {
fn default() -> Self {
Self::new(&EditorConfig::default())
}
}
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
_ => self.last_canvas_field,
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input,
}
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn set_current_field(&mut self, index: usize) {
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) {
match self.current_focus {
AddLogicFocus::InputLogicName => {
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());
}
AddLogicFocus::InputDescription => {
self.description_cursor_pos = pos.min(self.description_input.len());
}
_ => {}
}
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
fn get_suggestions(&self) -> Option<&[String]> {
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> {
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

@@ -55,6 +55,7 @@ pub struct AddTableState {
pub indexes: Vec<IndexDefinition>, pub indexes: Vec<IndexDefinition>,
pub links: Vec<LinkDefinition>, pub links: Vec<LinkDefinition>,
pub current_focus: AddTableFocus, pub current_focus: AddTableFocus,
pub last_canvas_field: usize,
pub column_table_state: TableState, pub column_table_state: TableState,
pub index_table_state: TableState, pub index_table_state: TableState,
pub link_table_state: TableState, pub link_table_state: TableState,
@@ -77,6 +78,7 @@ impl Default for AddTableState {
indexes: Vec::new(), indexes: Vec::new(),
links: Vec::new(), links: Vec::new(),
current_focus: AddTableFocus::InputTableName, current_focus: AddTableFocus::InputTableName,
last_canvas_field: 2,
column_table_state: TableState::default(), column_table_state: TableState::default(),
index_table_state: TableState::default(), index_table_state: TableState::default(),
link_table_state: TableState::default(), link_table_state: TableState::default(),
@@ -100,7 +102,7 @@ impl CanvasState for AddTableState {
AddTableFocus::InputColumnName => 1, AddTableFocus::InputColumnName => 1,
AddTableFocus::InputColumnType => 2, AddTableFocus::InputColumnType => 2,
// If focus is elsewhere, default to the first field for canvas rendering logic // If focus is elsewhere, default to the first field for canvas rendering logic
_ => 0, _ => self.last_canvas_field,
} }
} }
@@ -145,10 +147,20 @@ impl CanvasState for AddTableState {
} }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
// Update both current focus and last canvas field
self.current_focus = match index { self.current_focus = match index {
0 => AddTableFocus::InputTableName, 0 => {
1 => AddTableFocus::InputColumnName, self.last_canvas_field = 0;
2 => AddTableFocus::InputColumnType, AddTableFocus::InputTableName
},
1 => {
self.last_canvas_field = 1;
AddTableFocus::InputColumnName
},
2 => {
self.last_canvas_field = 2;
AddTableFocus::InputColumnType
},
_ => self.current_focus, // Stay on current focus if index is out of bounds _ => self.current_focus, // Stay on current focus if index is out of bounds
}; };
} }

View File

@@ -2,12 +2,14 @@
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::state::pages::add_table::AddTableState; use crate::state::pages::add_table::AddTableState;
use crate::state::pages::add_logic::AddLogicState;
// Define the focus states for the admin panel panes // Define the focus states for the admin panel panes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdminFocus { pub enum AdminFocus {
#[default] // Default focus is on the profiles list #[default] // Default focus is on the profiles list
Profiles, ProfilesPane,
InsideProfilesList,
Tables, Tables,
InsideTablesList, InsideTablesList,
Button1, Button1,
@@ -24,6 +26,7 @@ pub struct AdminState {
pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent) pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent)
pub current_focus: AdminFocus, // Tracks which pane is focused pub current_focus: AdminFocus, // Tracks which pane is focused
pub add_table_state: AddTableState, pub add_table_state: AddTableState,
pub add_logic_state: AddLogicState,
} }
impl AdminState { impl AdminState {

View File

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

View File

@@ -2,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

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

View File

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

View File

@@ -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

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

View File

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

View File

@@ -15,9 +15,9 @@ pub enum DialogPurpose {
LoginFailed, LoginFailed,
RegisterSuccess, RegisterSuccess,
RegisterFailed, RegisterFailed,
ConfirmDeleteColumns, // add_table delete selected Columns ConfirmDeleteColumns,
SaveTableSuccess, // add_table save table SaveTableSuccess,
SaveLogicSuccess,
// TODO in the future: // TODO in the future:
// ConfirmQuit, // ConfirmQuit,
} }

View File

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

View File

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

View File

@@ -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};
@@ -13,6 +14,7 @@ use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState; use crate::state::pages::auth::RegisterState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::state::pages::admin::AdminFocus;
use crate::state::pages::intro::IntroState; use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState; use crate::state::app::buffer::BufferState;
use crate::state::app::buffer::AppView; use crate::state::app::buffer::AppView;
@@ -21,39 +23,36 @@ use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui; use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult; use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult; use crate::tui::functions::common::register::RegisterResult;
use crate::tui::functions::common::add_table::handle_save_table_action; use crate::ui::handlers::context::DialogPurpose;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::{DialogPurpose, UiContext};
use crate::tui::functions::common::login; use crate::tui::functions::common::login;
use crate::tui::functions::common::register; use crate::tui::functions::common::register;
use std::time::Instant; use std::time::Instant;
use anyhow::{Context, Result}; use anyhow::{anyhow, Context, Result};
use crossterm::cursor::SetCursorStyle; use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event; use crossterm::event as crossterm_event;
use tracing::{error, info}; use tracing::{error, info, warn};
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub async fn run_ui() -> Result<()> { pub async fn run_ui() -> Result<()> {
let config = Config::load().context("Failed to load configuration")?; let config = Config::load().context("Failed to load configuration")?;
let theme = Theme::from_str(&config.colors.theme); let theme = Theme::from_str(&config.colors.theme);
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?; let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
let mut grpc_client = GrpcClient::new().await?; let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
let mut command_handler = CommandHandler::new(); let mut command_handler = CommandHandler::new();
// --- Channel for Login Results --- let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
let (login_result_sender, mut login_result_receiver) = let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
mpsc::channel::<LoginResult>(1); let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
let (register_result_sender, mut register_result_receiver) = let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) =
mpsc::channel::<Result<String>>(1);
let mut event_handler = EventHandler::new( let mut event_handler = EventHandler::new(
login_result_sender.clone(), login_result_sender.clone(),
register_result_sender.clone(), register_result_sender.clone(),
save_table_result_sender.clone(), save_table_result_sender.clone(),
).await.context("Failed to create event handler")?; save_logic_result_sender.clone(),
)
.await
.context("Failed to create event handler")?;
let event_reader = EventReader::new(); let event_reader = EventReader::new();
let mut auth_state = AuthState::default(); let mut auth_state = AuthState::default();
@@ -64,30 +63,72 @@ 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")?;
// Initialize app state with profile tree and table structure let mut auto_logged_in = false;
let column_names = match load_auth_data() {
UiService::initialize_app_state(&mut grpc_client, &mut app_state) Ok(Some(stored_data)) => {
.await.context("Failed to initialize app state from UI service")?; auth_state.auth_token = Some(stored_data.access_token);
let mut form_state = FormState::new(column_names); 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);
}
}
// Fetch the total count of Adresar entries // Initialize AppState and FormState with table data
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?; let (initial_profile, initial_table, initial_columns) =
form_state.reset_to_empty(); UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
.await
.context("Failed to initialize app state and form")?;
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
initial_columns,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
.await
.context(format!(
"Failed to fetch initial count for table {}.{}",
initial_profile, initial_table
))?;
// Load initial data for the form
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
event_handler.command_message = format!("Error loading initial data: {}", e);
}
} else {
form_state.reset_to_empty();
}
if auto_logged_in {
buffer_state.history = vec![AppView::Form];
buffer_state.active_index = 0;
info!("Initial view set to Form due to auto-login.");
}
// --- 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;
let mut needs_redraw = true; let mut needs_redraw = true;
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
let mut prev_view_table_name = app_state.current_view_table_name.clone();
loop { loop {
// --- Synchronize UI View from Active Buffer ---
if let Some(active_view) = buffer_state.get_active_view() { if let Some(active_view) = buffer_state.get_active_view() {
// Reset all flags first
app_state.ui.show_intro = false; app_state.ui.show_intro = false;
app_state.ui.show_login = false; app_state.ui.show_login = false;
app_state.ui.show_register = false; app_state.ui.show_register = false;
app_state.ui.show_admin = false; app_state.ui.show_admin = false;
app_state.ui.show_add_table = false; app_state.ui.show_add_table = false;
app_state.ui.show_add_logic = false;
app_state.ui.show_form = false; app_state.ui.show_form = false;
match active_view { match active_view {
AppView::Intro => app_state.ui.show_intro = true, AppView::Intro => app_state.ui.show_intro = true,
@@ -109,19 +150,105 @@ pub async fn run_ui() -> Result<()> {
.map(|p| p.name.clone()) .map(|p| p.name.clone())
.collect(); .collect();
admin_state.set_profiles(profile_names); admin_state.set_profiles(profile_names);
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;
}
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::AddTable => app_state.ui.show_add_table = true,
AppView::Form(_) => app_state.ui.show_form = true, AppView::AddLogic => app_state.ui.show_add_logic = true,
AppView::Scratch => {} // Or show a scratchpad component AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {}
} }
} }
// --- End Synchronization ---
// --- 3. Draw UI --- // Handle table change for FormView
// Draw the current state *first*. This ensures the loading dialog if app_state.ui.show_form {
// set in the *previous* iteration gets rendered before the pending let current_view_profile = app_state.current_view_profile_name.clone();
// action check below. let current_view_table = app_state.current_view_table_name.clone();
if needs_redraw {
if prev_view_profile_name != current_view_profile || prev_view_table_name != current_view_table {
if let (Some(prof_name), Some(tbl_name)) = (current_view_profile.as_ref(), current_view_table.as_ref()) {
app_state.show_loading_dialog("Loading Table", &format!("Fetching data for {}.{}...", prof_name, tbl_name));
needs_redraw = true;
match grpc_client.get_table_structure(prof_name.clone(), tbl_name.clone()).await {
Ok(structure_response) => {
let new_columns: Vec<String> = structure_response.columns.iter().map(|c| c.name.clone()).collect();
form_state = FormState::new(prof_name.clone(), tbl_name.clone(), new_columns);
if let Err(e) = UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error fetching count: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error loading data: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
app_state.hide_dialog();
}
} else {
form_state.reset_to_empty();
app_state.hide_dialog();
}
}
}
Err(e) => {
app_state.update_dialog_content(&format!("Error fetching table structure: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
app_state.current_view_profile_name = prev_view_profile_name.clone();
app_state.current_view_table_name = prev_view_table_name.clone();
}
}
}
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
needs_redraw = true;
}
}
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic {
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,
).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
);
}
}
if needs_redraw {
terminal.draw(|f| { terminal.draw(|f| {
render_ui( render_ui(
f, f,
@@ -133,14 +260,13 @@ pub async fn run_ui() -> Result<()> {
&mut admin_state, &mut admin_state,
&buffer_state, &buffer_state,
&theme, &theme,
event_handler.is_edit_mode, // Use event_handler's state event_handler.is_edit_mode,
&event_handler.highlight_state, &event_handler.highlight_state,
app_state.total_count,
app_state.current_position,
&app_state.current_dir,
&event_handler.command_input, &event_handler.command_input,
event_handler.command_mode, event_handler.command_mode,
&event_handler.command_message, &event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps, current_fps,
&app_state, &app_state,
); );
@@ -148,9 +274,29 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = false; needs_redraw = false;
} }
// --- Cursor Visibility Logic --- if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
// (Keep existing cursor logic here - depends on state drawn above) if app_state.ui.show_add_logic {
let current_mode = ModeManager::derive_mode(&app_state, &event_handler); 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;
}
}
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode { match current_mode {
AppMode::Edit => { terminal.show_cursor()?; } AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; } AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
@@ -165,24 +311,18 @@ pub async fn run_ui() -> Result<()> {
} }
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; } AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
} }
// --- End Cursor Visibility Logic ---
let total_count = app_state.total_count; let position_before_event = form_state.current_position;
let mut current_position = app_state.current_position;
let position_before_event = current_position;
// --- 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 { if app_state.ui.dialog.is_loading {
needs_redraw = true; needs_redraw = true;
} }
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new())); let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false; let mut event_processed = false;
// Poll for events *after* drawing and checking pending actions
if crossterm_event::poll(std::time::Duration::from_millis(1))? { if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?; 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_outcome_result = event_handler.handle_event(
event, event,
&config, &config,
@@ -197,8 +337,6 @@ pub async fn run_ui() -> Result<()> {
&mut admin_state, &mut admin_state,
&mut buffer_state, &mut buffer_state,
&mut app_state, &mut app_state,
total_count,
&mut current_position,
).await; ).await;
} }
@@ -206,40 +344,33 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = true; 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 ---
match login_result_receiver.try_recv() { match login_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) { if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true; needs_redraw = true;
} }
} }
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ } Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly."); error!("Login result channel disconnected unexpectedly.");
// Optionally show an error dialog here
} }
} }
// --- Check for Register Results from Channel ---
match register_result_receiver.try_recv() { match register_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) { if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true; needs_redraw = true;
} }
} }
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ } Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly."); error!("Register result channel disconnected unexpectedly.");
} }
} }
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() { match save_table_result_receiver.try_recv() {
Ok(result) => { Ok(result) => {
app_state.hide_dialog(); // Hide loading indicator app_state.hide_dialog();
match result { match result {
Ok(ref success_message) => { Ok(ref success_message) => {
app_state.show_dialog( app_state.show_dialog(
@@ -252,34 +383,26 @@ pub async fn run_ui() -> Result<()> {
} }
Err(e) => { Err(e) => {
event_handler.command_message = format!("Save failed: {}", e); event_handler.command_message = format!("Save failed: {}", e);
// Optionally show an error dialog instead of just command message
} }
} }
needs_redraw = true; needs_redraw = true;
} }
Err(mpsc::error::TryRecvError::Empty) => {} // No message Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly."); error!("Save table result channel disconnected unexpectedly.");
} }
} }
// --- Centralized Consequence Handling ---
let mut should_exit = false; let mut should_exit = false;
match event_outcome_result { match event_outcome_result {
Ok(outcome) => match outcome { Ok(outcome) => match outcome {
EventOutcome::Ok(message) => { EventOutcome::Ok(_message) => {}
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::Exit(message) => { EventOutcome::Exit(message) => {
event_handler.command_message = message; event_handler.command_message = message;
should_exit = true; should_exit = true;
} }
EventOutcome::DataSaved(save_outcome, message) => { 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( if let Err(e) = UiService::handle_save_outcome(
save_outcome, save_outcome,
&mut grpc_client, &mut grpc_client,
@@ -292,131 +415,112 @@ pub async fn run_ui() -> Result<()> {
format!("Error handling save outcome: {}", e); format!("Error handling save outcome: {}", e);
} }
} }
EventOutcome::ButtonSelected { context: _, index: _ } => { EventOutcome::ButtonSelected { context: _, index: _ } => {}
// 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.");
}
}, },
Err(e) => { Err(e) => {
event_handler.command_message = format!("Error: {}", e); event_handler.command_message = format!("Error: {}", e);
} }
} // --- End Consequence Handling --- }
// --- Position Change Handling (after outcome processing and pending actions) --- // --- MODIFIED: Position Change Handling (operates on form_state) ---
let position_changed = app_state.current_position != position_before_event; let position_changed = form_state.current_position != position_before_event;
let current_total_count = app_state.total_count;
let mut position_logic_needs_redraw = false; let mut position_logic_needs_redraw = false;
if app_state.ui.show_form {
if app_state.ui.show_form { // Only if the form is active
if position_changed && !event_handler.is_edit_mode { if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input(); // This part is okay: update cursor for the current field BEFORE loading new data
let max_cursor_pos = if !current_input.is_empty() { let current_input_before_load = form_state.get_current_input();
current_input.len() - 1 // Limit to last character in readonly mode let max_cursor_pos_before_load = if !current_input_before_load.is_empty() { current_input_before_load.chars().count() } else { 0 };
} else { form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_before_load);
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
position_logic_needs_redraw = true; position_logic_needs_redraw = true;
// Ensure position never exceeds total_count + 1 // Validate new form_state.current_position
if app_state.current_position > current_total_count + 1 { if form_state.total_count > 0 && form_state.current_position > form_state.total_count + 1 {
app_state.current_position = current_total_count + 1; form_state.current_position = form_state.total_count + 1; // Cap at new entry
} else if form_state.total_count == 0 && form_state.current_position > 1 {
form_state.current_position = 1; // Cap at new entry for empty table
}
if form_state.current_position == 0 && form_state.total_count > 0 {
form_state.current_position = 1; // Don't allow 0 if there are records
} }
if app_state.current_position > current_total_count {
// 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
let load_message = UiService::load_adresar_by_position(
&mut grpc_client,
&mut app_state, // Pass app_state mutably if needed by the service
&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 // Load data for the new position OR reset for new entry
&& !current_input.is_empty() if (form_state.total_count > 0 && form_state.current_position <= form_state.total_count && form_state.current_position > 0)
{ {
current_input.len() - 1 // In readonly mode, limit to last character // It's an existing record position
} else { match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
current_input.len() Ok(load_message) => {
}; if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
form_state.current_cursor_pos = event_handler event_handler.command_message = load_message;
.ideal_cursor_column }
.min(max_cursor_pos); }
// Don't overwrite message from handle_event if load_message is simple success Err(e) => {
if !load_message.starts_with("Loaded entry") event_handler.command_message = format!("Error loading data: {}", e);
|| event_handler.command_message.is_empty() // Consider what to do with form_state here - maybe revert position or clear form
{ }
event_handler.command_message = load_message;
} }
} else { } else {
// Invalid position (e.g., 0) - reset to first entry or new entry mode // Position indicates a new entry (or table is empty and position is 1)
app_state.current_position = form_state.reset_to_empty(); // This sets id=0, clears values, and sets current_position correctly
1.min(current_total_count + 1); // Go to 1 or new entry if empty event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
if app_state.current_position > total_count {
form_state.reset_to_empty();
form_state.current_field = 0;
}
} }
} else if !position_changed && !event_handler.is_edit_mode {
// If position didn't change but we are in read-only, just adjust cursor // NOW, after data is loaded or form is reset, get the current input string and its length
let current_input = form_state.get_current_input(); let current_input_after_load_str = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let current_input_len_after_load = current_input_after_load_str.chars().count();
current_input.len() - 1
let max_cursor_pos_for_readonly_after_load = if current_input_len_after_load > 0 {
current_input_len_after_load.saturating_sub(1)
} else { } else {
0 0
}; };
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos); if event_handler.is_edit_mode {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(current_input_len_after_load);
} else {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_for_readonly_after_load);
// The check for empty string is implicitly handled by max_cursor_pos_for_readonly_after_load being 0
}
} else if !position_changed && !event_handler.is_edit_mode && app_state.ui.show_form {
// Update cursor if not editing and position didn't change (e.g. arrow keys within field)
let current_input_str = form_state.get_current_input();
let current_input_len = current_input_str.chars().count();
let max_cursor_pos = if current_input_len > 0 {
current_input_len.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input(); let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
current_input.len() - 1
} else {
0
};
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
if !event_handler.is_edit_mode { if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input(); let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
current_input.len() - 1
} else {
0
};
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos); login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} }
} }
if position_logic_needs_redraw { if position_logic_needs_redraw {
needs_redraw = true; needs_redraw = true;
} }
// --- End Position Change Handling ---
// Check exit condition *after* all processing for the iteration
if should_exit { if should_exit {
return Ok(()); return Ok(());
} }
// --- FPS Calculation ---
let now = Instant::now(); let now = Instant::now();
let frame_duration = now.duration_since(last_frame_time); let frame_duration = now.duration_since(last_frame_time);
last_frame_time = now; last_frame_time = now;
if frame_duration.as_secs_f64() > 1e-6 { if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64(); current_fps = 1.0 / frame_duration.as_secs_f64();
} }
} // End main loop }
} }

View File

@@ -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

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

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,2 +1,3 @@
// src/shared/mod.rs // src/shared/mod.rs
pub mod date_utils; pub mod date_utils;
pub mod schema_qualifier;

View File

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

View File

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

View File

@@ -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;
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;
name: "adresar_id".to_string(), let table_schema = "gen";
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 })
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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