Compare commits

..

31 Commits

Author SHA1 Message Date
filipriec
8fcd28832d better answer parsing 2025-06-16 11:14:04 +02:00
filipriec
cccf029464 autocomplete is now perfectc 2025-06-16 10:52:28 +02:00
filipriec
512e7fb9e7 suggestions in the dropdown menu now works amazingly well 2025-06-15 23:11:27 +02:00
filipriec
0e69df8282 empty search is now allowed 2025-06-15 18:36:01 +02:00
filipriec
eb5532c200 finally works as i wanted it to 2025-06-15 14:23:19 +02:00
filipriec
49ed1dfe33 trash 2025-06-15 13:52:43 +02:00
filipriec
62d1c3f7f5 suggestion works, but not exactly, needs more stuff 2025-06-15 13:35:45 +02:00
filipriec
b49dce3334 dropdown is being triggered 2025-06-15 12:15:25 +02:00
filipriec
8ace9bc4d1 links are now in the get method of the backend 2025-06-14 18:09:30 +02:00
filipriec
ce490007ed fixing server responses, now push data links fixed 2025-06-14 17:39:59 +02:00
filipriec
eb96c64e26 links to the other tables 2025-06-14 12:47:59 +02:00
filipriec
2ac96a8486 working perfectly well with the search and debug in the status line when enabled 2025-06-13 20:46:33 +02:00
filipriec
b8e6cc22af way better debugging in the status line now 2025-06-13 16:57:58 +02:00
filipriec
634a01f618 service search changed 2025-06-13 16:53:39 +02:00
filipriec
6abea062ba ui debug in status line 2025-06-13 15:26:45 +02:00
filipriec
f50887a326 outputting to the status line 2025-06-13 13:38:40 +02:00
filipriec
3c0af05a3c the search tui is not working yet 2025-06-11 22:08:23 +02:00
filipriec
c9131d4457 working but not properly displaying search results 2025-06-11 16:46:55 +02:00
filipriec
2af79a3ef2 search added, but unable to trigger it yet 2025-06-11 16:24:42 +02:00
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
41 changed files with 3171 additions and 629 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
.env
/tantivy_indexes
server/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

@@ -17,6 +17,7 @@ toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
open_search = ["ctrl+f"]
[keybindings.common]
save = ["ctrl+s"]
@@ -69,10 +70,11 @@ prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"]
delete_char_backward = ["backspace"]
move_left = ["left"]
move_left = [""]
move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"]
trigger_autocomplete = ["left"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]

View File

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

View File

@@ -1,5 +1,6 @@
// src/components/common/autocomplete.rs
use common::proto::multieko2::search::search_response::Hit;
use crate::config::colors::themes::Theme;
use ratatui::{
layout::Rect,
@@ -7,9 +8,23 @@ use ratatui::{
widgets::{Block, List, ListItem, ListState},
Frame,
};
use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
/// Renders an opaque dropdown list for autocomplete suggestions.
/// Converts a serde_json::Value into a displayable String.
/// Handles String, Number, and Bool variants. Returns an empty string for Null and others.
fn json_value_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
// Return an empty string for Null, Array, or Object so we can filter them out.
_ => String::new(),
}
}
/// Renders an opaque dropdown list for simple string-based suggestions.
/// This function remains unchanged.
pub fn render_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
@@ -21,39 +36,32 @@ pub fn render_autocomplete_dropdown(
if suggestions.is_empty() {
return;
}
// --- Calculate Dropdown Size & Position ---
let max_suggestion_width = suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let max_suggestion_width =
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x, // Align horizontally with input
y: input_rect.y + 1, // Position directly below input
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
// --- Clamping Logic (prevent rendering off-screen) ---
// Clamp vertically (if it goes below the frame)
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); // Try rendering above
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
// Clamp horizontally (if it goes past the right edge)
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
// Ensure x is not negative (if clamping pushes it left)
dropdown_area.x = dropdown_area.x.max(0);
// Ensure y is not negative (if clamping pushes it up)
dropdown_area.y = dropdown_area.y.max(0);
// --- End Clamping ---
// Render a solid background block first to ensure opacity
let background_block = Block::default().style(Style::default().bg(Color::DarkGray));
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
// Create list items, ensuring each has a defined background
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
@@ -61,30 +69,140 @@ pub fn render_autocomplete_dropdown(
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s = format!("{}{}", s, " ".repeat(padding_needed as usize));
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg) // Text color on highlight
.bg(theme.highlight) // Highlight background
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
// Style for non-selected items (matching background block)
Style::default()
.fg(theme.fg) // Text color on gray
.bg(Color::DarkGray) // Explicit gray background
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
// Create the list widget (without its own block)
let list = List::new(items);
// State for managing selection highlight (still needed for logic)
let mut profile_list_state = ListState::default();
profile_list_state.select(selected_index);
// Render the list statefully *over* the background block
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
}
// --- MODIFIED FUNCTION FOR RICH SUGGESTIONS ---
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
/// Displays the value of the first meaningful column, followed by the Hit ID.
pub fn render_rich_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
frame_area: Rect,
theme: &Theme,
suggestions: &[Hit],
selected_index: Option<usize>,
) {
if suggestions.is_empty() {
return;
}
let display_names: Vec<String> = suggestions
.iter()
.map(|hit| {
// Use serde_json::Value to handle mixed types (string, null, etc.)
if let Ok(content_map) =
serde_json::from_str::<HashMap<String, serde_json::Value>>(
&hit.content_json,
)
{
// Define keys to ignore for a cleaner display
const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"];
// Get keys, filter out ignored ones, and sort for consistency
let mut keys: Vec<_> = content_map
.keys()
.filter(|k| !IGNORED_KEYS.contains(&k.as_str()))
.cloned()
.collect();
keys.sort();
// Get only the first non-empty value from the sorted keys
let values: Vec<_> = keys
.iter()
.map(|key| {
content_map
.get(key)
.map(json_value_to_string)
.unwrap_or_default()
})
.filter(|s| !s.is_empty()) // Filter out null/empty values
.take(1) // Changed from take(2) to take(1)
.collect();
let display_part = values.first().cloned().unwrap_or_default(); // Get the first value
if display_part.is_empty() {
format!("ID: {}", hit.id)
} else {
format!("{} | ID: {}", display_part, hit.id) // ID at the end
}
} else {
format!("ID: {} (parse error)", hit.id)
}
})
.collect();
// --- Calculate Dropdown Size & Position ---
let max_suggestion_width =
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
// --- Clamping Logic ---
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
dropdown_area.x = dropdown_area.x.max(0);
dropdown_area.y = dropdown_area.y.max(0);
// --- Rendering Logic ---
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
let items: Vec<ListItem> = display_names
.iter()
.enumerate()
.map(|(i, s)| {
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}

View File

@@ -0,0 +1,121 @@
// src/components/common/search_palette.rs
use crate::config::colors::themes::Theme;
use crate::state::app::search::SearchState;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
/// Renders the search palette dialog over the main UI.
pub fn render_search_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &SearchState,
) {
// --- Dialog Area Calculation ---
let height = (area.height as f32 * 0.7).min(30.0) as u16;
let width = (area.width as f32 * 0.6).min(100.0) as u16;
let dialog_area = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 4,
width,
height,
};
f.render_widget(Clear, dialog_area); // Clear background
let block = Block::default()
.title(format!(" Search in '{}' ", state.table_name))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent));
f.render_widget(block.clone(), dialog_area);
// --- Inner Layout (Input + Results) ---
let inner_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3), // For input box
Constraint::Min(0), // For results list
])
.split(dialog_area);
// --- Render Input Box ---
let input_block = Block::default()
.title("Query")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let input_text = Paragraph::new(state.input.as_str())
.block(input_block)
.style(Style::default().fg(theme.fg));
f.render_widget(input_text, inner_chunks[0]);
// Set cursor position
f.set_cursor(
inner_chunks[0].x + state.cursor_position as u16 + 1,
inner_chunks[0].y + 1,
);
// --- Render Results List ---
if state.is_loading {
let loading_p = Paragraph::new("Searching...")
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
f.render_widget(loading_p, inner_chunks[1]);
} else {
let list_items: Vec<ListItem> = state
.results
.iter()
.map(|hit| {
// Parse the JSON string to make it readable
let content_summary = match serde_json::from_str::<
serde_json::Value,
>(&hit.content_json)
{
Ok(json) => {
if let Some(obj) = json.as_object() {
// Create a summary from the first few non-null string values
obj.values()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" | ")
} else {
"Non-object JSON".to_string()
}
}
Err(_) => "Invalid JSON content".to_string(),
};
let line = Line::from(vec![
Span::styled(
format!("{:<4.2} ", hit.score),
Style::default().fg(theme.accent),
),
Span::raw(content_summary),
]);
ListItem::new(line)
})
.collect();
let results_list = List::new(list_items)
.block(Block::default().title("Results"))
.highlight_style(
Style::default()
.bg(theme.highlight)
.fg(theme.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
// We need a mutable ListState to render the selection
let mut list_state =
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
}
}

