Compare commits

..

64 Commits

Author SHA1 Message Date
filipriec
afd9228efa json in the otput of the tantivy 2025-06-11 14:07:22 +02:00
filipriec
495d77fda5 4 ngram tokenizer, not doing anything elsekeeping this as is 2025-06-10 23:56:31 +02:00
filipriec
679bb3b6ab search in common module, now fixing layer mixing issue 2025-06-10 13:47:18 +02:00
filipriec
350c522d19 better search but still has some flaws. It at least works, even tho its not perfect. Needs more testing, but im pretty happy with it rn, keeping it this way 2025-06-10 00:22:31 +02:00
filipriec
4760f42589 slovak language tokenized search 2025-06-09 16:36:18 +02:00
filipriec
50d15e321f automatic indexing is working perfectly well 2025-06-08 23:26:13 +02:00
filipriec
a3e7fd8f0a forgotten changes to the lib that are needed for a single port of two crates working separately 2025-06-08 22:40:46 +02:00
filipriec
645172747a we are now running search server at the same port as the whole backend service 2025-06-08 21:53:48 +02:00
filipriec
7c4ac1eebc search via tantivy on different grpc port works perfectly well now 2025-06-08 21:28:10 +02:00
filipriec
4b4301ad49 fixed now it all compiled successfuly 2025-06-08 20:14:44 +02:00
filipriec
b60e03eb70 search crate compiled, lets get to fixing all the other errors 2025-06-08 20:10:57 +02:00
filipriec
2c7bda3ff1 search crate created 2025-06-08 16:25:56 +02:00
filipriec
eeaaa3635b crucial dialog reloading bug fixed for good(hardest bug had a single line of code fix) 2025-06-08 10:53:46 +02:00
filipriec
e61cbb3956 features ui debug is now working perfectly well, it debugs the rerender flags 2025-06-08 09:26:56 +02:00
filipriec
f9841f2ef3 centralizing logic in the formstate 2025-06-08 00:00:37 +02:00
filipriec
dc232b2523 form is now working as expected 2025-06-07 15:25:35 +02:00
filipriec
b086b3e236 hardcoded firma is being removed part2 2025-06-07 15:12:00 +02:00
filipriec
387e1a0fe0 displaying data properly, fixing hardcoded backend to firma part one 2025-06-07 14:05:35 +02:00
filipriec
08e01d41f2 now properly not displaying in the frontend form fields that should be hidden from the user 2025-06-07 09:37:12 +02:00
filipriec
f5edf52571 working find palette now properly well 2025-06-07 09:16:43 +02:00
filipriec
02c62213c3 making select from the find file to work, not yet working, needs more redesign in how select is working 2025-06-06 23:44:29 +02:00
filipriec
d0722fbbbe working well now, creation of the columns 2025-06-06 20:18:51 +02:00
filipriec
4ec569342d hidden from the user now in the form 2025-06-03 18:47:14 +02:00
filipriec
9540d9ccb9 table definitions are now forbidden for user to allocated rust autoallocated table columns 2025-06-03 18:46:57 +02:00
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
90 changed files with 5833 additions and 3168 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
.env .env
/tantivy_indexes

465
Cargo.lock generated
View File

@@ -303,6 +303,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "bitpacking"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92"
dependencies = [
"crunchy",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -322,6 +331,31 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "bon"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced38439e7a86a4761f7f7d5ded5ff009135939ecb464a24452eaa4c1696af7d"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce61d2d3844c6b8d31b2353d9f66cf5e632b3e9549583fe3cac2f4f6136725e"
dependencies = [
"darling",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.100",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.17.0" version = "3.17.0"
@@ -361,9 +395,17 @@ version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [ dependencies = [
"jobserver",
"libc",
"shlex", "shlex",
] ]
[[package]]
name = "census"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@@ -409,6 +451,7 @@ dependencies = [
"prost", "prost",
"ratatui", "ratatui",
"serde", "serde",
"serde_json",
"time", "time",
"tokio", "tokio",
"toml", "toml",
@@ -445,6 +488,7 @@ version = "0.3.13"
dependencies = [ dependencies = [
"prost", "prost",
"serde", "serde",
"tantivy",
"tonic", "tonic",
"tonic-build", "tonic-build",
] ]
@@ -540,6 +584,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam" name = "crossbeam"
version = "0.8.4" version = "0.8.4"
@@ -621,6 +674,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -698,6 +757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde",
] ]
[[package]] [[package]]
@@ -771,6 +831,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -824,6 +890,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastdivide"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -883,6 +955,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs4"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1141,6 +1223,12 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "htmlescape"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]] [[package]]
name = "http" name = "http"
version = "1.3.1" version = "1.3.1"
@@ -1241,6 +1329,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hyperloglogplus"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.63" version = "0.1.63"
@@ -1519,6 +1616,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.2",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.77" version = "0.3.77"
@@ -1565,6 +1672,12 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "levenshtein_automata"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.172"
@@ -1650,6 +1763,12 @@ dependencies = [
"hashbrown 0.15.2", "hashbrown 0.15.2",
] ]
[[package]]
name = "lz4_flex"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5"
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -1666,18 +1785,42 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "measure_time"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
dependencies = [
"log",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.4" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memmap2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.8" version = "0.8.8"
@@ -1705,6 +1848,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
[[package]]
name = "murmurhash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -1722,6 +1871,16 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@@ -1856,6 +2015,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oneshot"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.72" version = "0.10.72"
@@ -1912,6 +2077,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "ownedbytes"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -2274,6 +2448,16 @@ dependencies = [
"getrandom 0.3.2", "getrandom 0.3.2",
] ]
[[package]]
name = "rand_distr"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
dependencies = [
"num-traits",
"rand 0.8.5",
]
[[package]] [[package]]
name = "rand_xoshiro" name = "rand_xoshiro"
version = "0.6.0" version = "0.6.0"
@@ -2304,6 +2488,26 @@ dependencies = [
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.11" version = "0.5.11"
@@ -2443,12 +2647,28 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
dependencies = [
"serde",
"serde_derive",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2511,6 +2731,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "search"
version = "0.3.13"
dependencies = [
"anyhow",
"common",
"prost",
"serde",
"serde_json",
"sqlx",
"tantivy",
"tokio",
"tonic",
"tonic-reflection",
"tracing",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@@ -2597,6 +2834,7 @@ dependencies = [
name = "server" name = "server"
version = "0.3.13" version = "0.3.13"
dependencies = [ dependencies = [
"anyhow",
"bcrypt", "bcrypt",
"chrono", "chrono",
"common", "common",
@@ -2607,11 +2845,14 @@ dependencies = [
"prost", "prost",
"regex", "regex",
"rstest", "rstest",
"rust-stemmers",
"search",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"steel-core", "steel-core",
"steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)", "steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)",
"tantivy",
"thiserror 2.0.12", "thiserror 2.0.12",
"time", "time",
"tokio", "tokio",
@@ -2721,6 +2962,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "sketches-ddsketch"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@@ -2770,9 +3020,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-macros", "sqlx-macros",
@@ -2783,9 +3033,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-core" name = "sqlx-core"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -2821,9 +3071,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros" name = "sqlx-macros"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2834,9 +3084,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros-core" name = "sqlx-macros-core"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
@@ -2853,16 +3103,15 @@ dependencies = [
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite", "sqlx-sqlite",
"syn 2.0.100", "syn 2.0.100",
"tempfile",
"tokio", "tokio",
"url", "url",
] ]
[[package]] [[package]]
name = "sqlx-mysql" name = "sqlx-mysql"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@@ -2905,9 +3154,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-postgres" name = "sqlx-postgres"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@@ -2945,9 +3194,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-sqlite" name = "sqlx-sqlite"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono", "chrono",
@@ -3161,6 +3410,152 @@ dependencies = [
"syn 2.0.100", "syn 2.0.100",
] ]
[[package]]
name = "tantivy"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
dependencies = [
"aho-corasick",
"arc-swap",
"base64",
"bitpacking",
"bon",
"byteorder",
"census",
"crc32fast",
"crossbeam-channel",
"downcast-rs",
"fastdivide",
"fnv",
"fs4",
"htmlescape",
"hyperloglogplus",
"itertools 0.14.0",
"levenshtein_automata",
"log",
"lru",
"lz4_flex",
"measure_time",
"memmap2",
"once_cell",
"oneshot",
"rayon",
"regex",
"rust-stemmers",
"rustc-hash",
"serde",
"serde_json",
"sketches-ddsketch",
"smallvec",
"tantivy-bitpacker",
"tantivy-columnar",
"tantivy-common",
"tantivy-fst",
"tantivy-query-grammar",
"tantivy-stacker",
"tantivy-tokenizer-api",
"tempfile",
"thiserror 2.0.12",
"time",
"uuid",
"winapi",
]
[[package]]
name = "tantivy-bitpacker"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494"
dependencies = [
"bitpacking",
]
[[package]]
name = "tantivy-columnar"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344"
dependencies = [
"downcast-rs",
"fastdivide",
"itertools 0.14.0",
"serde",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-sstable",
"tantivy-stacker",
]
[[package]]
name = "tantivy-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f"
dependencies = [
"async-trait",
"byteorder",
"ownedbytes",
"serde",
"time",
]
[[package]]
name = "tantivy-fst"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
dependencies = [
"byteorder",
"regex-syntax",
"utf8-ranges",
]
[[package]]
name = "tantivy-query-grammar"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a"
dependencies = [
"nom",
"serde",
"serde_json",
]
[[package]]
name = "tantivy-sstable"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416"
dependencies = [
"futures-util",
"itertools 0.14.0",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-fst",
"zstd",
]
[[package]]
name = "tantivy-stacker"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1"
dependencies = [
"murmurhash32",
"rand_distr",
"tantivy-common",
]
[[package]]
name = "tantivy-tokenizer-api"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.19.1" version = "3.19.1"
@@ -3423,9 +3818,9 @@ dependencies = [
[[package]] [[package]]
name = "tonic-reflection" name = "tonic-reflection"
version = "0.13.0" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88fa815be858816dad226a49439ee90b7bcf81ab55bee72fdb217f1e6778c3ca" checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
dependencies = [ dependencies = [
"prost", "prost",
"prost-types", "prost-types",
@@ -3647,6 +4042,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3849,7 +4250,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.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4213,3 +4614,31 @@ dependencies = [
"quote", "quote",
"syn 2.0.100", "syn 2.0.100",
] ]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.15+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["client", "server", "common"] members = ["client", "server", "common", "search"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@@ -16,4 +16,27 @@ categories = ["command-line-interface"]
# [workspace.metadata] # [workspace.metadata]
# TODO: # TODO:
# documentation = "https://docs.rs/accounting-client"` # documentation = "https://docs.rs/accounting-client"
[workspace.dependencies]
# Async and gRPC
tokio = { version = "1.44.2", features = ["full"] }
tonic = "0.13.0"
prost = "0.13.5"
async-trait = "0.1.88"
# Data Handling & Serialization
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41"
# Utilities & Error Handling
anyhow = "1.0.98"
dotenvy = "0.15.7"
lazy_static = "1.5.0"
tracing = "0.1.41"
# Search crate
tantivy = "0.24.1"
common = { path = "./common" }

View File

@@ -18,3 +18,8 @@ Client with tracing:
``` ```
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client' ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
``` ```
Client with debug that cant be traced
```
cargo run --package client --features ui-debug -- client
```

View File

@@ -16,6 +16,7 @@ lazy_static = "1.5.0"
prost = "0.13.5" prost = "0.13.5"
ratatui = { version = "0.29.0", features = ["crossterm"] } ratatui = { version = "0.29.0", features = ["crossterm"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41" time = "0.3.41"
tokio = { version = "1.44.2", features = ["full", "macros"] } tokio = { version = "1.44.2", features = ["full", "macros"] }
toml = "0.8.20" toml = "0.8.20"
@@ -25,3 +26,7 @@ tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] } 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"
[features]
default = []
ui-debug = []

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

View File

@@ -12,9 +12,8 @@ use ratatui::{
Frame, Frame,
}; };
use crate::components::handlers::canvas::render_canvas; use crate::components::handlers::canvas::render_canvas;
use crate::components::common::dialog; use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode; use crate::config::binds::config::EditorKeybindingMode;
use crate::components::common::text_editor::TextEditor;
pub fn render_add_logic( pub fn render_add_logic(
f: &mut Frame, f: &mut Frame,
@@ -35,27 +34,25 @@ pub fn render_add_logic(
let inner_area = main_block.inner(area); let inner_area = main_block.inner(area);
f.render_widget(main_block, area); f.render_widget(main_block, area);
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { // 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 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_color = if is_edit_mode { theme.highlight } else { theme.secondary };
let border_style = Style::default().fg(border_style_color); let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default()); editor_ref.set_cursor_line_style(Style::default());
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
let script_title_hint = match add_logic_state.editor_keybinding_mode { let script_title_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => { EditorKeybindingMode::Vim => {
let vim_mode_status = TextEditor::get_vim_mode_status(&add_logic_state.vim_state); let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
if is_edit_mode { format!("Script {}", vim_mode_status)
format!("Script (VIM {}) - Esc for Normal. Tab navigates from Normal.", vim_mode_status)
} else {
format!("Script (VIM {}) - 'i'/'a'/'o' for Insert. Tab to navigate.", vim_mode_status)
}
} }
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode { if is_edit_mode {
"Script (Editing - Esc to exit edit. Tab navigates after exit.)".to_string() "Script (Editing)".to_string()
} else { } else {
"Script (Press Enter or Ctrl+E to edit. Tab to navigate.)".to_string() "Script".to_string()
} }
} }
}; };
@@ -68,19 +65,59 @@ pub fn render_add_logic(
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(border_style), .border_style(border_style),
); );
// Remove .widget() call - just pass the reference directly
f.render_widget(&*editor_ref, inner_area); f.render_widget(&*editor_ref, inner_area);
return;
// Drop the editor borrow before accessing autocomplete state
drop(editor_ref);
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
// Get the current cursor position from textarea
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
};
let (cursor_line, cursor_col) = current_cursor;
// Account for TextArea's block borders (1 for each side)
let block_offset_x = 1;
let block_offset_y = 1;
// Position autocomplete at current cursor position
// Add 1 to column to position dropdown right after the cursor
let autocomplete_x = cursor_col + 1;
let autocomplete_y = cursor_line;
let input_rect = Rect {
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
width: 1, // Minimum width for positioning
height: 1,
};
// Render autocomplete dropdown
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
&add_logic_state.script_editor_suggestions,
add_logic_state.script_editor_selected_suggestion_index,
);
}
return; // Exit early for fullscreen mode
} }
// ... rest of the layout code ... // Regular layout with preview
let main_chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(3), // Top info
Constraint::Length(9), 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), Constraint::Min(5), // Script preview
Constraint::Length(3), Constraint::Length(3), // Buttons
]) ])
.split(inner_area); .split(inner_area);
@@ -89,6 +126,7 @@ pub fn render_add_logic(
let script_content_area = main_chunks[2]; let script_content_area = main_chunks[2];
let buttons_area = main_chunks[3]; let buttons_area = main_chunks[3];
// Top info
let profile_text = Paragraph::new(vec![ let profile_text = Paragraph::new(vec![
Line::from(Span::styled( Line::from(Span::styled(
format!("Profile: {}", add_logic_state.profile_name), format!("Profile: {}", add_logic_state.profile_name),
@@ -114,52 +152,89 @@ pub fn render_add_logic(
); );
f.render_widget(profile_text, top_info_area); f.render_widget(profile_text, top_info_area);
// Canvas
let focus_on_canvas_inputs = matches!( let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus, add_logic_state.current_focus,
AddLogicFocus::InputLogicName AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn | AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription | AddLogicFocus::InputDescription
); );
render_canvas( // Call render_canvas and get the active_field_rect
let active_field_rect = render_canvas(
f, f,
canvas_area, canvas_area,
add_logic_state, add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(), &add_logic_state.fields(),
&add_logic_state.current_field(), &add_logic_state.current_field(),
&add_logic_state.inputs(), &add_logic_state.inputs(),
theme, theme,
is_edit_mode && focus_on_canvas_inputs, is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
highlight_state, highlight_state,
); );
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
let selected = add_logic_state.get_selected_suggestion_index();
if !suggestions.is_empty() { // Only render if there are suggestions to show
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
suggestions,
selected,
);
}
}
}
}
// Script content preview
{ {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
editor_ref.set_cursor_line_style(Style::default()); editor_ref.set_cursor_line_style(Style::default());
let border_style_color = if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { 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 theme.highlight
} else { } else {
theme.secondary theme.secondary
}; };
let title_hint = match add_logic_state.editor_keybinding_mode { let title_text = "Script Preview"; // Title doesn't need to change based on focus here
EditorKeybindingMode::Vim => "Script Preview (VIM - Focus with Tab, then 'i'/'a'/'o' to edit)",
_ => "Script Preview (Focus with Tab, then Enter/Ctrl+E to edit)", 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( editor_ref.set_block(
Block::default() Block::default()
.title(title_hint) .title(Span::styled(title_text, title_style))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_style_color)), .border_style(Style::default().fg(border_style_color)),
); );
// Remove .widget() call here too
f.render_widget(&*editor_ref, script_content_area); f.render_widget(&*editor_ref, script_content_area);
} }
let get_button_style = |button_focus: AddLogicFocus, current_focus| { // Buttons
let is_focused = current_focus == button_focus; let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
let is_focused = current_focus_state == button_focus;
let base_style = Style::default().fg(if is_focused { let base_style = Style::default().fg(if is_focused {
theme.highlight theme.highlight
} else { } else {
@@ -172,11 +247,11 @@ pub fn render_add_logic(
} }
}; };
let get_button_border_style = |is_focused: bool, theme: &Theme| { let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
if is_focused { if is_focused {
Style::default().fg(theme.highlight) Style::default().fg(current_theme.highlight)
} else { } else {
Style::default().fg(theme.secondary) Style::default().fg(current_theme.secondary)
} }
}; };
@@ -222,6 +297,7 @@ pub fn render_add_logic(
); );
f.render_widget(cancel_button, button_chunks[1]); f.render_widget(cancel_button, button_chunks[1]);
// Dialog
if app_state.ui.dialog.dialog_show { if app_state.ui.dialog.dialog_show {
dialog::render_dialog( dialog::render_dialog(
f, f,

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

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

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
// src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{ use ratatui::{
style::Style,
layout::Rect, layout::Rect,
Frame, style::Style,
text::{Line, Span}, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
Frame,
}; };
use unicode_width::UnicodeWidthStr;
use crate::config::colors::themes::Theme;
use std::path::Path; use std::path::Path;
use unicode_width::UnicodeWidthStr;
pub fn render_status_line( pub fn render_status_line(
f: &mut Frame, f: &mut Frame,
@@ -16,11 +18,24 @@ pub fn render_status_line(
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
current_fps: f64, current_fps: f64,
app_state: &AppState,
) { ) {
// --- START FIX ---
// Ensure debug_text is always a &str, which implements UnicodeWidthStr.
#[cfg(feature = "ui-debug")]
let debug_text = app_state.debug_info.as_str();
#[cfg(not(feature = "ui-debug"))]
let debug_text = "";
// --- END FIX ---
let debug_width = UnicodeWidthStr::width(debug_text);
let debug_separator_width = if !debug_text.is_empty() { UnicodeWidthStr::width(" | ") } else { 0 };
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION")); let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" }; let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(); let home_dir =
dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
let display_dir = if current_dir.starts_with(&home_dir) { let display_dir = if current_dir.starts_with(&home_dir) {
current_dir.replacen(&home_dir, "~", 1) current_dir.replacen(&home_dir, "~", 1)
} else { } else {
@@ -36,15 +51,18 @@ pub fn render_status_line(
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; debug_separator_width + debug_width;
let show_fps = fixed_width_with_fps <= available_width;
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 +
if show_fps { separator_width + fps_width } else { 0 } separator_width + program_info_width +
(if show_fps { separator_width + fps_width } else { 0 }) +
debug_separator_width + debug_width,
); );
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir { let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
display_dir display_dir
} else { } else {
let dir_name = Path::new(current_dir) let dir_name = Path::new(current_dir)
@@ -54,24 +72,46 @@ pub fn render_status_line(
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![ let mut current_content_width = mode_width + separator_width +
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
separator_width + program_info_width +
debug_separator_width + debug_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)) #[cfg(feature = "ui-debug")]
{
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(debug_text, Style::default().fg(theme.accent)));
}
let padding_needed = available_width.saturating_sub(current_content_width);
if padding_needed > 0 {
line_spans.push(Span::styled(
" ".repeat(padding_needed),
Style::default().bg(theme.bg),
));
}
let paragraph = Paragraph::new(Line::from(line_spans))
.style(Style::default().bg(theme.bg)); .style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area); f.render_widget(paragraph, area);

View File

@@ -2,7 +2,7 @@
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers}; use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use ratatui::style::{Color, Style, Modifier}; use ratatui::style::{Color, Style, Modifier};
use tui_textarea::{Input, Key, TextArea, CursorMove, Scrolling}; use tui_textarea::{Input, Key, TextArea, CursorMove};
use std::fmt; use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -13,32 +13,33 @@ 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],
table_name: &str, // This parameter receives the correct table name
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, total_count: u64,
current_position: u64, current_position: u64,
) { ) {
// Create Adresar card // Use the dynamic `table_name` parameter for the title instead of a hardcoded string.
let card_title = format!(" {} ", table_name);
let adresar_card = Block::default() let adresar_card = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(theme.border))
.title(" Adresar ") .title(card_title) // Use the dynamic title
.style(Style::default().bg(theme.bg).fg(theme.fg)); .style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area); f.render_widget(adresar_card, area);
// Define inner area
let inner_area = area.inner(Margin { let inner_area = area.inner(Margin {
horizontal: 1, horizontal: 1,
vertical: 1, vertical: 1,
}); });
// Create main layout
let main_layout = Layout::default() let main_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -47,20 +48,27 @@ pub fn render_form(
]) ])
.split(inner_area); .split(inner_area);
// Render count/position let count_position_text = if total_count == 0 && current_position == 1 {
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position); "Total: 0 | New Entry".to_string()
} else if current_position > total_count && total_count > 0 {
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 {
format!("Total: 0 | New Entry ({})", current_position)
}
else {
format!("Total: {} | Position: {}/{}", total_count, current_position, total_count)
};
let count_para = Paragraph::new(count_position_text) 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);
f.render_widget(count_para, main_layout[0]); f.render_widget(count_para, main_layout[0]);
// Delegate input handling to canvas
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,
) { ) {
// --- Style Definitions --- // --- Style Definitions ---
let active_style = Style::default() let active_style = Style::default()
@@ -37,6 +39,8 @@ 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;
let current_table_name = app_state.current_view_table_name.as_deref();
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 +48,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

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

@@ -7,7 +7,7 @@ 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 | AppView::AddLogic => 2, AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form(_) | AppView::Scratch => 3, AppView::Form | AppView::Scratch => 3,
} }
} }

