Compare commits

...

24 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
41 changed files with 2069 additions and 910 deletions

1
.gitignore vendored
View File

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

464
Cargo.lock generated
View File

@@ -303,6 +303,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "bitpacking"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92"
dependencies = [
"crunchy",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -322,6 +331,31 @@ dependencies = [
"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]]
name = "bumpalo"
version = "3.17.0"
@@ -361,9 +395,17 @@ version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "census"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -446,6 +488,7 @@ version = "0.3.13"
dependencies = [
"prost",
"serde",
"tantivy",
"tonic",
"tonic-build",
]
@@ -541,6 +584,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "crossbeam"
version = "0.8.4"
@@ -622,6 +674,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -699,6 +757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
@@ -772,6 +831,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
[[package]]
name = "either"
version = "1.15.0"
@@ -825,6 +890,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastdivide"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -884,6 +955,16 @@ dependencies = [
"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]]
name = "futures-channel"
version = "0.3.31"
@@ -1142,6 +1223,12 @@ dependencies = [
"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]]
name = "http"
version = "1.3.1"
@@ -1242,6 +1329,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hyperloglogplus"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3"
dependencies = [
"serde",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
@@ -1520,6 +1616,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "js-sys"
version = "0.3.77"
@@ -1566,6 +1672,12 @@ dependencies = [
"spin",
]
[[package]]
name = "levenshtein_automata"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]]
name = "libc"
version = "0.2.172"
@@ -1651,6 +1763,12 @@ dependencies = [
"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]]
name = "matchit"
version = "0.8.4"
@@ -1667,18 +1785,42 @@ dependencies = [
"digest",
]
[[package]]
name = "measure_time"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
dependencies = [
"log",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memmap2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.8"
@@ -1706,6 +1848,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
[[package]]
name = "murmurhash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1723,6 +1871,16 @@ dependencies = [
"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]]
name = "nu-ansi-term"
version = "0.46.0"
@@ -1857,6 +2015,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oneshot"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea"
[[package]]
name = "openssl"
version = "0.10.72"
@@ -1913,6 +2077,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "parking"
version = "2.2.1"
@@ -2275,6 +2448,16 @@ dependencies = [
"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]]
name = "rand_xoshiro"
version = "0.6.0"
@@ -2305,6 +2488,26 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.11"
@@ -2444,12 +2647,28 @@ dependencies = [
"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]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2512,6 +2731,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "search"
version = "0.3.13"
dependencies = [
"anyhow",
"common",
"prost",
"serde",
"serde_json",
"sqlx",
"tantivy",
"tokio",
"tonic",
"tonic-reflection",
"tracing",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -2598,6 +2834,7 @@ dependencies = [
name = "server"
version = "0.3.13"
dependencies = [
"anyhow",
"bcrypt",
"chrono",
"common",
@@ -2608,11 +2845,14 @@ dependencies = [
"prost",
"regex",
"rstest",
"rust-stemmers",
"search",
"serde",
"serde_json",
"sqlx",
"steel-core",
"steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)",
"tantivy",
"thiserror 2.0.12",
"time",
"tokio",
@@ -2722,6 +2962,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "sketches-ddsketch"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
dependencies = [
"serde",
]
[[package]]
name = "slab"
version = "0.4.9"
@@ -2771,9 +3020,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -2784,9 +3033,9 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
@@ -2822,9 +3071,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
@@ -2835,9 +3084,9 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
@@ -2854,16 +3103,15 @@ dependencies = [
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.100",
"tempfile",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
@@ -2906,9 +3154,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
@@ -2946,9 +3194,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
@@ -3162,6 +3410,152 @@ dependencies = [
"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]]
name = "tempfile"
version = "3.19.1"
@@ -3424,9 +3818,9 @@ dependencies = [
[[package]]
name = "tonic-reflection"
version = "0.13.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88fa815be858816dad226a49439ee90b7bcf81ab55bee72fdb217f1e6778c3ca"
checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
dependencies = [
"prost",
"prost-types",
@@ -3648,6 +4042,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3850,7 +4250,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4214,3 +4614,31 @@ dependencies = [
"quote",
"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]
members = ["client", "server", "common"]
members = ["client", "server", "common", "search"]
resolver = "2"
[workspace.package]
@@ -16,4 +16,27 @@ categories = ["command-line-interface"]
# [workspace.metadata]
# 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'
```
Client with debug that cant be traced
```
cargo run --package client --features ui-debug -- client
```

View File

@@ -26,3 +26,7 @@ tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
unicode-segmentation = "1.12.0"
unicode-width = "0.2.0"
[features]
default = []
ui-debug = []

View File

@@ -1,14 +1,15 @@
// src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{
style::Style,
layout::Rect,
Frame,
style::Style,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use unicode_width::UnicodeWidthStr;
use crate::config::colors::themes::Theme;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
pub fn render_status_line(
f: &mut Frame,
@@ -17,11 +18,24 @@ pub fn render_status_line(
theme: &Theme,
is_edit_mode: bool,
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 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) {
current_dir.replacen(&home_dir, "~", 1)
} else {
@@ -37,23 +51,24 @@ pub fn render_status_line(
let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width;
let show_fps = fixed_width_with_fps <= available_width; // Use <= to show if it fits exactly
program_info_width + separator_width + fps_width +
debug_separator_width + debug_width;
let show_fps = fixed_width_with_fps <= available_width;
let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width + // after mode
separator_width + program_info_width + // after program_info
if show_fps { separator_width + fps_width } else { 0 } // after fps
mode_width + separator_width +
separator_width + program_info_width +
(if show_fps { separator_width + fps_width } else { 0 }) +
debug_separator_width + debug_width,
);
// Original directory display logic
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
display_dir // display_dir is already a String here
display_dir
} else {
let dir_name = Path::new(current_dir) // Use original current_dir for path logic
let dir_name = Path::new(current_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(current_dir); // Fallback to current_dir if no filename
.unwrap_or(current_dir);
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
@@ -61,10 +76,10 @@ pub fn render_status_line(
}
};
// Calculate current content width based on what will be displayed
let mut current_content_width = mode_width + separator_width +
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
separator_width + program_info_width;
separator_width + program_info_width +
debug_separator_width + debug_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
@@ -82,12 +97,17 @@ pub fn render_status_line(
line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
}
// Calculate padding
#[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), // Ensure padding uses background color
Style::default().bg(theme.bg),
));
}

View File

@@ -17,28 +17,29 @@ pub fn render_form(
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
table_name: &str, // This parameter receives the correct table name
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: 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()
.borders(Borders::ALL)
.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));
f.render_widget(adresar_card, area);
// Define inner area
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Create main layout
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -47,12 +48,11 @@ pub fn render_form(
])
.split(inner_area);
// Render count/position
let count_position_text = if total_count == 0 && current_position == 1 {
"Total: 0 | New Entry".to_string()
} else if current_position > total_count && total_count > 0 {
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 { // Should not happen if logic is correct
} else if total_count == 0 && current_position > 1 {
format!("Total: 0 | New Entry ({})", current_position)
}
else {
@@ -63,7 +63,6 @@ pub fn render_form(
.alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]);
// Delegate input handling to canvas
render_canvas(
f,
main_layout[1],

View File

@@ -18,7 +18,7 @@ pub fn render_buffer_list(
area: Rect,
theme: &Theme,
buffer_state: &BufferState,
app_state: &AppState, // Add this parameter
app_state: &AppState,
) {
// --- Style Definitions ---
let active_style = Style::default()
@@ -39,8 +39,7 @@ pub fn render_buffer_list(
let mut spans = Vec::new();
let mut current_width = 0;
// TODO: Replace with actual table name from server response
let current_table_name = Some("2025_customer");
let current_table_name = app_state.current_view_table_name.as_deref();
for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer

View File

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

View File

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

View File

@@ -82,6 +82,8 @@ impl TableDependencyGraph {
}
}
// ... (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,
@@ -114,7 +116,7 @@ impl NavigationState {
self.input.clear();
self.current_path.clear();
self.graph = None;
self.update_filtered_options(); // Initial filter with empty input
self.update_filtered_options();
}
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
@@ -123,7 +125,7 @@ impl NavigationState {
self.graph = Some(graph);
self.input.clear();
self.current_path.clear();
self.update_options_for_path(); // Initial options are root tables
self.update_options_for_path();
}
pub fn deactivate(&mut self) {
@@ -145,7 +147,6 @@ impl NavigationState {
NavigationType::TableTree => {
if c == '/' {
if !self.input.is_empty() {
// Append current input to path
if self.current_path.is_empty() {
self.current_path = self.input.clone();
} else {
@@ -155,10 +156,9 @@ impl NavigationState {
self.input.clear();
self.update_options_for_path();
}
// If input is empty and char is '/', do nothing or define behavior
} else {
self.input.push(c);
self.update_filtered_options(); // Filter current level options based on input
self.update_filtered_options();
}
}
}
@@ -172,24 +172,15 @@ impl NavigationState {
}
NavigationType::TableTree => {
if self.input.is_empty() {
// If input is empty, try to go up in path
if !self.current_path.is_empty() {
if let Some(last_slash_idx) =
self.current_path.rfind('/')
{
// Set input to the segment being removed from path
self.input = self.current_path
[last_slash_idx + 1..]
.to_string();
self.current_path =
self.current_path[..last_slash_idx].to_string();
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 {
// Path was a single segment
self.input = self.current_path.clone();
self.current_path.clear();
}
self.update_options_for_path();
// After path change, current input might match some options, so filter
self.update_filtered_options();
}
} else {
@@ -218,9 +209,7 @@ impl NavigationState {
return;
}
self.selected_index = match self.selected_index {
Some(current) if current >= self.filtered_options.len() - 1 => {
Some(0)
}
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
Some(current) => Some(current + 1),
None => Some(0),
};
@@ -234,18 +223,11 @@ impl NavigationState {
pub fn autocomplete_selected(&mut self) {
if let Some(selected_option_str) = self.get_selected_option_str() {
// The current `self.input` is the text being typed for the current segment/filter.
// We replace it with the full string of the selected option.
self.input = selected_option_str.to_string();
// After updating the input, we need to re-filter the options.
// This will typically result in the filtered_options containing only the
// autocompleted item (or items that start with it, if any).
self.update_filtered_options();
}
}
// Returns the string to display in the input line of the palette
pub fn get_display_input(&self) -> String {
match self.navigation_type {
NavigationType::FindFile => self.input.clone(),
@@ -259,11 +241,12 @@ impl NavigationState {
}
}
// Gets the full path of the currently selected item for TableTree, or input for FindFile
// --- START FIX ---
pub fn get_selected_value(&self) -> Option<String> {
match self.navigation_type {
NavigationType::FindFile => {
if self.input.is_empty() { None } else { Some(self.input.clone()) }
// 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| {
@@ -276,26 +259,23 @@ impl NavigationState {
}
}
}
// --- END FIX ---
// Update self.all_options based on current_path (for TableTree)
fn update_options_for_path(&mut self) {
if let NavigationType::TableTree = self.navigation_type {
if let Some(graph) = &self.graph {
self.all_options =
graph.get_dependent_children(&self.current_path);
self.all_options = graph.get_dependent_children(&self.current_path);
} else {
self.all_options.clear();
}
}
// For FindFile, all_options is set once at activation.
self.update_filtered_options();
}
// Update self.filtered_options based on self.all_options and self.input
fn update_filtered_options(&mut self) {
let filter_text = match self.navigation_type {
NavigationType::FindFile => &self.input,
NavigationType::TableTree => &self.input, // For TableTree, input is the current segment being typed
NavigationType::TableTree => &self.input,
}
.to_lowercase();
@@ -319,11 +299,12 @@ impl NavigationState {
if self.filtered_options.is_empty() {
self.selected_index = None;
} else {
self.selected_index = Some(0); // Default to selecting the first item
self.selected_index = Some(0);
}
}
}
pub async fn handle_command_navigation_event(
navigation_state: &mut NavigationState,
key: KeyEvent,
@@ -338,51 +319,15 @@ pub async fn handle_command_navigation_event(
navigation_state.deactivate();
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
}
KeyCode::Enter => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let message = match navigation_state.navigation_type {
NavigationType::FindFile => format!("Selected file: {}", selected_value),
NavigationType::TableTree => format!("Selected table: {}", selected_value),
};
navigation_state.deactivate();
Ok(EventOutcome::Ok(message))
} else {
// Enhanced Enter behavior for TableTree: if input is a valid partial path, try to navigate
if navigation_state.navigation_type == NavigationType::TableTree && !navigation_state.input.is_empty() {
// Check if current input is a prefix of any option or a full option name
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
if navigation_state.input == selected_opt_str {
// Input exactly matches the selected option, try to navigate
let input_before_slash = navigation_state.input.clone();
navigation_state.add_char('/');
if navigation_state.input.is_empty() {
return Ok(EventOutcome::Ok(format!("Navigated to: {}/", input_before_slash)));
} else {
return Ok(EventOutcome::Ok(format!("Selected leaf: {}", input_before_slash)));
}
}
}
}
Ok(EventOutcome::Ok("No valid selection to confirm or navigate".to_string()))
}
}
KeyCode::Tab => {
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
// Scenario 1: Input already exactly matches the selected option
if navigation_state.input == selected_opt_str {
// Only attempt to navigate deeper for TableTree mode
if navigation_state.navigation_type == NavigationType::TableTree {
let path_before_nav = navigation_state.current_path.clone();
let input_before_nav = navigation_state.input.clone();
navigation_state.add_char('/');
if navigation_state.input.is_empty() &&
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty()) {
// Navigation successful
} else {
// Revert if navigation didn't happen
if !(navigation_state.input.is_empty() &&
(navigation_state.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 {
@@ -393,20 +338,11 @@ pub async fn handle_command_navigation_event(
}
}
} else {
// Scenario 2: Input is a partial match - autocomplete
navigation_state.autocomplete_selected();
}
}
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Up => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Down => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Backspace => {
navigation_state.remove_char();
Ok(EventOutcome::Ok(String::new()))
@@ -428,12 +364,24 @@ pub async fn handle_command_navigation_event(
}
"select" => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let message = match navigation_state.navigation_type {
NavigationType::FindFile => format!("Selected file: {}", selected_value),
NavigationType::TableTree => format!("Selected table: {}", selected_value),
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(EventOutcome::Ok(message))
Ok(outcome)
} else {
Ok(EventOutcome::Ok("No selection".to_string()))
}

View File

@@ -7,7 +7,7 @@ use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::functions::modes::navigation::{add_table_nav, admin_nav};
use crate::modes::general::command_navigation::{
handle_command_navigation_event, NavigationState, TableDependencyGraph,
handle_command_navigation_event, NavigationState,
};
use crate::modes::{
canvas::{common_mode, edit, read_only},
@@ -52,6 +52,7 @@ pub enum EventOutcome {
Exit(String),
DataSaved(SaveOutcome, String),
ButtonSelected { context: UiContext, index: usize },
TableSelected { path: String },
}
impl EventOutcome {
@@ -132,7 +133,6 @@ impl EventHandler {
) -> Result<EventOutcome> {
let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
// Handle active command navigation first
if current_mode == AppMode::General && self.navigation_state.active {
if let Event::Key(key_event) = event {
let outcome =
@@ -154,43 +154,26 @@ impl EventHandler {
let current_view = {
let ui = &app_state.ui;
if ui.show_intro {
AppView::Intro
} else if ui.show_login {
AppView::Login
} else if ui.show_register {
AppView::Register
} else if ui.show_admin {
AppView::Admin
} else if ui.show_add_logic {
AppView::AddLogic
} else if ui.show_add_table {
AppView::AddTable
} else if ui.show_form {
AppView::Form
} else {
AppView::Scratch
}
if ui.show_intro { AppView::Intro }
else if ui.show_login { AppView::Login }
else if ui.show_register { AppView::Register }
else if ui.show_admin { AppView::Admin }
else if ui.show_add_logic { AppView::AddLogic }
else if ui.show_add_table { AppView::AddTable }
else if ui.show_form { AppView::Form }
else { AppView::Scratch }
};
buffer_state.update_history(current_view);
if app_state.ui.dialog.dialog_show {
if let Event::Key(key_event) = event {
if let Some(dialog_result) = dialog::handle_dialog_event(
&Event::Key(key_event),
config,
app_state,
login_state,
register_state,
buffer_state,
admin_state,
)
.await
{
&Event::Key(key_event), config, app_state, login_state,
register_state, buffer_state, admin_state,
).await {
return dialog_result;
}
} else if let Event::Resize(_, _) = event {
// Handle resize if needed
}
return Ok(EventOutcome::Ok(String::new()));
}
@@ -200,34 +183,16 @@ impl EventHandler {
let modifiers = key_event.modifiers;
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
let message = format!(
"Sidebar {}",
if app_state.ui.show_sidebar {
"shown"
} else {
"hidden"
}
);
let message = format!("Sidebar {}", if app_state.ui.show_sidebar { "shown" } else { "hidden" });
return Ok(EventOutcome::Ok(message));
}
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
let message = format!(
"Buffer {}",
if app_state.ui.show_buffer_list {
"shown"
} else {
"hidden"
}
);
let message = format!("Buffer {}", if app_state.ui.show_buffer_list { "shown" } else { "hidden" });
return Ok(EventOutcome::Ok(message));
}
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.global,
key_code,
modifiers,
) {
if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.global, key_code, modifiers) {
match action {
"next_buffer" => {
if buffer::switch_buffer(buffer_state, true) {
@@ -236,15 +201,12 @@ impl EventHandler {
}
"previous_buffer" => {
if buffer::switch_buffer(buffer_state, false) {
return Ok(EventOutcome::Ok(
"Switched to previous buffer".to_string(),
));
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
}
}
"close_buffer" => {
let current_table_name = Some("2025_customer");
let message =
buffer_state.close_buffer_with_intro_fallback(current_table_name);
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));
}
_ => {}
@@ -255,14 +217,7 @@ impl EventHandler {
match current_mode {
AppMode::General => {
if app_state.ui.show_admin && auth_state.role.as_deref() == Some("admin") {
if admin_nav::handle_admin_navigation(
key_event,
config,
app_state,
admin_state,
buffer_state,
&mut self.command_message,
) {
if admin_nav::handle_admin_navigation(key_event, config, app_state, admin_state, buffer_state, &mut self.command_message) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
@@ -270,17 +225,9 @@ impl EventHandler {
if app_state.ui.show_add_logic {
let client_clone = grpc_client.clone();
let sender_clone = self.save_logic_result_sender.clone();
if add_logic_nav::handle_add_logic_navigation(
key_event,
config,
app_state,
&mut admin_state.add_logic_state,
&mut self.is_edit_mode,
buffer_state,
client_clone,
sender_clone,
&mut self.command_message,
key_event, config, app_state, &mut admin_state.add_logic_state,
&mut self.is_edit_mode, buffer_state, client_clone, sender_clone, &mut self.command_message,
) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
@@ -289,85 +236,45 @@ impl EventHandler {
if app_state.ui.show_add_table {
let client_clone = grpc_client.clone();
let sender_clone = self.save_table_result_sender.clone();
if add_table_nav::handle_add_table_navigation(
key_event,
config,
app_state,
&mut admin_state.add_table_state,
client_clone,
sender_clone,
&mut self.command_message,
key_event, config, app_state, &mut admin_state.add_table_state,
client_clone, sender_clone, &mut self.command_message,
) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}
let nav_outcome = navigation::handle_navigation_event(
key_event,
config,
form_state,
app_state,
login_state,
register_state,
intro_state,
admin_state,
&mut self.command_mode,
&mut self.command_input,
&mut self.command_message,
&mut self.navigation_state,
)
.await;
key_event, config, form_state, app_state, login_state, register_state,
intro_state, admin_state, &mut self.command_mode, &mut self.command_input,
&mut self.command_message, &mut self.navigation_state,
).await;
match nav_outcome {
Ok(EventOutcome::ButtonSelected { context, index }) => {
let message = match context {
UiContext::Intro => {
intro::handle_intro_selection(app_state, buffer_state, index);
if app_state.ui.show_admin {
if !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
}
if app_state.ui.show_admin && !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
}
format!("Intro Option {} selected", index)
}
UiContext::Login => match index {
0 => login::initiate_login(
login_state,
app_state,
self.auth_client.clone(),
self.login_result_sender.clone(),
),
1 => {
login::back_to_main(login_state, app_state, buffer_state)
.await
}
0 => login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()),
1 => login::back_to_main(login_state, app_state, buffer_state).await,
_ => "Invalid Login Option".to_string(),
},
UiContext::Register => 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
}
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(),
},
UiContext::Admin => {
admin::handle_admin_selection(app_state, admin_state);
format!("Admin Option {} selected", index)
}
UiContext::Dialog => {
"Internal error: Unexpected dialog state".to_string()
}
UiContext::Dialog => "Internal error: Unexpected dialog state".to_string(),
};
return Ok(EventOutcome::Ok(message));
}
@@ -376,74 +283,27 @@ impl EventHandler {
}
AppMode::ReadOnly => {
if config.get_read_only_action_for_key(key_code, modifiers)
== Some("enter_highlight_mode_linewise")
&& 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,
};
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") && 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.command_message = "-- LINE HIGHLIGHT --".to_string();
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)
{
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()
};
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") && 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);
self.highlight_state = HighlightState::Characterwise { anchor };
self.command_message = "-- HIGHLIGHT --".to_string();
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)
{
} 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.edit_mode_cooldown = true;
self.command_message = "Edit mode".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
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)
{
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()
};
} 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) {
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 app_state.ui.show_login || app_state.ui.show_register {
login_state.set_current_cursor_pos(current_cursor_pos + 1);
@@ -459,10 +319,7 @@ impl EventHandler {
self.command_message = "Edit mode (after cursor)".to_string();
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
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)
{
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
self.command_mode = true;
self.command_input.clear();
self.command_message.clear();
@@ -473,26 +330,14 @@ impl EventHandler {
match action {
"save" | "force_quit" | "save_and_quit" | "revert" => {
return common_mode::handle_core_action(
action,
form_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
app_state,
)
.await;
action, form_state, auth_state, login_state, register_state,
grpc_client, &mut self.auth_client, terminal, app_state,
).await;
}
_ => {}
}
}
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let (_should_exit, message) = read_only::handle_read_only_event(
app_state,
key_event,
@@ -503,8 +348,7 @@ impl EventHandler {
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut current_position,
total_count,
// No more current_position or total_count arguments
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
@@ -515,30 +359,20 @@ impl EventHandler {
}
AppMode::Highlight => {
if config.get_highlight_action_for_key(key_code, modifiers)
== Some("exit_highlight_mode")
{
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
self.highlight_state = HighlightState::Off;
self.command_message = "Exited highlight mode".to_string();
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
return Ok(EventOutcome::Ok(self.command_message.clone()));
} else if config.get_highlight_action_for_key(key_code, modifiers)
== Some("enter_highlight_mode_linewise")
{
} else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
if let HighlightState::Characterwise { anchor } = self.highlight_state {
self.highlight_state = HighlightState::Linewise {
anchor_line: anchor.0,
};
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()));
}
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let (_should_exit, message) = read_only::handle_read_only_event(
app_state,
key_event,
@@ -549,8 +383,6 @@ impl EventHandler {
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut current_position,
total_count,
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
@@ -565,99 +397,45 @@ impl EventHandler {
match action {
"save" | "force_quit" | "save_and_quit" | "revert" => {
return common_mode::handle_core_action(
action,
form_state,
auth_state,
login_state,
register_state,
grpc_client,
&mut self.auth_client,
terminal,
app_state,
)
.await;
action, form_state, auth_state, login_state, register_state,
grpc_client, &mut self.auth_client, terminal, app_state,
).await;
}
_ => {}
}
}
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let edit_result = edit::handle_edit_event(
key_event,
config,
form_state,
login_state,
register_state,
admin_state,
&mut self.ideal_cursor_column,
&mut current_position,
total_count,
grpc_client,
app_state,
)
.await;
key_event, config, form_state, login_state, register_state, admin_state,
&mut self.ideal_cursor_column, &mut current_position, total_count,
grpc_client, app_state,
).await;
match edit_result {
Ok(edit::EditEventOutcome::ExitEditMode) => {
self.is_edit_mode = false;
self.edit_mode_cooldown = true;
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()
};
self.command_message = if has_changes {
"Exited edit mode (unsaved changes remain)".to_string()
} else {
"Read-only mode".to_string()
};
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() };
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)?;
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_cursor_pos = if app_state.ui.show_login {
login_state.current_cursor_pos()
} else if app_state.ui.show_register {
register_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
};
if !current_input.is_empty()
&& current_cursor_pos >= current_input.len()
{
let 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_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
let new_pos = current_input.len() - 1;
let target_state: &mut dyn CanvasState = if app_state.ui.show_login
{
login_state
} else if app_state.ui.show_register {
register_state
} else {
form_state
};
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
target_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = new_pos;
}
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
Ok(edit::EditEventOutcome::Message(msg)) => {
if !msg.is_empty() {
self.command_message = msg;
}
if !msg.is_empty() { self.command_message = msg; }
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
Err(e) => {
return Err(e.into());
}
Err(e) => { return Err(e.into()); }
}
}
@@ -671,31 +449,14 @@ impl EventHandler {
}
if config.is_command_execute(key_code, modifiers) {
// Extracting values to avoid borrow conflicts
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let outcome = command_mode::handle_command_event(
key_event,
config,
app_state,
login_state,
register_state,
form_state,
&mut self.command_input,
&mut self.command_message,
grpc_client,
command_handler,
terminal,
&mut current_position,
total_count,
)
.await?;
// Update form_state with potentially changed position
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);
@@ -711,40 +472,39 @@ impl EventHandler {
if let KeyCode::Char(c) = key_code {
if c == 'f' {
// Assuming 'f' is part of the sequence, e.g. ":f" or " f"
self.key_sequence_tracker.add_key(key_code);
let sequence = self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(&sequence)
== Some("find_file_palette_toggle")
{
if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") {
if app_state.ui.show_form || app_state.ui.show_intro {
// Build table graph from profile data
let graph = TableDependencyGraph::from_profile_tree(
&app_state.profile_tree,
);
// --- 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();
// Activate navigation with graph
self.navigation_state.activate_table_tree(graph);
self.navigation_state.activate_find_file(all_table_paths);
// --- END FIX ---
self.command_mode = false; // Exit command mode
self.command_mode = false;
self.command_input.clear();
// Message is set by render_find_file_palette's prompt_prefix
self.command_message.clear(); // Clear old command message
self.command_message.clear();
self.key_sequence_tracker.reset();
// ModeManager will derive AppMode::General due to navigation_state.active
// app_state.update_mode(AppMode::General); // This will be handled by ModeManager
return Ok(EventOutcome::Ok(
"Table tree palette activated".to_string(),
));
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();
self.command_message = "Find File not available in this view.".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
}
}

View File

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

View File

@@ -5,6 +5,7 @@ use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::app::state::AppState;
use crate::utils::columns::filter_user_columns;
use anyhow::{Context, Result};
pub struct UiService;
@@ -82,7 +83,7 @@ impl UiService {
.into_iter()
.map(|col| col.name)
.collect();
Ok(column_names)
Ok(filter_user_columns(column_names))
}
Err(e) => {
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
@@ -90,12 +91,10 @@ impl UiService {
}
}
}
// MODIFIED: To set initial view table in AppState and return initial column names
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
// Returns (initial_profile, initial_table, initial_columns)
) -> Result<(String, String, Vec<String>)> {
let profile_tree = grpc_client
.get_profile_tree()
@@ -104,7 +103,6 @@ impl UiService {
app_state.profile_tree = profile_tree;
// Determine initial table to load (e.g., first table of first profile, or a default)
// For now, let's hardcode a default for simplicity, but this should be more dynamic
let initial_profile_name = app_state
.profile_tree
.profiles
@@ -141,10 +139,11 @@ impl UiService {
.map(|col| col.name.clone())
.collect();
Ok((initial_profile_name, initial_table_name, column_names))
let filtered_columns = filter_user_columns(column_names);
Ok((initial_profile_name, initial_table_name, filtered_columns))
}
// NEW: Fetches and sets count for the current table in FormState
pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient,
form_state: &mut FormState,
@@ -161,35 +160,26 @@ impl UiService {
))?;
form_state.total_count = total_count;
// Set initial position: if table has items, point to first, else point to new entry
if total_count > 0 {
form_state.current_position = 1;
form_state.current_position = total_count;
} else {
form_state.current_position = 1; // For a new entry in an empty table
form_state.current_position = 1;
}
Ok(())
}
// MODIFIED: Generic table data loading
pub async fn load_table_data_by_position(
grpc_client: &mut GrpcClient,
form_state: &mut FormState, // Takes &mut FormState to update it
// position is now read from form_state.current_position
form_state: &mut FormState,
) -> Result<String> {
// Ensure current_position is valid before fetching
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
// This indicates a "new entry" state, no data to load from server.
// The caller should handle this by calling form_state.reset_to_empty()
// or ensuring this function isn't called for a new entry position.
// For now, let's assume reset_to_empty was called if needed.
form_state.reset_to_empty(); // Ensure fields are clear for new entry
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 {
// Table is empty, this is the position for a new entry
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for empty table {}.{}",
@@ -197,7 +187,6 @@ impl UiService {
));
}
match grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
@@ -207,8 +196,8 @@ impl UiService {
.await
{
Ok(response) => {
form_state.update_from_response(&response.data);
// ID, values, current_field, current_cursor_pos, has_unsaved_changes are set by update_from_response
// FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
Ok(format!(
"Loaded entry {}/{} for table {}.{}",
form_state.current_position,
@@ -218,9 +207,6 @@ impl UiService {
))
}
Err(e) => {
// If loading fails (e.g., record deleted, network error), what should happen?
// Maybe reset to a new entry state or show an error and keep current data.
// For now, log error and return error message.
tracing::error!(
"Error loading entry {} for table {}.{}: {}",
form_state.current_position,
@@ -228,8 +214,6 @@ impl UiService {
form_state.table_name,
e
);
// Potentially clear form or revert to a safe state
// form_state.reset_to_empty();
Err(anyhow::anyhow!(
"Error loading entry {}: {}",
form_state.current_position,
@@ -239,27 +223,20 @@ impl UiService {
}
}
// MODIFIED: To work with FormState's count and position
pub async fn handle_save_outcome(
save_outcome: SaveOutcome,
_grpc_client: &mut GrpcClient, // May not be needed if count is fetched separately
_app_state: &mut AppState, // May not be needed directly
_grpc_client: &mut GrpcClient,
_app_state: &mut AppState,
form_state: &mut FormState,
) -> Result<()> {
match save_outcome {
SaveOutcome::CreatedNew(new_id) => {
// form_state.total_count and form_state.current_position should have been updated
// by the `save` function itself.
// Ensure form_state.id is set.
form_state.id = new_id;
// Potentially, re-fetch count to be absolutely sure, but save should be authoritative.
// UiService::fetch_and_set_table_count(grpc_client, form_state).await?;
}
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No changes to total_count or current_position needed from here.
// No action needed
}
}
Ok(())
}
}

View File

@@ -44,6 +44,9 @@ pub struct AppState {
// UI preferences
pub ui: UiState,
#[cfg(feature = "ui-debug")]
pub debug_info: String,
}
impl AppState {
@@ -61,6 +64,9 @@ impl AppState {
focused_button_index: 0,
pending_table_structure_fetch: None,
ui: UiState::default(),
#[cfg(feature = "ui-debug")]
debug_info: String::new(),
})
}
@@ -133,6 +139,7 @@ impl AppState {
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
}
/// Sets the active button index, wrapping around if necessary.

View File

@@ -23,7 +23,9 @@ pub struct FormState {
}
impl FormState {
// MODIFIED constructor
/// Creates a new, empty FormState for a given table.
/// The position defaults to 1, representing either the first record
/// or the position for a new entry if the table is empty.
pub fn new(
profile_name: String,
table_name: String,
@@ -34,8 +36,9 @@ impl FormState {
id: 0, // Default to 0, indicating a new or unloaded record
profile_name,
table_name,
total_count: 0, // Will be fetched after initialization
current_position: 0, // Will be set after count is fetched (e.g., 1 or total_count + 1)
total_count: 0, // Will be fetched after initialization
// FIX: Default to 1. A position of 0 is an invalid state.
current_position: 1,
fields,
values,
current_field: 0,
@@ -51,7 +54,6 @@ impl FormState {
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
// total_count and current_position are now part of self
) {
let fields_str_slice: Vec<&str> =
self.fields.iter().map(|s| s.as_str()).collect();
@@ -64,24 +66,24 @@ impl FormState {
&fields_str_slice,
&self.current_field,
&values_str_slice,
&self.table_name,
theme,
is_edit_mode,
highlight_state,
self.total_count, // MODIFIED: Use self.total_count
self.current_position, // MODIFIED: Use self.current_position
self.total_count,
self.current_position,
);
}
// MODIFIED: Reset now also considers table context for counts
/// 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) {
self.id = 0;
self.values.iter_mut().for_each(|v| v.clear());
self.current_field = 0;
self.current_cursor_pos = 0;
self.has_unsaved_changes = false;
// current_position should be set to total_count + 1 for a new entry
// This might be better handled by the logic that calls reset_to_empty
// For now, let's ensure it's consistent with a "new" state.
// Set the position for a new entry.
if self.total_count > 0 {
self.current_position = self.total_count + 1;
} else {
@@ -102,41 +104,45 @@ impl FormState {
.expect("Invalid current_field index")
}
// MODIFIED: Update from a generic HashMap response
/// Updates the form's values from a data response and sets its position.
/// This is the single source of truth for populating the form after a data fetch.
pub fn update_from_response(
&mut self,
response_data: &HashMap<String, String>,
// FIX: Add new_position to make this method authoritative.
new_position: u64,
) {
self.values = self.fields
.iter()
.map(|field_name| {
response_data.get(field_name).cloned().unwrap_or_default()
})
.collect();
// 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();
if let Some(id_str) = response_data.get("id") {
match id_str.parse::<i64>() {
Ok(parsed_id) => self.id = parsed_id,
Err(e) => {
tracing::error!(
"Failed to parse 'id' field '{}' for table {}.{}: {}",
id_str,
self.profile_name,
self.table_name,
e
);
self.id = 0; // Default to 0 if parsing fails
}
// 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 {
// If no ID is present, it might be a new record structure or an error
// For now, assume it means the record doesn't have an ID from the server yet
self.id = 0;
}
// FIX: Set the position from the provided parameter.
self.current_position = new_position;
self.has_unsaved_changes = false;
// current_field and current_cursor_pos might need resetting or adjusting
// depending on the desired behavior after loading data.
// For now, let's reset current_field to 0.
self.current_field = 0;
self.current_cursor_pos = 0;
}

View File

@@ -21,9 +21,7 @@ pub async fn save(
return Ok(SaveOutcome::NoChange);
}
let data_map: HashMap<String, String> = form_state
.fields
.iter()
let data_map: HashMap<String, String> = form_state.fields.iter()
.zip(form_state.values.iter())
.map(|(field, value)| (field.clone(), value.clone()))
.collect();
@@ -126,6 +124,8 @@ pub async fn revert(
form_state.table_name
))?;
form_state.update_from_response(&response.data);
// 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())
}

View File

@@ -1,19 +1,15 @@
// src/tui/functions/form.rs
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use crate::services::ui_service::UiService;
use anyhow::{anyhow, Result};
pub async fn handle_action(
action: &str,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
_grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Check for unsaved changes in both cases
if form_state.has_unsaved_changes() {
return Ok(
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
@@ -21,56 +17,29 @@ pub async fn handle_action(
);
}
let total_count = form_state.total_count;
match action {
"previous_entry" => {
let new_position = form_state.current_position.saturating_sub(1);
if new_position >= 1 {
form_state.current_position = new_position;
*current_position = new_position;
if new_position <= form_state.total_count {
let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
Ok(load_message)
} else {
Ok(format!("Moved to position {}", new_position))
}
} else {
Ok("Already at first position".into())
// Only decrement if the current position is greater than the first record.
// This prevents wrapping from 1 to total_count.
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
if form_state.current_position > 1 {
form_state.current_position -= 1;
*ideal_cursor_column = 0;
}
}
"next_entry" => {
if form_state.current_position <= form_state.total_count {
// Only increment if the current position is not yet at the "New Entry" stage.
// The "New Entry" position is total_count + 1.
// This allows moving from the last record to "New Entry", but stops there.
if form_state.current_position <= total_count {
form_state.current_position += 1;
*current_position = form_state.current_position;
if form_state.current_position <= form_state.total_count {
let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
Ok(load_message)
} else {
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())
*ideal_cursor_column = 0;
}
}
_ => Err(anyhow!("Unknown form action: {}", action))
_ => return Err(anyhow!("Unknown form action: {}", action)),
}
Ok(String::new())
}

View File

@@ -137,6 +137,7 @@ pub fn render_ui(
f, app_state, 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_actual_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar, main_content_area
@@ -158,19 +159,39 @@ pub fn render_ui(
};
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
let values_vec: Vec<&String> = form_state.values.iter().collect();
// --- START FIX ---
// Add the missing `&form_state.table_name` argument to this function call.
render_form(
f, form_render_area, form_state, &fields_vec, &form_state.current_field,
&values_vec, theme, is_event_handler_edit_mode, highlight_state,
f,
form_render_area,
form_state,
&fields_vec,
&form_state.current_field,
&values_vec,
&form_state.table_name, // <-- THIS ARGUMENT WAS MISSING
theme,
is_event_handler_edit_mode,
highlight_state,
form_state.total_count,
form_state.current_position,
);
// --- END FIX ---
}
if let Some(area) = buffer_list_area {
render_buffer_list(f, area, theme, buffer_state, app_state);
}
render_status_line(f, status_line_area, current_dir, theme, is_event_handler_edit_mode, current_fps);
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 {

View File

@@ -26,6 +26,7 @@ use crate::tui::functions::common::register::RegisterResult;
use crate::ui::handlers::context::DialogPurpose;
use crate::tui::functions::common::login;
use crate::tui::functions::common::register;
use crate::utils::columns::filter_user_columns;
use std::time::Instant;
use anyhow::{anyhow, Context, Result};
use crossterm::cursor::SetCursorStyle;
@@ -81,16 +82,17 @@ pub async fn run_ui() -> Result<()> {
}
}
// Initialize AppState and FormState with table data
let (initial_profile, initial_table, initial_columns) =
let (initial_profile, initial_table, initial_columns_from_service) =
UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
.await
.context("Failed to initialize app state and form")?;
let filtered_columns = filter_user_columns(initial_columns_from_service);
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
initial_columns,
filtered_columns,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
@@ -100,7 +102,6 @@ pub async fn run_ui() -> Result<()> {
initial_profile, initial_table
))?;
// Load initial data for the form
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
event_handler.command_message = format!("Error loading initial data: {}", e);
@@ -120,210 +121,15 @@ pub async fn run_ui() -> Result<()> {
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 {
if let Some(active_view) = buffer_state.get_active_view() {
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
app_state.ui.show_register = false;
app_state.ui.show_admin = false;
app_state.ui.show_add_table = false;
app_state.ui.show_add_logic = false;
app_state.ui.show_form = false;
match active_view {
AppView::Intro => app_state.ui.show_intro = true,
AppView::Login => app_state.ui.show_login = true,
AppView::Register => app_state.ui.show_register = true,
AppView::Admin => {
info!("Active view is Admin, refreshing profile tree...");
match grpc_client.get_profile_tree().await {
Ok(refreshed_tree) => {
app_state.profile_tree = refreshed_tree;
}
Err(e) => {
error!("Failed to refresh profile tree for Admin panel: {}", e);
event_handler.command_message = format!("Error refreshing admin data: {}", e);
}
}
app_state.ui.show_admin = true;
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
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::AddLogic => app_state.ui.show_add_logic = true,
AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {}
}
}
// Handle table change for FormView
if app_state.ui.show_form {
let current_view_profile = app_state.current_view_profile_name.clone();
let current_view_table = app_state.current_view_table_name.clone();
if prev_view_profile_name != current_view_profile || prev_view_table_name != current_view_table {
if let (Some(prof_name), Some(tbl_name)) = (current_view_profile.as_ref(), current_view_table.as_ref()) {
app_state.show_loading_dialog("Loading Table", &format!("Fetching data for {}.{}...", prof_name, tbl_name));
needs_redraw = true;
match grpc_client.get_table_structure(prof_name.clone(), tbl_name.clone()).await {
Ok(structure_response) => {
let new_columns: Vec<String> = structure_response.columns.iter().map(|c| c.name.clone()).collect();
form_state = FormState::new(prof_name.clone(), tbl_name.clone(), new_columns);
if let Err(e) = UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error fetching count: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
app_state.update_dialog_content(&format!("Error loading data: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
} else {
app_state.hide_dialog();
}
} else {
form_state.reset_to_empty();
app_state.hide_dialog();
}
}
}
Err(e) => {
app_state.update_dialog_content(&format!("Error fetching table structure: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
app_state.current_view_profile_name = prev_view_profile_name.clone();
app_state.current_view_table_name = prev_view_table_name.clone();
}
}
}
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
needs_redraw = true;
}
}
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic {
if admin_state.add_logic_state.profile_name == profile_name &&
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
info!("Fetching table structure for {}.{}", profile_name, table_name);
let fetch_message = UiService::initialize_add_logic_table_data(
&mut grpc_client,
&mut admin_state.add_logic_state,
&app_state.profile_tree,
).await.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e)
});
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
info!("{}", fetch_message);
} else {
event_handler.command_message = fetch_message;
}
needs_redraw = true;
} else {
error!(
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
profile_name, table_name,
admin_state.add_logic_state.profile_name,
admin_state.add_logic_state.selected_table_name
);
}
} else {
warn!(
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
profile_name, table_name
);
}
}
if needs_redraw {
terminal.draw(|f| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode,
&event_handler.highlight_state,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps,
&app_state,
);
}).context("Terminal draw call failed")?;
needs_redraw = false;
}
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 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")?; }
}
let position_before_event = form_state.current_position;
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
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;
event_outcome_result = event_handler.handle_event(
let event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
@@ -338,10 +144,53 @@ pub async fn run_ui() -> Result<()> {
&mut buffer_state,
&mut app_state,
).await;
}
if event_processed {
needs_redraw = true;
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() {
@@ -393,62 +242,208 @@ pub async fn run_ui() -> Result<()> {
}
}
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(_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);
if let Some(active_view) = buffer_state.get_active_view() {
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
app_state.ui.show_register = false;
app_state.ui.show_admin = false;
app_state.ui.show_add_table = false;
app_state.ui.show_add_logic = false;
app_state.ui.show_form = false;
match active_view {
AppView::Intro => app_state.ui.show_intro = true,
AppView::Login => app_state.ui.show_login = true,
AppView::Register => app_state.ui.show_register = true,
AppView::Admin => {
info!("Active view is Admin, refreshing profile tree...");
match grpc_client.get_profile_tree().await {
Ok(refreshed_tree) => {
app_state.profile_tree = refreshed_tree;
}
Err(e) => {
error!("Failed to refresh profile tree for Admin panel: {}", e);
event_handler.command_message = format!("Error refreshing admin data: {}", e);
}
}
app_state.ui.show_admin = true;
let profile_names = app_state.profile_tree.profiles.iter()
.map(|p| p.name.clone())
.collect();
admin_state.set_profiles(profile_names);
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));
}
}
EventOutcome::ButtonSelected { context: _, index: _ } => {}
},
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
AppView::AddTable => app_state.ui.show_add_table = true,
AppView::AddLogic => app_state.ui.show_add_logic = true,
AppView::Form => app_state.ui.show_form = true,
AppView::Scratch => {}
}
}
if app_state.ui.show_form {
let current_view_profile = app_state.current_view_profile_name.clone();
let current_view_table = app_state.current_view_table_name.clone();
if prev_view_profile_name != current_view_profile
|| prev_view_table_name != current_view_table
{
if let (Some(prof_name), Some(tbl_name)) =
(current_view_profile.as_ref(), current_view_table.as_ref())
{
app_state.show_loading_dialog(
"Loading Table",
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
);
needs_redraw = true;
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;
}
}
// --- MODIFIED: Position Change Handling (operates on form_state) ---
let position_changed = form_state.current_position != position_before_event;
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form { // Only if the form is active
if app_state.ui.show_form && !table_just_switched {
if position_changed && !event_handler.is_edit_mode {
// This part is okay: update cursor for the current field BEFORE loading new data
let current_input_before_load = form_state.get_current_input();
let max_cursor_pos_before_load = if !current_input_before_load.is_empty() { current_input_before_load.chars().count() } else { 0 };
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_before_load);
position_logic_needs_redraw = true;
// Validate new form_state.current_position
if form_state.total_count > 0 && form_state.current_position > form_state.total_count + 1 {
form_state.current_position = form_state.total_count + 1; // Cap at new entry
} else if form_state.total_count == 0 && form_state.current_position > 1 {
form_state.current_position = 1; // Cap at new entry for empty table
}
if form_state.current_position == 0 && form_state.total_count > 0 {
form_state.current_position = 1; // Don't allow 0 if there are records
}
// Load data for the new position OR reset for new entry
if (form_state.total_count > 0 && form_state.current_position <= form_state.total_count && form_state.current_position > 0)
{
// It's an existing record position
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") {
@@ -457,34 +452,20 @@ pub async fn run_ui() -> Result<()> {
}
Err(e) => {
event_handler.command_message = format!("Error loading data: {}", e);
// Consider what to do with form_state here - maybe revert position or clear form
}
}
} else {
// Position indicates a new entry (or table is empty and position is 1)
form_state.reset_to_empty(); // This sets id=0, clears values, and sets current_position correctly
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
}
// NOW, after data is loaded or form is reset, get the current input string and its length
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_for_readonly_after_load = if current_input_len_after_load > 0 {
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);
if event_handler.is_edit_mode {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(current_input_len_after_load);
} else {
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_for_readonly_after_load);
// The check for empty string is implicitly handled by max_cursor_pos_for_readonly_after_load being 0
}
} else if !position_changed && !event_handler.is_edit_mode && app_state.ui.show_form {
// Update cursor if not editing and position didn't change (e.g. arrow keys within field)
} 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 {
@@ -512,8 +493,58 @@ pub async fn run_ui() -> Result<()> {
needs_redraw = true;
}
if should_exit {
return Ok(());
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| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode,
&event_handler.highlight_state,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps,
&app_state,
);
}).context("Terminal draw call failed")?;
needs_redraw = false;
}
let now = Instant::now();
@@ -522,5 +553,7 @@ pub async fn run_ui() -> Result<()> {
if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64();
}
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"
serde = { version = "1.0.219", features = ["derive"] }
# Search
tantivy = { workspace = true }
[build-dependencies]
tonic-build = "0.13.0"

View File

@@ -14,6 +14,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"proto/table_definition.proto",
"proto/tables_data.proto",
"proto/table_script.proto",
"proto/search.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

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

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]
common = { path = "../common" }
search = { path = "../search" }
anyhow = { workspace = true }
tantivy = { workspace = true }
chrono = { version = "0.4.40", features = ["serde"] }
dotenvy = "0.15.7"
prost = "0.13.5"
@@ -28,6 +31,7 @@ bcrypt = "0.17.0"
validator = { version = "0.20.0", features = ["derive"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] }
jsonwebtoken = "9.3.1"
rust-stemmers = "1.2.0"
[lib]
name = "server"

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
pub mod db;
pub mod auth;
pub mod indexer;
pub mod search_schema;
pub mod server;
pub mod adresar;
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_reflection::server::Builder as ReflectionBuilder;
use tokio::sync::mpsc;
use crate::indexer::{indexer_task, IndexCommand};
use common::proto::multieko2::FILE_DESCRIPTOR_SET;
use crate::server::services::{
AdresarService,
@@ -21,23 +23,38 @@ use common::proto::multieko2::{
table_script::table_script_server::TableScriptServer,
auth::auth_service_server::AuthServiceServer
};
use search::{SearcherService, SearcherServer};
pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
// Initialize JWT for authentication
crate::auth::logic::jwt::init_jwt()?;
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()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.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 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 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()
.add_service(AdresarServer::new(AdresarService { 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(TableScriptServer::new(table_script_service))
.add_service(AuthServiceServer::new(auth_service))
.add_service(SearcherServer::new(search_service)) // This now works correctly
.add_service(reflection_service)
.serve(addr)
.await?;

View File

@@ -1,5 +1,10 @@
// src/server/services/tables_data_service.rs
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::common::CountResponse;
use common::proto::multieko2::tables_data::{
@@ -15,6 +20,8 @@ use sqlx::PgPool;
#[derive(Debug)]
pub struct TablesDataService {
pub db_pool: PgPool,
// MODIFIED: Add the sender field
pub indexer_tx: mpsc::Sender<IndexCommand>,
}
#[tonic::async_trait]
@@ -24,25 +31,34 @@ impl TablesData for TablesDataService {
request: Request<PostTableDataRequest>,
) -> Result<Response<PostTableDataResponse>, Status> {
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))
}
// Add the new method implementation
// You will later apply the same pattern to put_table_data...
async fn put_table_data(
&self,
request: Request<PutTableDataRequest>,
) -> Result<Response<PutTableDataResponse>, Status> {
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?;
Ok(Response::new(response))
}
// ...and delete_table_data
async fn delete_table_data(
&self,
request: Request<DeleteTableDataRequest>,
) -> Result<Response<DeleteTableDataResponse>, Status> {
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?;
Ok(Response::new(response))
}

View File

@@ -45,6 +45,13 @@ fn map_field_type(field_type: &str) -> Result<&str, Status> {
.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(
db_pool: &PgPool,
request: PostTableDefinitionRequest,
@@ -55,6 +62,13 @@ pub async fn post_table_definition(
.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() {
@@ -142,8 +156,8 @@ async fn execute_table_definition(
RETURNING id"#,
profile.id,
&table_name,
json!(request.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()),
json!(request.indexes.iter().map(|i| i.clone()).collect::<Vec<_>>())
json!(columns),
json!(indexes)
)
.fetch_one(&mut **tx)
.await

View File

@@ -56,7 +56,6 @@ pub async fn get_table_data(
let system_columns = vec![
("id".to_string(), "BIGINT".to_string()),
("deleted".to_string(), "BOOLEAN".to_string()),
("firma".to_string(), "TEXT".to_string()),
];
let all_columns: Vec<(String, String)> = system_columns
.into_iter()

View File

@@ -1,4 +1,5 @@
// src/tables_data/handlers/post_table_data.rs
use tonic::Status;
use sqlx::{PgPool, Arguments};
use sqlx::postgres::PgArguments;
@@ -6,32 +7,28 @@ use chrono::{DateTime, Utc};
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
use std::collections::HashMap;
use std::sync::Arc;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
use crate::shared::schema_qualifier::qualify_table_name_for_data;
use crate::steel::server::execution::{self, Value};
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(
db_pool: &PgPool,
request: PostTableDataRequest,
indexer_tx: &mpsc::Sender<IndexCommand>,
) -> Result<PostTableDataResponse, Status> {
let profile_name = request.profile_name;
let table_name = request.table_name;
let mut data = HashMap::new();
// Process and validate all data values
for (key, value) in request.data {
let trimmed = 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);
}
data.insert(key, value.trim().to_string());
}
// Lookup profile
@@ -251,6 +248,21 @@ pub async fn post_table_data(
}
};
// 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 {
success: true,
message: "Data inserted successfully".into(),

View File

@@ -19,15 +19,12 @@ pub async fn put_table_data(
let mut processed_data = HashMap::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 {
let trimmed = value.trim().to_string();
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() {
if trimmed.is_empty() {
null_fields.push(key);
} else {
processed_data.insert(key, trimmed);
@@ -73,8 +70,9 @@ pub async fn put_table_data(
columns.push((name, sql_type));
}
// Validate system columns
let system_columns = ["firma", "deleted"];
// CORRECTED: "firma" is not a system column.
// It should be treated as a user-defined column.
let system_columns = ["deleted"];
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
// Validate input columns
@@ -91,9 +89,11 @@ pub async fn put_table_data(
// Add data parameters for non-empty fields
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()) {
match col.as_str() {
"firma" => "TEXT",
"deleted" => "BOOLEAN",
_ => return Err(Status::invalid_argument("Invalid system column")),
}
@@ -121,7 +121,7 @@ pub async fn put_table_data(
let val = value.parse::<bool>()
.map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?;
params.add(val)
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {} {}", col, e)))?;
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
},
"TIMESTAMPTZ" => {
let dt = DateTime::parse_from_rfc3339(value)
@@ -129,6 +129,13 @@ pub async fn put_table_data(
params.add(dt.with_timezone(&Utc))
.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))),
}