View File

@@ -1,11 +1,11 @@
// src/components/common/status_line.rs
// client/src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::Paragraph,
text::{Line, Span, Text},
widgets::{Paragraph, Wrap}, // Make sure Wrap is imported
Frame,
};
use std::path::Path;
@@ -20,22 +20,39 @@ pub fn render_status_line(
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 ---
{
if let Some(debug_state) = &app_state.debug_state {
let paragraph = if debug_state.is_error {
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
// 1. Create a `Text` object, which can contain multiple lines.
let error_text = Text::from(debug_state.displayed_message.clone());
let debug_width = UnicodeWidthStr::width(debug_text);
let debug_separator_width = if !debug_text.is_empty() { UnicodeWidthStr::width(" | ") } else { 0 };
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
Paragraph::new(error_text)
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
.style(Style::default().bg(theme.highlight).fg(theme.bg))
} else {
// --- This is for normal, single-line info messages ---
Paragraph::new(debug_state.displayed_message.as_str())
.style(Style::default().fg(theme.accent).bg(theme.bg))
};
f.render_widget(paragraph, area);
} else {
// Fallback for when debug state is None
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}
return; // Stop here and don't render the normal status line.
}
// --- The normal status line rendering logic (unchanged) ---
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 {
@@ -50,19 +67,30 @@ pub fn render_status_line(
let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width +
debug_separator_width + debug_width;
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;
let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width +
separator_width + program_info_width +
(if show_fps { separator_width + fps_width } else { 0 }) +
debug_separator_width + debug_width,
mode_width
+ separator_width
+ separator_width
+ program_info_width
+ (if show_fps {
separator_width + fps_width
} else {
0
}),
);
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
<= remaining_width_for_dir
{
display_dir
} else {
let dir_name = Path::new(current_dir)
@@ -72,14 +100,18 @@ pub fn render_status_line(
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name.chars().take(remaining_width_for_dir).collect::<String>()
dir_name
.chars()
.take(remaining_width_for_dir)
.collect::<String>()
}
};
let mut current_content_width = mode_width + separator_width +
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
separator_width + program_info_width +
debug_separator_width + debug_width;
let mut current_content_width = mode_width
+ separator_width
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
+ separator_width
+ program_info_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
@@ -87,20 +119,24 @@ pub fn render_status_line(
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(dir_display_text_str.as_str(), Style::default().fg(theme.fg)),
Span::styled(
dir_display_text_str.as_str(),
Style::default().fg(theme.fg),
),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(program_info.as_str(), Style::default().fg(theme.secondary)),
Span::styled(
program_info.as_str(),
Style::default().fg(theme.secondary),
),
];
if show_fps {
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
}
#[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)));
line_spans
.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(
fps_text.as_str(),
Style::default().fg(theme.secondary),
));
}
let padding_needed = available_width.saturating_sub(current_content_width);
@@ -111,8 +147,8 @@ pub fn render_status_line(
));
}
let paragraph = Paragraph::new(Line::from(line_spans))
.style(Style::default().bg(theme.bg));
let paragraph =
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}

View File

@@ -1,36 +1,37 @@
// src/components/form/form.rs
use crate::components::common::autocomplete; // <--- ADD THIS IMPORT
use crate::components::handlers::canvas::render_canvas;
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; // <--- CHANGE THIS IMPORT
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState;
use crate::components::handlers::canvas::render_canvas;
pub fn render_form(
f: &mut Frame,
area: Rect,
form_state_param: &impl CanvasState,
form_state: &FormState, // <--- CHANGE THIS to the concrete type
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
table_name: &str, // This parameter receives the correct table name
table_name: &str,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
// 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(card_title) // Use the dynamic title
.title(card_title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area);
@@ -42,10 +43,7 @@ pub fn render_form(
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
])
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner_area);
let count_position_text = if total_count == 0 && current_position == 1 {
@@ -54,19 +52,22 @@ pub fn render_form(
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 {
format!("Total: 0 | New Entry ({})", current_position)
}
else {
format!("Total: {} | Position: {}/{}", total_count, current_position, total_count)
} else {
format!(
"Total: {} | Position: {}/{}",
total_count, current_position, total_count
)
};
let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]);
render_canvas(
// Get the active field's rect from render_canvas
let active_field_rect = render_canvas(
f,
main_layout[1],
form_state_param,
form_state,
fields,
current_field_idx,
inputs,
@@ -74,4 +75,40 @@ pub fn render_form(
is_edit_mode,
highlight_state,
);
// --- NEW: RENDER AUTOCOMPLETE ---
if form_state.autocomplete_active {
// Use the Rect of the active field that render_canvas found for us.
if let Some(active_rect) = active_field_rect {
let selected_index = form_state.get_selected_suggestion_index();
// THE DECIDER LOGIC:
// 1. Check for rich suggestions first.
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
if !rich_suggestions.is_empty() {
autocomplete::render_rich_autocomplete_dropdown(
f,
active_rect,
f.area(), // Use f.area() for clamping, not f.size()
theme,
rich_suggestions,
selected_index,
);
}
}
// 2. Fallback to simple suggestions if rich ones aren't available.
else if let Some(simple_suggestions) = form_state.get_suggestions() {
if !simple_suggestions.is_empty() {
autocomplete::render_autocomplete_dropdown(
f,
active_rect,
f.area(),
theme,
simple_suggestions,
selected_index,
);
}
}
}
}
}

View File