View File

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

View File

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

@@ -10,9 +10,9 @@ use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::services::GrpcClient; use crate::services::GrpcClient;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use anyhow::Result; use anyhow::Result;
use common::proto::multieko2::table_script::PostTableScriptRequest;
use crate::components::common::text_editor::TextEditor; use crate::components::common::text_editor::TextEditor;
use tui_textarea::Input as TextAreaInput; use crate::services::ui_service::UiService;
use tui_textarea::CursorMove; // Ensure this import is present
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>; pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
@@ -24,239 +24,417 @@ pub fn handle_add_logic_navigation(
is_edit_mode: &mut bool, is_edit_mode: &mut bool,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
grpc_client: GrpcClient, grpc_client: GrpcClient,
save_logic_sender: SaveLogicResultSender, _save_logic_sender: SaveLogicResultSender, // Marked as unused
command_message: &mut String, command_message: &mut String,
) -> bool { ) -> bool {
let mut handled = false; // === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
let general_action = config.get_general_action(key_event.code, key_event.modifiers); 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();
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { add_logic_state.deactivate_script_editor_autocomplete();
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); add_logic_state.has_unsaved_changes = true;
match add_logic_state.editor_keybinding_mode { if let Some(pos) = trigger_pos {
EditorKeybindingMode::Vim => { let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
if *is_edit_mode { // App considers textarea to be in "typing" (Insert) mode
let changed = TextEditor::handle_input( 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, &mut editor_borrow,
key_event, key_event,
&add_logic_state.editor_keybinding_mode, &add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state, &mut add_logic_state.vim_state,
); );
if changed { add_logic_state.has_unsaved_changes = true; } }
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;
}
}
// Check if we've transitioned to Normal mode if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
if key_event.code == KeyCode::Esc && TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) { match add_logic_state.editor_keybinding_mode {
*is_edit_mode = false; EditorKeybindingMode::Vim => {
*command_message = "VIM: Normal Mode. Tab to navigate.".to_string(); if *is_edit_mode {
} {
handled = true; let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
} else { // App considers textarea to be in "navigation" (Normal) mode
match key_event.code {
// Keys to enter Vim Insert mode
KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Char('o') |
KeyCode::Char('I') | KeyCode::Char('A') | KeyCode::Char('O') => {
*is_edit_mode = true;
TextEditor::handle_input( TextEditor::handle_input(
&mut editor_borrow, &mut editor_borrow,
key_event, key_event,
&add_logic_state.editor_keybinding_mode, &add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state
);
*command_message = "VIM: Insert Mode.".to_string();
handled = true;
}
_ => {
if general_action.is_none() {
let changed = TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
if changed { add_logic_state.has_unsaved_changes = true; }
handled = true;
}
}
}
}
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if *is_edit_mode {
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
*is_edit_mode = false;
*command_message = "Exited script edit. Tab to navigate.".to_string();
handled = true;
} else if general_action.is_some() && (general_action.unwrap() == "next_field" || general_action.unwrap() == "prev_field") {
let changed = TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state
);
if changed { add_logic_state.has_unsaved_changes = true; }
handled = true;
} else {
let changed = TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state
);
if changed { add_logic_state.has_unsaved_changes = true; }
handled = true;
}
}
}
}
if handled { return true; }
}
// If not handled above (e.g., Tab/Shift+Tab, or Enter when script content not in edit mode),
// process general application-level actions.
let action_str = general_action.map(String::from);
match action_str.as_deref() {
Some("exit_view") | Some("cancel_action") => {
buffer_state.update_history(AppView::Admin);
app_state.ui.show_add_logic = false;
*command_message = "Exited Add Logic".to_string();
*is_edit_mode = false;
handled = true;
}
Some("next_field") | Some("prev_field") => {
let is_next = action_str.as_deref() == Some("next_field");
let previous_focus = add_logic_state.current_focus;
add_logic_state.current_focus = if is_next {
match add_logic_state.current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::InputScriptContent,
AddLogicFocus::InputScriptContent => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
}
} else {
match add_logic_state.current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputScriptContent => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::InputScriptContent,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
}
};
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent {
*is_edit_mode = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "'i'/'a'/'o' to insert",
_ => "Enter/Ctrl+E to edit",
};
*command_message = format!("Focus: Script Content. Press {} or Tab.", mode_hint);
} else if matches!(add_logic_state.current_focus, AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription) {
*is_edit_mode = true;
*command_message = format!("Focus: {:?}. Edit mode ON.", add_logic_state.current_focus);
} else {
*is_edit_mode = false;
*command_message = format!("Focus: {:?}", add_logic_state.current_focus);
}
app_state.ui.focus_outside_canvas = !matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription | AddLogicFocus::InputScriptContent
);
handled = true;
}
Some("select") => {
match add_logic_state.current_focus {
AddLogicFocus::InputScriptContent => {
*is_edit_mode = true;
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state, &mut add_logic_state.vim_state,
); );
*command_message = "VIM: Insert Mode.".to_string();
} }
_ => { if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
*command_message = "Entered script edit mode.".to_string(); *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();
} }
handled = true;
} }
AddLogicFocus::SaveButton => { handled = true; } _ => {
AddLogicFocus::CancelButton => { *is_edit_mode = false; handled = true; } 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 => { AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode; *is_edit_mode = !*is_edit_mode;
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" }); *command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
handled = true;
} }
_ => handled = false,
} }
} }
Some("toggle_edit_mode") => { Some("toggle_edit_mode") => {
match add_logic_state.current_focus { match current_focus {
AddLogicFocus::InputScriptContent => {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&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. Tab to navigate.".to_string();
} else {
*command_message = "VIM: Still in Insert Mode (toggle error?).".to_string();
}
} else {
TextEditor::handle_input(
&mut editor_borrow,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
*is_edit_mode = true;
*command_message = "VIM: Insert Mode.".to_string();
}
}
_ => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Script edit mode: {}", if *is_edit_mode { "ON" } else { "OFF. Tab to navigate." });
}
}
handled = true;
}
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => { AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode; *is_edit_mode = !*is_edit_mode;
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" }); *command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
handled = true;
} }
_ => { *command_message = "Cannot toggle edit mode here.".to_string(); handled = true; } _ => {
*command_message = "Cannot toggle edit mode here.".to_string();
}
} }
} }
_ => { _ => handled = false,
if add_logic_state.current_focus == AddLogicFocus::InputScriptContent && }
!*is_edit_mode &&
add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim { if handled && current_focus != new_focus {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); add_logic_state.current_focus = new_focus;
let changed = TextEditor::handle_input( let new_is_canvas_input_focus = matches!(new_focus,
&mut editor_borrow, AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
key_event, );
&add_logic_state.editor_keybinding_mode, if new_is_canvas_input_focus {
&mut add_logic_state.vim_state *is_edit_mode = false;
); app_state.ui.focus_outside_canvas = false;
if changed { add_logic_state.has_unsaved_changes = true; } } else {
handled = true; app_state.ui.focus_outside_canvas = true;
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
*is_edit_mode = false;
} }
} }
} }
handled 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,27 +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 crate::state::pages::add_logic::AddLogicState;
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));
@@ -33,252 +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 => {
*command_message = "Press Enter to select and scroll tables".to_string();
}
AdminFocus::InsideTablesList => {
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
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();
Some("move_down") => { handled = true;
match current_focus {
AdminFocus::Profiles => {
if profile_count > 0 {
admin_state.next_profile(profile_count);
*command_message = "Navigated profiles list".to_string();
}
} }
AdminFocus::Tables => { Some("previous_option") | Some("move_up") => {
*command_message = "Press Enter to select and scroll tables".to_string(); // No wrap-around: Stay on ProfilesPane if trying to go "before" it
} *command_message = "At first focusable pane.".to_string();
AdminFocus::InsideTablesList => { handled = true;
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
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 => {}
}
}
// --- Horizontal Navigation (Focus Change) ---
Some("next_option") | Some("previous_option") => {
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 },
AdminFocus::Tables => if is_next { AdminFocus::Button1 } else { AdminFocus::Profiles },
AdminFocus::Button1 => if is_next { AdminFocus::Button2 } else { AdminFocus::Tables },
AdminFocus::Button2 => if is_next { AdminFocus::Button3 } else { AdminFocus::Button1 },
AdminFocus::Button3 => if is_next { AdminFocus::Profiles } else { AdminFocus::Button2 },
AdminFocus::InsideTablesList => old_focus,
};
new_focus = admin_state.current_focus; // Update new_focus after changing admin_state.current_focus
*command_message = format!("Focus set to {:?}", new_focus);
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);
}
}
if old_focus == AdminFocus::Tables && new_focus != AdminFocus::Tables && old_focus != AdminFocus::InsideTablesList {
admin_state.table_list_state.select(None);
}
// No change needed for profile_list_state clearing here based on current logic
}
// --- Selection ---
Some("select") => {
match current_focus {
AdminFocus::Profiles => {
if let Some(nav_idx) = admin_state.profile_list_state.selected() {
admin_state.selected_profile_index = Some(nav_idx);
new_focus = AdminFocus::Tables;
admin_state.table_list_state.select(None);
admin_state.selected_table_index = None;
if let Some(profile) = app_state.profile_tree.profiles.get(nav_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0));
}
*command_message = format!("Selected profile: {}", app_state.profile_tree.profiles[nav_idx].name);
}
} else {
*command_message = "No profile selected".to_string();
}
}
AdminFocus::Tables => {
new_focus = AdminFocus::InsideTablesList;
if let Some(p_idx) = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index) {
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() {
admin_state.table_list_state.select(Some(0));
}
}
}
*command_message = "Entered Tables List (Select item with Enter, Exit with Esc)".to_string();
}
AdminFocus::InsideTablesList => {
if let Some(nav_idx) = admin_state.table_list_state.selected() {
admin_state.selected_table_index = Some(nav_idx);
let table_name = admin_state.profile_list_state.selected().or(admin_state.selected_profile_index)
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| p.tables.get(nav_idx).map(|t| t.name.clone()))
.unwrap_or_else(|| "N/A".to_string());
*command_message = format!("Selected table: {}", table_name);
} else {
*command_message = "No table highlighted".to_string();
}
}
// In src/functions/modes/navigation/admin_nav.rs
AdminFocus::Button1 => { // Add Logic
let mut logic_state_profile_name = "None (Global)".to_string();
let mut selected_table_id: Option<i64> = None;
let mut selected_table_name_for_logic: Option<String> = None;
if let Some(p_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
logic_state_profile_name = profile.name.clone();
// Check for persistently selected table within this profile
if let Some(t_idx) = admin_state.selected_table_index {
if let Some(table) = profile.tables.get(t_idx) {
selected_table_id = None;
selected_table_name_for_logic = Some(table.name.clone());
*command_message = format!("Adding logic for table: {}. CRITICAL: Table ID not found in profile tree response!", table.name);
} else {
*command_message = format!("Selected table index {} out of bounds for profile '{}'. Logic will not be table-specific.", t_idx, profile.name);
}} else {
*command_message = format!("No table selected in profile '{}'. Logic will not be table-specific.", profile.name);
}
} else {
*command_message = "Error: Selected profile index out of bounds, associating with 'None'.".to_string();
}
} else {
*command_message = "No profile selected ([*]), associating Logic with 'None (Global)'.".to_string();
// Keep logic_state_profile_name as "None (Global)"
}
// Create AddLogicState with the loaded config
admin_state.add_logic_state = AddLogicState {
profile_name: logic_state_profile_name.clone(),
editor_keybinding_mode: config.editor.keybinding_mode.clone(), // Add this line
..AddLogicState::default()
};
buffer_state.update_history(AppView::AddLogic);
app_state.ui.focus_outside_canvas = false;
// Rest of the code remains the same...
}
AdminFocus::Button2 => {
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();
let available_links: Vec<LinkDefinition> = profile
.tables
.iter()
.map(|table| LinkDefinition {
linked_table_name: table.name.clone(),
is_required: false,
selected: false,
})
.collect();
let new_add_table_state = AddTableState {
profile_name: selected_profile_name,
links: available_links,
..AddTableState::default()
};
admin_state.add_table_state = new_add_table_state;
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();
}
}
AdminFocus::Button3 => {
*command_message = "Action: Change Table (Not Implemented)".to_string();
}
}
}
Some("exit_table_scroll") => {
match current_focus {
AdminFocus::InsideTablesList => {
new_focus = AdminFocus::Tables;
admin_state.table_list_state.select(None);
*command_message = "Exited Tables List".to_string();
} }
_ => handled = false, _ => handled = false,
} }
} }
Some("toggle_sidebar") | Some("toggle_buffer_list") | Some("next_field") | Some("prev_field") => {
handled = false;
}
_ => handled = false,
}
if handled && admin_state.current_focus != new_focus { // Check admin_state.current_focus AdminFocus::InsideProfilesList => {
admin_state.current_focus = new_focus; match action.as_deref() {
if command_message.is_empty() || command_message.starts_with("Focus set to") { Some("move_up") => {
*command_message = format!("Focus set to {:?}", admin_state.current_focus); if profile_count > 0 {
list_select_previous(&mut admin_state.profile_list_state, profile_count);
*command_message = "".to_string();
handled = true;
}
}
Some("move_down") => {
if profile_count > 0 {
list_select_next(&mut admin_state.profile_list_state, profile_count);
*command_message = "".to_string();
handled = true;
}
}
Some("select") => {
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
admin_state.selected_table_index = None; // Deselect table when profile changes
if let Some(profile_idx) = admin_state.selected_profile_index {
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
if !profile.tables.is_empty() {
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
} else {
admin_state.table_list_state.select(None);
}
}
} else {
admin_state.table_list_state.select(None);
}
*command_message = format!(
"Profile '{}' set as active.",
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
);
handled = true;
}
Some("exit_table_scroll") => {
admin_state.current_focus = AdminFocus::ProfilesPane;
*command_message = "Focus: Profiles Pane".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Tables => {
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 !profile.tables.is_empty() {
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
*command_message = "".to_string();
handled = true;
} else {
*command_message = "No tables to navigate.".to_string();
handled = true;
}
}
} else {
*command_message = "No active profile for tables.".to_string();
handled = true;
}
}
Some("move_down") => {
let current_profile_idx = admin_state.selected_profile_index
.or_else(|| admin_state.profile_list_state.selected());
if let Some(p_idx) = current_profile_idx {
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
if !profile.tables.is_empty() {
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
*command_message = "".to_string();
handled = true;
} else {
*command_message = "No tables to navigate.".to_string();
handled = true;
}
}
} else {
*command_message = "No active profile for tables.".to_string();
handled = true;
}
}
Some("select") => { // This is for persistently selecting a table with [*]
admin_state.selected_table_index = admin_state.table_list_state.selected();
let table_name = admin_state.selected_profile_index
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
.map_or("N/A", |t| t.name.as_str());
*command_message = format!("Table '{}' set as active.", table_name);
handled = true;
}
Some("exit_table_scroll") => {
admin_state.current_focus = AdminFocus::Tables;
*command_message = "Focus: Tables Pane".to_string();
handled = true;
}
_ => handled = false,
}
}
AdminFocus::Button1 => { // Add Logic Button
match action.as_deref() {
Some("select") => { // 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();
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,
}
} }
} }
handled handled
} }

