Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d15e321f | ||
|
|
a3e7fd8f0a | ||
|
|
645172747a | ||
|
|
7c4ac1eebc | ||
|
|
4b4301ad49 | ||
|
|
b60e03eb70 | ||
|
|
2c7bda3ff1 | ||
|
|
eeaaa3635b | ||
|
|
e61cbb3956 | ||
|
|
f9841f2ef3 | ||
|
|
dc232b2523 | ||
|
|
b086b3e236 | ||
|
|
387e1a0fe0 | ||
|
|
08e01d41f2 | ||
|
|
f5edf52571 | ||
|
|
02c62213c3 | ||
|
|
d0722fbbbe | ||
|
|
4ec569342d | ||
|
|
9540d9ccb9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
/tantivy_indexes
|
||||
|
||||
432
Cargo.lock
generated
432
Cargo.lock
generated
@@ -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"
|
||||
@@ -541,6 +583,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 +673,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 +756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -772,6 +830,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 +889,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 +954,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 +1222,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 +1328,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 +1615,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 +1671,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 +1762,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 +1784,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 +1847,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 +1870,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 +2014,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 +2076,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 +2447,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 +2487,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 +2646,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 +2730,22 @@ 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",
|
||||
"tantivy",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -2598,6 +2832,7 @@ dependencies = [
|
||||
name = "server"
|
||||
version = "0.3.13"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"common",
|
||||
@@ -2608,11 +2843,13 @@ dependencies = [
|
||||
"prost",
|
||||
"regex",
|
||||
"rstest",
|
||||
"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 +2959,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"
|
||||
@@ -3162,6 +3408,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 +3816,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 +4040,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 +4248,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 +4612,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",
|
||||
]
|
||||
|
||||
27
Cargo.toml
27
Cargo.toml
@@ -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" }
|
||||
|
||||
@@ -18,3 +18,8 @@ Client with tracing:
|
||||
```
|
||||
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
||||
```
|
||||
|
||||
Client with debug that cant be traced
|
||||
```
|
||||
cargo run --package client --features ui-debug -- client
|
||||
```
|
||||
|
||||
@@ -26,3 +26,7 @@ tracing-subscriber = "0.3.19"
|
||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ui-debug = []
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// src/components/common/status_line.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
style::Style,
|
||||
layout::Rect,
|
||||
Frame,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_status_line(
|
||||
f: &mut Frame,
|
||||
@@ -17,11 +18,24 @@ pub fn render_status_line(
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// --- START FIX ---
|
||||
// Ensure debug_text is always a &str, which implements UnicodeWidthStr.
|
||||
#[cfg(feature = "ui-debug")]
|
||||
let debug_text = app_state.debug_info.as_str();
|
||||
#[cfg(not(feature = "ui-debug"))]
|
||||
let debug_text = "";
|
||||
// --- END FIX ---
|
||||
|
||||
let debug_width = UnicodeWidthStr::width(debug_text);
|
||||
let debug_separator_width = if !debug_text.is_empty() { UnicodeWidthStr::width(" | ") } else { 0 };
|
||||
|
||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
|
||||
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let home_dir =
|
||||
dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
||||
current_dir.replacen(&home_dir, "~", 1)
|
||||
} else {
|
||||
@@ -37,23 +51,24 @@ pub fn render_status_line(
|
||||
let separator_width = UnicodeWidthStr::width(separator);
|
||||
|
||||
let fixed_width_with_fps = mode_width + separator_width + separator_width +
|
||||
program_info_width + separator_width + fps_width;
|
||||
let show_fps = fixed_width_with_fps <= available_width; // Use <= to show if it fits exactly
|
||||
program_info_width + separator_width + fps_width +
|
||||
debug_separator_width + debug_width;
|
||||
let show_fps = fixed_width_with_fps <= available_width;
|
||||
|
||||
let remaining_width_for_dir = available_width.saturating_sub(
|
||||
mode_width + separator_width + // after mode
|
||||
separator_width + program_info_width + // after program_info
|
||||
if show_fps { separator_width + fps_width } else { 0 } // after fps
|
||||
mode_width + separator_width +
|
||||
separator_width + program_info_width +
|
||||
(if show_fps { separator_width + fps_width } else { 0 }) +
|
||||
debug_separator_width + debug_width,
|
||||
);
|
||||
|
||||
// Original directory display logic
|
||||
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
|
||||
display_dir // display_dir is already a String here
|
||||
display_dir
|
||||
} else {
|
||||
let dir_name = Path::new(current_dir) // Use original current_dir for path logic
|
||||
let dir_name = Path::new(current_dir)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(current_dir); // Fallback to current_dir if no filename
|
||||
.unwrap_or(current_dir);
|
||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
||||
dir_name.to_string()
|
||||
} else {
|
||||
@@ -61,10 +76,10 @@ pub fn render_status_line(
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate current content width based on what will be displayed
|
||||
let mut current_content_width = mode_width + separator_width +
|
||||
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
|
||||
separator_width + program_info_width;
|
||||
separator_width + program_info_width +
|
||||
debug_separator_width + debug_width;
|
||||
if show_fps {
|
||||
current_content_width += separator_width + fps_width;
|
||||
}
|
||||
@@ -82,12 +97,17 @@ pub fn render_status_line(
|
||||
line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
|
||||
}
|
||||
|
||||
// Calculate padding
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||
line_spans.push(Span::styled(debug_text, Style::default().fg(theme.accent)));
|
||||
}
|
||||
|
||||
let padding_needed = available_width.saturating_sub(current_content_width);
|
||||
if padding_needed > 0 {
|
||||
line_spans.push(Span::styled(
|
||||
" ".repeat(padding_needed),
|
||||
Style::default().bg(theme.bg), // Ensure padding uses background color
|
||||
Style::default().bg(theme.bg),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,28 +17,29 @@ pub fn render_form(
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
table_name: &str, // This parameter receives the correct table name
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
// Create Adresar card
|
||||
// Use the dynamic `table_name` parameter for the title instead of a hardcoded string.
|
||||
let card_title = format!(" {} ", table_name);
|
||||
|
||||
let adresar_card = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(" Adresar ")
|
||||
.title(card_title) // Use the dynamic title
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
f.render_widget(adresar_card, area);
|
||||
|
||||
// Define inner area
|
||||
let inner_area = area.inner(Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Create main layout
|
||||
let main_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -47,12 +48,11 @@ pub fn render_form(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Render count/position
|
||||
let count_position_text = if total_count == 0 && current_position == 1 {
|
||||
"Total: 0 | New Entry".to_string()
|
||||
} else if current_position > total_count && total_count > 0 {
|
||||
format!("Total: {} | New Entry ({})", total_count, current_position)
|
||||
} else if total_count == 0 && current_position > 1 { // Should not happen if logic is correct
|
||||
} else if total_count == 0 && current_position > 1 {
|
||||
format!("Total: 0 | New Entry ({})", current_position)
|
||||
}
|
||||
else {
|
||||
@@ -63,7 +63,6 @@ pub fn render_form(
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// Delegate input handling to canvas
|
||||
render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn render_buffer_list(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
buffer_state: &BufferState,
|
||||
app_state: &AppState, // Add this parameter
|
||||
app_state: &AppState,
|
||||
) {
|
||||
// --- Style Definitions ---
|
||||
let active_style = Style::default()
|
||||
@@ -39,8 +39,7 @@ pub fn render_buffer_list(
|
||||
let mut spans = Vec::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
// TODO: Replace with actual table name from server response
|
||||
let current_table_name = Some("2025_customer");
|
||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||
|
||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
||||
// Filter: Only process views matching the active layer
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ pub async fn handle_read_only_event(
|
||||
add_table_state: &mut AddTableState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
key_sequence_tracker: &mut KeySequenceTracker,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
edit_mode_cooldown: &mut bool,
|
||||
@@ -74,12 +72,10 @@ pub async fn handle_read_only_event(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
@@ -143,12 +139,10 @@ pub async fn handle_read_only_event(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||
crate::tui::functions::login::handle_action(action).await?
|
||||
} else if app_state.ui.show_add_table {
|
||||
add_table_ro::execute_action(
|
||||
@@ -177,7 +171,7 @@ pub async fn handle_read_only_event(
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login { // Handle login general actions
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
@@ -211,8 +205,6 @@ pub async fn handle_read_only_event(
|
||||
action,
|
||||
form_state,
|
||||
grpc_client,
|
||||
current_position,
|
||||
total_count,
|
||||
ideal_cursor_column,
|
||||
)
|
||||
.await?
|
||||
@@ -245,7 +237,7 @@ pub async fn handle_read_only_event(
|
||||
key_sequence_tracker,
|
||||
command_message,
|
||||
).await?
|
||||
} else if app_state.ui.show_login { // Handle login general actions
|
||||
} else if app_state.ui.show_login {
|
||||
auth_ro::execute_action(
|
||||
action,
|
||||
app_state,
|
||||
|
||||
@@ -82,6 +82,8 @@ impl TableDependencyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||
pub struct NavigationState {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
@@ -114,7 +116,7 @@ impl NavigationState {
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.graph = None;
|
||||
self.update_filtered_options(); // Initial filter with empty input
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
|
||||
@@ -123,7 +125,7 @@ impl NavigationState {
|
||||
self.graph = Some(graph);
|
||||
self.input.clear();
|
||||
self.current_path.clear();
|
||||
self.update_options_for_path(); // Initial options are root tables
|
||||
self.update_options_for_path();
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
@@ -145,7 +147,6 @@ impl NavigationState {
|
||||
NavigationType::TableTree => {
|
||||
if c == '/' {
|
||||
if !self.input.is_empty() {
|
||||
// Append current input to path
|
||||
if self.current_path.is_empty() {
|
||||
self.current_path = self.input.clone();
|
||||
} else {
|
||||
@@ -155,10 +156,9 @@ impl NavigationState {
|
||||
self.input.clear();
|
||||
self.update_options_for_path();
|
||||
}
|
||||
// If input is empty and char is '/', do nothing or define behavior
|
||||
} else {
|
||||
self.input.push(c);
|
||||
self.update_filtered_options(); // Filter current level options based on input
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,24 +172,15 @@ impl NavigationState {
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
if self.input.is_empty() {
|
||||
// If input is empty, try to go up in path
|
||||
if !self.current_path.is_empty() {
|
||||
if let Some(last_slash_idx) =
|
||||
self.current_path.rfind('/')
|
||||
{
|
||||
// Set input to the segment being removed from path
|
||||
self.input = self.current_path
|
||||
[last_slash_idx + 1..]
|
||||
.to_string();
|
||||
self.current_path =
|
||||
self.current_path[..last_slash_idx].to_string();
|
||||
if let Some(last_slash_idx) = self.current_path.rfind('/') {
|
||||
self.input = self.current_path[last_slash_idx + 1..].to_string();
|
||||
self.current_path = self.current_path[..last_slash_idx].to_string();
|
||||
} else {
|
||||
// Path was a single segment
|
||||
self.input = self.current_path.clone();
|
||||
self.current_path.clear();
|
||||
}
|
||||
self.update_options_for_path();
|
||||
// After path change, current input might match some options, so filter
|
||||
self.update_filtered_options();
|
||||
}
|
||||
} else {
|
||||
@@ -218,9 +209,7 @@ impl NavigationState {
|
||||
return;
|
||||
}
|
||||
self.selected_index = match self.selected_index {
|
||||
Some(current) if current >= self.filtered_options.len() - 1 => {
|
||||
Some(0)
|
||||
}
|
||||
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
|
||||
Some(current) => Some(current + 1),
|
||||
None => Some(0),
|
||||
};
|
||||
@@ -234,18 +223,11 @@ impl NavigationState {
|
||||
|
||||
pub fn autocomplete_selected(&mut self) {
|
||||
if let Some(selected_option_str) = self.get_selected_option_str() {
|
||||
// The current `self.input` is the text being typed for the current segment/filter.
|
||||
// We replace it with the full string of the selected option.
|
||||
self.input = selected_option_str.to_string();
|
||||
|
||||
// After updating the input, we need to re-filter the options.
|
||||
// This will typically result in the filtered_options containing only the
|
||||
// autocompleted item (or items that start with it, if any).
|
||||
self.update_filtered_options();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the string to display in the input line of the palette
|
||||
pub fn get_display_input(&self) -> String {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => self.input.clone(),
|
||||
@@ -259,11 +241,12 @@ impl NavigationState {
|
||||
}
|
||||
}
|
||||
|
||||
// Gets the full path of the currently selected item for TableTree, or input for FindFile
|
||||
// --- START FIX ---
|
||||
pub fn get_selected_value(&self) -> Option<String> {
|
||||
match self.navigation_type {
|
||||
NavigationType::FindFile => {
|
||||
if self.input.is_empty() { None } else { Some(self.input.clone()) }
|
||||
// Return the highlighted option, not the raw input buffer.
|
||||
self.get_selected_option_str().map(|s| s.to_string())
|
||||
}
|
||||
NavigationType::TableTree => {
|
||||
self.get_selected_option_str().map(|selected_name| {
|
||||
@@ -276,26 +259,23 @@ impl NavigationState {
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
// Update self.all_options based on current_path (for TableTree)
|
||||
fn update_options_for_path(&mut self) {
|
||||
if let NavigationType::TableTree = self.navigation_type {
|
||||
if let Some(graph) = &self.graph {
|
||||
self.all_options =
|
||||
graph.get_dependent_children(&self.current_path);
|
||||
self.all_options = graph.get_dependent_children(&self.current_path);
|
||||
} else {
|
||||
self.all_options.clear();
|
||||
}
|
||||
}
|
||||
// For FindFile, all_options is set once at activation.
|
||||
self.update_filtered_options();
|
||||
}
|
||||
|
||||
// Update self.filtered_options based on self.all_options and self.input
|
||||
fn update_filtered_options(&mut self) {
|
||||
let filter_text = match self.navigation_type {
|
||||
NavigationType::FindFile => &self.input,
|
||||
NavigationType::TableTree => &self.input, // For TableTree, input is the current segment being typed
|
||||
NavigationType::TableTree => &self.input,
|
||||
}
|
||||
.to_lowercase();
|
||||
|
||||
@@ -319,11 +299,12 @@ impl NavigationState {
|
||||
if self.filtered_options.is_empty() {
|
||||
self.selected_index = None;
|
||||
} else {
|
||||
self.selected_index = Some(0); // Default to selecting the first item
|
||||
self.selected_index = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn handle_command_navigation_event(
|
||||
navigation_state: &mut NavigationState,
|
||||
key: KeyEvent,
|
||||
@@ -338,51 +319,15 @@ pub async fn handle_command_navigation_event(
|
||||
navigation_state.deactivate();
|
||||
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_value) = navigation_state.get_selected_value() {
|
||||
let message = match navigation_state.navigation_type {
|
||||
NavigationType::FindFile => format!("Selected file: {}", selected_value),
|
||||
NavigationType::TableTree => format!("Selected table: {}", selected_value),
|
||||
};
|
||||
navigation_state.deactivate();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
// Enhanced Enter behavior for TableTree: if input is a valid partial path, try to navigate
|
||||
if navigation_state.navigation_type == NavigationType::TableTree && !navigation_state.input.is_empty() {
|
||||
// Check if current input is a prefix of any option or a full option name
|
||||
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
||||
if navigation_state.input == selected_opt_str {
|
||||
// Input exactly matches the selected option, try to navigate
|
||||
let input_before_slash = navigation_state.input.clone();
|
||||
navigation_state.add_char('/');
|
||||
|
||||
if navigation_state.input.is_empty() {
|
||||
return Ok(EventOutcome::Ok(format!("Navigated to: {}/", input_before_slash)));
|
||||
} else {
|
||||
return Ok(EventOutcome::Ok(format!("Selected leaf: {}", input_before_slash)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(EventOutcome::Ok("No valid selection to confirm or navigate".to_string()))
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
||||
// Scenario 1: Input already exactly matches the selected option
|
||||
if navigation_state.input == selected_opt_str {
|
||||
// Only attempt to navigate deeper for TableTree mode
|
||||
if navigation_state.navigation_type == NavigationType::TableTree {
|
||||
let path_before_nav = navigation_state.current_path.clone();
|
||||
let input_before_nav = navigation_state.input.clone();
|
||||
|
||||
navigation_state.add_char('/');
|
||||
|
||||
if navigation_state.input.is_empty() &&
|
||||
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty()) {
|
||||
// Navigation successful
|
||||
} else {
|
||||
// Revert if navigation didn't happen
|
||||
if !(navigation_state.input.is_empty() &&
|
||||
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
|
||||
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
|
||||
navigation_state.input = input_before_nav;
|
||||
if navigation_state.current_path != path_before_nav {
|
||||
@@ -393,20 +338,11 @@ pub async fn handle_command_navigation_event(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Scenario 2: Input is a partial match - autocomplete
|
||||
navigation_state.autocomplete_selected();
|
||||
}
|
||||
}
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Up => {
|
||||
navigation_state.move_up();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Down => {
|
||||
navigation_state.move_down();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
navigation_state.remove_char();
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
@@ -428,12 +364,24 @@ pub async fn handle_command_navigation_event(
|
||||
}
|
||||
"select" => {
|
||||
if let Some(selected_value) = navigation_state.get_selected_value() {
|
||||
let message = match navigation_state.navigation_type {
|
||||
NavigationType::FindFile => format!("Selected file: {}", selected_value),
|
||||
NavigationType::TableTree => format!("Selected table: {}", selected_value),
|
||||
let outcome = match navigation_state.navigation_type {
|
||||
// --- START FIX ---
|
||||
NavigationType::FindFile => {
|
||||
// The purpose of this palette is to select a table.
|
||||
// Emit a TableSelected event instead of a generic Ok message.
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
NavigationType::TableTree => {
|
||||
EventOutcome::TableSelected {
|
||||
path: selected_value,
|
||||
}
|
||||
}
|
||||
};
|
||||
navigation_state.deactivate();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("No selection".to_string()))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
|
||||
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
|
||||
use crate::functions::modes::navigation::{add_table_nav, admin_nav};
|
||||
use crate::modes::general::command_navigation::{
|
||||
handle_command_navigation_event, NavigationState, TableDependencyGraph,
|
||||
handle_command_navigation_event, NavigationState,
|
||||
};
|
||||
use crate::modes::{
|
||||
canvas::{common_mode, edit, read_only},
|
||||
@@ -52,6 +52,7 @@ pub enum EventOutcome {
|
||||
Exit(String),
|
||||
DataSaved(SaveOutcome, String),
|
||||
ButtonSelected { context: UiContext, index: usize },
|
||||
TableSelected { path: String },
|
||||
}
|
||||
|
||||
impl EventOutcome {
|
||||
@@ -132,7 +133,6 @@ impl EventHandler {
|
||||
) -> Result<EventOutcome> {
|
||||
let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
||||
|
||||
// Handle active command navigation first
|
||||
if current_mode == AppMode::General && self.navigation_state.active {
|
||||
if let Event::Key(key_event) = event {
|
||||
let outcome =
|
||||
@@ -154,43 +154,26 @@ impl EventHandler {
|
||||
|
||||
let current_view = {
|
||||
let ui = &app_state.ui;
|
||||
if ui.show_intro {
|
||||
AppView::Intro
|
||||
} else if ui.show_login {
|
||||
AppView::Login
|
||||
} else if ui.show_register {
|
||||
AppView::Register
|
||||
} else if ui.show_admin {
|
||||
AppView::Admin
|
||||
} else if ui.show_add_logic {
|
||||
AppView::AddLogic
|
||||
} else if ui.show_add_table {
|
||||
AppView::AddTable
|
||||
} else if ui.show_form {
|
||||
AppView::Form
|
||||
} else {
|
||||
AppView::Scratch
|
||||
}
|
||||
if ui.show_intro { AppView::Intro }
|
||||
else if ui.show_login { AppView::Login }
|
||||
else if ui.show_register { AppView::Register }
|
||||
else if ui.show_admin { AppView::Admin }
|
||||
else if ui.show_add_logic { AppView::AddLogic }
|
||||
else if ui.show_add_table { AppView::AddTable }
|
||||
else if ui.show_form { AppView::Form }
|
||||
else { AppView::Scratch }
|
||||
};
|
||||
buffer_state.update_history(current_view);
|
||||
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
if let Event::Key(key_event) = event {
|
||||
if let Some(dialog_result) = dialog::handle_dialog_event(
|
||||
&Event::Key(key_event),
|
||||
config,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
buffer_state,
|
||||
admin_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
&Event::Key(key_event), config, app_state, login_state,
|
||||
register_state, buffer_state, admin_state,
|
||||
).await {
|
||||
return dialog_result;
|
||||
}
|
||||
} else if let Event::Resize(_, _) = event {
|
||||
// Handle resize if needed
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
@@ -200,34 +183,16 @@ impl EventHandler {
|
||||
let modifiers = key_event.modifiers;
|
||||
|
||||
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
|
||||
let message = format!(
|
||||
"Sidebar {}",
|
||||
if app_state.ui.show_sidebar {
|
||||
"shown"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
);
|
||||
let message = format!("Sidebar {}", if app_state.ui.show_sidebar { "shown" } else { "hidden" });
|
||||
return Ok(EventOutcome::Ok(message));
|
||||
}
|
||||
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
|
||||
let message = format!(
|
||||
"Buffer {}",
|
||||
if app_state.ui.show_buffer_list {
|
||||
"shown"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
);
|
||||
let message = format!("Buffer {}", if app_state.ui.show_buffer_list { "shown" } else { "hidden" });
|
||||
return Ok(EventOutcome::Ok(message));
|
||||
}
|
||||
|
||||
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
|
||||
if let Some(action) = config.get_action_for_key_in_mode(
|
||||
&config.keybindings.global,
|
||||
key_code,
|
||||
modifiers,
|
||||
) {
|
||||
if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.global, key_code, modifiers) {
|
||||
match action {
|
||||
"next_buffer" => {
|
||||
if buffer::switch_buffer(buffer_state, true) {
|
||||
@@ -236,15 +201,12 @@ impl EventHandler {
|
||||
}
|
||||
"previous_buffer" => {
|
||||
if buffer::switch_buffer(buffer_state, false) {
|
||||
return Ok(EventOutcome::Ok(
|
||||
"Switched to previous buffer".to_string(),
|
||||
));
|
||||
return Ok(EventOutcome::Ok("Switched to previous buffer".to_string()));
|
||||
}
|
||||
}
|
||||
"close_buffer" => {
|
||||
let current_table_name = Some("2025_customer");
|
||||
let message =
|
||||
buffer_state.close_buffer_with_intro_fallback(current_table_name);
|
||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||
let message = buffer_state.close_buffer_with_intro_fallback(current_table_name);
|
||||
return Ok(EventOutcome::Ok(message));
|
||||
}
|
||||
_ => {}
|
||||
@@ -255,14 +217,7 @@ impl EventHandler {
|
||||
match current_mode {
|
||||
AppMode::General => {
|
||||
if app_state.ui.show_admin && auth_state.role.as_deref() == Some("admin") {
|
||||
if admin_nav::handle_admin_navigation(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
admin_state,
|
||||
buffer_state,
|
||||
&mut self.command_message,
|
||||
) {
|
||||
if admin_nav::handle_admin_navigation(key_event, config, app_state, admin_state, buffer_state, &mut self.command_message) {
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
}
|
||||
@@ -270,17 +225,9 @@ impl EventHandler {
|
||||
if app_state.ui.show_add_logic {
|
||||
let client_clone = grpc_client.clone();
|
||||
let sender_clone = self.save_logic_result_sender.clone();
|
||||
|
||||
if add_logic_nav::handle_add_logic_navigation(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut self.is_edit_mode,
|
||||
buffer_state,
|
||||
client_clone,
|
||||
sender_clone,
|
||||
&mut self.command_message,
|
||||
key_event, config, app_state, &mut admin_state.add_logic_state,
|
||||
&mut self.is_edit_mode, buffer_state, client_clone, sender_clone, &mut self.command_message,
|
||||
) {
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
@@ -289,85 +236,45 @@ impl EventHandler {
|
||||
if app_state.ui.show_add_table {
|
||||
let client_clone = grpc_client.clone();
|
||||
let sender_clone = self.save_table_result_sender.clone();
|
||||
|
||||
if add_table_nav::handle_add_table_navigation(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
&mut admin_state.add_table_state,
|
||||
client_clone,
|
||||
sender_clone,
|
||||
&mut self.command_message,
|
||||
key_event, config, app_state, &mut admin_state.add_table_state,
|
||||
client_clone, sender_clone, &mut self.command_message,
|
||||
) {
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let nav_outcome = navigation::handle_navigation_event(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
intro_state,
|
||||
admin_state,
|
||||
&mut self.command_mode,
|
||||
&mut self.command_input,
|
||||
&mut self.command_message,
|
||||
&mut self.navigation_state,
|
||||
)
|
||||
.await;
|
||||
key_event, config, form_state, app_state, login_state, register_state,
|
||||
intro_state, admin_state, &mut self.command_mode, &mut self.command_input,
|
||||
&mut self.command_message, &mut self.navigation_state,
|
||||
).await;
|
||||
|
||||
match nav_outcome {
|
||||
Ok(EventOutcome::ButtonSelected { context, index }) => {
|
||||
let message = match context {
|
||||
UiContext::Intro => {
|
||||
intro::handle_intro_selection(app_state, buffer_state, index);
|
||||
if app_state.ui.show_admin {
|
||||
if !app_state.profile_tree.profiles.is_empty() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
if app_state.ui.show_admin && !app_state.profile_tree.profiles.is_empty() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
format!("Intro Option {} selected", index)
|
||||
}
|
||||
UiContext::Login => match index {
|
||||
0 => login::initiate_login(
|
||||
login_state,
|
||||
app_state,
|
||||
self.auth_client.clone(),
|
||||
self.login_result_sender.clone(),
|
||||
),
|
||||
1 => {
|
||||
login::back_to_main(login_state, app_state, buffer_state)
|
||||
.await
|
||||
}
|
||||
0 => login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()),
|
||||
1 => login::back_to_main(login_state, app_state, buffer_state).await,
|
||||
_ => "Invalid Login Option".to_string(),
|
||||
},
|
||||
UiContext::Register => match index {
|
||||
0 => register::initiate_registration(
|
||||
register_state,
|
||||
app_state,
|
||||
self.auth_client.clone(),
|
||||
self.register_result_sender.clone(),
|
||||
),
|
||||
1 => {
|
||||
register::back_to_login(
|
||||
register_state,
|
||||
app_state,
|
||||
buffer_state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
0 => register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone()),
|
||||
1 => register::back_to_login(register_state, app_state, buffer_state).await,
|
||||
_ => "Invalid Login Option".to_string(),
|
||||
},
|
||||
UiContext::Admin => {
|
||||
admin::handle_admin_selection(app_state, admin_state);
|
||||
format!("Admin Option {} selected", index)
|
||||
}
|
||||
UiContext::Dialog => {
|
||||
"Internal error: Unexpected dialog state".to_string()
|
||||
}
|
||||
UiContext::Dialog => "Internal error: Unexpected dialog state".to_string(),
|
||||
};
|
||||
return Ok(EventOutcome::Ok(message));
|
||||
}
|
||||
@@ -376,74 +283,27 @@ impl EventHandler {
|
||||
}
|
||||
|
||||
AppMode::ReadOnly => {
|
||||
if config.get_read_only_action_for_key(key_code, modifiers)
|
||||
== Some("enter_highlight_mode_linewise")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = if app_state.ui.show_login {
|
||||
login_state.current_field()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_field()
|
||||
} else {
|
||||
form_state.current_field()
|
||||
};
|
||||
self.highlight_state = HighlightState::Linewise {
|
||||
anchor_line: current_field_index,
|
||||
};
|
||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") && ModeManager::can_enter_highlight_mode(current_mode) {
|
||||
let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
|
||||
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index };
|
||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers)
|
||||
== Some("enter_highlight_mode")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = if app_state.ui.show_login {
|
||||
login_state.current_field()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_field()
|
||||
} else {
|
||||
form_state.current_field()
|
||||
};
|
||||
let current_cursor_pos = if app_state.ui.show_login {
|
||||
login_state.current_cursor_pos()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_cursor_pos()
|
||||
} else {
|
||||
form_state.current_cursor_pos()
|
||||
};
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") && ModeManager::can_enter_highlight_mode(current_mode) {
|
||||
let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
|
||||
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||
let anchor = (current_field_index, current_cursor_pos);
|
||||
self.highlight_state = HighlightState::Characterwise { anchor };
|
||||
self.command_message = "-- HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config
|
||||
.get_read_only_action_for_key(key_code, modifiers)
|
||||
.as_deref()
|
||||
== Some("enter_edit_mode_before")
|
||||
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||
{
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") && ModeManager::can_enter_edit_mode(current_mode) {
|
||||
self.is_edit_mode = true;
|
||||
self.edit_mode_cooldown = true;
|
||||
self.command_message = "Edit mode".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config
|
||||
.get_read_only_action_for_key(key_code, modifiers)
|
||||
.as_deref()
|
||||
== Some("enter_edit_mode_after")
|
||||
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||
{
|
||||
let current_input = if app_state.ui.show_login || app_state.ui.show_register
|
||||
{
|
||||
login_state.get_current_input()
|
||||
} else {
|
||||
form_state.get_current_input()
|
||||
};
|
||||
let current_cursor_pos =
|
||||
if app_state.ui.show_login || app_state.ui.show_register {
|
||||
login_state.current_cursor_pos()
|
||||
} else {
|
||||
form_state.current_cursor_pos()
|
||||
};
|
||||
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") && ModeManager::can_enter_edit_mode(current_mode) {
|
||||
let current_input = if app_state.ui.show_login || app_state.ui.show_register { login_state.get_current_input() } else { form_state.get_current_input() };
|
||||
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register { login_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||
if app_state.ui.show_login || app_state.ui.show_register {
|
||||
login_state.set_current_cursor_pos(current_cursor_pos + 1);
|
||||
@@ -459,10 +319,7 @@ impl EventHandler {
|
||||
self.command_message = "Edit mode (after cursor)".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers)
|
||||
== Some("enter_command_mode")
|
||||
&& ModeManager::can_enter_command_mode(current_mode)
|
||||
{
|
||||
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
|
||||
self.command_mode = true;
|
||||
self.command_input.clear();
|
||||
self.command_message.clear();
|
||||
@@ -473,26 +330,14 @@ impl EventHandler {
|
||||
match action {
|
||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||
return common_mode::handle_core_action(
|
||||
action,
|
||||
form_state,
|
||||
auth_state,
|
||||
login_state,
|
||||
register_state,
|
||||
grpc_client,
|
||||
&mut self.auth_client,
|
||||
terminal,
|
||||
app_state,
|
||||
)
|
||||
.await;
|
||||
action, form_state, auth_state, login_state, register_state,
|
||||
grpc_client, &mut self.auth_client, terminal, app_state,
|
||||
).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracting values to avoid borrow conflicts
|
||||
let mut current_position = form_state.current_position;
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
let (_should_exit, message) = read_only::handle_read_only_event(
|
||||
app_state,
|
||||
key_event,
|
||||
@@ -503,8 +348,7 @@ impl EventHandler {
|
||||
&mut admin_state.add_table_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut self.key_sequence_tracker,
|
||||
&mut current_position,
|
||||
total_count,
|
||||
// No more current_position or total_count arguments
|
||||
grpc_client,
|
||||
&mut self.command_message,
|
||||
&mut self.edit_mode_cooldown,
|
||||
@@ -515,30 +359,20 @@ impl EventHandler {
|
||||
}
|
||||
|
||||
AppMode::Highlight => {
|
||||
if config.get_highlight_action_for_key(key_code, modifiers)
|
||||
== Some("exit_highlight_mode")
|
||||
{
|
||||
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
|
||||
self.highlight_state = HighlightState::Off;
|
||||
self.command_message = "Exited highlight mode".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config.get_highlight_action_for_key(key_code, modifiers)
|
||||
== Some("enter_highlight_mode_linewise")
|
||||
{
|
||||
} else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
|
||||
if let HighlightState::Characterwise { anchor } = self.highlight_state {
|
||||
self.highlight_state = HighlightState::Linewise {
|
||||
anchor_line: anchor.0,
|
||||
};
|
||||
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
|
||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
|
||||
// Extracting values to avoid borrow conflicts
|
||||
let mut current_position = form_state.current_position;
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
let (_should_exit, message) = read_only::handle_read_only_event(
|
||||
app_state,
|
||||
key_event,
|
||||
@@ -549,8 +383,6 @@ impl EventHandler {
|
||||
&mut admin_state.add_table_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
&mut self.key_sequence_tracker,
|
||||
&mut current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
&mut self.command_message,
|
||||
&mut self.edit_mode_cooldown,
|
||||
@@ -565,99 +397,45 @@ impl EventHandler {
|
||||
match action {
|
||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||
return common_mode::handle_core_action(
|
||||
action,
|
||||
form_state,
|
||||
auth_state,
|
||||
login_state,
|
||||
register_state,
|
||||
grpc_client,
|
||||
&mut self.auth_client,
|
||||
terminal,
|
||||
app_state,
|
||||
)
|
||||
.await;
|
||||
action, form_state, auth_state, login_state, register_state,
|
||||
grpc_client, &mut self.auth_client, terminal, app_state,
|
||||
).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracting values to avoid borrow conflicts
|
||||
let mut current_position = form_state.current_position;
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
let edit_result = edit::handle_edit_event(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
admin_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
&mut current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
app_state,
|
||||
)
|
||||
.await;
|
||||
key_event, config, form_state, login_state, register_state, admin_state,
|
||||
&mut self.ideal_cursor_column, &mut current_position, total_count,
|
||||
grpc_client, app_state,
|
||||
).await;
|
||||
|
||||
match edit_result {
|
||||
Ok(edit::EditEventOutcome::ExitEditMode) => {
|
||||
self.is_edit_mode = false;
|
||||
self.edit_mode_cooldown = true;
|
||||
let has_changes = if app_state.ui.show_login {
|
||||
login_state.has_unsaved_changes()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes()
|
||||
} else {
|
||||
form_state.has_unsaved_changes()
|
||||
};
|
||||
self.command_message = if has_changes {
|
||||
"Exited edit mode (unsaved changes remain)".to_string()
|
||||
} else {
|
||||
"Read-only mode".to_string()
|
||||
};
|
||||
let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() } else if app_state.ui.show_register { register_state.has_unsaved_changes() } else { form_state.has_unsaved_changes() };
|
||||
self.command_message = if has_changes { "Exited edit mode (unsaved changes remain)".to_string() } else { "Read-only mode".to_string() };
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
let current_input = if app_state.ui.show_login {
|
||||
login_state.get_current_input()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.get_current_input()
|
||||
} else {
|
||||
form_state.get_current_input()
|
||||
};
|
||||
let current_cursor_pos = if app_state.ui.show_login {
|
||||
login_state.current_cursor_pos()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.current_cursor_pos()
|
||||
} else {
|
||||
form_state.current_cursor_pos()
|
||||
};
|
||||
if !current_input.is_empty()
|
||||
&& current_cursor_pos >= current_input.len()
|
||||
{
|
||||
let current_input = if app_state.ui.show_login { login_state.get_current_input() } else if app_state.ui.show_register { register_state.get_current_input() } else { form_state.get_current_input() };
|
||||
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
||||
let new_pos = current_input.len() - 1;
|
||||
let target_state: &mut dyn CanvasState = if app_state.ui.show_login
|
||||
{
|
||||
login_state
|
||||
} else if app_state.ui.show_register {
|
||||
register_state
|
||||
} else {
|
||||
form_state
|
||||
};
|
||||
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
|
||||
target_state.set_current_cursor_pos(new_pos);
|
||||
self.ideal_cursor_column = new_pos;
|
||||
}
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
Ok(edit::EditEventOutcome::Message(msg)) => {
|
||||
if !msg.is_empty() {
|
||||
self.command_message = msg;
|
||||
}
|
||||
if !msg.is_empty() { self.command_message = msg; }
|
||||
self.key_sequence_tracker.reset();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(e) => { return Err(e.into()); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,31 +449,14 @@ impl EventHandler {
|
||||
}
|
||||
|
||||
if config.is_command_execute(key_code, modifiers) {
|
||||
|
||||
// Extracting values to avoid borrow conflicts
|
||||
let mut current_position = form_state.current_position;
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
let outcome = command_mode::handle_command_event(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state,
|
||||
&mut self.command_input,
|
||||
&mut self.command_message,
|
||||
grpc_client,
|
||||
command_handler,
|
||||
terminal,
|
||||
&mut current_position,
|
||||
total_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update form_state with potentially changed position
|
||||
key_event, config, app_state, login_state, register_state, form_state,
|
||||
&mut self.command_input, &mut self.command_message, grpc_client,
|
||||
command_handler, terminal, &mut current_position, total_count,
|
||||
).await?;
|
||||
form_state.current_position = current_position;
|
||||
|
||||
self.command_mode = false;
|
||||
self.key_sequence_tracker.reset();
|
||||
let new_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
||||
@@ -711,40 +472,39 @@ impl EventHandler {
|
||||
|
||||
if let KeyCode::Char(c) = key_code {
|
||||
if c == 'f' {
|
||||
// Assuming 'f' is part of the sequence, e.g. ":f" or " f"
|
||||
self.key_sequence_tracker.add_key(key_code);
|
||||
let sequence = self.key_sequence_tracker.get_sequence();
|
||||
|
||||
if config.matches_key_sequence_generalized(&sequence)
|
||||
== Some("find_file_palette_toggle")
|
||||
{
|
||||
if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") {
|
||||
if app_state.ui.show_form || app_state.ui.show_intro {
|
||||
// Build table graph from profile data
|
||||
let graph = TableDependencyGraph::from_profile_tree(
|
||||
&app_state.profile_tree,
|
||||
);
|
||||
// --- START FIX ---
|
||||
let mut all_table_paths: Vec<String> = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.flat_map(|profile| {
|
||||
profile.tables.iter().map(move |table| {
|
||||
format!("{}/{}", profile.name, table.name)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
all_table_paths.sort();
|
||||
|
||||
// Activate navigation with graph
|
||||
self.navigation_state.activate_table_tree(graph);
|
||||
self.navigation_state.activate_find_file(all_table_paths);
|
||||
// --- END FIX ---
|
||||
|
||||
self.command_mode = false; // Exit command mode
|
||||
self.command_mode = false;
|
||||
self.command_input.clear();
|
||||
// Message is set by render_find_file_palette's prompt_prefix
|
||||
self.command_message.clear(); // Clear old command message
|
||||
self.command_message.clear();
|
||||
self.key_sequence_tracker.reset();
|
||||
// ModeManager will derive AppMode::General due to navigation_state.active
|
||||
// app_state.update_mode(AppMode::General); // This will be handled by ModeManager
|
||||
return Ok(EventOutcome::Ok(
|
||||
"Table tree palette activated".to_string(),
|
||||
));
|
||||
return Ok(EventOutcome::Ok("Table selection palette activated".to_string()));
|
||||
} else {
|
||||
self.key_sequence_tracker.reset();
|
||||
self.command_input.push('f');
|
||||
if sequence.len() > 1 && sequence[0] == KeyCode::Char('f') {
|
||||
self.command_input.push('f');
|
||||
}
|
||||
self.command_message =
|
||||
"Find File not available in this view.".to_string();
|
||||
self.command_message = "Find File not available in this view.".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ pub async fn handle_highlight_event(
|
||||
&mut admin_state.add_table_state,
|
||||
&mut admin_state.add_logic_state,
|
||||
key_sequence_tracker,
|
||||
current_position,
|
||||
total_count,
|
||||
grpc_client,
|
||||
command_message, // Pass the message buffer
|
||||
edit_mode_cooldown,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::state::pages::form::FormState;
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub struct UiService;
|
||||
@@ -82,7 +83,7 @@ impl UiService {
|
||||
.into_iter()
|
||||
.map(|col| col.name)
|
||||
.collect();
|
||||
Ok(column_names)
|
||||
Ok(filter_user_columns(column_names))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
@@ -90,12 +91,10 @@ impl UiService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MODIFIED: To set initial view table in AppState and return initial column names
|
||||
|
||||
pub async fn initialize_app_state_and_form(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
// Returns (initial_profile, initial_table, initial_columns)
|
||||
) -> Result<(String, String, Vec<String>)> {
|
||||
let profile_tree = grpc_client
|
||||
.get_profile_tree()
|
||||
@@ -104,7 +103,6 @@ impl UiService {
|
||||
app_state.profile_tree = profile_tree;
|
||||
|
||||
// Determine initial table to load (e.g., first table of first profile, or a default)
|
||||
// For now, let's hardcode a default for simplicity, but this should be more dynamic
|
||||
let initial_profile_name = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
@@ -141,10 +139,11 @@ impl UiService {
|
||||
.map(|col| col.name.clone())
|
||||
.collect();
|
||||
|
||||
Ok((initial_profile_name, initial_table_name, column_names))
|
||||
let filtered_columns = filter_user_columns(column_names);
|
||||
|
||||
Ok((initial_profile_name, initial_table_name, filtered_columns))
|
||||
}
|
||||
|
||||
// NEW: Fetches and sets count for the current table in FormState
|
||||
pub async fn fetch_and_set_table_count(
|
||||
grpc_client: &mut GrpcClient,
|
||||
form_state: &mut FormState,
|
||||
@@ -161,35 +160,26 @@ impl UiService {
|
||||
))?;
|
||||
form_state.total_count = total_count;
|
||||
|
||||
// Set initial position: if table has items, point to first, else point to new entry
|
||||
if total_count > 0 {
|
||||
form_state.current_position = 1;
|
||||
form_state.current_position = total_count;
|
||||
} else {
|
||||
form_state.current_position = 1; // For a new entry in an empty table
|
||||
form_state.current_position = 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// MODIFIED: Generic table data loading
|
||||
pub async fn load_table_data_by_position(
|
||||
grpc_client: &mut GrpcClient,
|
||||
form_state: &mut FormState, // Takes &mut FormState to update it
|
||||
// position is now read from form_state.current_position
|
||||
form_state: &mut FormState,
|
||||
) -> Result<String> {
|
||||
// Ensure current_position is valid before fetching
|
||||
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
|
||||
// This indicates a "new entry" state, no data to load from server.
|
||||
// The caller should handle this by calling form_state.reset_to_empty()
|
||||
// or ensuring this function isn't called for a new entry position.
|
||||
// For now, let's assume reset_to_empty was called if needed.
|
||||
form_state.reset_to_empty(); // Ensure fields are clear for new entry
|
||||
form_state.reset_to_empty();
|
||||
return Ok(format!(
|
||||
"New entry mode for table {}.{}",
|
||||
form_state.profile_name, form_state.table_name
|
||||
));
|
||||
}
|
||||
if form_state.total_count == 0 && form_state.current_position == 1 {
|
||||
// Table is empty, this is the position for a new entry
|
||||
form_state.reset_to_empty();
|
||||
return Ok(format!(
|
||||
"New entry mode for empty table {}.{}",
|
||||
@@ -197,7 +187,6 @@ impl UiService {
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
match grpc_client
|
||||
.get_table_data_by_position(
|
||||
form_state.profile_name.clone(),
|
||||
@@ -207,8 +196,8 @@ impl UiService {
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
form_state.update_from_response(&response.data);
|
||||
// ID, values, current_field, current_cursor_pos, has_unsaved_changes are set by update_from_response
|
||||
// FIX: Pass the current position as the second argument
|
||||
form_state.update_from_response(&response.data, form_state.current_position);
|
||||
Ok(format!(
|
||||
"Loaded entry {}/{} for table {}.{}",
|
||||
form_state.current_position,
|
||||
@@ -218,9 +207,6 @@ impl UiService {
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
// If loading fails (e.g., record deleted, network error), what should happen?
|
||||
// Maybe reset to a new entry state or show an error and keep current data.
|
||||
// For now, log error and return error message.
|
||||
tracing::error!(
|
||||
"Error loading entry {} for table {}.{}: {}",
|
||||
form_state.current_position,
|
||||
@@ -228,8 +214,6 @@ impl UiService {
|
||||
form_state.table_name,
|
||||
e
|
||||
);
|
||||
// Potentially clear form or revert to a safe state
|
||||
// form_state.reset_to_empty();
|
||||
Err(anyhow::anyhow!(
|
||||
"Error loading entry {}: {}",
|
||||
form_state.current_position,
|
||||
@@ -239,27 +223,20 @@ impl UiService {
|
||||
}
|
||||
}
|
||||
|
||||
// MODIFIED: To work with FormState's count and position
|
||||
pub async fn handle_save_outcome(
|
||||
save_outcome: SaveOutcome,
|
||||
_grpc_client: &mut GrpcClient, // May not be needed if count is fetched separately
|
||||
_app_state: &mut AppState, // May not be needed directly
|
||||
_grpc_client: &mut GrpcClient,
|
||||
_app_state: &mut AppState,
|
||||
form_state: &mut FormState,
|
||||
) -> Result<()> {
|
||||
match save_outcome {
|
||||
SaveOutcome::CreatedNew(new_id) => {
|
||||
// form_state.total_count and form_state.current_position should have been updated
|
||||
// by the `save` function itself.
|
||||
// Ensure form_state.id is set.
|
||||
form_state.id = new_id;
|
||||
// Potentially, re-fetch count to be absolutely sure, but save should be authoritative.
|
||||
// UiService::fetch_and_set_table_count(grpc_client, form_state).await?;
|
||||
}
|
||||
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
|
||||
// No changes to total_count or current_position needed from here.
|
||||
// No action needed
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct AppState {
|
||||
|
||||
// UI preferences
|
||||
pub ui: UiState,
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
pub debug_info: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -61,6 +64,9 @@ impl AppState {
|
||||
focused_button_index: 0,
|
||||
pending_table_structure_fetch: None,
|
||||
ui: UiState::default(),
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
debug_info: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +139,7 @@ impl AppState {
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.focus_outside_canvas = false;
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
|
||||
/// Sets the active button index, wrapping around if necessary.
|
||||
|
||||
@@ -23,7 +23,9 @@ pub struct FormState {
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
// MODIFIED constructor
|
||||
/// Creates a new, empty FormState for a given table.
|
||||
/// The position defaults to 1, representing either the first record
|
||||
/// or the position for a new entry if the table is empty.
|
||||
pub fn new(
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
@@ -34,8 +36,9 @@ impl FormState {
|
||||
id: 0, // Default to 0, indicating a new or unloaded record
|
||||
profile_name,
|
||||
table_name,
|
||||
total_count: 0, // Will be fetched after initialization
|
||||
current_position: 0, // Will be set after count is fetched (e.g., 1 or total_count + 1)
|
||||
total_count: 0, // Will be fetched after initialization
|
||||
// FIX: Default to 1. A position of 0 is an invalid state.
|
||||
current_position: 1,
|
||||
fields,
|
||||
values,
|
||||
current_field: 0,
|
||||
@@ -51,7 +54,6 @@ impl FormState {
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
// total_count and current_position are now part of self
|
||||
) {
|
||||
let fields_str_slice: Vec<&str> =
|
||||
self.fields.iter().map(|s| s.as_str()).collect();
|
||||
@@ -64,24 +66,24 @@ impl FormState {
|
||||
&fields_str_slice,
|
||||
&self.current_field,
|
||||
&values_str_slice,
|
||||
&self.table_name,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
self.total_count, // MODIFIED: Use self.total_count
|
||||
self.current_position, // MODIFIED: Use self.current_position
|
||||
self.total_count,
|
||||
self.current_position,
|
||||
);
|
||||
}
|
||||
|
||||
// MODIFIED: Reset now also considers table context for counts
|
||||
/// Resets the form to a state for creating a new entry.
|
||||
/// It clears all values and sets the position to be one after the last record.
|
||||
pub fn reset_to_empty(&mut self) {
|
||||
self.id = 0;
|
||||
self.values.iter_mut().for_each(|v| v.clear());
|
||||
self.current_field = 0;
|
||||
self.current_cursor_pos = 0;
|
||||
self.has_unsaved_changes = false;
|
||||
// current_position should be set to total_count + 1 for a new entry
|
||||
// This might be better handled by the logic that calls reset_to_empty
|
||||
// For now, let's ensure it's consistent with a "new" state.
|
||||
// Set the position for a new entry.
|
||||
if self.total_count > 0 {
|
||||
self.current_position = self.total_count + 1;
|
||||
} else {
|
||||
@@ -102,41 +104,45 @@ impl FormState {
|
||||
.expect("Invalid current_field index")
|
||||
}
|
||||
|
||||
// MODIFIED: Update from a generic HashMap response
|
||||
/// Updates the form's values from a data response and sets its position.
|
||||
/// This is the single source of truth for populating the form after a data fetch.
|
||||
pub fn update_from_response(
|
||||
&mut self,
|
||||
response_data: &HashMap<String, String>,
|
||||
// FIX: Add new_position to make this method authoritative.
|
||||
new_position: u64,
|
||||
) {
|
||||
self.values = self.fields
|
||||
.iter()
|
||||
.map(|field_name| {
|
||||
response_data.get(field_name).cloned().unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
// Create a new vector for the values, ensuring they are in the correct order.
|
||||
self.values = self.fields.iter().map(|field_from_schema| {
|
||||
// For each field from our schema, find the corresponding key in the
|
||||
// response data by doing a case-insensitive comparison.
|
||||
response_data
|
||||
.iter()
|
||||
.find(|(key_from_data, _)| key_from_data.eq_ignore_ascii_case(field_from_schema))
|
||||
.map(|(_, value)| value.clone()) // If found, clone its value.
|
||||
.unwrap_or_default() // If not found, use an empty string.
|
||||
}).collect();
|
||||
|
||||
if let Some(id_str) = response_data.get("id") {
|
||||
match id_str.parse::<i64>() {
|
||||
Ok(parsed_id) => self.id = parsed_id,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to parse 'id' field '{}' for table {}.{}: {}",
|
||||
id_str,
|
||||
self.profile_name,
|
||||
self.table_name,
|
||||
e
|
||||
);
|
||||
self.id = 0; // Default to 0 if parsing fails
|
||||
}
|
||||
// Now, do the same case-insensitive lookup for the 'id' field.
|
||||
let id_str_opt = response_data
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
|
||||
.map(|(_, v)| v);
|
||||
|
||||
if let Some(id_str) = id_str_opt {
|
||||
if let Ok(parsed_id) = id_str.parse::<i64>() {
|
||||
self.id = parsed_id;
|
||||
} else {
|
||||
tracing::error!( "Failed to parse 'id' field '{}' for table {}.{}", id_str, self.profile_name, self.table_name);
|
||||
self.id = 0;
|
||||
}
|
||||
} else {
|
||||
// If no ID is present, it might be a new record structure or an error
|
||||
// For now, assume it means the record doesn't have an ID from the server yet
|
||||
self.id = 0;
|
||||
}
|
||||
|
||||
// FIX: Set the position from the provided parameter.
|
||||
self.current_position = new_position;
|
||||
self.has_unsaved_changes = false;
|
||||
// current_field and current_cursor_pos might need resetting or adjusting
|
||||
// depending on the desired behavior after loading data.
|
||||
// For now, let's reset current_field to 0.
|
||||
self.current_field = 0;
|
||||
self.current_cursor_pos = 0;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ pub async fn save(
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
|
||||
let data_map: HashMap<String, String> = form_state
|
||||
.fields
|
||||
.iter()
|
||||
let data_map: HashMap<String, String> = form_state.fields.iter()
|
||||
.zip(form_state.values.iter())
|
||||
.map(|(field, value)| (field.clone(), value.clone()))
|
||||
.collect();
|
||||
@@ -126,6 +124,8 @@ pub async fn revert(
|
||||
form_state.table_name
|
||||
))?;
|
||||
|
||||
form_state.update_from_response(&response.data);
|
||||
// FIX: Pass the current position as the second argument
|
||||
form_state.update_from_response(&response.data, form_state.current_position);
|
||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
// src/tui/functions/form.rs
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::pages::canvas_state::CanvasState;
|
||||
use crate::services::ui_service::UiService;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub async fn handle_action(
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
current_position: &mut u64,
|
||||
total_count: u64,
|
||||
_grpc_client: &mut GrpcClient,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Check for unsaved changes in both cases
|
||||
if form_state.has_unsaved_changes() {
|
||||
return Ok(
|
||||
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
||||
@@ -21,56 +17,29 @@ pub async fn handle_action(
|
||||
);
|
||||
}
|
||||
|
||||
let total_count = form_state.total_count;
|
||||
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
let new_position = form_state.current_position.saturating_sub(1);
|
||||
if new_position >= 1 {
|
||||
form_state.current_position = new_position;
|
||||
*current_position = new_position;
|
||||
|
||||
if new_position <= form_state.total_count {
|
||||
let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
|
||||
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
|
||||
Ok(load_message)
|
||||
} else {
|
||||
Ok(format!("Moved to position {}", new_position))
|
||||
}
|
||||
} else {
|
||||
Ok("Already at first position".into())
|
||||
// Only decrement if the current position is greater than the first record.
|
||||
// This prevents wrapping from 1 to total_count.
|
||||
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
|
||||
if form_state.current_position > 1 {
|
||||
form_state.current_position -= 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
"next_entry" => {
|
||||
if form_state.current_position <= form_state.total_count {
|
||||
// Only increment if the current position is not yet at the "New Entry" stage.
|
||||
// The "New Entry" position is total_count + 1.
|
||||
// This allows moving from the last record to "New Entry", but stops there.
|
||||
if form_state.current_position <= total_count {
|
||||
form_state.current_position += 1;
|
||||
*current_position = form_state.current_position;
|
||||
|
||||
if form_state.current_position <= form_state.total_count {
|
||||
let load_message = UiService::load_table_data_by_position(grpc_client, form_state).await?;
|
||||
|
||||
let current_input = form_state.get_current_input();
|
||||
let max_cursor_pos = if !current_input.is_empty() {
|
||||
current_input.len() - 1
|
||||
} else { 0 };
|
||||
form_state.current_cursor_pos = (*ideal_cursor_column).min(max_cursor_pos);
|
||||
|
||||
Ok(load_message)
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
form_state.current_field = 0;
|
||||
form_state.current_cursor_pos = 0;
|
||||
*ideal_cursor_column = 0;
|
||||
Ok("New form entry mode".into())
|
||||
}
|
||||
} else {
|
||||
Ok("Already at last entry".into())
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
_ => Err(anyhow!("Unknown form action: {}", action))
|
||||
_ => return Err(anyhow!("Unknown form action: {}", action)),
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ pub fn render_ui(
|
||||
f, app_state, auth_state, admin_state, main_content_area, theme,
|
||||
&app_state.profile_tree, &app_state.selected_profile,
|
||||
);
|
||||
|
||||
} else if app_state.ui.show_form {
|
||||
let (sidebar_area, form_actual_area) = calculate_sidebar_layout(
|
||||
app_state.ui.show_sidebar, main_content_area
|
||||
@@ -158,19 +159,39 @@ pub fn render_ui(
|
||||
};
|
||||
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
|
||||
let values_vec: Vec<&String> = form_state.values.iter().collect();
|
||||
|
||||
// --- START FIX ---
|
||||
// Add the missing `&form_state.table_name` argument to this function call.
|
||||
render_form(
|
||||
f, form_render_area, form_state, &fields_vec, &form_state.current_field,
|
||||
&values_vec, theme, is_event_handler_edit_mode, highlight_state,
|
||||
f,
|
||||
form_render_area,
|
||||
form_state,
|
||||
&fields_vec,
|
||||
&form_state.current_field,
|
||||
&values_vec,
|
||||
&form_state.table_name, // <-- THIS ARGUMENT WAS MISSING
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
highlight_state,
|
||||
form_state.total_count,
|
||||
form_state.current_position,
|
||||
);
|
||||
// --- END FIX ---
|
||||
}
|
||||
|
||||
if let Some(area) = buffer_list_area {
|
||||
render_buffer_list(f, area, theme, buffer_state, app_state);
|
||||
}
|
||||
|
||||
render_status_line(f, status_line_area, current_dir, theme, is_event_handler_edit_mode, current_fps);
|
||||
render_status_line(
|
||||
f,
|
||||
status_line_area,
|
||||
current_dir,
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
current_fps,
|
||||
app_state,
|
||||
);
|
||||
|
||||
if let Some(palette_or_command_area) = command_render_area { // Use the calculated area
|
||||
if navigation_state.active {
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::tui::functions::common::register::RegisterResult;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::tui::functions::common::login;
|
||||
use crate::tui::functions::common::register;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use std::time::Instant;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
@@ -81,16 +82,17 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize AppState and FormState with table data
|
||||
let (initial_profile, initial_table, initial_columns) =
|
||||
let (initial_profile, initial_table, initial_columns_from_service) =
|
||||
UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
|
||||
.await
|
||||
.context("Failed to initialize app state and form")?;
|
||||
|
||||
let filtered_columns = filter_user_columns(initial_columns_from_service);
|
||||
|
||||
let mut form_state = FormState::new(
|
||||
initial_profile.clone(),
|
||||
initial_table.clone(),
|
||||
initial_columns,
|
||||
filtered_columns,
|
||||
);
|
||||
|
||||
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
|
||||
@@ -100,7 +102,6 @@ pub async fn run_ui() -> Result<()> {
|
||||
initial_profile, initial_table
|
||||
))?;
|
||||
|
||||
// Load initial data for the form
|
||||
if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
event_handler.command_message = format!("Error loading initial data: {}", e);
|
||||
@@ -120,210 +121,15 @@ pub async fn run_ui() -> Result<()> {
|
||||
let mut needs_redraw = true;
|
||||
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
|
||||
let mut prev_view_table_name = app_state.current_view_table_name.clone();
|
||||
let mut table_just_switched = false;
|
||||
|
||||
loop {
|
||||
if let Some(active_view) = buffer_state.get_active_view() {
|
||||
app_state.ui.show_intro = false;
|
||||
app_state.ui.show_login = false;
|
||||
app_state.ui.show_register = false;
|
||||
app_state.ui.show_admin = false;
|
||||
app_state.ui.show_add_table = false;
|
||||
app_state.ui.show_add_logic = false;
|
||||
app_state.ui.show_form = false;
|
||||
match active_view {
|
||||
AppView::Intro => app_state.ui.show_intro = true,
|
||||
AppView::Login => app_state.ui.show_login = true,
|
||||
AppView::Register => app_state.ui.show_register = true,
|
||||
AppView::Admin => {
|
||||
info!("Active view is Admin, refreshing profile tree...");
|
||||
match grpc_client.get_profile_tree().await {
|
||||
Ok(refreshed_tree) => {
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to refresh profile tree for Admin panel: {}", e);
|
||||
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
||||
}
|
||||
}
|
||||
app_state.ui.show_admin = true;
|
||||
let profile_names = app_state.profile_tree.profiles.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
admin_state.set_profiles(profile_names);
|
||||
|
||||
if admin_state.current_focus == AdminFocus::default() ||
|
||||
!matches!(admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList |
|
||||
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppView::AddTable => app_state.ui.show_add_table = true,
|
||||
AppView::AddLogic => app_state.ui.show_add_logic = true,
|
||||
AppView::Form => app_state.ui.show_form = true,
|
||||
AppView::Scratch => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle table change for FormView
|
||||
if app_state.ui.show_form {
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
|
||||
if prev_view_profile_name != current_view_profile || prev_view_table_name != current_view_table {
|
||||
if let (Some(prof_name), Some(tbl_name)) = (current_view_profile.as_ref(), current_view_table.as_ref()) {
|
||||
app_state.show_loading_dialog("Loading Table", &format!("Fetching data for {}.{}...", prof_name, tbl_name));
|
||||
needs_redraw = true;
|
||||
|
||||
match grpc_client.get_table_structure(prof_name.clone(), tbl_name.clone()).await {
|
||||
Ok(structure_response) => {
|
||||
let new_columns: Vec<String> = structure_response.columns.iter().map(|c| c.name.clone()).collect();
|
||||
form_state = FormState::new(prof_name.clone(), tbl_name.clone(), new_columns);
|
||||
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state).await {
|
||||
app_state.update_dialog_content(&format!("Error fetching count: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
|
||||
} else {
|
||||
if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
app_state.update_dialog_content(&format!("Error loading data: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
|
||||
} else {
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app_state.update_dialog_content(&format!("Error fetching table structure: {}", e), vec!["OK".to_string()], DialogPurpose::LoginFailed);
|
||||
app_state.current_view_profile_name = prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name = prev_view_table_name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if app_state.ui.show_add_logic {
|
||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
|
||||
info!("Fetching table structure for {}.{}", profile_name, table_name);
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
&mut grpc_client,
|
||||
&mut admin_state.add_logic_state,
|
||||
&app_state.profile_tree,
|
||||
).await.unwrap_or_else(|e| {
|
||||
error!("Error initializing add_logic_table_data: {}", e);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
event_handler.command_message = fetch_message;
|
||||
}
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
|
||||
profile_name, table_name,
|
||||
admin_state.add_logic_state.profile_name,
|
||||
admin_state.add_logic_state.selected_table_name
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if needs_redraw {
|
||||
terminal.draw(|f| {
|
||||
render_ui(
|
||||
f,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
&login_state,
|
||||
®ister_state,
|
||||
&intro_state,
|
||||
&mut admin_state,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
event_handler.is_edit_mode,
|
||||
&event_handler.highlight_state,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
&app_state.current_dir,
|
||||
current_fps,
|
||||
&app_state,
|
||||
);
|
||||
}).context("Terminal draw call failed")?;
|
||||
needs_redraw = false;
|
||||
}
|
||||
|
||||
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
|
||||
if app_state.ui.show_add_logic {
|
||||
let profile_name = admin_state.add_logic_state.profile_name.clone();
|
||||
|
||||
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
|
||||
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
|
||||
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
|
||||
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
|
||||
match current_mode {
|
||||
AppMode::Edit => { terminal.show_cursor()?; }
|
||||
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
||||
AppMode::ReadOnly => {
|
||||
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
||||
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
||||
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
||||
}
|
||||
AppMode::General => {
|
||||
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
||||
else { terminal.hide_cursor()?; }
|
||||
}
|
||||
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
||||
}
|
||||
|
||||
let position_before_event = form_state.current_position;
|
||||
|
||||
if app_state.ui.dialog.is_loading {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
|
||||
let mut event_processed = false;
|
||||
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
|
||||
let event = event_reader.read_event().context("Failed to read terminal event")?;
|
||||
event_processed = true;
|
||||
event_outcome_result = event_handler.handle_event(
|
||||
let event_outcome_result = event_handler.handle_event(
|
||||
event,
|
||||
&config,
|
||||
&mut terminal,
|
||||
@@ -338,10 +144,53 @@ pub async fn run_ui() -> Result<()> {
|
||||
&mut buffer_state,
|
||||
&mut app_state,
|
||||
).await;
|
||||
}
|
||||
|
||||
if event_processed {
|
||||
needs_redraw = true;
|
||||
let mut should_exit = false;
|
||||
match event_outcome_result {
|
||||
Ok(outcome) => match outcome {
|
||||
EventOutcome::Ok(message) => {
|
||||
if !message.is_empty() {
|
||||
event_handler.command_message = message;
|
||||
}
|
||||
}
|
||||
EventOutcome::Exit(message) => {
|
||||
event_handler.command_message = message;
|
||||
should_exit = true;
|
||||
}
|
||||
EventOutcome::DataSaved(save_outcome, message) => {
|
||||
event_handler.command_message = message;
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&mut form_state,
|
||||
).await {
|
||||
event_handler.command_message =
|
||||
format!("Error handling save outcome: {}", e);
|
||||
}
|
||||
}
|
||||
EventOutcome::ButtonSelected { .. } => {}
|
||||
EventOutcome::TableSelected { path } => {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let profile_name = parts[0].to_string();
|
||||
let table_name = parts[1].to_string();
|
||||
|
||||
app_state.set_current_view_table(profile_name, table_name);
|
||||
buffer_state.update_history(AppView::Form);
|
||||
event_handler.command_message = format!("Loading table: {}", path);
|
||||
} else {
|
||||
event_handler.command_message = format!("Invalid table path: {}", path);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
if should_exit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
match login_result_receiver.try_recv() {
|
||||
@@ -393,62 +242,208 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut should_exit = false;
|
||||
match event_outcome_result {
|
||||
Ok(outcome) => match outcome {
|
||||
EventOutcome::Ok(_message) => {}
|
||||
EventOutcome::Exit(message) => {
|
||||
event_handler.command_message = message;
|
||||
should_exit = true;
|
||||
}
|
||||
EventOutcome::DataSaved(save_outcome, message) => {
|
||||
event_handler.command_message = message;
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&mut form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
event_handler.command_message =
|
||||
format!("Error handling save outcome: {}", e);
|
||||
if let Some(active_view) = buffer_state.get_active_view() {
|
||||
app_state.ui.show_intro = false;
|
||||
app_state.ui.show_login = false;
|
||||
app_state.ui.show_register = false;
|
||||
app_state.ui.show_admin = false;
|
||||
app_state.ui.show_add_table = false;
|
||||
app_state.ui.show_add_logic = false;
|
||||
app_state.ui.show_form = false;
|
||||
match active_view {
|
||||
AppView::Intro => app_state.ui.show_intro = true,
|
||||
AppView::Login => app_state.ui.show_login = true,
|
||||
AppView::Register => app_state.ui.show_register = true,
|
||||
AppView::Admin => {
|
||||
info!("Active view is Admin, refreshing profile tree...");
|
||||
match grpc_client.get_profile_tree().await {
|
||||
Ok(refreshed_tree) => {
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to refresh profile tree for Admin panel: {}", e);
|
||||
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
||||
}
|
||||
}
|
||||
app_state.ui.show_admin = true;
|
||||
let profile_names = app_state.profile_tree.profiles.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
admin_state.set_profiles(profile_names);
|
||||
|
||||
if admin_state.current_focus == AdminFocus::default() ||
|
||||
!matches!(admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList |
|
||||
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
EventOutcome::ButtonSelected { context: _, index: _ } => {}
|
||||
},
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error: {}", e);
|
||||
AppView::AddTable => app_state.ui.show_add_table = true,
|
||||
AppView::AddLogic => app_state.ui.show_add_logic = true,
|
||||
AppView::Form => app_state.ui.show_form = true,
|
||||
AppView::Scratch => {}
|
||||
}
|
||||
}
|
||||
|
||||
if app_state.ui.show_form {
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
|
||||
if prev_view_profile_name != current_view_profile
|
||||
|| prev_view_table_name != current_view_table
|
||||
{
|
||||
if let (Some(prof_name), Some(tbl_name)) =
|
||||
(current_view_profile.as_ref(), current_view_table.as_ref())
|
||||
{
|
||||
app_state.show_loading_dialog(
|
||||
"Loading Table",
|
||||
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
||||
);
|
||||
needs_redraw = true;
|
||||
|
||||
match grpc_client
|
||||
.get_table_structure(prof_name.clone(), tbl_name.clone())
|
||||
.await
|
||||
{
|
||||
Ok(structure_response) => {
|
||||
let new_columns: Vec<String> = structure_response
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
|
||||
let filtered_columns = filter_user_columns(new_columns);
|
||||
form_state = FormState::new(
|
||||
prof_name.clone(),
|
||||
tbl_name.clone(),
|
||||
filtered_columns,
|
||||
);
|
||||
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||
&mut grpc_client,
|
||||
&mut form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error fetching count: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(
|
||||
&mut grpc_client,
|
||||
&mut form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading data: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else {
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
table_just_switched = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error fetching table structure: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
app_state.current_view_profile_name =
|
||||
prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name =
|
||||
prev_view_table_name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if app_state.ui.show_add_logic {
|
||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
|
||||
info!("Fetching table structure for {}.{}", profile_name, table_name);
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
&mut grpc_client,
|
||||
&mut admin_state.add_logic_state,
|
||||
&app_state.profile_tree,
|
||||
).await.unwrap_or_else(|e| {
|
||||
error!("Error initializing add_logic_table_data: {}", e);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
event_handler.command_message = fetch_message;
|
||||
}
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
|
||||
profile_name, table_name,
|
||||
admin_state.add_logic_state.profile_name,
|
||||
admin_state.add_logic_state.selected_table_name
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
|
||||
if app_state.ui.show_add_logic {
|
||||
let profile_name = admin_state.add_logic_state.profile_name.clone();
|
||||
|
||||
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
|
||||
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
|
||||
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
|
||||
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MODIFIED: Position Change Handling (operates on form_state) ---
|
||||
let position_changed = form_state.current_position != position_before_event;
|
||||
let mut position_logic_needs_redraw = false;
|
||||
|
||||
if app_state.ui.show_form { // Only if the form is active
|
||||
if app_state.ui.show_form && !table_just_switched {
|
||||
if position_changed && !event_handler.is_edit_mode {
|
||||
// This part is okay: update cursor for the current field BEFORE loading new data
|
||||
let current_input_before_load = form_state.get_current_input();
|
||||
let max_cursor_pos_before_load = if !current_input_before_load.is_empty() { current_input_before_load.chars().count() } else { 0 };
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_before_load);
|
||||
position_logic_needs_redraw = true;
|
||||
|
||||
// Validate new form_state.current_position
|
||||
if form_state.total_count > 0 && form_state.current_position > form_state.total_count + 1 {
|
||||
form_state.current_position = form_state.total_count + 1; // Cap at new entry
|
||||
} else if form_state.total_count == 0 && form_state.current_position > 1 {
|
||||
form_state.current_position = 1; // Cap at new entry for empty table
|
||||
}
|
||||
if form_state.current_position == 0 && form_state.total_count > 0 {
|
||||
form_state.current_position = 1; // Don't allow 0 if there are records
|
||||
}
|
||||
|
||||
|
||||
// Load data for the new position OR reset for new entry
|
||||
if (form_state.total_count > 0 && form_state.current_position <= form_state.total_count && form_state.current_position > 0)
|
||||
{
|
||||
// It's an existing record position
|
||||
if form_state.current_position > form_state.total_count {
|
||||
form_state.reset_to_empty();
|
||||
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||
} else {
|
||||
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
Ok(load_message) => {
|
||||
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
|
||||
@@ -457,34 +452,20 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error loading data: {}", e);
|
||||
// Consider what to do with form_state here - maybe revert position or clear form
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Position indicates a new entry (or table is empty and position is 1)
|
||||
form_state.reset_to_empty(); // This sets id=0, clears values, and sets current_position correctly
|
||||
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||
}
|
||||
|
||||
// NOW, after data is loaded or form is reset, get the current input string and its length
|
||||
let current_input_after_load_str = form_state.get_current_input();
|
||||
let current_input_len_after_load = current_input_after_load_str.chars().count();
|
||||
|
||||
let max_cursor_pos_for_readonly_after_load = if current_input_len_after_load > 0 {
|
||||
let max_cursor_pos = if current_input_len_after_load > 0 {
|
||||
current_input_len_after_load.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
|
||||
if event_handler.is_edit_mode {
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(current_input_len_after_load);
|
||||
} else {
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_for_readonly_after_load);
|
||||
// The check for empty string is implicitly handled by max_cursor_pos_for_readonly_after_load being 0
|
||||
}
|
||||
|
||||
} else if !position_changed && !event_handler.is_edit_mode && app_state.ui.show_form {
|
||||
// Update cursor if not editing and position didn't change (e.g. arrow keys within field)
|
||||
} else if !position_changed && !event_handler.is_edit_mode {
|
||||
let current_input_str = form_state.get_current_input();
|
||||
let current_input_len = current_input_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len > 0 {
|
||||
@@ -512,8 +493,58 @@ pub async fn run_ui() -> Result<()> {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
if should_exit {
|
||||
return Ok(());
|
||||
if app_state.ui.dialog.is_loading {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
app_state.debug_info = format!(
|
||||
"Redraw -> event: {}, needs_redraw: {}, pos_changed: {}",
|
||||
event_processed, needs_redraw, position_changed
|
||||
);
|
||||
}
|
||||
|
||||
if event_processed || needs_redraw || position_changed {
|
||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
|
||||
match current_mode {
|
||||
AppMode::Edit => { terminal.show_cursor()?; }
|
||||
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
||||
AppMode::ReadOnly => {
|
||||
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
||||
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
||||
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
||||
}
|
||||
AppMode::General => {
|
||||
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
||||
else { terminal.hide_cursor()?; }
|
||||
}
|
||||
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
||||
}
|
||||
|
||||
terminal.draw(|f| {
|
||||
render_ui(
|
||||
f,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
&login_state,
|
||||
®ister_state,
|
||||
&intro_state,
|
||||
&mut admin_state,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
event_handler.is_edit_mode,
|
||||
&event_handler.highlight_state,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
&app_state.current_dir,
|
||||
current_fps,
|
||||
&app_state,
|
||||
);
|
||||
}).context("Terminal draw call failed")?;
|
||||
needs_redraw = false;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
@@ -522,5 +553,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
if frame_duration.as_secs_f64() > 1e-6 {
|
||||
current_fps = 1.0 / frame_duration.as_secs_f64();
|
||||
}
|
||||
|
||||
table_just_switched = false;
|
||||
}
|
||||
}
|
||||
|
||||
14
client/src/utils/columns.rs
Normal file
14
client/src/utils/columns.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/utils/columns.rs
|
||||
pub fn is_system_column(column_name: &str) -> bool {
|
||||
match column_name {
|
||||
"id" | "deleted" | "created_at" => true,
|
||||
name if name.ends_with("_id") => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_user_columns(all_columns: Vec<String>) -> Vec<String> {
|
||||
all_columns.into_iter()
|
||||
.filter(|col| !is_system_column(col))
|
||||
.collect()
|
||||
}
|
||||
4
client/src/utils/mod.rs
Normal file
4
client/src/utils/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/utils/mod.rs
|
||||
|
||||
pub mod columns;
|
||||
pub use columns::*;
|
||||
@@ -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
20
common/proto/search.proto
Normal 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; // The PostgreSQL row ID
|
||||
float score = 2;
|
||||
}
|
||||
repeated Hit hits = 1;
|
||||
}
|
||||
@@ -25,6 +25,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.
315
common/src/proto/multieko2.search.rs
Normal file
315
common/src/proto/multieko2.search.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
// 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, Copy, PartialEq, ::prost::Message)]
|
||||
pub struct Hit {
|
||||
/// The PostgreSQL row ID
|
||||
#[prost(int64, tag = "1")]
|
||||
pub id: i64,
|
||||
#[prost(float, tag = "2")]
|
||||
pub score: f32,
|
||||
}
|
||||
}
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
18
search/Cargo.toml
Normal file
18
search/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[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"
|
||||
109
search/src/lib.rs
Normal file
109
search/src/lib.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
// src/lib.rs
|
||||
|
||||
use std::path::Path;
|
||||
use tantivy::{collector::TopDocs, query::QueryParser, Index, TantivyDocument};
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use common::proto::multieko2::search::{
|
||||
search_response::Hit, SearchRequest, SearchResponse,
|
||||
};
|
||||
use common::proto::multieko2::search::searcher_server::Searcher;
|
||||
pub use common::proto::multieko2::search::searcher_server::SearcherServer;
|
||||
use tantivy::schema::Value;
|
||||
|
||||
pub struct SearcherService;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Searcher for SearcherService {
|
||||
async fn search_table(
|
||||
&self,
|
||||
request: Request<SearchRequest>,
|
||||
) -> Result<Response<SearchResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let table_name = req.table_name;
|
||||
let query_str = req.query;
|
||||
|
||||
if query_str.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Query cannot be empty"));
|
||||
}
|
||||
|
||||
// Open the index for this table
|
||||
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
|
||||
)));
|
||||
}
|
||||
|
||||
// Open the index
|
||||
let index = Index::open_in_dir(&index_path).map_err(|e| {
|
||||
Status::internal(format!("Failed to open index: {}", e))
|
||||
})?;
|
||||
|
||||
// Create reader and searcher
|
||||
let reader = index.reader().map_err(|e| {
|
||||
Status::internal(format!("Failed to create index reader: {}", e))
|
||||
})?;
|
||||
|
||||
let searcher = reader.searcher();
|
||||
let schema = index.schema();
|
||||
|
||||
// Get the fields we need
|
||||
let all_text_field = match schema.get_field("all_text") {
|
||||
Ok(field) => field,
|
||||
Err(_) => {
|
||||
return Err(Status::internal(
|
||||
"Schema is missing the 'all_text' field.",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let pg_id_field = match schema.get_field("pg_id") {
|
||||
Ok(field) => field,
|
||||
Err(_) => {
|
||||
return Err(Status::internal(
|
||||
"Schema is missing the 'pg_id' field.",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the query
|
||||
let query_parser =
|
||||
QueryParser::for_index(&index, vec![all_text_field]);
|
||||
let query = query_parser.parse_query(&query_str).map_err(|e| {
|
||||
Status::invalid_argument(format!("Invalid query: {}", e))
|
||||
})?;
|
||||
|
||||
// Perform the search
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(100))
|
||||
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
|
||||
|
||||
// Convert results to our response format
|
||||
let mut hits = 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() {
|
||||
hits.push(Hit {
|
||||
id: pg_id as i64,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = SearchResponse { hits };
|
||||
Ok(Response::new(response))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
83
server/src/bin/manual_indexer.rs
Normal file
83
server/src/bin/manual_indexer.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
// In server/src/bin/manual_indexer.rs
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
use tantivy::schema::*;
|
||||
use tantivy::{doc, Index};
|
||||
use std::path::Path;
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// IMPORTANT: Change this to a table name that actually exists and has data in your test DB.
|
||||
// From your grpcurl output, "2025_test_post" is a good candidate.
|
||||
const TABLE_TO_INDEX: &str = "2025_test_post2";
|
||||
const INDEX_DIR: &str = "./tantivy_indexes";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// --- Database Connection ---
|
||||
// This assumes you have a .env file with DATABASE_URL
|
||||
dotenvy::dotenv().ok();
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set in your .env file");
|
||||
let pool = PgPool::connect(&database_url).await?;
|
||||
println!("Connected to database.");
|
||||
|
||||
// --- Tantivy Schema Definition ---
|
||||
let mut schema_builder = Schema::builder();
|
||||
// This field will store the original Postgres row ID. It's crucial.
|
||||
schema_builder.add_u64_field("pg_id", INDEXED | STORED);
|
||||
// This field will contain ALL text data from the row, concatenated.
|
||||
schema_builder.add_text_field("all_text", TEXT | STORED);
|
||||
let schema = schema_builder.build();
|
||||
|
||||
// --- Index Creation ---
|
||||
let index_path = Path::new(INDEX_DIR).join(TABLE_TO_INDEX);
|
||||
if index_path.exists() {
|
||||
println!("Removing existing index at: {}", index_path.display());
|
||||
std::fs::remove_dir_all(&index_path)?;
|
||||
}
|
||||
std::fs::create_dir_all(&index_path)?;
|
||||
let index = Index::create_in_dir(&index_path, schema.clone())?;
|
||||
let mut index_writer = index.writer(100_000_000)?; // 100MB heap
|
||||
|
||||
println!("Indexing table: {}", TABLE_TO_INDEX);
|
||||
|
||||
// --- Data Fetching and Indexing ---
|
||||
let qualified_table = format!("gen.\"{}\"", TABLE_TO_INDEX);
|
||||
let query_str = format!("SELECT id, to_jsonb(t) AS data FROM {} t", qualified_table);
|
||||
let rows = sqlx::query(&query_str).fetch_all(&pool).await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
println!("Warning: No rows found in table '{}'. Index will be empty.", TABLE_TO_INDEX);
|
||||
}
|
||||
|
||||
let pg_id_field = schema.get_field("pg_id").unwrap();
|
||||
let all_text_field = schema.get_field("all_text").unwrap();
|
||||
|
||||
for row in &rows {
|
||||
let id: i64 = row.try_get("id")?;
|
||||
let data: serde_json::Value = row.try_get("data")?;
|
||||
|
||||
// Concatenate all text values from the JSON into one big string.
|
||||
let mut full_text = String::new();
|
||||
if let Some(obj) = data.as_object() {
|
||||
for value in obj.values() {
|
||||
if let Some(s) = value.as_str() {
|
||||
full_text.push_str(s);
|
||||
full_text.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the document to Tantivy
|
||||
index_writer.add_document(doc!(
|
||||
pg_id_field => id as u64,
|
||||
all_text_field => full_text
|
||||
))?;
|
||||
}
|
||||
|
||||
// --- Finalize ---
|
||||
index_writer.commit()?;
|
||||
println!("Successfully indexed {} documents into '{}'", rows.len(), index_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
140
server/src/indexer.rs
Normal file
140
server/src/indexer.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
// src/indexer.rs
|
||||
|
||||
use std::path::Path;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tantivy::schema::{Schema, Term, TEXT, STORED, INDEXED};
|
||||
use tantivy::{doc, Index, IndexWriter};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
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.
|
||||
/// It listens for commands on the receiver and updates the Tantivy index.
|
||||
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<()> {
|
||||
// 1. Fetch the full row data from PostgreSQL
|
||||
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")?;
|
||||
|
||||
// 2. Prepare the Tantivy document
|
||||
let mut full_text = String::new();
|
||||
if let Some(obj) = json_data.as_object() {
|
||||
for value in obj.values() {
|
||||
if let Some(s) = value.as_str() {
|
||||
full_text.push_str(s);
|
||||
full_text.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Open the index and write the document
|
||||
let (mut writer, schema) = get_index_writer(&data.table_name)?;
|
||||
let pg_id_field = schema.get_field("pg_id").unwrap();
|
||||
let all_text_field = schema.get_field("all_text").unwrap();
|
||||
|
||||
// First, delete any existing document with this ID to handle updates
|
||||
let id_term = Term::from_field_u64(pg_id_field, data.row_id as u64);
|
||||
writer.delete_term(id_term);
|
||||
|
||||
// Add the new document
|
||||
writer.add_document(doc!(
|
||||
pg_id_field => data.row_id as u64,
|
||||
all_text_field => full_text
|
||||
))?;
|
||||
|
||||
// 4. Commit changes
|
||||
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_path = Path::new(INDEX_DIR).join(table_name);
|
||||
std::fs::create_dir_all(&index_path)?;
|
||||
|
||||
let index = Index::open_in_dir(&index_path).or_else(|_| {
|
||||
// If it doesn't exist, create it with the standard schema
|
||||
let mut schema_builder = Schema::builder();
|
||||
schema_builder.add_u64_field("pg_id", INDEXED | STORED);
|
||||
schema_builder.add_text_field("all_text", TEXT | STORED);
|
||||
let schema = schema_builder.build();
|
||||
Index::create_in_dir(&index_path, schema)
|
||||
})?;
|
||||
|
||||
let schema = index.schema();
|
||||
let writer = index.writer(100_000_000)?; // 100MB heap
|
||||
Ok((writer, schema))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/lib.rs
|
||||
pub mod db;
|
||||
pub mod auth;
|
||||
pub mod indexer;
|
||||
pub mod server;
|
||||
pub mod adresar;
|
||||
pub mod uctovnictvo;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
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,22 +24,35 @@ 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() };
|
||||
let search_service = SearcherService;
|
||||
|
||||
Server::builder()
|
||||
.add_service(AdresarServer::new(AdresarService { db_pool: db_pool.clone() }))
|
||||
@@ -46,6 +62,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))
|
||||
.add_service(reflection_service)
|
||||
.serve(addr)
|
||||
.await?;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ fn map_field_type(field_type: &str) -> Result<&str, Status> {
|
||||
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
|
||||
}
|
||||
|
||||
fn is_invalid_table_name(table_name: &str) -> bool {
|
||||
table_name.ends_with("_id") ||
|
||||
table_name == "id" ||
|
||||
table_name == "deleted" ||
|
||||
table_name == "created_at"
|
||||
}
|
||||
|
||||
pub async fn post_table_definition(
|
||||
db_pool: &PgPool,
|
||||
request: PostTableDefinitionRequest,
|
||||
@@ -55,6 +62,13 @@ pub async fn post_table_definition(
|
||||
.trim_matches('_')
|
||||
.to_lowercase();
|
||||
|
||||
// New validation check
|
||||
if is_invalid_table_name(&user_part_cleaned) {
|
||||
return Err(Status::invalid_argument(
|
||||
"Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"
|
||||
));
|
||||
}
|
||||
|
||||
if !user_part_cleaned.is_empty() && !is_valid_identifier(&user_part_cleaned) {
|
||||
return Err(Status::invalid_argument("Invalid table name"));
|
||||
} else if user_part_cleaned.is_empty() {
|
||||
@@ -142,8 +156,8 @@ async fn execute_table_definition(
|
||||
RETURNING id"#,
|
||||
profile.id,
|
||||
&table_name,
|
||||
json!(request.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()),
|
||||
json!(request.indexes.iter().map(|i| i.clone()).collect::<Vec<_>>())
|
||||
json!(columns),
|
||||
json!(indexes)
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
|
||||
@@ -56,7 +56,6 @@ pub async fn get_table_data(
|
||||
let system_columns = vec![
|
||||
("id".to_string(), "BIGINT".to_string()),
|
||||
("deleted".to_string(), "BOOLEAN".to_string()),
|
||||
("firma".to_string(), "TEXT".to_string()),
|
||||
];
|
||||
let all_columns: Vec<(String, String)> = system_columns
|
||||
.into_iter()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/tables_data/handlers/post_table_data.rs
|
||||
|
||||
use tonic::Status;
|
||||
use sqlx::{PgPool, Arguments};
|
||||
use sqlx::postgres::PgArguments;
|
||||
@@ -6,32 +7,28 @@ use chrono::{DateTime, Utc};
|
||||
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
|
||||
use crate::shared::schema_qualifier::qualify_table_name_for_data;
|
||||
|
||||
use crate::steel::server::execution::{self, Value};
|
||||
use crate::steel::server::functions::SteelContext;
|
||||
|
||||
// Add these imports
|
||||
use crate::indexer::{IndexCommand, IndexCommandData};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::error;
|
||||
|
||||
// MODIFIED: Function signature now accepts the indexer sender
|
||||
pub async fn post_table_data(
|
||||
db_pool: &PgPool,
|
||||
request: PostTableDataRequest,
|
||||
indexer_tx: &mpsc::Sender<IndexCommand>,
|
||||
) -> Result<PostTableDataResponse, Status> {
|
||||
let profile_name = request.profile_name;
|
||||
let table_name = request.table_name;
|
||||
let mut data = HashMap::new();
|
||||
|
||||
// Process and validate all data values
|
||||
for (key, value) in request.data {
|
||||
let trimmed = value.trim().to_string();
|
||||
|
||||
// Handle specially - it cannot be empty
|
||||
if trimmed.is_empty() {
|
||||
return Err(Status::invalid_argument("Firma cannot be empty"));
|
||||
}
|
||||
|
||||
// Add trimmed non-empty values to data map
|
||||
if !trimmed.is_empty() {
|
||||
data.insert(key, trimmed);
|
||||
}
|
||||
data.insert(key, value.trim().to_string());
|
||||
}
|
||||
|
||||
// Lookup profile
|
||||
@@ -251,6 +248,21 @@ pub async fn post_table_data(
|
||||
}
|
||||
};
|
||||
|
||||
// After a successful insert, send a command to the indexer.
|
||||
let command = IndexCommand::AddOrUpdate(IndexCommandData {
|
||||
table_name: table_name.clone(),
|
||||
row_id: inserted_id,
|
||||
});
|
||||
|
||||
if let Err(e) = indexer_tx.send(command).await {
|
||||
// If sending fails, the DB is updated but the index will be stale.
|
||||
// This is a critical situation to log and monitor.
|
||||
error!(
|
||||
"CRITICAL: DB insert for table '{}' (id: {}) succeeded but failed to queue for indexing: {}. Search index is now inconsistent.",
|
||||
table_name, inserted_id, e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(PostTableDataResponse {
|
||||
success: true,
|
||||
message: "Data inserted successfully".into(),
|
||||
|
||||
@@ -19,15 +19,12 @@ pub async fn put_table_data(
|
||||
let mut processed_data = HashMap::new();
|
||||
let mut null_fields = Vec::new();
|
||||
|
||||
// CORRECTED: Generic handling for all fields.
|
||||
// Any field with an empty string will be added to the null_fields list.
|
||||
// The special, hardcoded logic for "firma" has been removed.
|
||||
for (key, value) in request.data {
|
||||
let trimmed = value.trim().to_string();
|
||||
|
||||
if key == "firma" && trimmed.is_empty() {
|
||||
return Err(Status::invalid_argument("Firma cannot be empty"));
|
||||
}
|
||||
|
||||
// Store fields that should be set to NULL
|
||||
if key != "firma" && trimmed.is_empty() {
|
||||
if trimmed.is_empty() {
|
||||
null_fields.push(key);
|
||||
} else {
|
||||
processed_data.insert(key, trimmed);
|
||||
@@ -73,8 +70,9 @@ pub async fn put_table_data(
|
||||
columns.push((name, sql_type));
|
||||
}
|
||||
|
||||
// Validate system columns
|
||||
let system_columns = ["firma", "deleted"];
|
||||
// CORRECTED: "firma" is not a system column.
|
||||
// It should be treated as a user-defined column.
|
||||
let system_columns = ["deleted"];
|
||||
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
||||
|
||||
// Validate input columns
|
||||
@@ -91,9 +89,11 @@ pub async fn put_table_data(
|
||||
|
||||
// Add data parameters for non-empty fields
|
||||
for (col, value) in &processed_data {
|
||||
// CORRECTED: The logic for "firma" is removed from this match.
|
||||
// It will now fall through to the `else` block and have its type
|
||||
// correctly looked up from the `columns` vector.
|
||||
let sql_type = if system_columns.contains(&col.as_str()) {
|
||||
match col.as_str() {
|
||||
"firma" => "TEXT",
|
||||
"deleted" => "BOOLEAN",
|
||||
_ => return Err(Status::invalid_argument("Invalid system column")),
|
||||
}
|
||||
@@ -121,7 +121,7 @@ pub async fn put_table_data(
|
||||
let val = value.parse::<bool>()
|
||||
.map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?;
|
||||
params.add(val)
|
||||
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}极 {}", col, e)))?;
|
||||
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
|
||||
},
|
||||
"TIMESTAMPTZ" => {
|
||||
let dt = DateTime::parse_from_rfc3339(value)
|
||||
@@ -129,6 +129,13 @@ pub async fn put_table_data(
|
||||
params.add(dt.with_timezone(&Utc))
|
||||
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
|
||||
},
|
||||
// ADDED: BIGINT handling for completeness, if needed for other columns.
|
||||
"BIGINT" => {
|
||||
let val = value.parse::<i64>()
|
||||
.map_err(|_| Status::invalid_argument(format!("Invalid integer for {}", col)))?;
|
||||
params.add(val)
|
||||
.map_err(|e| Status::internal(format!("Failed to add integer parameter for {}: {}", col, e)))?;
|
||||
},
|
||||
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user