@@ -29,7 +29,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
form_state,
grpc_client,
).await;
match save_result {
Ok(save_outcome) => {
let message = match save_outcome {
@@ -47,7 +47,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
form_state,
grpc_client,
).await;
match revert_result {
Ok(message) => Ok(EventOutcome::Ok(message)),
Err(e) => Err(e),

View File

@@ -1,5 +1,7 @@
// client/src/main.rs
use client::run_ui;
#[cfg(feature = "ui-debug")]
use client::utils::debug_logger::UiDebugWriter;
use dotenvy::dotenv;
use anyhow::Result;
use tracing_subscriber;
@@ -7,8 +9,22 @@ use std::env;
#[tokio::main]
async fn main() -> Result<()> {
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
#[cfg(feature = "ui-debug")]
{
// If ui-debug is on, set up our custom writer.
let writer = UiDebugWriter::new();
tracing_subscriber::fmt()
.with_level(false) // Don't show INFO, ERROR, etc.
.with_target(false) // Don't show the module path.
.without_time() // This is the correct and simpler method.
.with_writer(move || writer.clone())
.init();
}
#[cfg(not(feature = "ui-debug"))]
{
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
}
}
dotenv().ok();

View File

@@ -1,20 +1,22 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::functions::modes::edit::{
add_logic_e, add_table_e, auth_e, form_e,
};
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
canvas_state::CanvasState,
form::FormState,
};
use crate::state::pages::form::FormState; // <<< ADD THIS LINE
// AddLogicState is already imported
// AddTableState is already imported
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::state::app::state::AppState;
use anyhow::Result;
use crossterm::event::KeyEvent; // Removed KeyCode, KeyModifiers as they were unused
use tracing::debug;
use common::proto::multieko2::search::search_response::Hit;
use crossterm::event::{KeyCode, KeyEvent};
use tokio::sync::mpsc;
use tracing::{debug, info};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
@@ -22,231 +24,302 @@ pub enum EditEventOutcome {
ExitEditMode,
}
/// Helper function to spawn a non-blocking search task for autocomplete.
async fn trigger_form_autocomplete_search(
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
sender: mpsc::UnboundedSender<Vec<Hit>>,
) {
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
if field_def.is_link {
if let Some(target_table) = &field_def.link_target_table {
// 1. Update state for immediate UI feedback
form_state.autocomplete_loading = true;
form_state.autocomplete_active = true;
form_state.autocomplete_suggestions.clear();
form_state.selected_suggestion_index = None;
// 2. Clone everything needed for the background task
let query = form_state.get_current_input().to_string();
let table_to_search = target_table.clone();
let mut grpc_client_clone = grpc_client.clone();
info!(
"[Autocomplete] Spawning search in '{}' for query: '{}'",
table_to_search, query
);
// 3. Spawn the non-blocking task
tokio::spawn(async move {
match grpc_client_clone
.search_table(table_to_search, query)
.await
{
Ok(response) => {
// Send results back through the channel
let _ = sender.send(response.hits);
}
Err(e) => {
tracing::error!(
"[Autocomplete] Search failed: {:?}",
e
);
// Send an empty vec on error so the UI can stop loading
let _ = sender.send(vec![]);
}
}
});
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState, // Now FormState is in scope
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
ideal_cursor_column: &mut usize,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
event_handler: &mut EventHandler,
app_state: &AppState,
) -> Result<EditEventOutcome> {
// --- Global command mode check ---
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global, // Assuming command mode can be entered globally
key.code,
key.modifiers,
) {
// This check might be redundant if EventHandler already prevents entering Edit mode
// when command_mode is true. However, it's a safeguard.
return Ok(EditEventOutcome::Message(
"Cannot enter command mode from edit mode here.".to_string(),
));
}
// --- Common actions (save, revert) ---
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key.code,
key.modifiers,
).as_deref() {
if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action(action, login_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_register {
auth_e::execute_common_action(action, register_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_add_table {
// TODO: Implement common actions for AddTable if needed
format!("Action '{}' not implemented for Add Table in edit mode.", action)
} else if app_state.ui.show_add_logic {
// TODO: Implement common actions for AddLogic if needed
format!("Action '{}' not implemented for Add Logic in edit mode.", action)
} else { // Assuming Form view
let outcome = form_e::execute_common_action(action, form_state, grpc_client).await?;
match outcome {
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
_ => format!("Unexpected outcome from common action: {:?}", outcome),
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
if app_state.ui.show_form && form_state.autocomplete_active {
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
match action {
"suggestion_down" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let next = (current + 1)
% form_state.autocomplete_suggestions.len();
form_state.selected_suggestion_index = Some(next);
}
return Ok(EditEventOutcome::Message(String::new()));
}
};
return Ok(EditEventOutcome::Message(message_string));
"suggestion_up" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
form_state.autocomplete_suggestions.len() - 1
} else {
current - 1
};
form_state.selected_suggestion_index = Some(prev);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"exit" => {
form_state.deactivate_autocomplete();
return Ok(EditEventOutcome::Message(
"Autocomplete cancelled".to_string(),
));
}
"enter_decider" => {
if let Some(selected_idx) =
form_state.selected_suggestion_index
{
if let Some(selection) = form_state
.autocomplete_suggestions
.get(selected_idx)
.cloned()
{
let current_input =
form_state.get_current_input_mut();
*current_input = selection.id.to_string();
let new_cursor_pos = current_input.len();
form_state.set_current_cursor_pos(new_cursor_pos);
// FIX: Access ideal_cursor_column through event_handler
event_handler.ideal_cursor_column = new_cursor_pos;
form_state.deactivate_autocomplete();
form_state.set_has_unsaved_changes(true);
return Ok(EditEventOutcome::Message(
"Selection made".to_string(),
));
}
}
form_state.deactivate_autocomplete();
// Fall through to default 'enter' behavior
}
_ => {} // Let other keys fall through to the live search logic
}
}
}
// --- Edit-specific actions ---
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers).as_deref() {
// --- Handle "enter_decider" (Enter key) ---
if action_str == "enter_decider" {
let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode
&& register_state.current_field() == 4 { // Role field
"select_suggestion"
} else if app_state.ui.show_add_logic
&& admin_state.add_logic_state.in_target_column_suggestion_mode
&& admin_state.add_logic_state.current_field() == 1 { // Target Column field
"select_suggestion"
} else {
"next_field" // Default action for Enter
};
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
let mut trigger_search = false;
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(effective_action, key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(effective_action, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(effective_action, key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action(effective_action, key, form_state, ideal_cursor_column).await?
};
if app_state.ui.show_form {
// Manual trigger
if let Some("trigger_autocomplete") =
config.get_edit_action_for_key(key.code, key.modifiers)
{
if !form_state.autocomplete_active {
trigger_search = true;
}
}
// Live search trigger while typing
else if form_state.autocomplete_active {
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
let action = if let KeyCode::Backspace = key.code {
"delete_char_backward"
} else {
"insert_char"
};
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
action,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
trigger_search = true;
}
}
}
if trigger_search {
trigger_form_autocomplete_search(
form_state,
&mut event_handler.grpc_client,
event_handler.autocomplete_result_sender.clone(),
)
.await;
return Ok(EditEventOutcome::Message("Searching...".to_string()));
}
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
if let Some(action_str) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIX: Pass &mut event_handler.ideal_cursor_column
let msg = form_e::execute_edit_action(
"next_field",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
// --- Handle "exit" (Escape key) ---
// Handle exiting edit mode
if action_str == "exit" {
if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action("exit_suggestion_mode", key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
} else if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
return Ok(EditEventOutcome::Message("Exited column suggestions".to_string()));
} else {
return Ok(EditEventOutcome::ExitEditMode);
}
return Ok(EditEventOutcome::ExitEditMode);
}
// --- Autocomplete for AddLogicState Target Column ---
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 { // Target Column field
if action_str == "suggestion_down" { // "Tab" is mapped to suggestion_down
if !admin_state.add_logic_state.in_target_column_suggestion_mode {
// Attempt to open suggestions
if let Some(profile_name) = admin_state.add_logic_state.profile_name.clone().into() {
if let Some(table_name) = admin_state.add_logic_state.selected_table_name.clone() {
debug!("Fetching table structure for autocomplete: Profile='{}', Table='{}'", profile_name, table_name);
match grpc_client.get_table_structure(profile_name, table_name).await {
Ok(ts_response) => {
admin_state.add_logic_state.table_columns_for_suggestions =
ts_response.columns.into_iter().map(|c| c.name).collect();
admin_state.add_logic_state.update_target_column_suggestions();
if !admin_state.add_logic_state.target_column_suggestions.is_empty() {
admin_state.add_logic_state.in_target_column_suggestion_mode = true;
// update_target_column_suggestions handles initial selection
return Ok(EditEventOutcome::Message("Column suggestions shown".to_string()));
} else {
return Ok(EditEventOutcome::Message("No column suggestions for current input".to_string()));
}
}
Err(e) => {
debug!("Error fetching table structure: {}", e);
admin_state.add_logic_state.table_columns_for_suggestions.clear(); // Clear old data on error
admin_state.add_logic_state.update_target_column_suggestions();
return Ok(EditEventOutcome::Message(format!("Error fetching columns: {}", e)));
}
}
} else {
return Ok(EditEventOutcome::Message("No table selected for column suggestions".to_string()));
}
} else { // Should not happen if AddLogic is properly initialized
return Ok(EditEventOutcome::Message("Profile name missing for column suggestions".to_string()));
}
} else { // Already in suggestion mode, navigate down
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
} else if admin_state.add_logic_state.in_target_column_suggestion_mode && action_str == "suggestion_up" {
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Autocomplete for RegisterState Role Field ---
if app_state.ui.show_register && register_state.current_field() == 4 { // Role field
if !register_state.in_suggestion_mode && action_str == "suggestion_down" { // Tab
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
// update_role_suggestions should handle initial selection
return Ok(EditEventOutcome::Message("Role suggestions shown".to_string()));
} else {
// If Tab doesn't open suggestions, it might fall through to "next_field"
// or you might want specific behavior. For now, let it fall through.
}
}
if register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up") {
let msg = auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Dispatch other edit actions ---
// Handle all other edit actions
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(action_str, key, login_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
key,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(action_str, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
action_str,
key,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// If not a suggestion action handled above for AddLogic
if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
action_str,
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
if !(register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
} else { // Form view
form_e::execute_edit_action(action_str, key, form_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
key,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
action_str,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- Character insertion ---
// If character insertion happens while in suggestion mode, exit suggestion mode first.
let mut exited_suggestion_mode_for_typing = false;
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
key,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
key,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
"insert_char",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
let mut char_insert_msg = if app_state.ui.show_login {
auth_e::execute_edit_action("insert_char", key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action("insert_char", key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action("insert_char", key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action("insert_char", key, form_state, ideal_cursor_column).await?
};
// After character insertion, update suggestions if applicable
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
// If we just exited suggestion mode by typing, don't immediately show them again unless Tab is pressed.
// However, update_role_suggestions will set show_role_suggestions if matches are found.
// This is fine, as the render logic checks in_suggestion_mode.
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 {
admin_state.add_logic_state.update_target_column_suggestions();
}
if exited_suggestion_mode_for_typing && char_insert_msg.is_empty() {
char_insert_msg = "Suggestions hidden".to_string();
}
Ok(EditEventOutcome::Message(char_insert_msg))
Ok(EditEventOutcome::Message(String::new())) // No action taken
}

View File

@@ -21,6 +21,7 @@ use crate::state::{
app::{
buffer::{AppView, BufferState},
highlight::HighlightState,
search::SearchState, // Correctly imported
state::AppState,
},
pages::{
@@ -41,10 +42,12 @@ use crate::tui::{
use crate::ui::handlers::context::UiContext;
use crate::ui::handlers::rat_state::UiStateHandler;
use anyhow::Result;
use common::proto::multieko2::search::search_response::Hit;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::{Event, KeyEvent};
use crossterm::event::{Event, KeyCode, KeyEvent};
use tokio::sync::mpsc;
use tokio::sync::mpsc::unbounded_channel;
use tracing::{error, info};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventOutcome {
@@ -74,11 +77,17 @@ pub struct EventHandler {
pub ideal_cursor_column: usize,
pub key_sequence_tracker: KeySequenceTracker,
pub auth_client: AuthClient,
pub grpc_client: GrpcClient,
pub login_result_sender: mpsc::Sender<LoginResult>,
pub register_result_sender: mpsc::Sender<RegisterResult>,
pub save_table_result_sender: SaveTableResultSender,
pub save_logic_result_sender: SaveLogicResultSender,
pub navigation_state: NavigationState,
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub search_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
// --- ADDED FOR LIVE AUTOCOMPLETE ---
pub autocomplete_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
}
impl EventHandler {
@@ -87,7 +96,10 @@ impl EventHandler {
register_result_sender: mpsc::Sender<RegisterResult>,
save_table_result_sender: SaveTableResultSender,
save_logic_result_sender: SaveLogicResultSender,
grpc_client: GrpcClient,
) -> Result<Self> {
let (search_tx, search_rx) = unbounded_channel();
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
Ok(EventHandler {
command_mode: false,
command_input: String::new(),
@@ -98,11 +110,17 @@ impl EventHandler {
ideal_cursor_column: 0,
key_sequence_tracker: KeySequenceTracker::new(400),
auth_client: AuthClient::new().await?,
grpc_client,
login_result_sender,
register_result_sender,
save_table_result_sender,
save_logic_result_sender,
navigation_state: NavigationState::new(),
search_result_sender: search_tx,
search_result_receiver: search_rx,
// --- ADDED ---
autocomplete_result_sender: autocomplete_tx,
autocomplete_result_receiver: autocomplete_rx,
})
}
@@ -114,13 +132,122 @@ impl EventHandler {
self.navigation_state.activate_find_file(options);
}
// This function handles state changes.
async fn handle_search_palette_event(
&mut self,
key_event: KeyEvent,
form_state: &mut FormState,
app_state: &mut AppState,
) -> Result<EventOutcome> {
let mut should_close = false;
let mut outcome_message = String::new();
let mut trigger_search = false;
if let Some(search_state) = app_state.search_state.as_mut() {
match key_event.code {
KeyCode::Esc => {
should_close = true;
outcome_message = "Search cancelled".to_string();
}
KeyCode::Enter => {
if let Some(selected_hit) =
search_state.results.get(search_state.selected_index)
{
if let Ok(data) = serde_json::from_str::<
std::collections::HashMap<String, String>,
>(&selected_hit.content_json)
{
let detached_pos = form_state.total_count + 2;
form_state
.update_from_response(&data, detached_pos);
}
should_close = true;
outcome_message =
format!("Loaded record ID {}", selected_hit.id);
}
}
KeyCode::Up => search_state.previous_result(),
KeyCode::Down => search_state.next_result(),
KeyCode::Char(c) => {
search_state
.input
.insert(search_state.cursor_position, c);
search_state.cursor_position += 1;
trigger_search = true;
}
KeyCode::Backspace => {
if search_state.cursor_position > 0 {
search_state.cursor_position -= 1;
search_state.input.remove(search_state.cursor_position);
trigger_search = true;
}
}
KeyCode::Left => {
search_state.cursor_position =
search_state.cursor_position.saturating_sub(1);
}
KeyCode::Right => {
if search_state.cursor_position < search_state.input.len()
{
search_state.cursor_position += 1;
}
}
_ => {}
}
// --- START CORRECTED LOGIC ---
if trigger_search {
search_state.is_loading = true;
search_state.results.clear();
search_state.selected_index = 0;
let query = search_state.input.clone();
let table_name = search_state.table_name.clone();
let sender = self.search_result_sender.clone();
let mut grpc_client = self.grpc_client.clone();
info!(
"--- 1. Spawning search task for query: '{}' ---",
query
);
// We now move the grpc_client into the task, just like with login.
tokio::spawn(async move {
info!("--- 2. Background task started. ---");
match grpc_client.search_table(table_name, query).await {
Ok(response) => {
info!(
"--- 3a. gRPC call successful. Found {} hits. ---",
response.hits.len()
);
let _ = sender.send(response.hits);
}
Err(e) => {
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
error!("--- 3b. gRPC call failed: {:?} ---", e);
let _ = sender.send(vec![]);
}
}
});
}
}
// The borrow on `app_state.search_state` ends here.
// Now we can safely modify the Option itself.
if should_close {
app_state.search_state = None;
app_state.ui.show_search_palette = false;
app_state.ui.focus_outside_canvas = false;
}
Ok(EventOutcome::Ok(outcome_message))
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_event(
&mut self,
event: Event,
config: &Config,
terminal: &mut TerminalCore,
grpc_client: &mut GrpcClient,
command_handler: &mut CommandHandler,
form_state: &mut FormState,
auth_state: &mut AuthState,
@@ -131,17 +258,36 @@ impl EventHandler {
buffer_state: &mut BufferState,
app_state: &mut AppState,
) -> Result<EventOutcome> {
let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
if app_state.ui.show_search_palette {
if let Event::Key(key_event) = event {
// The call no longer passes grpc_client
return self
.handle_search_palette_event(
key_event,
form_state,
app_state,
)
.await;
}
return Ok(EventOutcome::Ok(String::new()));
}
let mut current_mode =
ModeManager::derive_mode(app_state, self, admin_state);
if current_mode == AppMode::General && self.navigation_state.active {
if let Event::Key(key_event) = event {
let outcome =
handle_command_navigation_event(&mut self.navigation_state, key_event, config)
.await?;
let outcome = handle_command_navigation_event(
&mut self.navigation_state,
key_event,
config,
)
.await?;
if !self.navigation_state.active {
self.command_message = outcome.get_message_if_ok();
current_mode = ModeManager::derive_mode(app_state, self, admin_state);
current_mode =
ModeManager::derive_mode(app_state, self, admin_state);
}
app_state.update_mode(current_mode);
return Ok(outcome);
@@ -154,23 +300,39 @@ 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 {
@@ -182,99 +344,227 @@ impl EventHandler {
let key_code = key_event.code;
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" });
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"
}
);
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" });
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"
}
);
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) {
return Ok(EventOutcome::Ok("Switched to next buffer".to_string()));
return Ok(EventOutcome::Ok(
"Switched to next buffer".to_string(),
));
}
}
"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 = app_state.current_view_table_name.as_deref();
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));
}
_ => {}
}
}
if let Some(action) =
config.get_general_action(key_code, modifiers)
{
if action == "open_search" {
if app_state.ui.show_form {
if let Some(table_name) =
app_state.current_view_table_name.clone()
{
app_state.ui.show_search_palette = true;
app_state.search_state =
Some(SearchState::new(table_name));
app_state.ui.focus_outside_canvas = true;
return Ok(EventOutcome::Ok(
"Search palette opened".to_string(),
));
}
}
}
}
}
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) {
return Ok(EventOutcome::Ok(self.command_message.clone()));
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,
) {
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
}
if app_state.ui.show_add_logic {
let client_clone = grpc_client.clone();
let client_clone = self.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()));
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
}
if app_state.ui.show_add_table {
let client_clone = grpc_client.clone();
let client_clone = self.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()));
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 && !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
intro::handle_intro_selection(
app_state,
buffer_state,
index,
);
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);
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));
}
@@ -326,35 +616,46 @@ impl EventHandler {
return Ok(EventOutcome::Ok(String::new()));
}
if let Some(action) = config.get_common_action(key_code, modifiers) {
if let Some(action) =
config.get_common_action(key_code, modifiers)
{
match action {
"save" | "force_quit" | "save_and_quit" | "revert" => {
"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,
&mut self.grpc_client,
&mut self.auth_client,
terminal,
app_state,
)
.await;
}
_ => {}
}
}
let (_should_exit, message) = read_only::handle_read_only_event(
app_state,
key_event,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
// No more current_position or total_count arguments
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
)
.await?;
let (_should_exit, message) =
read_only::handle_read_only_event(
app_state,
key_event,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut self.grpc_client, // <-- FIX 1
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
)
.await?;
return Ok(EventOutcome::Ok(message));
}
@@ -373,33 +674,45 @@ impl EventHandler {
return Ok(EventOutcome::Ok("".to_string()));
}
let (_should_exit, message) = read_only::handle_read_only_event(
app_state,
key_event,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
)
.await?;
let (_should_exit, message) =
read_only::handle_read_only_event(
app_state,
key_event,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut self.grpc_client, // <-- FIX 2
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
)
.await?;
return Ok(EventOutcome::Ok(message));
}
AppMode::Edit => {
if let Some(action) = config.get_common_action(key_code, modifiers) {
if let Some(action) =
config.get_common_action(key_code, modifiers)
{
match action {
"save" | "force_quit" | "save_and_quit" | "revert" => {
"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,
&mut self.grpc_client,
&mut self.auth_client,
terminal,
app_state,
)
.await;
}
_ => {}
}
@@ -407,11 +720,20 @@ impl EventHandler {
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
// --- MODIFIED: Pass `self` instead of `grpc_client` ---
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 current_position,
total_count,
self,
app_state,
)
.await;
match edit_result {
Ok(edit::EditEventOutcome::ExitEditMode) => {
@@ -428,14 +750,22 @@ impl EventHandler {
target_state.set_current_cursor_pos(new_pos);
self.ideal_cursor_column = new_pos;
}
return Ok(EventOutcome::Ok(self.command_message.clone()));
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
Ok(edit::EditEventOutcome::Message(msg)) => {
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()));
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
Err(e) => {
return Err(e.into());
}
Err(e) => { return Err(e.into()); }
}
}
@@ -445,21 +775,38 @@ impl EventHandler {
self.command_message.clear();
self.command_mode = false;
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
return Ok(EventOutcome::Ok(
"Exited command mode".to_string(),
));
}
if config.is_command_execute(key_code, modifiers) {
let mut current_position = form_state.current_position;
let total_count = form_state.total_count;
let outcome = command_mode::handle_command_event(
key_event, config, app_state, login_state, register_state, form_state,
&mut self.command_input, &mut self.command_message, grpc_client,
command_handler, terminal, &mut current_position, total_count,
).await?;
key_event,
config,
app_state,
login_state,
register_state,
form_state,
&mut self.command_input,
&mut self.command_message,
&mut self.grpc_client, // <-- FIX 5
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);
let new_mode = ModeManager::derive_mode(
app_state,
self,
admin_state,
);
app_state.update_mode(new_mode);
return Ok(outcome);
}
@@ -473,39 +820,59 @@ impl EventHandler {
if let KeyCode::Char(c) = key_code {
if c == 'f' {
self.key_sequence_tracker.add_key(key_code);
let sequence = self.key_sequence_tracker.get_sequence();
let sequence =
self.key_sequence_tracker.get_sequence();
if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") {
if app_state.ui.show_form || app_state.ui.show_intro {
// --- START FIX ---
let mut all_table_paths: Vec<String> = app_state
.profile_tree
.profiles
.iter()
.flat_map(|profile| {
profile.tables.iter().map(move |table| {
format!("{}/{}", profile.name, table.name)
if config.matches_key_sequence_generalized(
&sequence,
) == Some("find_file_palette_toggle")
{
if app_state.ui.show_form
|| app_state.ui.show_intro
{
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();
.collect();
all_table_paths.sort();
self.navigation_state.activate_find_file(all_table_paths);
// --- END FIX ---
self.navigation_state
.activate_find_file(all_table_paths);
self.command_mode = false;
self.command_input.clear();
self.command_message.clear();
self.key_sequence_tracker.reset();
return Ok(EventOutcome::Ok("Table selection palette activated".to_string()));
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') {
if sequence.len() > 1
&& sequence[0] == KeyCode::Char('f')
{
self.command_input.push('f');
}
self.command_message = "Find File not available in this view.".to_string();
return Ok(EventOutcome::Ok(self.command_message.clone()));
self.command_message = "Find File not available in this view."
.to_string();
return Ok(EventOutcome::Ok(
self.command_message.clone(),
));
}
}
@@ -514,7 +881,9 @@ impl EventHandler {
}
}
if c != 'f' && !self.key_sequence_tracker.current_sequence.is_empty() {
if c != 'f'
&& !self.key_sequence_tracker.current_sequence.is_empty()
{
self.key_sequence_tracker.reset();
}

View File

@@ -20,6 +20,9 @@ use common::proto::multieko2::tables_data::{
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use common::proto::multieko2::search::{
searcher_client::SearcherClient, SearchRequest, SearchResponse,
};
use anyhow::{Context, Result}; // Added Context
use std::collections::HashMap; // NEW
@@ -28,36 +31,32 @@ pub struct GrpcClient {
table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>, // NEW
tables_data_client: TablesDataClient<Channel>,
search_client: SearcherClient<Channel>,
}
impl GrpcClient {
pub async fn new() -> Result<Self> {
let table_structure_client = TableStructureServiceClient::connect(
"http://[::1]:50051",
)
.await
.context("Failed to connect to TableStructureService")?;
let table_definition_client = TableDefinitionClient::connect(
"http://[::1]:50051",
)
.await
.context("Failed to connect to TableDefinitionService")?;
let table_script_client =
TableScriptClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TableScriptService")?;
let tables_data_client =
TablesDataClient::connect("http://[::1]:50051")
.await
.context("Failed to connect to TablesDataService")?; // NEW
let channel = Channel::from_static("http://[::1]:50051")
.connect()
.await
.context("Failed to create gRPC channel")?;
let table_structure_client =
TableStructureServiceClient::new(channel.clone());
let table_definition_client =
TableDefinitionClient::new(channel.clone());
let table_script_client = TableScriptClient::new(channel.clone());
let tables_data_client = TablesDataClient::new(channel.clone());
// NEW: Instantiate the search client
let search_client = SearcherClient::new(channel.clone());
Ok(Self {
// adresar_client, // REMOVE
table_structure_client,
table_definition_client,
table_script_client,
tables_data_client, // NEW
tables_data_client,
search_client, // NEW
})
}
@@ -197,4 +196,17 @@ impl GrpcClient {
.context("gRPC PutTableData call failed")?;
Ok(response.into_inner())
}
pub async fn search_table(
&mut self,
table_name: String,
query: String,
) -> Result<SearchResponse> {
let request = tonic::Request::new(SearchRequest { table_name, query });
let response = self
.search_client
.search_table(request)
.await?;
Ok(response.into_inner())
}
}

View File

@@ -2,4 +2,5 @@
pub mod state;
pub mod buffer;
pub mod search;
pub mod highlight;

View File

@@ -0,0 +1,56 @@
// src/state/app/search.rs
use common::proto::multieko2::search::search_response::Hit;
/// Holds the complete state for the search palette.
pub struct SearchState {
/// The name of the table being searched.
pub table_name: String,
/// The current text entered by the user.
pub input: String,
/// The position of the cursor within the input text.
pub cursor_position: usize,
/// The search results returned from the server.
pub results: Vec<Hit>,
/// The index of the currently selected search result.
pub selected_index: usize,
/// A flag to indicate if a search is currently in progress.
pub is_loading: bool,
}
impl SearchState {
/// Creates a new SearchState for a given table.
pub fn new(table_name: String) -> Self {
Self {
table_name,
input: String::new(),
cursor_position: 0,
results: Vec::new(),
selected_index: 0,
is_loading: false,
}
}
/// Moves the selection to the next item, wrapping around if at the end.
pub fn next_result(&mut self) {
if !self.results.is_empty() {
let next = self.selected_index + 1;
self.selected_index = if next >= self.results.len() {
0 // Wrap to the start
} else {
next
};
}
}
/// Moves the selection to the previous item, wrapping around if at the beginning.
pub fn previous_result(&mut self) {
if !self.results.is_empty() {
self.selected_index = if self.selected_index == 0 {
self.results.len() - 1 // Wrap to the end
} else {
self.selected_index - 1
};
}
}
}

View File

@@ -1,11 +1,15 @@
// src/state/state.rs
// src/state/app/state.rs
use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::search::SearchState; // ADDED
use anyhow::Result;
#[cfg(feature = "ui-debug")]
use std::time::Instant;
// --- YOUR EXISTING DIALOGSTATE IS UNTOUCHED ---
pub struct DialogState {
pub dialog_show: bool,
pub dialog_title: String,
@@ -26,10 +30,19 @@ pub struct UiState {
pub show_form: bool,
pub show_login: bool,
pub show_register: bool,
pub show_search_palette: bool, // ADDED
pub focus_outside_canvas: bool,
pub dialog: DialogState,
}
#[cfg(feature = "ui-debug")]
#[derive(Debug, Clone)]
pub struct DebugState {
pub displayed_message: String,
pub is_error: bool,
pub display_start_time: Instant,
}
pub struct AppState {
// Core editor state
pub current_dir: String,
@@ -42,11 +55,14 @@ pub struct AppState {
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
// ADDED: State for the search palette
pub search_state: Option<SearchState>,
// UI preferences
pub ui: UiState,
#[cfg(feature = "ui-debug")]
pub debug_info: String,
pub debug_state: Option<DebugState>,
}
impl AppState {
@@ -63,25 +79,25 @@ impl AppState {
current_mode: AppMode::General,
focused_button_index: 0,
pending_table_structure_fetch: None,
search_state: None, // ADDED
ui: UiState::default(),
#[cfg(feature = "ui-debug")]
debug_info: String::new(),
debug_state: None,
})
}
// --- ALL YOUR EXISTING METHODS ARE UNTOUCHED ---
pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode;
}
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
self.current_view_profile_name = Some(profile_name);
self.current_view_table_name = Some(table_name);
}
// Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons.
/// The first button (index 0) is active by default.
pub fn show_dialog(
&mut self,
title: &str,
@@ -99,19 +115,17 @@ impl AppState {
self.ui.focus_outside_canvas = true;
}
/// Shows a dialog specifically for loading states.
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
self.ui.dialog.dialog_title = title.to_string();
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons.clear(); // No buttons during loading
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; // Purpose is set when loading finishes
self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true; // Keep focus management consistent
self.ui.focus_outside_canvas = true;
}
/// Updates the content of an existing dialog, typically after loading.
pub fn update_dialog_content(
&mut self,
message: &str,
@@ -121,16 +135,12 @@ impl AppState {
if self.ui.dialog.dialog_show {
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0; // Reset focus
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false; // Loading finished
// Keep dialog_show = true
// Keep focus_outside_canvas = true
self.ui.dialog.is_loading = false;
}
}
/// Hides the dialog and clears its content.
pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false;
self.ui.dialog.dialog_title.clear();
@@ -142,30 +152,27 @@ impl AppState {
self.ui.dialog.is_loading = false;
}
/// Sets the active button index, wrapping around if necessary.
pub fn next_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
% self.ui.dialog.dialog_buttons.len();
self.ui.dialog.dialog_active_button_index = next_index; // Use new name
self.ui.dialog.dialog_active_button_index = next_index;
}
}
/// Sets the active button index, wrapping around if necessary.
pub fn previous_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let len = self.ui.dialog.dialog_buttons.len();
let prev_index =
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
self.ui.dialog.dialog_active_button_index = prev_index; // Use new name
self.ui.dialog.dialog_active_button_index = prev_index;
}
}
/// Gets the label of the currently active button, if any.
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
self.ui.dialog
.dialog_buttons // Use new name
.get(self.ui.dialog.dialog_active_button_index) // Use new name
.dialog_buttons
.get(self.ui.dialog.dialog_active_button_index)
.map(|s| s.as_str())
}
}
@@ -182,13 +189,13 @@ impl Default for UiState {
show_login: false,
show_register: false,
show_buffer_list: true,
show_search_palette: false, // ADDED
focus_outside_canvas: false,
dialog: DialogState::default(),
}
}
}
// Update the Default implementation for DialogState itself
impl Default for DialogState {
fn default() -> Self {
Self {

View File

@@ -1,5 +1,6 @@
// src/state/canvas_state.rs
// src/state/pages/canvas_state.rs
use common::proto::multieko2::search::search_response::Hit;
pub trait CanvasState {
fn current_field(&self) -> usize;
@@ -17,4 +18,7 @@ pub trait CanvasState {
// --- Autocomplete Support ---
fn get_suggestions(&self) -> Option<&[String]>;
fn get_selected_suggestion_index(&self) -> Option<usize>;
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
None
}
}

View File

@@ -1,49 +1,65 @@
// src/state/pages/form.rs
use std::collections::HashMap; // NEW
use crate::config::colors::themes::Theme;
use ratatui::layout::Rect;
use ratatui::Frame;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
use common::proto::multieko2::search::search_response::Hit; // Import Hit
use ratatui::layout::Rect;
use ratatui::Frame;
use std::collections::HashMap;
// A struct to bridge the display name (label) to the data key from the server.
#[derive(Debug, Clone)]
pub struct FieldDefinition {
pub display_name: String,
pub data_key: String,
pub is_link: bool,
pub link_target_table: Option<String>,
}
#[derive(Clone)]
pub struct FormState {
pub id: i64,
// NEW fields for dynamic table context
pub profile_name: String,
pub table_name: String,
pub total_count: u64,
pub current_position: u64, // 1-based index, 0 or total_count + 1 for new entry
pub fields: Vec<String>, // Already dynamic, which is good
pub current_position: u64,
pub fields: Vec<FieldDefinition>,
pub values: Vec<String>,
pub current_field: usize,
pub has_unsaved_changes: bool,
pub current_cursor_pos: usize,
// --- MODIFIED AUTOCOMPLETE STATE ---
pub autocomplete_active: bool,
pub autocomplete_suggestions: Vec<Hit>, // Changed to use the Hit struct
pub selected_suggestion_index: Option<usize>,
pub autocomplete_loading: bool, // To show a loading indicator
}
impl FormState {
/// 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,
fields: Vec<String>,
fields: Vec<FieldDefinition>,
) -> Self {
let values = vec![String::new(); fields.len()];
FormState {
id: 0, // Default to 0, indicating a new or unloaded record
id: 0,
profile_name,
table_name,
total_count: 0, // Will be fetched after initialization
// FIX: Default to 1. A position of 0 is an invalid state.
total_count: 0,
current_position: 1,
fields,
values,
current_field: 0,
has_unsaved_changes: false,
current_cursor_pos: 0,
// --- INITIALIZE NEW STATE ---
autocomplete_active: false,
autocomplete_suggestions: Vec::new(),
selected_suggestion_index: None,
autocomplete_loading: false, // Initialize loading state
}
}
@@ -56,13 +72,13 @@ impl FormState {
highlight_state: &HighlightState,
) {
let fields_str_slice: Vec<&str> =
self.fields.iter().map(|s| s.as_str()).collect();
self.fields().iter().map(|s| *s).collect();
let values_str_slice: Vec<&String> = self.values.iter().collect();
crate::components::form::form::render_form(
f,
area,
self, // Pass self as CanvasState
self, // <--- This now correctly passes the concrete &FormState
&fields_str_slice,
&self.current_field,
&values_str_slice,
@@ -75,20 +91,18 @@ impl FormState {
);
}
/// 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;
// Set the position for a new entry.
if self.total_count > 0 {
self.current_position = self.total_count + 1;
} else {
self.current_position = 1; // If table is empty, new record is at position 1
self.current_position = 1;
}
self.deactivate_autocomplete(); // Deactivate on reset
}
pub fn get_current_input(&self) -> &str {
@@ -104,26 +118,22 @@ impl FormState {
.expect("Invalid current_field index")
}
/// 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,
) {
// 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();
self.values = self
.fields
.iter()
.map(|field_def| {
response_data
.get(&field_def.data_key)
.cloned()
.unwrap_or_default()
})
.collect();
// Now, do the same case-insensitive lookup for the 'id' field.
let id_str_opt = response_data
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
@@ -133,18 +143,32 @@ impl FormState {
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);
tracing::error!(
"Failed to parse 'id' field '{}' for table {}.{}",
id_str,
self.profile_name,
self.table_name
);
self.id = 0;
}
} else {
self.id = 0;
}
// FIX: Set the position from the provided parameter.
self.current_position = new_position;
self.has_unsaved_changes = false;
self.current_field = 0;
self.current_cursor_pos = 0;
self.deactivate_autocomplete(); // Deactivate on update
}
// --- NEW HELPER METHOD ---
/// Deactivates autocomplete and clears its state.
pub fn deactivate_autocomplete(&mut self) {
self.autocomplete_active = false;
self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None;
self.autocomplete_loading = false;
}
}
@@ -166,23 +190,26 @@ impl CanvasState for FormState {
}
fn get_current_input(&self) -> &str {
// Re-use the struct's own method
FormState::get_current_input(self)
}
fn get_current_input_mut(&mut self) -> &mut String {
// Re-use the struct's own method
FormState::get_current_input_mut(self)
}
fn fields(&self) -> Vec<&str> {
self.fields.iter().map(|s| s.as_str()).collect()
self.fields
.iter()
.map(|f| f.display_name.as_str())
.collect()
}
fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() {
self.current_field = index;
}
// Deactivate autocomplete when changing fields
self.deactivate_autocomplete();
}
fn set_current_cursor_pos(&mut self, pos: usize) {
@@ -193,11 +220,27 @@ impl CanvasState for FormState {
self.has_unsaved_changes = changed;
}
// --- MODIFIED: Implement autocomplete trait methods ---
/// Returns None because this state uses rich suggestions.
fn get_suggestions(&self) -> Option<&[String]> {
None
}
/// Returns rich suggestions.
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
if self.autocomplete_active {
Some(&self.autocomplete_suggestions)
} else {
None
}
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None
if self.autocomplete_active {
self.selected_suggestion_index
} else {
None
}
}
}

View File

@@ -23,7 +23,7 @@ pub async fn save(
let data_map: HashMap<String, String> = form_state.fields.iter()
.zip(form_state.values.iter())
.map(|(field, value)| (field.clone(), value.clone()))
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
.collect();
let outcome: SaveOutcome;

View File

@@ -1,34 +1,36 @@
// client/src/ui/handlers/render.rs
// src/ui/handlers/render.rs
use crate::components::{
admin::add_logic::render_add_logic,
admin::render_add_table,
auth::{login::render_login, register::render_register},
common::dialog::render_dialog,
common::find_file_palette,
common::search_palette::render_search_palette,
form::form::render_form,
handlers::sidebar::{self, calculate_sidebar_layout},
intro::intro::render_intro,
render_background,
render_buffer_list,
render_command_line,
render_status_line,
intro::intro::render_intro,
handlers::sidebar::{self, calculate_sidebar_layout},
form::form::render_form,
admin::render_add_table,
admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register},
common::find_file_palette,
};
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::buffer::BufferState;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::form::FormState;
use crate::state::pages::intro::IntroState;
use ratatui::{
layout::{Constraint, Direction, Layout},
Frame,
};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState;
use crate::modes::general::command_navigation::NavigationState;
#[allow(clippy::too_many_arguments)]
pub fn render_ui(
@@ -53,16 +55,28 @@ pub fn render_ui(
) {
render_background(f, f.area(), theme);
// --- START DYNAMIC LAYOUT LOGIC ---
let mut status_line_height = 1;
#[cfg(feature = "ui-debug")]
{
if let Some(debug_state) = &app_state.debug_state {
if debug_state.is_error {
status_line_height = 4;
}
}
}
// --- END DYNAMIC LAYOUT LOGIC ---
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(1)];
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
let command_palette_area_height = if navigation_state.active {
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
} else if event_handler_command_mode_active {
1
} else {
0 // Neither is active
0
};
if command_palette_area_height > 0 {
@@ -75,7 +89,6 @@ pub fn render_ui(
}
main_layout_constraints.extend(bottom_area_constraints);
let root_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(main_layout_constraints)
@@ -106,77 +119,95 @@ pub fn render_ui(
None
};
if app_state.ui.show_intro {
render_intro(f, intro_state, main_content_area, theme);
} else if app_state.ui.show_register {
render_register(
f, main_content_area, theme, register_state, app_state,
f,
main_content_area,
theme,
register_state,
app_state,
register_state.current_field() < 4,
highlight_state,
);
} else if app_state.ui.show_add_table {
render_add_table(
f, main_content_area, theme, app_state, &mut admin_state.add_table_state,
f,
main_content_area,
theme,
app_state,
&mut admin_state.add_table_state,
is_event_handler_edit_mode,
highlight_state,
);
} else if app_state.ui.show_add_logic {
render_add_logic(
f, main_content_area, theme, app_state, &mut admin_state.add_logic_state,
is_event_handler_edit_mode, highlight_state,
f,
main_content_area,
theme,
app_state,
&mut admin_state.add_logic_state,
is_event_handler_edit_mode,
highlight_state,
);
} else if app_state.ui.show_login {
render_login(
f, main_content_area, theme, login_state, app_state,
f,
main_content_area,
theme,
login_state,
app_state,
login_state.current_field() < 2,
highlight_state,
);
} else if app_state.ui.show_admin {
crate::components::admin::admin_panel::render_admin_panel(
f, app_state, auth_state, admin_state, main_content_area, theme,
&app_state.profile_tree, &app_state.selected_profile,
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
);
let (sidebar_area, form_actual_area) =
calculate_sidebar_layout(app_state.ui.show_sidebar, main_content_area);
if let Some(sidebar_rect) = sidebar_area {
sidebar::render_sidebar(
f, sidebar_rect, theme, &app_state.profile_tree, &app_state.selected_profile
f,
sidebar_rect,
theme,
&app_state.profile_tree,
&app_state.selected_profile,
);
}
let available_width = form_actual_area.width;
let form_render_area = if available_width >= 80 {
Layout::default().direction(Direction::Horizontal)
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
.split(form_actual_area)[1]
} else {
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(available_width), Constraint::Min(0)])
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(available_width),
Constraint::Min(0),
])
.split(form_actual_area)[1]
};
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
let values_vec: Vec<&String> = form_state.values.iter().collect();
// --- START FIX ---
// Add the missing `&form_state.table_name` argument to this function call.
render_form(
form_state.render(
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 {
@@ -193,23 +224,41 @@ pub fn render_ui(
app_state,
);
if let Some(palette_or_command_area) = command_render_area { // Use the calculated area
if let Some(palette_or_command_area) = command_render_area {
if navigation_state.active {
find_file_palette::render_find_file_palette(
f,
palette_or_command_area, // Use the correct area
palette_or_command_area,
theme,
navigation_state, // Pass the navigation_state directly
navigation_state,
);
} else if event_handler_command_mode_active {
render_command_line(
f,
palette_or_command_area, // Use the correct area
palette_or_command_area,
event_handler_command_input,
true, // Assuming it's always active when this branch is hit
true,
theme,
event_handler_command_message,
);
}
}
// This block now correctly handles drawing popups over any view.
if app_state.ui.show_search_palette {
if let Some(search_state) = &app_state.search_state {
render_search_palette(f, f.area(), theme, search_state);
}
} else if app_state.ui.dialog.dialog_show {
render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -9,7 +9,7 @@ use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
@@ -27,12 +27,16 @@ 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;
use crossterm::event as crossterm_event;
use tracing::{error, info, warn};
use tokio::sync::mpsc;
use std::time::{Duration, Instant};
#[cfg(feature = "ui-debug")]
use crate::state::app::state::DebugState;
#[cfg(feature = "ui-debug")]
use crate::utils::debug_logger::pop_next_debug_message;
pub async fn run_ui() -> Result<()> {
let config = Config::load().context("Failed to load configuration")?;
@@ -51,6 +55,7 @@ pub async fn run_ui() -> Result<()> {
register_result_sender.clone(),
save_table_result_sender.clone(),
save_logic_result_sender.clone(),
grpc_client.clone(),
)
.await
.context("Failed to create event handler")?;
@@ -87,12 +92,20 @@ pub async fn run_ui() -> Result<()> {
.await
.context("Failed to initialize app state and form")?;
let filtered_columns = filter_user_columns(initial_columns_from_service);
let initial_field_defs: Vec<FieldDefinition> = filter_user_columns(initial_columns_from_service)
.into_iter()
.map(|col_name| FieldDefinition {
display_name: col_name.clone(),
data_key: col_name,
is_link: false,
link_target_table: None,
})
.collect();
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
filtered_columns,
initial_field_defs,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
@@ -126,6 +139,51 @@ pub async fn run_ui() -> Result<()> {
loop {
let position_before_event = form_state.current_position;
let mut event_processed = false;
// --- CHANNEL RECEIVERS ---
// For main search palette
match event_handler.search_result_receiver.try_recv() {
Ok(hits) => {
info!("--- 4. Main loop received message from channel. ---");
if let Some(search_state) = app_state.search_state.as_mut() {
search_state.results = hits;
search_state.is_loading = false;
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {
}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Search result channel disconnected!");
}
}
// --- ADDED: For live form autocomplete ---
match event_handler.autocomplete_result_receiver.try_recv() {
Ok(hits) => {
if form_state.autocomplete_active {
form_state.autocomplete_suggestions = hits;
form_state.autocomplete_loading = false;
if !form_state.autocomplete_suggestions.is_empty() {
form_state.selected_suggestion_index = Some(0);
} else {
form_state.selected_suggestion_index = None;
}
event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len());
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Autocomplete result channel disconnected!");
}
}
if app_state.ui.show_search_palette {
needs_redraw = true;
}
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;
@@ -133,7 +191,6 @@ pub async fn run_ui() -> Result<()> {
event,
&config,
&mut terminal,
&mut grpc_client,
&mut command_handler,
&mut form_state,
&mut auth_state,
@@ -310,17 +367,56 @@ pub async fn run_ui() -> Result<()> {
.await
{
Ok(structure_response) => {
let new_columns: Vec<String> = structure_response
// --- START OF MODIFIED LOGIC ---
let all_columns: Vec<String> = structure_response
.columns
.iter()
.map(|c| c.name.clone())
.collect();
let filtered_columns = filter_user_columns(new_columns);
let mut field_definitions: Vec<FieldDefinition> =
filter_user_columns(all_columns)
.into_iter()
.filter(|col_name| !col_name.ends_with("_id"))
.map(|col_name| FieldDefinition {
display_name: col_name.clone(),
data_key: col_name,
is_link: false,
link_target_table: None, // Regular fields have no target
})
.collect();
let linked_tables: Vec<String> = app_state
.profile_tree
.profiles
.iter()
.find(|p| p.name == *prof_name)
.and_then(|profile| {
profile.tables.iter().find(|t| t.name == *tbl_name)
})
.map_or(vec![], |table| table.depends_on.clone());
for linked_table_name in linked_tables {
let base_name = linked_table_name
.split_once('_')
.map_or(linked_table_name.as_str(), |(_, rest)| rest);
let data_key = format!("{}_id", base_name);
let display_name = linked_table_name.clone(); // Clone for use below
field_definitions.push(FieldDefinition {
display_name,
data_key,
is_link: true,
// --- POPULATE THE NEW FIELD ---
link_target_table: Some(linked_table_name),
});
}
// --- END OF MODIFIED LOGIC ---
form_state = FormState::new(
prof_name.clone(),
tbl_name.clone(),
filtered_columns,
field_definitions, // This now contains the complete definitions
);
if let Err(e) = UiService::fetch_and_set_table_count(
@@ -358,6 +454,7 @@ pub async fn run_ui() -> Result<()> {
prev_view_table_name = current_view_table;
table_just_switched = true;
}
Err(e) => {
app_state.update_dialog_content(
&format!("Error fetching table structure: {}", e),
@@ -499,10 +596,20 @@ pub async fn run_ui() -> Result<()> {
#[cfg(feature = "ui-debug")]
{
app_state.debug_info = format!(
"Redraw -> event: {}, needs_redraw: {}, pos_changed: {}",
event_processed, needs_redraw, position_changed
);
let can_display_next = match &app_state.debug_state {
Some(current) => current.display_start_time.elapsed() >= Duration::from_secs(2),
None => true,
};
if can_display_next {
if let Some((new_message, is_error)) = pop_next_debug_message() {
app_state.debug_state = Some(DebugState {
displayed_message: new_message,
is_error,
display_start_time: Instant::now(),
});
}
}
}
if event_processed || needs_redraw || position_changed {

View File

@@ -0,0 +1,46 @@
// client/src/utils/debug_logger.rs
use lazy_static::lazy_static;
use std::collections::VecDeque; // <-- FIX: Import VecDeque
use std::io;
use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex
lazy_static! {
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
Arc::new(Mutex::new(VecDeque::from([(String::from("Logger initialized..."), false)])));
}
#[derive(Clone)]
pub struct UiDebugWriter;
impl Default for UiDebugWriter {
fn default() -> Self {
Self::new()
}
}
impl UiDebugWriter {
pub fn new() -> Self {
Self
}
}
impl io::Write for UiDebugWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
let message = String::from_utf8_lossy(buf);
let trimmed_message = message.trim().to_string();
let is_error = trimmed_message.starts_with("ERROR");
// Add the new message to the back of the queue
buffer.push_back((trimmed_message, is_error));
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// A public function to pop the next message from the front of the queue.
pub fn pop_next_debug_message() -> Option<(String, bool)> {
UI_DEBUG_BUFFER.lock().unwrap().pop_front()
}

View File

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

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"] }

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

@@ -0,0 +1,302 @@
// 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};
use tracing::info;
// We need to hold the database pool in our service struct.
pub struct SearcherService {
pub pool: PgPool,
}
// normalize_slovak_text function remains unchanged...
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;
// --- MODIFIED LOGIC ---
// If the query is empty, fetch the 5 most recent records.
if query_str.trim().is_empty() {
info!(
"Empty query for table '{}'. Fetching default results.",
table_name
);
let qualified_table = format!("gen.\"{}\"", table_name);
let sql = format!(
"SELECT id, to_jsonb(t) AS data FROM {} t ORDER BY id DESC LIMIT 5",
qualified_table
);
let rows = sqlx::query(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| {
Status::internal(format!(
"DB query for default results failed: {}",
e
))
})?;
let hits: Vec<Hit> = rows
.into_iter()
.map(|row| {
let id: i64 = row.try_get("id").unwrap_or_default();
let json_data: serde_json::Value =
row.try_get("data").unwrap_or_default();
Hit {
id,
// Score is 0.0 as this is not a relevance-ranked search
score: 0.0,
content_json: json_data.to_string(),
}
})
.collect();
info!("--- SERVER: Successfully processed empty query. Returning {} default hits. ---", hits.len());
return Ok(Response::new(SearchResponse { hits }));
}
// --- END OF MODIFIED LOGIC ---
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 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();
info!("--- SERVER: Successfully processed search. Returning {} hits. ---", hits.len());
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

@@ -1,9 +1,10 @@
// src/tables_data/handlers/get_table_data.rs
use tonic::Status;
use sqlx::{PgPool, Row};
use std::collections::HashMap;
use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse};
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
use crate::shared::schema_qualifier::qualify_table_name_for_data;
pub async fn get_table_data(
db_pool: &PgPool,
@@ -48,27 +49,44 @@ pub async fn get_table_data(
return Err(Status::internal("Invalid column format"));
}
let name = parts[0].trim_matches('"').to_string();
let sql_type = parts[1].to_string();
user_columns.push((name, sql_type));
user_columns.push(name);
}
// Prepare all columns (system + user-defined)
let system_columns = vec![
("id".to_string(), "BIGINT".to_string()),
("deleted".to_string(), "BOOLEAN".to_string()),
];
let all_columns: Vec<(String, String)> = system_columns
.into_iter()
.chain(user_columns.into_iter())
.collect();
// --- START OF FIX ---
// Build SELECT clause with COALESCE and type casting
let columns_clause = all_columns
// 1. Get all foreign key columns for this table
let fk_columns_query = sqlx::query!(
r#"SELECT ltd.table_name
FROM table_definition_links tdl
JOIN table_definitions ltd ON tdl.linked_table_id = ltd.id
WHERE tdl.source_table_id = $1"#,
table_def.id
)
.fetch_all(db_pool)
.await
.map_err(|e| Status::internal(format!("Foreign key lookup error: {}", e)))?;
// 2. Build the list of foreign key column names
let mut foreign_key_columns = Vec::new();
for fk in fk_columns_query {
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
foreign_key_columns.push(format!("{}_id", base_name));
}
// 3. Prepare a complete list of all columns to select
let mut all_column_names = vec!["id".to_string(), "deleted".to_string()];
all_column_names.extend(user_columns);
all_column_names.extend(foreign_key_columns);
// 4. Build the SELECT clause with all columns
let columns_clause = all_column_names
.iter()
.map(|(name, _)| format!("COALESCE(\"{0}\"::TEXT, '') AS \"{0}\"", name))
.map(|name| format!("COALESCE(\"{0}\"::TEXT, '') AS \"{0}\"", name))
.collect::<Vec<_>>()
.join(", ");
// --- END OF FIX ---
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(&table_name)?;
@@ -87,7 +105,6 @@ pub async fn get_table_data(
Ok(row) => row,
Err(sqlx::Error::RowNotFound) => return Err(Status::not_found("Record not found")),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
@@ -100,9 +117,9 @@ pub async fn get_table_data(
}
};
// Build response data
// Build response data from the complete list of columns
let mut data = HashMap::new();
for (column_name, _) in &all_columns {
for column_name in &all_column_names {
let value: String = row
.try_get(column_name.as_str())
.map_err(|e| Status::internal(format!("Failed to get column {}: {}", column_name, e)))?;

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,22 +7,26 @@ 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();
// CORRECTED: Process and trim all incoming data values.
// We remove the hardcoded validation. We will let the database's
// NOT NULL constraints or Steel validation scripts handle required fields.
for (key, value) in request.data {
data.insert(key, value.trim().to_string());
}
@@ -80,7 +85,7 @@ pub async fn post_table_data(
// Build system columns with foreign keys
let mut system_columns = vec!["deleted".to_string()];
for fk in fk_columns {
let base_name = fk.table_name.split('_').last().unwrap_or(&fk.table_name);
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
system_columns.push(format!("{}_id", base_name));
}
@@ -243,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(),