View File

@@ -67,7 +67,7 @@ fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
pub async fn execute_action( pub async fn execute_action(
action: &str, action: &str,
app_state: &mut AppState, app_state: &mut AppState,
state: &mut AddLogicState, // Changed state: &mut AddLogicState,
ideal_cursor_column: &mut usize, ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, command_message: &mut String,
@@ -75,7 +75,7 @@ pub async fn execute_action(
match action { match action {
"move_up" => { "move_up" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); } if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field(); let current_field = state.current_field();
@@ -87,20 +87,13 @@ pub async fn execute_action(
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);
} else { } else {
// Moving up from the first field (InputLogicName) *command_message = "At top of form.".to_string();
app_state.ui.focus_outside_canvas = true;
// Focus should go to the element logically above the canvas.
// Based on AddLogicFocus, this might be CancelButton or another element.
// For AddLogic, let's assume it's CancelButton, similar to AddTable.
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::CancelButton; // Changed
key_sequence_tracker.reset();
return Ok("Focus moved above canvas".to_string());
} }
Ok("".to_string()) Ok(command_message.clone())
} }
"move_down" => { "move_down" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); } if num_fields == 0 { return Ok("No fields.".to_string()); }
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;
@@ -113,21 +106,19 @@ pub async fn execute_action(
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);
} else { } else {
// Moving down from the last field (InputDescription) // 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; app_state.ui.focus_outside_canvas = true;
// Focus should go to the element logically below the canvas. state.last_canvas_field = 2;
// This is likely InputScriptContent or SaveButton. state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
// The add_logic_nav.rs handles transitions to InputScriptContent. *command_message = "Focus moved to script preview".to_string();
// If moving from canvas directly to buttons, it would be SaveButton.
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::InputScriptContent; // Or SaveButton
key_sequence_tracker.reset();
return Ok("Focus moved to script/button area".to_string());
} }
Ok("".to_string()) Ok(command_message.clone())
} }
// ... (rest of the actions remain the same) ...
"move_first_line" => { "move_first_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 { // Changed if AddLogicState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0); state.set_current_field(0);
let current_input = state.get_current_input(); 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 max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
@@ -139,7 +130,7 @@ pub async fn execute_action(
} }
"move_last_line" => { "move_last_line" => {
key_sequence_tracker.reset(); key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT; // Changed let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields > 0 { if num_fields > 0 {
let last_field_index = num_fields - 1; let last_field_index = num_fields - 1;
state.set_current_field(last_field_index); state.set_current_field(last_field_index);

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

@@ -7,6 +7,7 @@ pub mod components;
pub mod modes; pub mod modes;
pub mod functions; pub mod functions;
pub mod services; pub mod services;
pub mod utils;
pub use ui::run_ui; pub use ui::run_ui;

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

View File

@@ -23,8 +23,6 @@ pub async fn handle_read_only_event(
add_table_state: &mut AddTableState, add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState, add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker, key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
command_message: &mut String, command_message: &mut String,
edit_mode_cooldown: &mut bool, edit_mode_cooldown: &mut bool,
@@ -74,12 +72,10 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions } else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await? crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_ro::execute_action( add_table_ro::execute_action(
@@ -143,12 +139,10 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions } else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await? crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
add_table_ro::execute_action( add_table_ro::execute_action(
@@ -177,7 +171,7 @@ 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_login { // Handle login general actions } else if app_state.ui.show_login {
auth_ro::execute_action( auth_ro::execute_action(
action, action,
app_state, app_state,
@@ -211,8 +205,6 @@ pub async fn handle_read_only_event(
action, action,
form_state, form_state,
grpc_client, grpc_client,
current_position,
total_count,
ideal_cursor_column, ideal_cursor_column,
) )
.await? .await?
@@ -245,7 +237,7 @@ 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_login { // Handle login general actions } else if app_state.ui.show_login {
auth_ro::execute_action( auth_ro::execute_action(
action, action,
app_state, app_state,

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,396 @@
// 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()
}
}
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
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();
}
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
self.active = true;
self.navigation_type = NavigationType::TableTree;
self.graph = Some(graph);
self.input.clear();
self.current_path.clear();
self.update_options_for_path();
}
pub fn deactivate(&mut self) {
self.active = false;
self.input.clear();
self.all_options.clear();
self.filtered_options.clear();
self.selected_index = None;
self.current_path.clear();
self.graph = None;
}
pub fn add_char(&mut self, c: char) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.push(c);
self.update_filtered_options();
}
NavigationType::TableTree => {
if c == '/' {
if !self.input.is_empty() {
if self.current_path.is_empty() {
self.current_path = self.input.clone();
} else {
self.current_path.push('/');
self.current_path.push_str(&self.input);
}
self.input.clear();
self.update_options_for_path();
}
} else {
self.input.push(c);
self.update_filtered_options();
}
}
}
}
pub fn remove_char(&mut self) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.pop();
self.update_filtered_options();
}
NavigationType::TableTree => {
if self.input.is_empty() {
if !self.current_path.is_empty() {
if let Some(last_slash_idx) = self.current_path.rfind('/') {
self.input = self.current_path[last_slash_idx + 1..].to_string();
self.current_path = self.current_path[..last_slash_idx].to_string();
} else {
self.input = self.current_path.clone();
self.current_path.clear();
}
self.update_options_for_path();
self.update_filtered_options();
}
} else {
self.input.pop();
self.update_filtered_options();
}
}
}
}
pub fn move_up(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(0) => Some(self.filtered_options.len() - 1),
Some(current) => Some(current - 1),
None => Some(self.filtered_options.len() - 1),
};
}
pub fn move_down(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
Some(current) => Some(current + 1),
None => Some(0),
};
}
pub fn get_selected_option_str(&self) -> Option<&str> {
self.selected_index
.and_then(|idx| self.filtered_options.get(idx))
.map(|(_, option_str)| option_str.as_str())
}
pub fn autocomplete_selected(&mut self) {
if let Some(selected_option_str) = self.get_selected_option_str() {
self.input = selected_option_str.to_string();
self.update_filtered_options();
}
}
pub fn get_display_input(&self) -> String {
match self.navigation_type {
NavigationType::FindFile => self.input.clone(),
NavigationType::TableTree => {
if self.current_path.is_empty() {
self.input.clone()
} else {
format!("{}/{}", self.current_path, self.input)
}
}
}
}
// --- START FIX ---
pub fn get_selected_value(&self) -> Option<String> {
match self.navigation_type {
NavigationType::FindFile => {
// Return the highlighted option, not the raw input buffer.
self.get_selected_option_str().map(|s| s.to_string())
}
NavigationType::TableTree => {
self.get_selected_option_str().map(|selected_name| {
if self.current_path.is_empty() {
selected_name.to_string()
} else {
format!("{}/{}", self.current_path, selected_name)
}
})
}
}
}
// --- END FIX ---
fn update_options_for_path(&mut self) {
if let NavigationType::TableTree = self.navigation_type {
if let Some(graph) = &self.graph {
self.all_options = graph.get_dependent_children(&self.current_path);
} else {
self.all_options.clear();
}
}
self.update_filtered_options();
}
fn update_filtered_options(&mut self) {
let filter_text = match self.navigation_type {
NavigationType::FindFile => &self.input,
NavigationType::TableTree => &self.input,
}
.to_lowercase();
if filter_text.is_empty() {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.map(|(i, opt)| (i, opt.clone()))
.collect();
} else {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
.map(|(i, opt)| (i, opt.clone()))
.collect();
}
if self.filtered_options.is_empty() {
self.selected_index = None;
} else {
self.selected_index = Some(0);
}
}
}
pub async fn handle_command_navigation_event(
navigation_state: &mut NavigationState,
key: KeyEvent,
config: &Config,
) -> Result<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::Tab => {
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
if navigation_state.input == selected_opt_str {
if navigation_state.navigation_type == NavigationType::TableTree {
let path_before_nav = navigation_state.current_path.clone();
let input_before_nav = navigation_state.input.clone();
navigation_state.add_char('/');
if !(navigation_state.input.is_empty() &&
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
navigation_state.input = input_before_nav;
if navigation_state.current_path != path_before_nav {
navigation_state.current_path = path_before_nav;
}
navigation_state.update_options_for_path();
}
}
}
} else {
navigation_state.autocomplete_selected();
}
}
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Backspace => {
navigation_state.remove_char();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Char(c) => {
navigation_state.add_char(c);
Ok(EventOutcome::Ok(String::new()))
}
_ => {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
"move_down" => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
"select" => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let outcome = match navigation_state.navigation_type {
// --- START FIX ---
NavigationType::FindFile => {
// The purpose of this palette is to select a table.
// Emit a TableSelected event instead of a generic Ok message.
EventOutcome::TableSelected {
path: selected_value,
}
}
// --- END FIX ---
NavigationType::TableTree => {
EventOutcome::TableSelected {
path: selected_value,
}
}
};
navigation_state.deactivate();
Ok(outcome)
} else {
Ok(EventOutcome::Ok("No selection".to_string()))
}
}
_ => Ok(EventOutcome::Ok(String::new())),
}
} else {
Ok(EventOutcome::Ok(String::new()))
}
}
}
}

View File

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

View File

@@ -1,48 +1,50 @@
// src/modes/handlers/event.rs // src/modes/handlers/event.rs
use crossterm::event::Event;
use crossterm::cursor::SetCursorStyle;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::config::binds::config::Config; use crate::config::binds::config::Config;
use crate::ui::handlers::rat_state::UiStateHandler; use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::ui::handlers::context::UiContext;
use crate::functions::common::buffer; use crate::functions::common::buffer;
use anyhow::Result; use crate::functions::modes::navigation::add_logic_nav;
use crate::tui::{ use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
terminal::core::TerminalCore, use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
functions::{ use crate::functions::modes::navigation::{add_table_nav, admin_nav};
common::{form::SaveOutcome, login, register}, use crate::modes::general::command_navigation::{
}, handle_command_navigation_event, NavigationState,
{intro, admin},
}; };
use crate::modes::{
canvas::{common_mode, edit, read_only},
common::{command_mode, commands::CommandHandler},
general::{dialog, navigation},
handlers::mode_manager::{AppMode, ModeManager},
};
use crate::services::auth::AuthClient;
use crate::services::grpc_client::GrpcClient;
use crate::state::{ use crate::state::{
app::{ app::{
buffer::{AppView, BufferState},
highlight::HighlightState, highlight::HighlightState,
state::AppState, state::AppState,
buffer::{AppView, BufferState},
}, },
pages::{ pages::{
auth::{AuthState, LoginState, RegisterState},
admin::AdminState, admin::AdminState,
auth::{AuthState, LoginState, RegisterState},
canvas_state::CanvasState, canvas_state::CanvasState,
form::FormState, form::FormState,
intro::IntroState, intro::IntroState,
}, },
}; };
use crate::modes::{
common::{command_mode, commands::CommandHandler},
handlers::mode_manager::{ModeManager, AppMode},
canvas::{edit, read_only, common_mode},
general::{navigation, dialog},
};
use crate::functions::modes::navigation::{admin_nav, add_table_nav};
use crate::config::binds::key_sequences::KeySequenceTracker;
use tokio::sync::mpsc;
use crate::tui::functions::common::login::LoginResult; use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult; use crate::tui::functions::common::register::RegisterResult;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender; use crate::tui::{
use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender; functions::common::{form::SaveOutcome, login, register},
use crate::functions::modes::navigation::add_logic_nav; terminal::core::TerminalCore,
{admin, intro},
};
use crate::ui::handlers::context::UiContext;
use crate::ui::handlers::rat_state::UiStateHandler;
use anyhow::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::{Event, KeyEvent};
use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventOutcome { pub enum EventOutcome {
@@ -50,6 +52,16 @@ pub enum EventOutcome {
Exit(String), Exit(String),
DataSaved(SaveOutcome, String), DataSaved(SaveOutcome, String),
ButtonSelected { context: UiContext, index: usize }, ButtonSelected { context: UiContext, index: usize },
TableSelected { path: String },
}
impl EventOutcome {
pub fn get_message_if_ok(&self) -> String {
match self {
EventOutcome::Ok(msg) => msg.clone(),
_ => String::new(),
}
}
} }
pub struct EventHandler { pub struct EventHandler {
@@ -66,6 +78,7 @@ pub struct EventHandler {
pub register_result_sender: mpsc::Sender<RegisterResult>, pub register_result_sender: mpsc::Sender<RegisterResult>,
pub save_table_result_sender: SaveTableResultSender, pub save_table_result_sender: SaveTableResultSender,
pub save_logic_result_sender: SaveLogicResultSender, pub save_logic_result_sender: SaveLogicResultSender,
pub navigation_state: NavigationState,
} }
impl EventHandler { impl EventHandler {
@@ -83,15 +96,25 @@ impl EventHandler {
highlight_state: HighlightState::Off, highlight_state: HighlightState::Off,
edit_mode_cooldown: false, edit_mode_cooldown: false,
ideal_cursor_column: 0, ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(800), key_sequence_tracker: KeySequenceTracker::new(400),
auth_client: AuthClient::new().await?, auth_client: AuthClient::new().await?,
login_result_sender, login_result_sender,
register_result_sender, register_result_sender,
save_table_result_sender, save_table_result_sender,
save_logic_result_sender, save_logic_result_sender,
navigation_state: NavigationState::new(),
}) })
} }
pub fn is_navigation_active(&self) -> bool {
self.navigation_state.active
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.navigation_state.activate_find_file(options);
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_event( pub async fn handle_event(
&mut self, &mut self,
event: Event, event: Event,
@@ -107,10 +130,26 @@ impl EventHandler {
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &mut BufferState, buffer_state: &mut BufferState,
app_state: &mut AppState, app_state: &mut AppState,
total_count: u64,
current_position: &mut u64,
) -> Result<EventOutcome> { ) -> Result<EventOutcome> {
let current_mode = ModeManager::derive_mode(app_state, self, admin_state); let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
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 = {
@@ -121,50 +160,39 @@ impl EventHandler {
else if ui.show_admin { AppView::Admin } else if ui.show_admin { AppView::Admin }
else if ui.show_add_logic { AppView::AddLogic } else if ui.show_add_logic { AppView::AddLogic }
else if ui.show_add_table { AppView::AddTable } else if ui.show_add_table { AppView::AddTable }
else if ui.show_form { else if ui.show_form { AppView::Form }
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
AppView::Form(form_name)
}
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), config, app_state, login_state,
app_state, register_state, buffer_state, admin_state,
login_state, ).await {
register_state, return dialog_result;
buffer_state, }
admin_state, } else if let Event::Resize(_, _) = event {
).await {
return dialog_result;
} }
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!("Sidebar {}", if app_state.ui.show_sidebar { "shown" } else { "hidden" });
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!("Buffer {}", if app_state.ui.show_buffer_list { "shown" } else { "hidden" });
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" => {
if buffer::switch_buffer(buffer_state, true) { if buffer::switch_buffer(buffer_state, true) {
@@ -176,6 +204,11 @@ impl EventHandler {
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string())); return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
} }
} }
"close_buffer" => {
let current_table_name = app_state.current_view_table_name.as_deref();
let message = buffer_state.close_buffer_with_intro_fallback(current_table_name);
return Ok(EventOutcome::Ok(message));
}
_ => {} _ => {}
} }
} }
@@ -183,170 +216,96 @@ 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 if admin_nav::handle_admin_navigation(key_event, config, app_state, admin_state, buffer_state, &mut self.command_message) {
&& auth_state.role.as_deref() == Some("admin") {
if admin_nav::handle_admin_navigation(
key,
config,
app_state,
admin_state,
buffer_state,
&mut self.command_message,
) {
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
// --- Add Logic Page Navigation ---
if app_state.ui.show_add_logic { if app_state.ui.show_add_logic {
let client_clone = grpc_client.clone(); let client_clone = grpc_client.clone();
let sender_clone = self.save_logic_result_sender.clone(); let sender_clone = self.save_logic_result_sender.clone();
if add_logic_nav::handle_add_logic_navigation( if add_logic_nav::handle_add_logic_navigation(
key, key_event, config, app_state, &mut admin_state.add_logic_state,
config, &mut self.is_edit_mode, buffer_state, client_clone, sender_clone, &mut self.command_message,
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())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
} }
// --- Add Table Page Navigation ---
if app_state.ui.show_add_table { if app_state.ui.show_add_table {
let client_clone = grpc_client.clone(); let client_clone = grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone(); let sender_clone = self.save_table_result_sender.clone();
if add_table_nav::handle_add_table_navigation( if add_table_nav::handle_add_table_navigation(
key, key_event, config, app_state, &mut admin_state.add_table_state,
config, client_clone, sender_clone, &mut self.command_message,
app_state,
&mut admin_state.add_table_state,
client_clone,
sender_clone,
&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, form_state, app_state, login_state, register_state,
config, intro_state, admin_state, &mut self.command_mode, &mut self.command_input,
form_state, &mut self.command_message, &mut self.navigation_state,
app_state,
login_state,
register_state,
intro_state,
admin_state,
&mut self.command_mode,
&mut self.command_input,
&mut self.command_message,
).await; ).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 {
UiContext::Intro => { UiContext::Intro => {
intro::handle_intro_selection(app_state, buffer_state, index); intro::handle_intro_selection(app_state, buffer_state, index);
if app_state.ui.show_admin { if app_state.ui.show_admin && !app_state.profile_tree.profiles.is_empty() {
if !app_state.profile_tree.profiles.is_empty() { admin_state.profile_list_state.select(Some(0));
admin_state.profile_list_state.select(Some(0));
}
} }
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(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()),
0 => { 1 => login::back_to_main(login_state, app_state, buffer_state).await,
login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()) _ => "Invalid Login Option".to_string(),
}, },
1 => login::back_to_main(login_state, app_state, buffer_state).await, UiContext::Register => match index {
_ => "Invalid Login Option".to_string(), 0 => register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone()),
}; 1 => register::back_to_login(register_state, app_state, buffer_state).await,
login_action_message _ => "Invalid Login Option".to_string(),
} },
UiContext::Register => {
let register_action_message = match index {
0 => {
register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone())
},
1 => register::back_to_login(register_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
};
register_action_message
}
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)
} }
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) == Some("enter_highlight_mode_linewise") && ModeManager::can_enter_highlight_mode(current_mode) {
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
else if app_state.ui.show_register { register_state.current_field() }
else { form_state.current_field() };
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index }; 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) == Some("enter_highlight_mode") && ModeManager::can_enter_highlight_mode(current_mode) {
// Check for Character-wise highlight let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") 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() };
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
else if app_state.ui.show_register { register_state.current_field() }
else { 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.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") && ModeManager::can_enter_edit_mode(current_mode) {
// Check for entering edit mode (before cursor)
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == 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.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") && ModeManager::can_enter_edit_mode(current_mode) {
// Check for entering edit mode (after cursor) let current_input = if app_state.ui.show_login || app_state.ui.show_register { login_state.get_current_input() } else { form_state.get_current_input() };
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register { login_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
&& 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()
} else {
form_state.get_current_input()
};
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register{
login_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 {
@@ -360,42 +319,28 @@ 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) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
// Check for entering command mode self.command_mode = true;
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") self.command_input.clear();
&& ModeManager::can_enter_command_mode(current_mode) { self.command_message.clear();
self.command_mode = true; return Ok(EventOutcome::Ok(String::new()));
self.command_input.clear();
self.command_message.clear();
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" => {
return common_mode::handle_core_action( return common_mode::handle_core_action(
action, action, form_state, auth_state, login_state, register_state,
form_state, grpc_client, &mut self.auth_client, terminal, app_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
app_state,
current_position,
total_count,
).await; ).await;
}, }
_ => {} _ => {}
} }
} }
// If no mode change or specific common action handled, delegate to read_only handler
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,
@@ -403,45 +348,41 @@ impl EventHandler {
&mut admin_state.add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
&mut self.key_sequence_tracker, &mut self.key_sequence_tracker,
current_position, // No more current_position or total_count arguments
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) == Some("exit_highlight_mode") {
// 1. Check for Exit first self.highlight_state = HighlightState::Off;
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") { self.command_message = "Exited highlight mode".to_string();
self.highlight_state = HighlightState::Off; terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
self.command_message = "Exited highlight mode".to_string(); return Ok(EventOutcome::Ok(self.command_message.clone()));
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; } else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
return Ok(EventOutcome::Ok(self.command_message.clone())); if let HighlightState::Characterwise { anchor } = self.highlight_state {
} self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
// 2. Check for Switch to Linewise self.command_message = "-- LINE HIGHLIGHT --".to_string();
else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") { return Ok(EventOutcome::Ok(self.command_message.clone()));
// Only switch if currently characterwise }
if let HighlightState::Characterwise { anchor } = self.highlight_state {
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
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()));
} }
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,
key_event,
config,
form_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 admin_state.add_logic_state,
&mut self.key_sequence_tracker, &mut self.key_sequence_tracker,
current_position,
total_count,
grpc_client, grpc_client,
&mut self.command_message, &mut self.command_message,
&mut self.edit_mode_cooldown, &mut self.edit_mode_cooldown,
@@ -452,73 +393,35 @@ 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, auth_state, login_state, register_state,
form_state, grpc_client, &mut self.auth_client, terminal, app_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
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 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, form_state, login_state, register_state, admin_state,
config, &mut self.ideal_cursor_column, &mut current_position, total_count,
form_state, grpc_client, app_state,
login_state,
register_state,
admin_state,
&mut self.ideal_cursor_column,
current_position,
total_count,
grpc_client,
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 { login_state.has_unsaved_changes() } else if app_state.ui.show_register { register_state.has_unsaved_changes() } else { form_state.has_unsaved_changes() };
else if app_state.ui.show_register { register_state.has_unsaved_changes() } self.command_message = if has_changes { "Exited edit mode (unsaved changes remain)".to_string() } else { "Read-only mode".to_string() };
else { form_state.has_unsaved_changes() };
self.command_message = if has_changes {
"Exited edit mode (unsaved changes remain)".to_string()
} else {
"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 { login_state.get_current_input() } else if app_state.ui.show_register { register_state.get_current_input() } else { form_state.get_current_input() };
let current_input = if app_state.ui.show_login { login_state.get_current_input() } 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() };
else if app_state.ui.show_register { register_state.get_current_input() }
else { form_state.get_current_input() };
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() { 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 };
@@ -528,48 +431,110 @@ impl EventHandler {
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() { self.command_message = msg; }
if !msg.is_empty() { self.key_sequence_tracker.reset();
self.command_message = msg;
}
self.key_sequence_tracker.reset(); // Reset sequence tracker on successful edit action
return Ok(EventOutcome::Ok(self.command_message.clone())); return Ok(EventOutcome::Ok(self.command_message.clone()));
} }
Err(e) => { Err(e) => { return Err(e.into()); }
// Handle error from the edit handler
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) {
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?;
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' {
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 {
// --- START FIX ---
let mut all_table_paths: Vec<String> = app_state
.profile_tree
.profiles
.iter()
.flat_map(|profile| {
profile.tables.iter().map(move |table| {
format!("{}/{}", profile.name, table.name)
})
})
.collect();
all_table_paths.sort();
self.navigation_state.activate_find_file(all_table_paths);
// --- END FIX ---
self.command_mode = false;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok("Table selection palette activated".to_string()));
} else {
self.key_sequence_tracker.reset();
self.command_input.push('f');
if sequence.len() > 1 && sequence[0] == KeyCode::Char('f') {
self.command_input.push('f');
}
self.command_message = "Find File not available in this view.".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
if config.is_key_sequence_prefix(&sequence) {
return Ok(EventOutcome::Ok(String::new()));
}
}
if c != 'f' && !self.key_sequence_tracker.current_sequence.is_empty() {
self.key_sequence_tracker.reset();
}
self.command_input.push(c);
return Ok(EventOutcome::Ok(String::new()));
}
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(String::new()));
} }
} }
} else if let Event::Resize(_, _) = event {
return Ok(EventOutcome::Ok("Resized".to_string()));
} }
self.edit_mode_cooldown = false; self.edit_mode_cooldown = false;
Ok(EventOutcome::Ok(self.command_message.clone())) Ok(EventOutcome::Ok(self.command_message.clone()))
} }
fn is_processed_command(&self, command: &str) -> bool {
matches!(command, "w" | "q" | "q!" | "wq" | "r")
}
} }

View File

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

View File

@@ -44,8 +44,6 @@ pub async fn handle_highlight_event(
&mut admin_state.add_table_state, &mut admin_state.add_table_state,
&mut admin_state.add_logic_state, &mut admin_state.add_logic_state,
key_sequence_tracker, key_sequence_tracker,
current_position,
total_count,
grpc_client, grpc_client,
command_message, // Pass the message buffer command_message, // Pass the message buffer
edit_mode_cooldown, edit_mode_cooldown,

View File

@@ -1,90 +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 common::proto::multieko2::table_script::{ use common::proto::multieko2::table_script::{
table_script_client::TableScriptClient, table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse, PostTableScriptRequest, TableScriptResponse,
}; };
use anyhow::Result; use common::proto::multieko2::tables_data::{
tables_data_client::TablesDataClient,
GetTableDataByPositionRequest,
GetTableDataResponse,
GetTableDataCountRequest,
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use anyhow::{Context, Result}; // Added Context
use std::collections::HashMap; // NEW
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcClient { pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>, table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>, table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>, table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>, // NEW
} }
impl GrpcClient { impl GrpcClient {
pub async fn new() -> Result<Self> { pub async fn new() -> Result<Self> {
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?; let table_structure_client = TableStructureServiceClient::connect(
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?; "http://[::1]:50051",
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?; )
let table_script_client = TableScriptClient::connect("http://[::1]:50051").await?; .await
.context("Failed to connect to TableStructureService")?;
let table_definition_client = TableDefinitionClient::connect(
"http://[::1]:50051",
)
.await
.context("Failed to connect to TableDefinitionService")?;
let table_script_client =
TableScriptClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TableScriptService")?;
let tables_data_client =
TablesDataClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TablesDataService")?; // NEW
Ok(Self { Ok(Self {
adresar_client, // adresar_client, // REMOVE
table_structure_client, table_structure_client,
table_definition_client, table_definition_client,
table_script_client, table_script_client,
tables_data_client, // NEW
}) })
} }
pub async fn get_adresar_count(&mut self) -> Result<u64> { 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()) Ok(response.into_inner())
} }
pub async fn post_table_script(&mut self, request: PostTableScriptRequest) -> Result<TableScriptResponse> { pub async fn post_table_script(
&mut self,
request: PostTableScriptRequest,
) -> Result<TableScriptResponse> {
let tonic_request = tonic::Request::new(request); let tonic_request = tonic::Request::new(request);
let response = self.table_script_client.post_table_script(tonic_request).await?; let response = self
.table_script_client
.post_table_script(tonic_request)
.await
.context("gRPC PostTableScript call failed")?;
Ok(response.into_inner())
}
// NEW Methods for TablesData service
pub async fn get_table_data_count(
&mut self,
profile_name: String,
table_name: String,
) -> Result<u64> {
let grpc_request = GetTableDataCountRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_count(request)
.await
.context("gRPC GetTableDataCount call failed")?;
Ok(response.into_inner().count as u64)
}
pub async fn get_table_data_by_position(
&mut self,
profile_name: String,
table_name: String,
position: i32,
) -> Result<GetTableDataResponse> {
let grpc_request = GetTableDataByPositionRequest {
profile_name,
table_name,
position,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_by_position(request)
.await
.context("gRPC GetTableDataByPosition call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_data(
&mut self,
profile_name: String,
table_name: String,
data: HashMap<String, String>,
) -> Result<PostTableDataResponse> {
let grpc_request = PostTableDataRequest {
profile_name,
table_name,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.post_table_data(request)
.await
.context("gRPC PostTableData call failed")?;
Ok(response.into_inner())
}
pub async fn put_table_data(
&mut self,
profile_name: String,
table_name: String,
id: i64,
data: HashMap<String, String>,
) -> Result<PutTableDataResponse> {
let grpc_request = PutTableDataRequest {
profile_name,
table_name,
id,
data,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.put_table_data(request)
.await
.context("gRPC PutTableData call failed")?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
} }

View File

@@ -3,111 +3,240 @@
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 crate::utils::columns::filter_user_columns;
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(filter_user_columns(column_names))
}
Err(e) => {
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
Err(e.into())
}
}
}
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient, grpc_client: &mut GrpcClient,
app_state: &mut AppState, app_state: &mut AppState,
) -> Result<Vec<String>> { ) -> Result<(String, String, Vec<String>)> {
// Fetch profile tree let profile_tree = grpc_client
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?; .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?; 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) let filtered_columns = filter_user_columns(column_names);
Ok((initial_profile_name, initial_table_name, filtered_columns))
} }
pub async fn initialize_adresar_count( pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient,
form_state: &mut FormState,
) -> Result<()> {
let total_count = grpc_client
.get_table_data_count(
form_state.profile_name.clone(),
form_state.table_name.clone(),
)
.await
.context(format!(
"Failed to get count for table {}.{}",
form_state.profile_name, form_state.table_name
))?;
form_state.total_count = total_count;
if total_count > 0 {
form_state.current_position = total_count;
} else {
form_state.current_position = 1;
}
Ok(())
}
pub async fn load_table_data_by_position(
grpc_client: &mut GrpcClient, 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<String> { ) -> Result<String> {
match grpc_client.get_adresar_by_position(position).await { if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for table {}.{}",
form_state.profile_name, form_state.table_name
));
}
if form_state.total_count == 0 && form_state.current_position == 1 {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for empty table {}.{}",
form_state.profile_name, form_state.table_name
));
}
match grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.current_position as i32,
)
.await
{
Ok(response) => { Ok(response) => {
// Set the ID properly // FIX: Pass the current position as the second argument
form_state.id = response.id; form_state.update_from_response(&response.data, form_state.current_position);
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)) tracing::error!(
"Error loading entry {} for table {}.{}: {}",
form_state.current_position,
form_state.profile_name,
form_state.table_name,
e
);
Err(anyhow::anyhow!(
"Error loading entry {}: {}",
form_state.current_position,
e
))
} }
} }
} }
/// Handles the consequences of a save operation, like updating counts.
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,
app_state: &mut AppState, _app_state: &mut AppState,
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.id = new_id;
UiService::update_adresar_count(grpc_client, app_state).await?;
// Navigate to the new record (now that count is updated)
app_state.update_current_position(app_state.total_count);
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it)
} }
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => { SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No count update needed for these outcomes // No action needed
} }
} }
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,
@@ -9,12 +8,13 @@ pub enum AppView {
Admin, Admin,
AddTable, AddTable,
AddLogic, AddLogic,
Form(String), 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",
@@ -23,13 +23,24 @@ impl AppView {
AppView::Admin => "Admin_Panel", AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table", AppView::AddTable => "Add_Table",
AppView::AddLogic => "Add_Logic", AppView::AddLogic => "Add_Logic",
AppView::Form(name) => name.as_str(), 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>,
@@ -39,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;
@@ -63,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 {
@@ -33,15 +33,20 @@ 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,
#[cfg(feature = "ui-debug")]
pub debug_info: String,
} }
impl AppState { impl AppState {
@@ -51,29 +56,29 @@ 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(),
#[cfg(feature = "ui-debug")]
debug_info: String::new(),
}) })
} }
// 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.
/// The first button (index 0) is active by default. /// The first button (index 0) is active by default.
@@ -134,6 +139,7 @@ impl AppState {
self.ui.dialog.dialog_active_button_index = 0; self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false; self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
} }
/// Sets the active button index, wrapping around if necessary. /// Sets the active button index, wrapping around if necessary.

View File

@@ -1,7 +1,7 @@
// src/state/pages/add_logic.rs // src/state/pages/add_logic.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::components::common::text_editor::{TextEditor, VimState}; // Add VimState import use crate::components::common::text_editor::{TextEditor, VimState};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use tui_textarea::TextArea; use tui_textarea::TextArea;
@@ -11,8 +11,9 @@ pub enum AddLogicFocus {
#[default] #[default]
InputLogicName, InputLogicName,
InputTargetColumn, InputTargetColumn,
InputScriptContent,
InputDescription, InputDescription,
ScriptContentPreview,
InsideScriptContent,
SaveButton, SaveButton,
CancelButton, CancelButton,
} }
@@ -27,12 +28,32 @@ pub struct AddLogicState {
pub script_content_editor: Rc<RefCell<TextArea<'static>>>, pub script_content_editor: Rc<RefCell<TextArea<'static>>>,
pub description_input: String, pub description_input: String,
pub current_focus: AddLogicFocus, pub current_focus: AddLogicFocus,
pub last_canvas_field: usize,
pub logic_name_cursor_pos: usize, pub logic_name_cursor_pos: usize,
pub target_column_cursor_pos: usize, pub target_column_cursor_pos: usize,
pub description_cursor_pos: usize, pub description_cursor_pos: usize,
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
pub editor_keybinding_mode: EditorKeybindingMode, pub editor_keybinding_mode: EditorKeybindingMode,
pub vim_state: VimState, // Add this field 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 { impl AddLogicState {
@@ -47,16 +68,163 @@ impl AddLogicState {
script_content_editor: Rc::new(RefCell::new(editor)), script_content_editor: Rc::new(RefCell::new(editor)),
description_input: String::new(), description_input: String::new(),
current_focus: AddLogicFocus::InputLogicName, current_focus: AddLogicFocus::InputLogicName,
last_canvas_field: 2,
logic_name_cursor_pos: 0, logic_name_cursor_pos: 0,
target_column_cursor_pos: 0, target_column_cursor_pos: 0,
description_cursor_pos: 0, description_cursor_pos: 0,
has_unsaved_changes: false, has_unsaved_changes: false,
editor_keybinding_mode: editor_config.keybinding_mode.clone(), editor_keybinding_mode: editor_config.keybinding_mode.clone(),
vim_state: VimState::default(), // Add this field initialization 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; 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 { impl Default for AddLogicState {
@@ -65,14 +233,13 @@ impl Default for AddLogicState {
} }
} }
// ... rest of the CanvasState implementation remains the same
impl CanvasState for AddLogicState { impl CanvasState for AddLogicState {
fn current_field(&self) -> usize { fn current_field(&self) -> usize {
match self.current_focus { match self.current_focus {
AddLogicFocus::InputLogicName => 0, AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1, AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2, AddLogicFocus::InputDescription => 2,
_ => 0, _ => self.last_canvas_field,
} }
} }
@@ -120,12 +287,20 @@ impl CanvasState for AddLogicState {
} }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
self.current_focus = match index { let new_focus = match index {
0 => AddLogicFocus::InputLogicName, 0 => AddLogicFocus::InputLogicName,
1 => AddLogicFocus::InputTargetColumn, 1 => AddLogicFocus::InputTargetColumn,
2 => AddLogicFocus::InputDescription, 2 => AddLogicFocus::InputDescription,
_ => self.current_focus, _ => 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) { fn set_current_cursor_pos(&mut self, pos: usize) {
@@ -134,12 +309,10 @@ impl CanvasState for AddLogicState {
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len()); self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
} }
AddLogicFocus::InputTargetColumn => { AddLogicFocus::InputTargetColumn => {
self.target_column_cursor_pos = self.target_column_cursor_pos = pos.min(self.target_column_input.len());
pos.min(self.target_column_input.len());
} }
AddLogicFocus::InputDescription => { AddLogicFocus::InputDescription => {
self.description_cursor_pos = self.description_cursor_pos = pos.min(self.description_input.len());
pos.min(self.description_input.len());
} }
_ => {} _ => {}
} }
@@ -150,10 +323,24 @@ impl CanvasState for AddLogicState {
} }
fn get_suggestions(&self) -> Option<&[String]> { fn get_suggestions(&self) -> Option<&[String]> {
None if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
Some(&self.target_column_suggestions)
} else {
None
}
} }
fn get_selected_suggestion_index(&self) -> Option<usize> { fn get_selected_suggestion_index(&self) -> Option<usize> {
None if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
self.selected_target_column_suggestion_index
} else {
None
}
} }
} }

View File

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

@@ -8,7 +8,8 @@ use crate::state::pages::add_logic::AddLogicState;
#[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,

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,22 @@ pub struct FormState {
} }
impl FormState { impl FormState {
/// Create a new FormState with dynamic fields. /// Creates a new, empty FormState for a given table.
pub fn new(fields: Vec<String>) -> Self { /// The position defaults to 1, representing either the first record
let values = vec![String::new(); fields.len()]; // Initialize values for each field /// or the position for a new entry if the table is empty.
pub fn new(
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
// FIX: Default to 1. A position of 0 is an invalid state.
current_position: 1,
fields, fields,
values, values,
current_field: 0, current_field: 0,
@@ -35,31 +54,41 @@ impl FormState {
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64,
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,
&self.table_name,
theme, theme,
is_edit_mode, is_edit_mode,
highlight_state, highlight_state,
total_count, self.total_count,
current_position, self.current_position,
); );
} }
/// Resets the form to a state for creating a new entry.
/// It clears all values and sets the position to be one after the last record.
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;
// Set the position for a new entry.
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 +104,47 @@ 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) { /// Updates the form's values from a data response and sets its position.
self.id = response.id; /// This is the single source of truth for populating the form after a data fetch.
self.values = vec![ pub fn update_from_response(
response.firma, response.kz, response.drc, &mut self,
response.ulica, response.psc, response.mesto, response_data: &HashMap<String, String>,
response.stat, response.banka, response.ucet, // FIX: Add new_position to make this method authoritative.
response.skladm, response.ico, response.kontakt, new_position: u64,
response.telefon, response.skladu, response.fax, ) {
]; // Create a new vector for the values, ensuring they are in the correct order.
self.values = self.fields.iter().map(|field_from_schema| {
// For each field from our schema, find the corresponding key in the
// response data by doing a case-insensitive comparison.
response_data
.iter()
.find(|(key_from_data, _)| key_from_data.eq_ignore_ascii_case(field_from_schema))
.map(|(_, value)| value.clone()) // If found, clone its value.
.unwrap_or_default() // If not found, use an empty string.
}).collect();
// Now, do the same case-insensitive lookup for the 'id' field.
let id_str_opt = response_data
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
.map(|(_, v)| v);
if let Some(id_str) = id_str_opt {
if let Ok(parsed_id) = id_str.parse::<i64>() {
self.id = parsed_id;
} else {
tracing::error!( "Failed to parse 'id' field '{}' for table {}.{}", id_str, self.profile_name, self.table_name);
self.id = 0;
}
} else {
self.id = 0;
}
// FIX: Set the position from the provided parameter.
self.current_position = new_position;
self.has_unsaved_changes = false;
self.current_field = 0;
self.current_cursor_pos = 0;
} }
} }
@@ -105,31 +166,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 +193,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.fields.iter()
let post_request = PostAdresarRequest { .zip(form_state.values.iter())
firma: form_state.values[0].clone(), .map(|(field, value)| (field.clone(), value.clone()))
kz: form_state.values[1].clone(), .collect();
drc: form_state.values[2].clone(),
ulica: form_state.values[3].clone(), let outcome: SaveOutcome;
psc: form_state.values[4].clone(),
mesto: form_state.values[5].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) ;
stat: form_state.values[6].clone(),
banka: form_state.values[7].clone(),
ucet: form_state.values[8].clone(), if is_new_entry {
skladm: form_state.values[9].clone(), let response = grpc_client
ico: form_state.values[10].clone(), .post_table_data(
kontakt: form_state.values[11].clone(), form_state.profile_name.clone(),
telefon: form_state.values[12].clone(), form_state.table_name.clone(),
skladu: form_state.values[13].clone(), data_map,
fax: form_state.values[14].clone(), )
}; .await
let response = grpc_client.post_adresar(post_request).await?; .context("Failed to post new table data")?;
let new_id = response.into_inner().id;
form_state.id = new_id; if response.success {
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID 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; // FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
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

@@ -1,19 +1,15 @@
// src/tui/functions/form.rs // src/tui/functions/form.rs
use crate::state::pages::canvas_state::CanvasState;
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 anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
pub async fn handle_action( pub async fn handle_action(
action: &str, action: &str,
form_state: &mut FormState, form_state: &mut FormState,
grpc_client: &mut GrpcClient, _grpc_client: &mut GrpcClient,
current_position: &mut 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
// 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."
@@ -21,71 +17,29 @@ pub async fn handle_action(
); );
} }
let total_count = form_state.total_count;
match action { match action {
"previous_entry" => { "previous_entry" => {
let new_position = current_position.saturating_sub(1); // Only decrement if the current position is greater than the first record.
if new_position >= 1 { // This prevents wrapping from 1 to total_count.
*current_position = new_position; // It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
let response = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position > 1 {
form_state.current_position -= 1;
// Direct field assignments *ideal_cursor_column = 0;
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" => { "next_entry" => {
if *current_position <= total_count { // Only increment if the current position is not yet at the "New Entry" stage.
*current_position += 1; // The "New Entry" position is total_count + 1.
if *current_position <= total_count { // This allows moving from the last record to "New Entry", but stops there.
let response = grpc_client.get_adresar_by_position(*current_position).await?; if form_state.current_position <= total_count {
form_state.current_position += 1;
// Direct field assignments *ideal_cursor_column = 0;
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 {
form_state.reset_to_empty();
form_state.current_field = 0;
form_state.current_cursor_pos = 0;
*ideal_cursor_column = 0;
Ok("New form entry mode".into())
}
} else {
Ok("Already at last entry".into())
} }
} }
_ => Err(anyhow!("Unknown form action: {}", action)) _ => return Err(anyhow!("Unknown form action: {}", action)),
} }
Ok(String::new())
} }

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

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

View File

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

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/render.rs // client/src/ui/handlers/render.rs
use crate::components::{ use crate::components::{
render_background, render_background,
@@ -11,10 +11,14 @@ use crate::components::{
admin::render_add_table, admin::render_add_table,
admin::add_logic::render_add_logic, admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register}, auth::{login::render_login, register::render_register},
common::find_file_palette,
}; };
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::{
use ratatui::Frame; layout::{Constraint, Direction, Layout},
Frame,
};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState; use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState; use crate::state::pages::auth::LoginState;
@@ -24,7 +28,9 @@ use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState; use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState; use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::modes::general::command_navigation::NavigationState;
#[allow(clippy::too_many_arguments)]
pub fn render_ui( pub fn render_ui(
f: &mut Frame, f: &mut Frame,
form_state: &mut FormState, form_state: &mut FormState,
@@ -35,175 +41,175 @@ pub fn render_ui(
admin_state: &mut AdminState, admin_state: &mut AdminState,
buffer_state: &BufferState, buffer_state: &BufferState,
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_event_handler_edit_mode: bool,
highlight_state: &HighlightState, highlight_state: &HighlightState,
total_count: u64, event_handler_command_input: &str,
current_position: u64, event_handler_command_mode_active: bool,
event_handler_command_message: &str,
navigation_state: &NavigationState,
current_dir: &str, current_dir: &str,
command_input: &str,
command_mode: bool,
command_message: &str,
current_fps: f64, current_fps: f64,
app_state: &AppState, app_state: &AppState,
) { ) {
render_background(f, f.area(), theme); render_background(f, f.area(), theme);
// Adjust layout based on whether buffer list is shown const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let constraints = if app_state.ui.show_buffer_list {
vec![ let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(1)];
Constraint::Length(1), // Buffer list
Constraint::Min(1), // Main content let command_palette_area_height = if navigation_state.active {
Constraint::Length(1), // Status line 1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
Constraint::Length(1), // Command line } else if event_handler_command_mode_active {
] 1
} else { } else {
vec![ 0 // Neither is active
Constraint::Min(1), // Main content
Constraint::Length(1), // Status line (no buffer list)
Constraint::Length(1), // Command line
]
}; };
let root = Layout::default() if command_palette_area_height > 0 {
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
}
let mut main_layout_constraints = vec![Constraint::Min(1)];
if app_state.ui.show_buffer_list {
main_layout_constraints.insert(0, Constraint::Length(1));
}
main_layout_constraints.extend(bottom_area_constraints);
let root_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(main_layout_constraints)
.split(f.area()); .split(f.area());
let mut buffer_list_area = None; let mut chunk_idx = 0;
let main_content_area; let buffer_list_area = if app_state.ui.show_buffer_list {
let status_line_area; let area = Some(root_chunks[chunk_idx]);
let command_line_area; chunk_idx += 1;
area
// Assign areas based on layout
if app_state.ui.show_buffer_list {
buffer_list_area = Some(root[0]);
main_content_area = root[1];
status_line_area = root[2];
command_line_area = root[3];
} else { } else {
main_content_area = root[0]; None
status_line_area = root[1]; };
command_line_area = root[2];
} let main_content_area = root_chunks[chunk_idx];
chunk_idx += 1;
let status_line_area = root_chunks[chunk_idx];
chunk_idx += 1;
let command_render_area = if command_palette_area_height > 0 {
if root_chunks.len() > chunk_idx {
Some(root_chunks[chunk_idx])
} else {
None
}
} else {
None
};
if app_state.ui.show_intro { if app_state.ui.show_intro {
render_intro(f, intro_state, main_content_area, theme); render_intro(f, intro_state, main_content_area, theme);
} else if app_state.ui.show_register { } else if app_state.ui.show_register {
render_register( render_register(
f, f, main_content_area, theme, register_state, app_state,
main_content_area, register_state.current_field() < 4,
theme,
register_state,
app_state,
register_state.current_field < 4,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_table { } else if app_state.ui.show_add_table {
render_add_table( render_add_table(
f, f, main_content_area, theme, app_state, &mut admin_state.add_table_state,
main_content_area, is_event_handler_edit_mode,
theme,
app_state,
&mut admin_state.add_table_state,
login_state.current_field < 3,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_add_logic { } else if app_state.ui.show_add_logic {
render_add_logic( render_add_logic(
f, f, main_content_area, theme, app_state, &mut admin_state.add_logic_state,
main_content_area, is_event_handler_edit_mode, highlight_state,
theme,
app_state,
&mut admin_state.add_logic_state,
is_edit_mode, // Pass the general edit mode status
highlight_state,
); );
} else if app_state.ui.show_login { } else if app_state.ui.show_login {
render_login( render_login(
f, f, main_content_area, theme, login_state, app_state,
main_content_area, login_state.current_field() < 2,
theme,
login_state,
app_state,
login_state.current_field < 2,
highlight_state, highlight_state,
); );
} else if app_state.ui.show_admin { } else if app_state.ui.show_admin {
crate::components::admin::admin_panel::render_admin_panel( crate::components::admin::admin_panel::render_admin_panel(
f, f, app_state, auth_state, admin_state, main_content_area, theme,
app_state, &app_state.profile_tree, &app_state.selected_profile,
auth_state,
admin_state,
main_content_area,
theme,
&app_state.profile_tree,
&app_state.selected_profile,
);
} else if app_state.ui.show_form {
let (sidebar_area, form_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar,
main_content_area
); );
} else if app_state.ui.show_form {
let (sidebar_area, form_actual_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar, 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();
let values_vec: Vec<&String> = form_state.values.iter().collect();
// Convert fields to &[&str] and values to &[&String] // --- START FIX ---
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect(); // Add the missing `&form_state.table_name` argument to this function call.
let values: Vec<&String> = form_state.values.iter().collect();
render_form( render_form(
f, f,
form_constraint, form_render_area,
form_state, form_state,
&fields, &fields_vec,
&form_state.current_field, &form_state.current_field,
&values, &values_vec,
&form_state.table_name, // <-- THIS ARGUMENT WAS MISSING
theme, theme,
is_edit_mode, is_event_handler_edit_mode,
highlight_state, highlight_state,
total_count, form_state.total_count,
current_position, form_state.current_position,
); );
// --- END FIX ---
} }
// 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,
app_state,
);
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,42 +23,37 @@ 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 crate::utils::columns::filter_user_columns;
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 (save_logic_result_sender, mut save_logic_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(),
save_logic_result_sender.clone(), save_logic_result_sender.clone(),
).await.context("Failed to create event handler")?; )
.await
.context("Failed to create event handler")?;
let event_reader = EventReader::new(); let event_reader = EventReader::new();
let mut auth_state = AuthState::default(); let mut auth_state = AuthState::default();
@@ -67,25 +64,185 @@ 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 let (initial_profile, initial_table, initial_columns_from_service) =
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?; UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
form_state.reset_to_empty(); .await
.context("Failed to initialize app state and form")?;
let filtered_columns = filter_user_columns(initial_columns_from_service);
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
filtered_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
))?;
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();
let mut table_just_switched = false;
loop { loop {
// --- Synchronize UI View from Active Buffer --- let position_before_event = form_state.current_position;
let mut event_processed = false;
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?;
event_processed = true;
let event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
&mut grpc_client,
&mut command_handler,
&mut form_state,
&mut auth_state,
&mut login_state,
&mut register_state,
&mut intro_state,
&mut admin_state,
&mut buffer_state,
&mut app_state,
).await;
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(message) => {
if !message.is_empty() {
event_handler.command_message = message;
}
}
EventOutcome::Exit(message) => {
event_handler.command_message = message;
should_exit = true;
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message;
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
&mut app_state,
&mut form_state,
).await {
event_handler.command_message =
format!("Error handling save outcome: {}", e);
}
}
EventOutcome::ButtonSelected { .. } => {}
EventOutcome::TableSelected { path } => {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 2 {
let profile_name = parts[0].to_string();
let table_name = parts[1].to_string();
app_state.set_current_view_table(profile_name, table_name);
buffer_state.update_history(AppView::Form);
event_handler.command_message = format!("Loading table: {}", path);
} else {
event_handler.command_message = format!("Invalid table path: {}", path);
}
}
},
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
}
}
if should_exit {
return Ok(());
}
}
match login_result_receiver.try_recv() {
Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
}
}
match register_result_receiver.try_recv() {
Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog();
match result {
Ok(ref success_message) => {
app_state.show_dialog(
"Save Successful",
success_message,
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
if let Some(active_view) = buffer_state.get_active_view() { 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;
@@ -113,20 +270,258 @@ 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::AddLogic => app_state.ui.show_add_logic = true, AppView::AddLogic => app_state.ui.show_add_logic = true,
AppView::Form(_) => app_state.ui.show_form = true, AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {} // Or show a scratchpad component AppView::Scratch => {}
} }
} }
// --- End Synchronization ---
// --- 3. Draw UI --- if app_state.ui.show_form {
// Draw the current state *first*. This ensures the loading dialog let current_view_profile = app_state.current_view_profile_name.clone();
// set in the *previous* iteration gets rendered before the pending let current_view_table = app_state.current_view_table_name.clone();
// action check below.
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();
let filtered_columns = filter_user_columns(new_columns);
form_state = FormState::new(
prof_name.clone(),
tbl_name.clone(),
filtered_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();
}
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
table_just_switched = true;
}
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();
}
}
}
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 let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
if app_state.ui.show_add_logic {
let profile_name = admin_state.add_logic_state.profile_name.clone();
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
}
Err(e) => {
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
}
}
needs_redraw = true;
}
}
let position_changed = form_state.current_position != position_before_event;
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form && !table_just_switched {
if position_changed && !event_handler.is_edit_mode {
position_logic_needs_redraw = true;
if form_state.current_position > form_state.total_count {
form_state.reset_to_empty();
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
} else {
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
Ok(load_message) => {
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
event_handler.command_message = load_message;
}
}
Err(e) => {
event_handler.command_message = format!("Error loading data: {}", e);
}
}
}
let current_input_after_load_str = form_state.get_current_input();
let current_input_len_after_load = current_input_after_load_str.chars().count();
let max_cursor_pos = if current_input_len_after_load > 0 {
current_input_len_after_load.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} else if !position_changed && !event_handler.is_edit_mode {
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 {
if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_login {
if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
}
if position_logic_needs_redraw {
needs_redraw = true;
}
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
#[cfg(feature = "ui-debug")]
{
app_state.debug_info = format!(
"Redraw -> event: {}, needs_redraw: {}, pos_changed: {}",
event_processed, needs_redraw, position_changed
);
}
if event_processed || needs_redraw || position_changed {
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
else { terminal.hide_cursor()?; }
}
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
}
terminal.draw(|f| { terminal.draw(|f| {
render_ui( render_ui(
f, f,
@@ -138,14 +533,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,
); );
@@ -153,275 +547,13 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = false; needs_redraw = false;
} }
// --- Cursor Visibility Logic ---
// (Keep existing cursor logic here - depends on state drawn above)
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
else { terminal.hide_cursor()?; }
}
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 mut current_position = app_state.current_position;
let position_before_event = current_position;
// --- Determine if redraw is needed based on active login ---
// Always redraw if the loading dialog is currently showing.
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false;
// Poll for events *after* drawing and checking pending actions
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?;
event_processed = true; // Mark that we received and will process an event
event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
&mut grpc_client,
&mut command_handler,
&mut form_state,
&mut auth_state,
&mut login_state,
&mut register_state,
&mut intro_state,
&mut admin_state,
&mut buffer_state,
&mut app_state,
total_count,
&mut current_position,
).await;
}
if event_processed {
needs_redraw = true;
}
// Update position based on handler's modification
// This happens *after* the event is handled
app_state.current_position = current_position;
// --- Check for Login Results from Channel ---
match login_result_receiver.try_recv() {
Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
// Optionally show an error dialog here
}
}
// --- Check for Register Results from Channel ---
match register_result_receiver.try_recv() {
Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog(); // Hide loading indicator
match result {
Ok(ref success_message) => {
app_state.show_dialog(
"Save Successful",
success_message,
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
// Optionally show an error dialog instead of just command message
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {} // No message
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
// --- Centralized Consequence Handling ---
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(message) => {
if !message.is_empty() {
// Update command message only if event handling produced one
// Avoid overwriting messages potentially set by pending actions
// event_handler.command_message = message;
}
}
EventOutcome::Exit(message) => {
event_handler.command_message = message;
should_exit = true;
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message; // Show save status
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
&mut app_state,
&mut form_state,
)
.await
{
event_handler.command_message =
format!("Error handling save outcome: {}", e);
}
}
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) => {
event_handler.command_message = format!("Error: {}", e);
}
} // --- End Consequence Handling ---
// --- Position Change Handling (after outcome processing and pending actions) ---
let position_changed = app_state.current_position != position_before_event;
let current_total_count = app_state.total_count;
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form {
if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1 // Limit to last character in readonly mode
} else {
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
position_logic_needs_redraw = true;
// Ensure position never exceeds total_count + 1
if app_state.current_position > current_total_count + 1 {
app_state.current_position = current_total_count + 1;
}
if app_state.current_position > current_total_count {
// New entry - reset form
form_state.reset_to_empty();
form_state.current_field = 0;
} else if app_state.current_position >= 1
&& app_state.current_position <= current_total_count
{
// Existing entry - load data
let current_position_to_load = app_state.current_position; // Use a copy
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
&& !current_input.is_empty()
{
current_input.len() - 1 // In readonly mode, limit to last character
} else {
current_input.len()
};
form_state.current_cursor_pos = event_handler
.ideal_cursor_column
.min(max_cursor_pos);
// Don't overwrite message from handle_event if load_message is simple success
if !load_message.starts_with("Loaded entry")
|| event_handler.command_message.is_empty()
{
event_handler.command_message = load_message;
}
} else {
// Invalid position (e.g., 0) - reset to first entry or new entry mode
app_state.current_position =
1.min(current_total_count + 1); // Go to 1 or new entry if empty
if app_state.current_position > total_count {
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
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
form_state.current_cursor_pos =
event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_register {
if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_login {
if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else {
0
};
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
}
if position_logic_needs_redraw {
needs_redraw = true;
}
// --- End Position Change Handling ---
// Check exit condition *after* all processing for the iteration
if should_exit {
return Ok(());
}
// --- 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
}
table_just_switched = false;
}
}

View File

@@ -0,0 +1,14 @@
// src/utils/columns.rs
pub fn is_system_column(column_name: &str) -> bool {
match column_name {
"id" | "deleted" | "created_at" => true,
name if name.ends_with("_id") => true,
_ => false,
}
}
pub fn filter_user_columns(all_columns: Vec<String>) -> Vec<String> {
all_columns.into_iter()
.filter(|col| !is_system_column(col))
.collect()
}

4
client/src/utils/mod.rs Normal file
View File

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

View File

@@ -9,5 +9,8 @@ tonic = "0.13.0"
prost = "0.13.5" prost = "0.13.5"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
# Search
tantivy = { workspace = true }
[build-dependencies] [build-dependencies]
tonic-build = "0.13.0" tonic-build = "0.13.0"

View File

@@ -14,6 +14,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"proto/table_definition.proto", "proto/table_definition.proto",
"proto/tables_data.proto", "proto/tables_data.proto",
"proto/table_script.proto", "proto/table_script.proto",
"proto/search.proto",
], ],
&["proto"], &["proto"],
)?; )?;

20
common/proto/search.proto Normal file
View File

@@ -0,0 +1,20 @@
// In common/proto/search.proto
syntax = "proto3";
package multieko2.search;
service Searcher {
rpc SearchTable(SearchRequest) returns (SearchResponse);
}
message SearchRequest {
string table_name = 1;
string query = 2;
}
message SearchResponse {
message Hit {
int64 id = 1; // PostgreSQL row ID
float score = 2;
string content_json = 3;
}
repeated Hit hits = 1;
}

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);
} }

View File

@@ -1,4 +1,7 @@
// common/src/lib.rs // common/src/lib.rs
pub mod search;
pub mod proto { pub mod proto {
pub mod multieko2 { pub mod multieko2 {
pub mod adresar { pub mod adresar {
@@ -25,6 +28,9 @@ pub mod proto {
pub mod table_script { pub mod table_script {
include!("proto/multieko2.table_script.rs"); include!("proto/multieko2.table_script.rs");
} }
pub mod search {
include!("proto/multieko2.search.rs");
}
pub const FILE_DESCRIPTOR_SET: &[u8] = pub const FILE_DESCRIPTOR_SET: &[u8] =
include_bytes!("proto/descriptor.bin"); include_bytes!("proto/descriptor.bin");
} }

Binary file not shown.

View File

@@ -0,0 +1,317 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchRequest {
#[prost(string, tag = "1")]
pub table_name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub query: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchResponse {
#[prost(message, repeated, tag = "1")]
pub hits: ::prost::alloc::vec::Vec<search_response::Hit>,
}
/// Nested message and enum types in `SearchResponse`.
pub mod search_response {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Hit {
/// PostgreSQL row ID
#[prost(int64, tag = "1")]
pub id: i64,
#[prost(float, tag = "2")]
pub score: f32,
#[prost(string, tag = "3")]
pub content_json: ::prost::alloc::string::String,
}
}
/// Generated client implementations.
pub mod searcher_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
#[derive(Debug, Clone)]
pub struct SearcherClient<T> {
inner: tonic::client::Grpc<T>,
}
impl SearcherClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> SearcherClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> SearcherClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
SearcherClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn search_table(
&mut self,
request: impl tonic::IntoRequest<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, 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.search.Searcher/SearchTable",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("multieko2.search.Searcher", "SearchTable"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod searcher_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with SearcherServer.
#[async_trait]
pub trait Searcher: std::marker::Send + std::marker::Sync + 'static {
async fn search_table(
&self,
request: tonic::Request<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status>;
}
#[derive(Debug)]
pub struct SearcherServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> SearcherServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for SearcherServer<T>
where
T: Searcher,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/multieko2.search.Searcher/SearchTable" => {
#[allow(non_camel_case_types)]
struct SearchTableSvc<T: Searcher>(pub Arc<T>);
impl<T: Searcher> tonic::server::UnaryService<super::SearchRequest>
for SearchTableSvc<T> {
type Response = super::SearchResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::SearchRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as Searcher>::search_table(&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 = SearchTableSvc(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)
}
_ => {
Box::pin(async move {
let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut();
headers
.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers
.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
})
}
}
}
}
impl<T> Clone for SearcherServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "multieko2.search.Searcher";
impl<T> tonic::server::NamedService for SearcherServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}

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(

78
common/src/search.rs Normal file
View File

@@ -0,0 +1,78 @@
// common/src/search.rs
use tantivy::schema::*;
use tantivy::tokenizer::*;
use tantivy::Index;
/// Creates a hybrid Slovak search schema with optimized prefix fields.
pub fn create_search_schema() -> Schema {
let mut schema_builder = Schema::builder();
schema_builder.add_u64_field("pg_id", INDEXED | STORED);
// FIELD 1: For prefixes (1-4 chars).
let short_prefix_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_prefix_edge")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let short_prefix_options = TextOptions::default()
.set_indexing_options(short_prefix_indexing)
.set_stored();
schema_builder.add_text_field("prefix_edge", short_prefix_options);
// FIELD 2: For the full word.
let full_word_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_prefix_full")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let full_word_options = TextOptions::default()
.set_indexing_options(full_word_indexing)
.set_stored();
schema_builder.add_text_field("prefix_full", full_word_options);
// NGRAM FIELD: For substring matching.
let ngram_field_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_ngram")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let ngram_options = TextOptions::default()
.set_indexing_options(ngram_field_indexing)
.set_stored();
schema_builder.add_text_field("text_ngram", ngram_options);
schema_builder.build()
}
/// Registers all necessary Slovak tokenizers with the index.
///
/// This must be called by ANY process that opens the index
/// to ensure the tokenizers are loaded into memory.
pub fn register_slovak_tokenizers(index: &Index) -> tantivy::Result<()> {
let tokenizer_manager = index.tokenizers();
// TOKENIZER for `prefix_edge`: Edge N-gram (1-4 chars)
let edge_tokenizer =
TextAnalyzer::builder(NgramTokenizer::new(1, 4, true)?)
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_prefix_edge", edge_tokenizer);
// TOKENIZER for `prefix_full`: Simple word tokenizer
let full_tokenizer =
TextAnalyzer::builder(SimpleTokenizer::default())
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_prefix_full", full_tokenizer);
// NGRAM TOKENIZER: For substring matching.
let ngram_tokenizer =
TextAnalyzer::builder(NgramTokenizer::new(3, 3, false)?)
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_ngram", ngram_tokenizer);
Ok(())
}

19
search/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "search"
version.workspace = true
edition.workspace = true
license = "AGPL-3.0-or-later"
[dependencies]
anyhow = { workspace = true }
prost = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tonic = { workspace = true }
tracing = { workspace = true }
tantivy = { workspace = true }
common = { path = "../common" }
tonic-reflection = "0.13.1"
sqlx = { version = "0.8.6", features = ["postgres"] }

269
search/src/lib.rs Normal file
View File

@@ -0,0 +1,269 @@
// src/lib.rs
use std::collections::HashMap;
use std::path::Path;
use tantivy::collector::TopDocs;
use tantivy::query::{
BooleanQuery, BoostQuery, FuzzyTermQuery, Occur, Query, QueryParser,
TermQuery,
};
use tantivy::schema::{IndexRecordOption, Value};
use tantivy::{Index, TantivyDocument, Term};
use tonic::{Request, Response, Status};
use common::proto::multieko2::search::{
search_response::Hit, SearchRequest, SearchResponse,
};
pub use common::proto::multieko2::search::searcher_server::SearcherServer;
use common::proto::multieko2::search::searcher_server::Searcher;
use common::search::register_slovak_tokenizers;
use sqlx::{PgPool, Row}; // <-- Import PgPool and Row
// We need to hold the database pool in our service struct.
pub struct SearcherService {
pub pool: PgPool,
}
// Normalize diacritics in queries (no changes here)
fn normalize_slovak_text(text: &str) -> String {
// ... function content is unchanged ...
text.chars()
.map(|c| match c {
'á' | 'à' | 'â' | 'ä' | 'ă' | 'ā' => 'a',
'Á' | 'À' | 'Â' | 'Ä' | 'Ă' | 'Ā' => 'A',
'é' | 'è' | 'ê' | 'ë' | 'ě' | 'ē' => 'e',
'É' | 'È' | 'Ê' | 'Ë' | 'Ě' | 'Ē' => 'E',
'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i',
'Í' | 'Ì' | 'Î' | 'Ï' | 'Ī' => 'I',
'ó' | 'ò' | 'ô' | 'ö' | 'ō' | 'ő' => 'o',
'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Ō' | 'Ő' => 'O',
'ú' | 'ù' | 'û' | 'ü' | 'ū' | 'ű' => 'u',
'Ú' | 'Ù' | 'Û' | 'Ü' | 'Ū' | 'Ű' => 'U',
'ý' | 'ỳ' | 'ŷ' | 'ÿ' => 'y',
'Ý' | 'Ỳ' | 'Ŷ' | 'Ÿ' => 'Y',
'č' => 'c',
'Č' => 'C',
'ď' => 'd',
'Ď' => 'D',
'ľ' => 'l',
'Ľ' => 'L',
'ň' => 'n',
'Ň' => 'N',
'ř' => 'r',
'Ř' => 'R',
'š' => 's',
'Š' => 'S',
'ť' => 't',
'Ť' => 'T',
'ž' => 'z',
'Ž' => 'Z',
_ => c,
})
.collect()
}
#[tonic::async_trait]
impl Searcher for SearcherService {
async fn search_table(
&self,
request: Request<SearchRequest>,
) -> Result<Response<SearchResponse>, Status> {
let req = request.into_inner();
let table_name = req.table_name;
let query_str = req.query;
if query_str.trim().is_empty() {
return Err(Status::invalid_argument("Query cannot be empty"));
}
let index_path = Path::new("./tantivy_indexes").join(&table_name);
if !index_path.exists() {
return Err(Status::not_found(format!(
"No search index found for table '{}'",
table_name
)));
}
let index = Index::open_in_dir(&index_path)
.map_err(|e| Status::internal(format!("Failed to open index: {}", e)))?;
register_slovak_tokenizers(&index).map_err(|e| {
Status::internal(format!("Failed to register Slovak tokenizers: {}", e))
})?;
let reader = index.reader().map_err(|e| {
Status::internal(format!("Failed to create index reader: {}", e))
})?;
let searcher = reader.searcher();
let schema = index.schema();
let prefix_edge_field = schema.get_field("prefix_edge").map_err(|_| {
Status::internal("Schema is missing the 'prefix_edge' field.")
})?;
let prefix_full_field = schema.get_field("prefix_full").map_err(|_| {
Status::internal("Schema is missing the 'prefix_full' field.")
})?;
let text_ngram_field = schema.get_field("text_ngram").map_err(|_| {
Status::internal("Schema is missing the 'text_ngram' field.")
})?;
let pg_id_field = schema.get_field("pg_id").map_err(|_| {
Status::internal("Schema is missing the 'pg_id' field.")
})?;
// --- Query Building Logic (no changes here) ---
let prefix_edge_field = schema.get_field("prefix_edge").unwrap();
let prefix_full_field = schema.get_field("prefix_full").unwrap();
let text_ngram_field = schema.get_field("text_ngram").unwrap();
let normalized_query = normalize_slovak_text(&query_str);
let words: Vec<&str> = normalized_query.split_whitespace().collect();
if words.is_empty() {
return Ok(Response::new(SearchResponse { hits: vec![] }));
}
let mut query_layers: Vec<(Occur, Box<dyn Query>)> = Vec::new();
// ... all your query building layers remain exactly the same ...
// ===============================
// LAYER 1: PREFIX MATCHING (HIGHEST PRIORITY, Boost: 4.0)
// ===============================
{
let mut must_clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for word in &words {
let edge_term =
Term::from_field_text(prefix_edge_field, word);
let full_term =
Term::from_field_text(prefix_full_field, word);
let per_word_query = BooleanQuery::new(vec![
(
Occur::Should,
Box::new(TermQuery::new(
edge_term,
IndexRecordOption::Basic,
)),
),
(
Occur::Should,
Box::new(TermQuery::new(
full_term,
IndexRecordOption::Basic,
)),
),
]);
must_clauses.push((Occur::Must, Box::new(per_word_query) as Box<dyn Query>));
}
if !must_clauses.is_empty() {
let prefix_query = BooleanQuery::new(must_clauses);
let boosted_query =
BoostQuery::new(Box::new(prefix_query), 4.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
// ===============================
// LAYER 2: FUZZY MATCHING (HIGH PRIORITY, Boost: 3.0)
// ===============================
{
let last_word = words.last().unwrap();
let fuzzy_term =
Term::from_field_text(prefix_full_field, last_word);
let fuzzy_query = FuzzyTermQuery::new(fuzzy_term, 2, true);
let boosted_query = BoostQuery::new(Box::new(fuzzy_query), 3.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
// ===============================
// LAYER 3: PHRASE MATCHING WITH SLOP (MEDIUM PRIORITY, Boost: 2.0)
// ===============================
if words.len() > 1 {
let slop_parser =
QueryParser::for_index(&index, vec![prefix_full_field]);
let slop_query_str = format!("\"{}\"~3", normalized_query);
if let Ok(slop_query) = slop_parser.parse_query(&slop_query_str) {
let boosted_query = BoostQuery::new(slop_query, 2.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
// ===============================
// LAYER 4: NGRAM SUBSTRING MATCHING (LOWEST PRIORITY, Boost: 1.0)
// ===============================
{
let ngram_parser =
QueryParser::for_index(&index, vec![text_ngram_field]);
if let Ok(ngram_query) =
ngram_parser.parse_query(&normalized_query)
{
let boosted_query = BoostQuery::new(ngram_query, 1.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
let master_query = BooleanQuery::new(query_layers);
// --- End of Query Building Logic ---
let top_docs = searcher
.search(&master_query, &TopDocs::with_limit(100))
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
if top_docs.is_empty() {
return Ok(Response::new(SearchResponse { hits: vec![] }));
}
// --- NEW LOGIC: Fetch from DB and combine results ---
// Step 1: Extract (score, pg_id) from Tantivy results.
let mut scored_ids: Vec<(f32, u64)> = Vec::new();
for (score, doc_address) in top_docs {
let doc: TantivyDocument = searcher.doc(doc_address).map_err(|e| {
Status::internal(format!("Failed to retrieve document: {}", e))
})?;
if let Some(pg_id_value) = doc.get_first(pg_id_field) {
if let Some(pg_id) = pg_id_value.as_u64() {
scored_ids.push((score, pg_id));
}
}
}
// Step 2: Fetch all corresponding rows from Postgres in a single query.
let pg_ids: Vec<i64> =
scored_ids.iter().map(|(_, id)| *id as i64).collect();
let qualified_table = format!("gen.\"{}\"", table_name);
let query_str = format!(
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE id = ANY($1)",
qualified_table
);
let rows = sqlx::query(&query_str)
.bind(&pg_ids)
.fetch_all(&self.pool)
.await
.map_err(|e| {
Status::internal(format!("Database query failed: {}", e))
})?;
// Step 3: Map the database results by ID for quick lookup.
let mut content_map: HashMap<i64, String> = HashMap::new();
for row in rows {
let id: i64 = row.try_get("id").unwrap_or(0);
let json_data: serde_json::Value =
row.try_get("data").unwrap_or(serde_json::Value::Null);
content_map.insert(id, json_data.to_string());
}
// Step 4: Build the final response, combining Tantivy scores with PG content.
let hits: Vec<Hit> = scored_ids
.into_iter()
.filter_map(|(score, pg_id)| {
content_map
.get(&(pg_id as i64))
.map(|content_json| Hit {
id: pg_id as i64,
score,
content_json: content_json.clone(),
})
})
.collect();
let response = SearchResponse { hits };
Ok(Response::new(response))
}
}

View File

@@ -6,7 +6,10 @@ license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
search = { path = "../search" }
anyhow = { workspace = true }
tantivy = { workspace = true }
chrono = { version = "0.4.40", features = ["serde"] } chrono = { version = "0.4.40", features = ["serde"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
prost = "0.13.5" prost = "0.13.5"
@@ -28,6 +31,7 @@ bcrypt = "0.17.0"
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] } uuid = { version = "1.16.0", features = ["serde", "v4"] }
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
rust-stemmers = "1.2.0"
[lib] [lib]
name = "server" name = "server"

View File

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

139
server/src/indexer.rs Normal file
View File

@@ -0,0 +1,139 @@
// server/src/indexer.rs
use sqlx::{PgPool, Row};
use tantivy::schema::Term;
use tantivy::{doc, IndexWriter};
use tokio::sync::mpsc::Receiver;
use tracing::{error, info, warn};
use tantivy::schema::Schema;
use crate::search_schema;
const INDEX_DIR: &str = "./tantivy_indexes";
/// Defines the commands that can be sent to the indexer task.
#[derive(Debug)]
pub enum IndexCommand {
/// Add a new document or update an existing one.
AddOrUpdate(IndexCommandData),
/// Remove a document from the index.
Delete(IndexCommandData),
}
#[derive(Debug)]
pub struct IndexCommandData {
pub table_name: String,
pub row_id: i64,
}
/// The main loop for the background indexer task.
pub async fn indexer_task(pool: PgPool, mut receiver: Receiver<IndexCommand>) {
info!("Background indexer task started.");
while let Some(command) = receiver.recv().await {
info!("Indexer received command: {:?}", command);
let result = match command {
IndexCommand::AddOrUpdate(data) => {
handle_add_or_update(&pool, data).await
}
IndexCommand::Delete(data) => handle_delete(&pool, data).await,
};
if let Err(e) = result {
error!("Failed to process index command: {}", e);
}
}
warn!("Indexer channel closed. Task is shutting down.");
}
/// Handles adding or updating a document in a Tantivy index.
async fn handle_add_or_update(
pool: &PgPool,
data: IndexCommandData,
) -> anyhow::Result<()> {
let qualified_table = format!("gen.\"{}\"", data.table_name);
let query_str = format!(
"SELECT to_jsonb(t) AS data FROM {} t WHERE id = $1",
qualified_table
);
let row = sqlx::query(&query_str)
.bind(data.row_id)
.fetch_one(pool)
.await?;
let json_data: serde_json::Value = row.try_get("data")?;
let slovak_text = extract_text_content(&json_data);
let (mut writer, schema) = get_index_writer(&data.table_name)?;
let pg_id_field = schema.get_field("pg_id").unwrap();
let prefix_edge_field = schema.get_field("prefix_edge").unwrap();
let prefix_full_field = schema.get_field("prefix_full").unwrap();
let text_ngram_field = schema.get_field("text_ngram").unwrap();
let id_term = Term::from_field_u64(pg_id_field, data.row_id as u64);
writer.delete_term(id_term);
writer.add_document(doc!(
pg_id_field => data.row_id as u64,
prefix_edge_field => slovak_text.clone(),
prefix_full_field => slovak_text.clone(),
text_ngram_field => slovak_text
))?;
writer.commit()?;
info!(
"Successfully indexed document id:{} for table:{}",
data.row_id, data.table_name
);
Ok(())
}
/// Handles deleting a document from a Tantivy index.
async fn handle_delete(
_pool: &PgPool,
data: IndexCommandData,
) -> anyhow::Result<()> {
let (mut writer, schema) = get_index_writer(&data.table_name)?;
let pg_id_field = schema.get_field("pg_id").unwrap();
let id_term = Term::from_field_u64(pg_id_field, data.row_id as u64);
writer.delete_term(id_term);
writer.commit()?;
info!(
"Successfully deleted document id:{} from table:{}",
data.row_id, data.table_name
);
Ok(())
}
/// Helper to get or create an index and return its writer and schema.
fn get_index_writer(
table_name: &str,
) -> anyhow::Result<(IndexWriter, Schema)> {
let index = search_schema::get_or_create_index(table_name)?;
let schema = index.schema();
let writer = index.writer(100_000_000)?; // 100MB heap
Ok((writer, schema))
}
/// Extract all text content from a JSON object for indexing
fn extract_text_content(json_data: &serde_json::Value) -> String {
let mut full_text = String::new();
if let Some(obj) = json_data.as_object() {
for value in obj.values() {
match value {
serde_json::Value::String(s) => {
full_text.push_str(s);
full_text.push(' ');
}
serde_json::Value::Number(n) => {
full_text.push_str(&n.to_string());
full_text.push(' ');
}
_ => {}
}
}
}
full_text.trim().to_string()
}

View File

@@ -1,6 +1,8 @@
// src/lib.rs // src/lib.rs
pub mod db; pub mod db;
pub mod auth; pub mod auth;
pub mod indexer;
pub mod search_schema;
pub mod server; pub mod server;
pub mod adresar; pub mod adresar;
pub mod uctovnictvo; pub mod uctovnictvo;

View File

@@ -0,0 +1,26 @@
// server/src/search_schema.rs
use std::path::Path;
use tantivy::Index;
// Re-export the functions from the common crate.
// This makes them available as `crate::search_schema::create_search_schema`, etc.
pub use common::search::{create_search_schema, register_slovak_tokenizers};
/// Gets an existing index or creates a new one.
/// This function now uses the shared logic from the `common` crate.
pub fn get_or_create_index(table_name: &str) -> tantivy::Result<Index> {
let index_path = Path::new("./tantivy_indexes").join(table_name);
std::fs::create_dir_all(&index_path)?;
let index = if index_path.join("meta.json").exists() {
Index::open_in_dir(&index_path)?
} else {
let schema = create_search_schema();
Index::create_in_dir(&index_path, schema)?
};
// This now calls the single, authoritative function from `common`.
register_slovak_tokenizers(&index)?;
Ok(index)
}

View File

@@ -1,7 +1,9 @@
// src/server/run.rs
use tonic::transport::Server; use tonic::transport::Server;
use tonic_reflection::server::Builder as ReflectionBuilder; use tonic_reflection::server::Builder as ReflectionBuilder;
use tokio::sync::mpsc;
use crate::indexer::{indexer_task, IndexCommand};
use common::proto::multieko2::FILE_DESCRIPTOR_SET; use common::proto::multieko2::FILE_DESCRIPTOR_SET;
use crate::server::services::{ use crate::server::services::{
AdresarService, AdresarService,
@@ -21,23 +23,38 @@ use common::proto::multieko2::{
table_script::table_script_server::TableScriptServer, table_script::table_script_server::TableScriptServer,
auth::auth_service_server::AuthServiceServer auth::auth_service_server::AuthServiceServer
}; };
use search::{SearcherService, SearcherServer};
pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> { pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
// Initialize JWT for authentication // Initialize JWT for authentication
crate::auth::logic::jwt::init_jwt()?; crate::auth::logic::jwt::init_jwt()?;
let addr = "[::1]:50051".parse()?; let addr = "[::1]:50051".parse()?;
println!("Unified Server listening on {}", addr);
// 1. Create the MPSC channel for indexer commands
let (indexer_tx, indexer_rx) = mpsc::channel::<IndexCommand>(100); // Buffer of 100 messages
// 2. Spawn the background indexer task
let indexer_pool = db_pool.clone();
tokio::spawn(indexer_task(indexer_pool, indexer_rx));
let reflection_service = ReflectionBuilder::configure() let reflection_service = ReflectionBuilder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.build_v1()?; .build_v1()?;
// Initialize services // Initialize services, passing the indexer sender to the relevant ones
let table_definition_service = TableDefinitionService { db_pool: db_pool.clone() }; let table_definition_service = TableDefinitionService { db_pool: db_pool.clone() };
let tables_data_service = TablesDataService { db_pool: db_pool.clone() }; let tables_data_service = TablesDataService {
db_pool: db_pool.clone(),
indexer_tx: indexer_tx.clone(), // Pass the sender
};
let table_script_service = TableScriptService { db_pool: db_pool.clone() }; let table_script_service = TableScriptService { db_pool: db_pool.clone() };
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() }; let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
// MODIFIED: Instantiate SearcherService with the database pool
let search_service = SearcherService { pool: db_pool.clone() };
Server::builder() Server::builder()
.add_service(AdresarServer::new(AdresarService { db_pool: db_pool.clone() })) .add_service(AdresarServer::new(AdresarService { db_pool: db_pool.clone() }))
.add_service(UctovnictvoServer::new(UctovnictvoService { db_pool: db_pool.clone() })) .add_service(UctovnictvoServer::new(UctovnictvoService { db_pool: db_pool.clone() }))
@@ -46,6 +63,7 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
.add_service(TablesDataServer::new(tables_data_service)) .add_service(TablesDataServer::new(tables_data_service))
.add_service(TableScriptServer::new(table_script_service)) .add_service(TableScriptServer::new(table_script_service))
.add_service(AuthServiceServer::new(auth_service)) .add_service(AuthServiceServer::new(auth_service))
.add_service(SearcherServer::new(search_service)) // This now works correctly
.add_service(reflection_service) .add_service(reflection_service)
.serve(addr) .serve(addr)
.await?; .await?;

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,5 +1,10 @@
// src/server/services/tables_data_service.rs // src/server/services/tables_data_service.rs
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
// Add these imports
use tokio::sync::mpsc;
use crate::indexer::IndexCommand;
use common::proto::multieko2::tables_data::tables_data_server::TablesData; use common::proto::multieko2::tables_data::tables_data_server::TablesData;
use common::proto::multieko2::common::CountResponse; use common::proto::multieko2::common::CountResponse;
use common::proto::multieko2::tables_data::{ use common::proto::multieko2::tables_data::{
@@ -15,6 +20,8 @@ use sqlx::PgPool;
#[derive(Debug)] #[derive(Debug)]
pub struct TablesDataService { pub struct TablesDataService {
pub db_pool: PgPool, pub db_pool: PgPool,
// MODIFIED: Add the sender field
pub indexer_tx: mpsc::Sender<IndexCommand>,
} }
#[tonic::async_trait] #[tonic::async_trait]
@@ -24,25 +31,34 @@ impl TablesData for TablesDataService {
request: Request<PostTableDataRequest>, request: Request<PostTableDataRequest>,
) -> Result<Response<PostTableDataResponse>, Status> { ) -> Result<Response<PostTableDataResponse>, Status> {
let request = request.into_inner(); let request = request.into_inner();
let response = post_table_data(&self.db_pool, request).await?; // MODIFIED: Pass the indexer_tx to the handler
let response = post_table_data(
&self.db_pool,
request,
&self.indexer_tx,
)
.await?;
Ok(Response::new(response)) Ok(Response::new(response))
} }
// Add the new method implementation // You will later apply the same pattern to put_table_data...
async fn put_table_data( async fn put_table_data(
&self, &self,
request: Request<PutTableDataRequest>, request: Request<PutTableDataRequest>,
) -> Result<Response<PutTableDataResponse>, Status> { ) -> Result<Response<PutTableDataResponse>, Status> {
let request = request.into_inner(); let request = request.into_inner();
// TODO: Update put_table_data handler to accept and use indexer_tx
let response = put_table_data(&self.db_pool, request).await?; let response = put_table_data(&self.db_pool, request).await?;
Ok(Response::new(response)) Ok(Response::new(response))
} }
// ...and delete_table_data
async fn delete_table_data( async fn delete_table_data(
&self, &self,
request: Request<DeleteTableDataRequest>, request: Request<DeleteTableDataRequest>,
) -> Result<Response<DeleteTableDataResponse>, Status> { ) -> Result<Response<DeleteTableDataResponse>, Status> {
let request = request.into_inner(); let request = request.into_inner();
// TODO: Update delete_table_data handler to accept and use indexer_tx
let response = delete_table_data(&self.db_pool, request).await?; let response = delete_table_data(&self.db_pool, request).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)
} }
@@ -45,33 +45,46 @@ fn map_field_type(field_type: &str) -> Result<&str, Status> {
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type))) .ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
} }
fn is_invalid_table_name(table_name: &str) -> bool {
table_name.ends_with("_id") ||
table_name == "id" ||
table_name == "deleted" ||
table_name == "created_at"
}
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 != '_', "")
return Err(Status::invalid_argument("Invalid table name")); .trim_matches('_')
.to_lowercase();
// New validation check
if is_invalid_table_name(&user_part_cleaned) {
return Err(Status::invalid_argument(
"Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"
));
}
if !user_part_cleaned.is_empty() && !is_valid_identifier(&user_part_cleaned) {
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 +96,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 +106,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 +125,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 +135,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)
@@ -160,7 +170,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 +184,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 +209,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,
@@ -55,7 +56,6 @@ pub async fn get_table_data(
let system_columns = vec![ let system_columns = vec![
("id".to_string(), "BIGINT".to_string()), ("id".to_string(), "BIGINT".to_string()),
("deleted".to_string(), "BOOLEAN".to_string()), ("deleted".to_string(), "BOOLEAN".to_string()),
("firma".to_string(), "TEXT".to_string()),
]; ];
let all_columns: Vec<(String, String)> = system_columns let all_columns: Vec<(String, String)> = system_columns
.into_iter() .into_iter()
@@ -69,20 +69,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

@@ -1,4 +1,5 @@
// src/tables_data/handlers/post_table_data.rs // src/tables_data/handlers/post_table_data.rs
use tonic::Status; use tonic::Status;
use sqlx::{PgPool, Arguments}; use sqlx::{PgPool, Arguments};
use sqlx::postgres::PgArguments; use sqlx::postgres::PgArguments;
@@ -6,31 +7,28 @@ 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;
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;
// Add these imports
use crate::indexer::{IndexCommand, IndexCommandData};
use tokio::sync::mpsc;
use tracing::error;
// MODIFIED: Function signature now accepts the indexer sender
pub async fn post_table_data( pub async fn post_table_data(
db_pool: &PgPool, db_pool: &PgPool,
request: PostTableDataRequest, request: PostTableDataRequest,
indexer_tx: &mpsc::Sender<IndexCommand>,
) -> Result<PostTableDataResponse, Status> { ) -> Result<PostTableDataResponse, Status> {
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 mut data = HashMap::new(); let mut data = HashMap::new();
// Process and validate all data values
for (key, value) in request.data { for (key, value) in request.data {
let trimmed = value.trim().to_string(); data.insert(key, value.trim().to_string());
// Handle specially - it cannot be empty
if trimmed.is_empty() {
return Err(Status::invalid_argument("Firma cannot be empty"));
}
// Add trimmed non-empty values to data map
if !trimmed.is_empty() {
data.insert(key, trimmed);
}
} }
// Lookup profile // Lookup profile
@@ -123,13 +121,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 +217,51 @@ 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)));
}
};
// After a successful insert, send a command to the indexer.
let command = IndexCommand::AddOrUpdate(IndexCommandData {
table_name: table_name.clone(),
row_id: inserted_id,
});
if let Err(e) = indexer_tx.send(command).await {
// If sending fails, the DB is updated but the index will be stale.
// This is a critical situation to log and monitor.
error!(
"CRITICAL: DB insert for table '{}' (id: {}) succeeded but failed to queue for indexing: {}. Search index is now inconsistent.",
table_name, inserted_id, 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,
@@ -18,15 +19,12 @@ pub async fn put_table_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();
// CORRECTED: Generic handling for all fields.
// Any field with an empty string will be added to the null_fields list.
// The special, hardcoded logic for "firma" has been removed.
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 trimmed.is_empty() {
if key == "firma" && trimmed.is_empty() {
return Err(Status::invalid_argument("Firma cannot be empty"));
}
// Store fields that should be set to NULL
if key != "firma" && trimmed.is_empty() {
null_fields.push(key); null_fields.push(key);
} else { } else {
processed_data.insert(key, trimmed); processed_data.insert(key, trimmed);
@@ -72,8 +70,9 @@ pub async fn put_table_data(
columns.push((name, sql_type)); columns.push((name, sql_type));
} }
// Validate system columns // CORRECTED: "firma" is not a system column.
let system_columns = ["firma", "deleted"]; // It should be treated as a user-defined column.
let system_columns = ["deleted"];
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect(); let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
// Validate input columns // Validate input columns
@@ -90,9 +89,11 @@ pub async fn put_table_data(
// Add data parameters for non-empty fields // Add data parameters for non-empty fields
for (col, value) in &processed_data { for (col, value) in &processed_data {
// CORRECTED: The logic for "firma" is removed from this match.
// It will now fall through to the `else` block and have its type
// correctly looked up from the `columns` vector.
let sql_type = if system_columns.contains(&col.as_str()) { let sql_type = if system_columns.contains(&col.as_str()) {
match col.as_str() { match col.as_str() {
"firma" => "TEXT",
"deleted" => "BOOLEAN", "deleted" => "BOOLEAN",
_ => return Err(Status::invalid_argument("Invalid system column")), _ => return Err(Status::invalid_argument("Invalid system column")),
} }
@@ -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(")
@@ -129,6 +129,13 @@ pub async fn put_table_data(
params.add(dt.with_timezone(&Utc)) params.add(dt.with_timezone(&Utc))
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?; .map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
}, },
// ADDED: BIGINT handling for completeness, if needed for other columns.
"BIGINT" => {
let val = value.parse::<i64>()
.map_err(|_| Status::invalid_argument(format!("Invalid integer for {}", col)))?;
params.add(val)
.map_err(|e| Status::internal(format!("Failed to add integer parameter for {}: {}", col, e)))?;
},
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))), _ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
} }
@@ -154,25 +161,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)))
}
} }
} }