Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d15e321f | ||
|
|
a3e7fd8f0a | ||
|
|
645172747a | ||
|
|
7c4ac1eebc | ||
|
|
4b4301ad49 | ||
|
|
b60e03eb70 | ||
|
|
2c7bda3ff1 | ||
|
|
eeaaa3635b | ||
|
|
e61cbb3956 | ||
|
|
f9841f2ef3 | ||
|
|
dc232b2523 | ||
|
|
b086b3e236 | ||
|
|
387e1a0fe0 | ||
|
|
08e01d41f2 | ||
|
|
f5edf52571 | ||
|
|
02c62213c3 | ||
|
|
d0722fbbbe | ||
|
|
4ec569342d | ||
|
|
9540d9ccb9 | ||
|
|
6b5cbe854b | ||
|
|
59ed52814e | ||
|
|
3488ab4f6b | ||
|
|
6e2fc5349b | ||
|
|
ea88c2686d | ||
|
|
3df4baec92 | ||
|
|
ff74e1aaa1 | ||
|
|
b0c865ab76 | ||
|
|
3dbc086f10 | ||
|
|
e9b4b34fb4 | ||
|
|
668eeee197 | ||
|
|
799d8471c9 | ||
|
|
f77c16dec9 | ||
|
|
45026cac6a | ||
|
|
edf6ab5bca | ||
|
|
462b1f14e2 | ||
|
|
7a8f18b116 | ||
|
|
d255e4abb6 | ||
|
|
b770240f0d | ||
|
|
43b064673b | ||
|
|
bf2726c151 | ||
|
|
f3cd921c76 | ||
|
|
913f6b6b64 | ||
|
|
3463a52960 | ||
|
|
116db3566f | ||
|
|
32210a5f7c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
/tantivy_indexes
|
||||||
|
|||||||
432
Cargo.lock
generated
432
Cargo.lock
generated
@@ -303,6 +303,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitpacking"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -322,6 +331,31 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bon"
|
||||||
|
version = "3.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ced38439e7a86a4761f7f7d5ded5ff009135939ecb464a24452eaa4c1696af7d"
|
||||||
|
dependencies = [
|
||||||
|
"bon-macros",
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bon-macros"
|
||||||
|
version = "3.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ce61d2d3844c6b8d31b2353d9f66cf5e632b3e9549583fe3cac2f4f6136725e"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"ident_case",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.100",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.17.0"
|
version = "3.17.0"
|
||||||
@@ -361,9 +395,17 @@ version = "1.2.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
|
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "census"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -541,6 +583,15 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam"
|
name = "crossbeam"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -622,6 +673,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -699,6 +756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -772,6 +830,12 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -825,6 +889,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastdivide"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -884,6 +954,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs4"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8"
|
||||||
|
dependencies = [
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1142,6 +1222,12 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "htmlescape"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -1242,6 +1328,15 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyperloglogplus"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.63"
|
version = "0.1.63"
|
||||||
@@ -1520,6 +1615,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.77"
|
version = "0.3.77"
|
||||||
@@ -1566,6 +1671,12 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "levenshtein_automata"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.172"
|
version = "0.2.172"
|
||||||
@@ -1651,6 +1762,12 @@ dependencies = [
|
|||||||
"hashbrown 0.15.2",
|
"hashbrown 0.15.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lz4_flex"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1667,18 +1784,42 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "measure_time"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.4"
|
version = "2.7.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
@@ -1706,6 +1847,12 @@ version = "0.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
|
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "murmurhash32"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
@@ -1723,6 +1870,16 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
@@ -1857,6 +2014,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oneshot"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.72"
|
version = "0.10.72"
|
||||||
@@ -1913,6 +2076,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ownedbytes"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -2275,6 +2447,16 @@ dependencies = [
|
|||||||
"getrandom 0.3.2",
|
"getrandom 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_distr"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_xoshiro"
|
name = "rand_xoshiro"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -2305,6 +2487,26 @@ dependencies = [
|
|||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.11"
|
version = "0.5.11"
|
||||||
@@ -2444,12 +2646,28 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-stemmers"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.24"
|
version = "0.1.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2512,6 +2730,22 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "search"
|
||||||
|
version = "0.3.13"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"prost",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tantivy",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
"tonic-reflection",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -2598,6 +2832,7 @@ dependencies = [
|
|||||||
name = "server"
|
name = "server"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"common",
|
"common",
|
||||||
@@ -2608,11 +2843,13 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
"regex",
|
"regex",
|
||||||
"rstest",
|
"rstest",
|
||||||
|
"search",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"steel-core",
|
"steel-core",
|
||||||
"steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)",
|
"steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)",
|
||||||
|
"tantivy",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2722,6 +2959,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sketches-ddsketch"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -3162,6 +3408,152 @@ dependencies = [
|
|||||||
"syn 2.0.100",
|
"syn 2.0.100",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"arc-swap",
|
||||||
|
"base64",
|
||||||
|
"bitpacking",
|
||||||
|
"bon",
|
||||||
|
"byteorder",
|
||||||
|
"census",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"downcast-rs",
|
||||||
|
"fastdivide",
|
||||||
|
"fnv",
|
||||||
|
"fs4",
|
||||||
|
"htmlescape",
|
||||||
|
"hyperloglogplus",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"levenshtein_automata",
|
||||||
|
"log",
|
||||||
|
"lru",
|
||||||
|
"lz4_flex",
|
||||||
|
"measure_time",
|
||||||
|
"memmap2",
|
||||||
|
"once_cell",
|
||||||
|
"oneshot",
|
||||||
|
"rayon",
|
||||||
|
"regex",
|
||||||
|
"rust-stemmers",
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sketches-ddsketch",
|
||||||
|
"smallvec",
|
||||||
|
"tantivy-bitpacker",
|
||||||
|
"tantivy-columnar",
|
||||||
|
"tantivy-common",
|
||||||
|
"tantivy-fst",
|
||||||
|
"tantivy-query-grammar",
|
||||||
|
"tantivy-stacker",
|
||||||
|
"tantivy-tokenizer-api",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
|
"uuid",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-bitpacker"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494"
|
||||||
|
dependencies = [
|
||||||
|
"bitpacking",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-columnar"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344"
|
||||||
|
dependencies = [
|
||||||
|
"downcast-rs",
|
||||||
|
"fastdivide",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"serde",
|
||||||
|
"tantivy-bitpacker",
|
||||||
|
"tantivy-common",
|
||||||
|
"tantivy-sstable",
|
||||||
|
"tantivy-stacker",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-common"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"byteorder",
|
||||||
|
"ownedbytes",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-fst"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"regex-syntax",
|
||||||
|
"utf8-ranges",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-query-grammar"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-sstable"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"tantivy-bitpacker",
|
||||||
|
"tantivy-common",
|
||||||
|
"tantivy-fst",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-stacker"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1"
|
||||||
|
dependencies = [
|
||||||
|
"murmurhash32",
|
||||||
|
"rand_distr",
|
||||||
|
"tantivy-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tantivy-tokenizer-api"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@@ -3424,9 +3816,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tonic-reflection"
|
name = "tonic-reflection"
|
||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88fa815be858816dad226a49439ee90b7bcf81ab55bee72fdb217f1e6778c3ca"
|
checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
@@ -3648,6 +4040,12 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-ranges"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3850,7 +4248,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4214,3 +4612,31 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.100",
|
"syn 2.0.100",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.15+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|||||||
27
Cargo.toml
27
Cargo.toml
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["client", "server", "common"]
|
members = ["client", "server", "common", "search"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -16,4 +16,27 @@ categories = ["command-line-interface"]
|
|||||||
|
|
||||||
# [workspace.metadata]
|
# [workspace.metadata]
|
||||||
# TODO:
|
# TODO:
|
||||||
# documentation = "https://docs.rs/accounting-client"`
|
# documentation = "https://docs.rs/accounting-client"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Async and gRPC
|
||||||
|
tokio = { version = "1.44.2", features = ["full"] }
|
||||||
|
tonic = "0.13.0"
|
||||||
|
prost = "0.13.5"
|
||||||
|
async-trait = "0.1.88"
|
||||||
|
|
||||||
|
# Data Handling & Serialization
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
time = "0.3.41"
|
||||||
|
|
||||||
|
# Utilities & Error Handling
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
lazy_static = "1.5.0"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
|
||||||
|
# Search crate
|
||||||
|
tantivy = "0.24.1"
|
||||||
|
|
||||||
|
common = { path = "./common" }
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ Client with tracing:
|
|||||||
```
|
```
|
||||||
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Client with debug that cant be traced
|
||||||
|
```
|
||||||
|
cargo run --package client --features ui-debug -- client
|
||||||
|
```
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ tracing-subscriber = "0.3.19"
|
|||||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
unicode-width = "0.2.0"
|
unicode-width = "0.2.0"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ui-debug = []
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ quit = ["q"]
|
|||||||
force_quit = ["q!"]
|
force_quit = ["q!"]
|
||||||
save_and_quit = ["wq"]
|
save_and_quit = ["wq"]
|
||||||
revert = ["r"]
|
revert = ["r"]
|
||||||
|
find_file_palette_toggle = ["ff"]
|
||||||
|
|
||||||
[editor]
|
[editor]
|
||||||
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ use ratatui::{
|
|||||||
use crate::components::handlers::canvas::render_canvas;
|
use crate::components::handlers::canvas::render_canvas;
|
||||||
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
|
||||||
use crate::config::binds::config::EditorKeybindingMode;
|
use crate::config::binds::config::EditorKeybindingMode;
|
||||||
use crate::modes::handlers::mode_manager::AppMode; // For checking AppMode::Edit
|
|
||||||
|
|
||||||
pub fn render_add_logic(
|
pub fn render_add_logic(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
add_logic_state: &mut AddLogicState, // Changed to &mut
|
add_logic_state: &mut AddLogicState,
|
||||||
is_edit_mode: bool, // This is the general edit mode from EventHandler
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
) {
|
) {
|
||||||
let main_block = Block::default()
|
let main_block = Block::default()
|
||||||
@@ -47,13 +46,10 @@ pub fn render_add_logic(
|
|||||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||||
EditorKeybindingMode::Vim => {
|
EditorKeybindingMode::Vim => {
|
||||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||||
// Vim mode status is relevant regardless of the general `is_edit_mode`
|
|
||||||
format!("Script {}", vim_mode_status)
|
format!("Script {}", vim_mode_status)
|
||||||
}
|
}
|
||||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||||
// For default/emacs, the general `is_edit_mode` (passed to this function)
|
if is_edit_mode {
|
||||||
// indicates if the text area itself is in an "editing" state.
|
|
||||||
if is_edit_mode { // This `is_edit_mode` refers to the text area's active editing.
|
|
||||||
"Script (Editing)".to_string()
|
"Script (Editing)".to_string()
|
||||||
} else {
|
} else {
|
||||||
"Script".to_string()
|
"Script".to_string()
|
||||||
@@ -70,7 +66,48 @@ pub fn render_add_logic(
|
|||||||
.border_style(border_style),
|
.border_style(border_style),
|
||||||
);
|
);
|
||||||
f.render_widget(&*editor_ref, inner_area);
|
f.render_widget(&*editor_ref, inner_area);
|
||||||
return;
|
|
||||||
|
// Drop the editor borrow before accessing autocomplete state
|
||||||
|
drop(editor_ref);
|
||||||
|
|
||||||
|
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||||
|
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||||
|
// Get the current cursor position from textarea
|
||||||
|
let current_cursor = {
|
||||||
|
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||||
|
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (cursor_line, cursor_col) = current_cursor;
|
||||||
|
|
||||||
|
// Account for TextArea's block borders (1 for each side)
|
||||||
|
let block_offset_x = 1;
|
||||||
|
let block_offset_y = 1;
|
||||||
|
|
||||||
|
// Position autocomplete at current cursor position
|
||||||
|
// Add 1 to column to position dropdown right after the cursor
|
||||||
|
let autocomplete_x = cursor_col + 1;
|
||||||
|
let autocomplete_y = cursor_line;
|
||||||
|
|
||||||
|
let input_rect = Rect {
|
||||||
|
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
||||||
|
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
||||||
|
width: 1, // Minimum width for positioning
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render autocomplete dropdown
|
||||||
|
autocomplete::render_autocomplete_dropdown(
|
||||||
|
f,
|
||||||
|
input_rect,
|
||||||
|
f.area(), // Full frame area for clamping
|
||||||
|
theme,
|
||||||
|
&add_logic_state.script_editor_suggestions,
|
||||||
|
add_logic_state.script_editor_selected_suggestion_index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // Exit early for fullscreen mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular layout with preview
|
// Regular layout with preview
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod text_editor;
|
|||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod autocomplete;
|
pub mod autocomplete;
|
||||||
|
pub mod find_file_palette;
|
||||||
|
|
||||||
pub use command_line::*;
|
pub use command_line::*;
|
||||||
pub use status_line::*;
|
pub use status_line::*;
|
||||||
@@ -12,3 +13,4 @@ pub use text_editor::*;
|
|||||||
pub use background::*;
|
pub use background::*;
|
||||||
pub use dialog::*;
|
pub use dialog::*;
|
||||||
pub use autocomplete::*;
|
pub use autocomplete::*;
|
||||||
|
pub use find_file_palette::*;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// src/client/components/command_line.rs
|
// src/components/common/command_line.rs
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
widgets::{Block, Paragraph},
|
widgets::{Block, Paragraph},
|
||||||
style::Style,
|
style::Style,
|
||||||
@@ -6,30 +7,63 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
|
use unicode_width::UnicodeWidthStr; // Import for width calculation
|
||||||
|
|
||||||
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
|
pub fn render_command_line(
|
||||||
let prompt = if active {
|
f: &mut Frame,
|
||||||
":"
|
area: Rect,
|
||||||
} else {
|
input: &str, // This is event_handler.command_input
|
||||||
""
|
active: bool, // This is event_handler.command_mode
|
||||||
|
theme: &Theme,
|
||||||
|
message: &str, // This is event_handler.command_message
|
||||||
|
) {
|
||||||
|
// Original logic for determining display_text
|
||||||
|
let display_text = if !active {
|
||||||
|
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
|
||||||
|
// Or if command mode is off and message is empty (render minimally)
|
||||||
|
if message.is_empty() {
|
||||||
|
"".to_string() // Render an empty string, background will cover
|
||||||
|
} else {
|
||||||
|
message.to_string()
|
||||||
|
}
|
||||||
|
} else { // active is true (normal command mode)
|
||||||
|
let prompt = ":";
|
||||||
|
if message.is_empty() || message == ":" {
|
||||||
|
format!("{}{}", prompt, input)
|
||||||
|
} else {
|
||||||
|
if input.is_empty() { // If command was just executed, input is cleared, show message
|
||||||
|
message.to_string()
|
||||||
|
} else { // Show input and message
|
||||||
|
format!("{}{} | {}", prompt, input, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine the prompt, input, and message
|
let content_width = UnicodeWidthStr::width(display_text.as_str());
|
||||||
let display_text = if message.is_empty() {
|
let available_width = area.width as usize;
|
||||||
format!("{}{}", prompt, input)
|
let padding_needed = available_width.saturating_sub(content_width);
|
||||||
|
|
||||||
|
let display_text_padded = if padding_needed > 0 {
|
||||||
|
format!("{}{}", display_text, " ".repeat(padding_needed))
|
||||||
} else {
|
} else {
|
||||||
format!("{}{} | {}", prompt, input, message)
|
// If text is too long, ratatui's Paragraph will handle truncation.
|
||||||
|
// We could also truncate here if specific behavior is needed:
|
||||||
|
// display_text.chars().take(available_width).collect::<String>()
|
||||||
|
display_text
|
||||||
};
|
};
|
||||||
|
|
||||||
let style = if active {
|
// Determine style based on active state, but apply to the whole paragraph
|
||||||
|
let text_style = if active {
|
||||||
Style::default().fg(theme.accent)
|
Style::default().fg(theme.accent)
|
||||||
} else {
|
} else {
|
||||||
|
// If not active, but there's a message, use default foreground.
|
||||||
|
// If message is also empty, this style won't matter much for empty text.
|
||||||
Style::default().fg(theme.fg)
|
Style::default().fg(theme.fg)
|
||||||
};
|
};
|
||||||
|
|
||||||
let paragraph = Paragraph::new(display_text)
|
let paragraph = Paragraph::new(display_text_padded)
|
||||||
.block(Block::default().style(Style::default().bg(theme.bg)))
|
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
|
||||||
.style(style);
|
.style(text_style); // Style for the text itself
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
f.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|||||||
142
client/src/components/common/find_file_palette.rs
Normal file
142
client/src/components/common/find_file_palette.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// src/components/common/find_file_palette.rs
|
||||||
|
|
||||||
|
use crate::config::colors::themes::Theme;
|
||||||
|
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::Style,
|
||||||
|
widgets::{Block, List, ListItem, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
|
||||||
|
const PADDING_CHAR: &str = " ";
|
||||||
|
|
||||||
|
pub fn render_find_file_palette(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
theme: &Theme,
|
||||||
|
navigation_state: &NavigationState,
|
||||||
|
) {
|
||||||
|
let palette_display_input = navigation_state.get_display_input(); // Use the new method
|
||||||
|
|
||||||
|
let num_total_filtered = navigation_state.filtered_options.len();
|
||||||
|
let current_selected_list_idx = navigation_state.selected_index;
|
||||||
|
|
||||||
|
let mut display_start_offset = 0;
|
||||||
|
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
|
||||||
|
if let Some(sel_idx) = current_selected_list_idx {
|
||||||
|
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
|
||||||
|
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
|
||||||
|
} else if sel_idx < display_start_offset {
|
||||||
|
display_start_offset = sel_idx;
|
||||||
|
}
|
||||||
|
display_start_offset = display_start_offset
|
||||||
|
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
display_start_offset = display_start_offset.max(0);
|
||||||
|
|
||||||
|
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
|
||||||
|
.min(num_total_filtered);
|
||||||
|
|
||||||
|
// navigation_state.filtered_options is Vec<(usize, String)>
|
||||||
|
// We only need the String part for display.
|
||||||
|
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
|
||||||
|
navigation_state.filtered_options
|
||||||
|
[display_start_offset..display_end_offset]
|
||||||
|
.iter()
|
||||||
|
.map(|(_, opt_str)| opt_str)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // For palette input line
|
||||||
|
Constraint::Min(0), // For options list, take remaining space
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
|
||||||
|
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
|
||||||
|
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
|
||||||
|
|
||||||
|
|
||||||
|
let input_area = chunks[0];
|
||||||
|
// let list_area = chunks[1]; // Use final_list_area
|
||||||
|
|
||||||
|
let prompt_prefix = match navigation_state.navigation_type {
|
||||||
|
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
|
||||||
|
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
|
||||||
|
};
|
||||||
|
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
|
||||||
|
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
|
||||||
|
let input_area_width = input_area.width as usize;
|
||||||
|
let input_padding_needed =
|
||||||
|
input_area_width.saturating_sub(prompt_text_width);
|
||||||
|
|
||||||
|
let padded_prompt_text = if input_padding_needed > 0 {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
base_prompt_text,
|
||||||
|
PADDING_CHAR.repeat(input_padding_needed)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
base_prompt_text
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_paragraph = Paragraph::new(padded_prompt_text)
|
||||||
|
.style(Style::default().fg(theme.accent).bg(theme.bg));
|
||||||
|
f.render_widget(input_paragraph, input_area);
|
||||||
|
|
||||||
|
let mut display_list_items: Vec<ListItem> =
|
||||||
|
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
|
||||||
|
|
||||||
|
for (idx_in_visible_slice, opt_str) in
|
||||||
|
visible_options_slice.iter().enumerate()
|
||||||
|
{
|
||||||
|
// The selected_index in navigation_state is relative to the full filtered_options list.
|
||||||
|
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
|
||||||
|
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
|
||||||
|
let is_selected =
|
||||||
|
current_selected_list_idx == Some(original_filtered_idx);
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default().fg(theme.bg).bg(theme.accent)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.fg).bg(theme.bg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let opt_width = opt_str.width() as u16;
|
||||||
|
let list_item_width = final_list_area.width;
|
||||||
|
let padding_amount = list_item_width.saturating_sub(opt_width);
|
||||||
|
let padded_opt_str = format!(
|
||||||
|
"{}{}",
|
||||||
|
opt_str,
|
||||||
|
PADDING_CHAR.repeat(padding_amount as usize)
|
||||||
|
);
|
||||||
|
display_list_items.push(ListItem::new(padded_opt_str).style(style));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining lines in the list area to maintain fixed height appearance
|
||||||
|
let num_rendered_options = display_list_items.len();
|
||||||
|
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
|
||||||
|
for _ in num_rendered_options..(final_list_area.height as usize) {
|
||||||
|
let empty_padded_str =
|
||||||
|
PADDING_CHAR.repeat(final_list_area.width as usize);
|
||||||
|
display_list_items.push(
|
||||||
|
ListItem::new(empty_padded_str)
|
||||||
|
.style(Style::default().fg(theme.bg).bg(theme.bg)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let options_list_widget = List::new(display_list_items)
|
||||||
|
.block(Block::default().style(Style::default().bg(theme.bg)));
|
||||||
|
f.render_widget(options_list_widget, final_list_area);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
|
// src/components/common/status_line.rs
|
||||||
|
use crate::config::colors::themes::Theme;
|
||||||
|
use crate::state::app::state::AppState;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
style::Style,
|
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
Frame,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
|
Frame,
|
||||||
};
|
};
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
pub fn render_status_line(
|
pub fn render_status_line(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
@@ -16,11 +18,24 @@ pub fn render_status_line(
|
|||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
current_fps: f64,
|
current_fps: f64,
|
||||||
|
app_state: &AppState,
|
||||||
) {
|
) {
|
||||||
|
// --- START FIX ---
|
||||||
|
// Ensure debug_text is always a &str, which implements UnicodeWidthStr.
|
||||||
|
#[cfg(feature = "ui-debug")]
|
||||||
|
let debug_text = app_state.debug_info.as_str();
|
||||||
|
#[cfg(not(feature = "ui-debug"))]
|
||||||
|
let debug_text = "";
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
|
let debug_width = UnicodeWidthStr::width(debug_text);
|
||||||
|
let debug_separator_width = if !debug_text.is_empty() { UnicodeWidthStr::width(" | ") } else { 0 };
|
||||||
|
|
||||||
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
|
||||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||||
|
|
||||||
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
let home_dir =
|
||||||
|
dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
let display_dir = if current_dir.starts_with(&home_dir) {
|
||||||
current_dir.replacen(&home_dir, "~", 1)
|
current_dir.replacen(&home_dir, "~", 1)
|
||||||
} else {
|
} else {
|
||||||
@@ -35,16 +50,19 @@ pub fn render_status_line(
|
|||||||
let separator = " | ";
|
let separator = " | ";
|
||||||
let separator_width = UnicodeWidthStr::width(separator);
|
let separator_width = UnicodeWidthStr::width(separator);
|
||||||
|
|
||||||
let fixed_width_with_fps = mode_width + separator_width + separator_width +
|
let fixed_width_with_fps = mode_width + separator_width + separator_width +
|
||||||
program_info_width + separator_width + fps_width;
|
program_info_width + separator_width + fps_width +
|
||||||
let show_fps = fixed_width_with_fps < available_width;
|
debug_separator_width + debug_width;
|
||||||
|
let show_fps = fixed_width_with_fps <= available_width;
|
||||||
|
|
||||||
let remaining_width_for_dir = available_width.saturating_sub(
|
let remaining_width_for_dir = available_width.saturating_sub(
|
||||||
mode_width + separator_width + separator_width + program_info_width +
|
mode_width + separator_width +
|
||||||
if show_fps { separator_width + fps_width } else { 0 }
|
separator_width + program_info_width +
|
||||||
|
(if show_fps { separator_width + fps_width } else { 0 }) +
|
||||||
|
debug_separator_width + debug_width,
|
||||||
);
|
);
|
||||||
|
|
||||||
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
|
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
|
||||||
display_dir
|
display_dir
|
||||||
} else {
|
} else {
|
||||||
let dir_name = Path::new(current_dir)
|
let dir_name = Path::new(current_dir)
|
||||||
@@ -54,24 +72,46 @@ pub fn render_status_line(
|
|||||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
||||||
dir_name.to_string()
|
dir_name.to_string()
|
||||||
} else {
|
} else {
|
||||||
dir_name.chars().take(remaining_width_for_dir).collect()
|
dir_name.chars().take(remaining_width_for_dir).collect::<String>()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut spans = vec![
|
let mut current_content_width = mode_width + separator_width +
|
||||||
|
UnicodeWidthStr::width(dir_display_text_str.as_str()) +
|
||||||
|
separator_width + program_info_width +
|
||||||
|
debug_separator_width + debug_width;
|
||||||
|
if show_fps {
|
||||||
|
current_content_width += separator_width + fps_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut line_spans = vec![
|
||||||
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
||||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
Span::styled(separator, Style::default().fg(theme.border)),
|
||||||
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
|
Span::styled(dir_display_text_str.as_str(), Style::default().fg(theme.fg)),
|
||||||
Span::styled(" | ", Style::default().fg(theme.border)),
|
Span::styled(separator, Style::default().fg(theme.border)),
|
||||||
Span::styled(program_info, Style::default().fg(theme.secondary)),
|
Span::styled(program_info.as_str(), Style::default().fg(theme.secondary)),
|
||||||
];
|
];
|
||||||
|
|
||||||
if show_fps {
|
if show_fps {
|
||||||
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
|
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||||
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
|
line_spans.push(Span::styled(fps_text.as_str(), Style::default().fg(theme.secondary)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Line::from(spans))
|
#[cfg(feature = "ui-debug")]
|
||||||
|
{
|
||||||
|
line_spans.push(Span::styled(separator, Style::default().fg(theme.border)));
|
||||||
|
line_spans.push(Span::styled(debug_text, Style::default().fg(theme.accent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let padding_needed = available_width.saturating_sub(current_content_width);
|
||||||
|
if padding_needed > 0 {
|
||||||
|
line_spans.push(Span::styled(
|
||||||
|
" ".repeat(padding_needed),
|
||||||
|
Style::default().bg(theme.bg),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Line::from(line_spans))
|
||||||
.style(Style::default().bg(theme.bg));
|
.style(Style::default().bg(theme.bg));
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
f.render_widget(paragraph, area);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||||
use ratatui::style::{Color, Style, Modifier};
|
use ratatui::style::{Color, Style, Modifier};
|
||||||
use tui_textarea::{Input, Key, TextArea, CursorMove, Scrolling};
|
use tui_textarea::{Input, Key, TextArea, CursorMove};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -13,32 +13,33 @@ use crate::components::handlers::canvas::render_canvas;
|
|||||||
pub fn render_form(
|
pub fn render_form(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
form_state: &impl CanvasState,
|
form_state_param: &impl CanvasState,
|
||||||
fields: &[&str],
|
fields: &[&str],
|
||||||
current_field: &usize,
|
current_field_idx: &usize,
|
||||||
inputs: &[&String],
|
inputs: &[&String],
|
||||||
|
table_name: &str, // This parameter receives the correct table name
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
total_count: u64,
|
total_count: u64,
|
||||||
current_position: u64,
|
current_position: u64,
|
||||||
) {
|
) {
|
||||||
// Create Adresar card
|
// Use the dynamic `table_name` parameter for the title instead of a hardcoded string.
|
||||||
|
let card_title = format!(" {} ", table_name);
|
||||||
|
|
||||||
let adresar_card = Block::default()
|
let adresar_card = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(Style::default().fg(theme.border))
|
||||||
.title(" Adresar ")
|
.title(card_title) // Use the dynamic title
|
||||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
f.render_widget(adresar_card, area);
|
f.render_widget(adresar_card, area);
|
||||||
|
|
||||||
// Define inner area
|
|
||||||
let inner_area = area.inner(Margin {
|
let inner_area = area.inner(Margin {
|
||||||
horizontal: 1,
|
horizontal: 1,
|
||||||
vertical: 1,
|
vertical: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create main layout
|
|
||||||
let main_layout = Layout::default()
|
let main_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -47,20 +48,27 @@ pub fn render_form(
|
|||||||
])
|
])
|
||||||
.split(inner_area);
|
.split(inner_area);
|
||||||
|
|
||||||
// Render count/position
|
let count_position_text = if total_count == 0 && current_position == 1 {
|
||||||
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
|
"Total: 0 | New Entry".to_string()
|
||||||
|
} else if current_position > total_count && total_count > 0 {
|
||||||
|
format!("Total: {} | New Entry ({})", total_count, current_position)
|
||||||
|
} else if total_count == 0 && current_position > 1 {
|
||||||
|
format!("Total: 0 | New Entry ({})", current_position)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
format!("Total: {} | Position: {}/{}", total_count, current_position, total_count)
|
||||||
|
};
|
||||||
let count_para = Paragraph::new(count_position_text)
|
let count_para = Paragraph::new(count_position_text)
|
||||||
.style(Style::default().fg(theme.fg))
|
.style(Style::default().fg(theme.fg))
|
||||||
.alignment(Alignment::Left);
|
.alignment(Alignment::Left);
|
||||||
f.render_widget(count_para, main_layout[0]);
|
f.render_widget(count_para, main_layout[0]);
|
||||||
|
|
||||||
// Delegate input handling to canvas
|
|
||||||
render_canvas(
|
render_canvas(
|
||||||
f,
|
f,
|
||||||
main_layout[1],
|
main_layout[1],
|
||||||
form_state,
|
form_state_param,
|
||||||
fields,
|
fields,
|
||||||
current_field,
|
current_field_idx,
|
||||||
inputs,
|
inputs,
|
||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use crate::state::app::buffer::BufferState;
|
use crate::state::app::buffer::BufferState;
|
||||||
|
use crate::state::app::state::AppState; // Add this import
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
@@ -17,6 +18,7 @@ pub fn render_buffer_list(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
buffer_state: &BufferState,
|
buffer_state: &BufferState,
|
||||||
|
app_state: &AppState,
|
||||||
) {
|
) {
|
||||||
// --- Style Definitions ---
|
// --- Style Definitions ---
|
||||||
let active_style = Style::default()
|
let active_style = Style::default()
|
||||||
@@ -37,6 +39,8 @@ pub fn render_buffer_list(
|
|||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut current_width = 0;
|
let mut current_width = 0;
|
||||||
|
|
||||||
|
let current_table_name = app_state.current_view_table_name.as_deref();
|
||||||
|
|
||||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
||||||
// Filter: Only process views matching the active layer
|
// Filter: Only process views matching the active layer
|
||||||
if get_view_layer(view) != active_layer {
|
if get_view_layer(view) != active_layer {
|
||||||
@@ -44,7 +48,7 @@ pub fn render_buffer_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let is_active = original_index == buffer_state.active_index;
|
let is_active = original_index == buffer_state.active_index;
|
||||||
let buffer_name = view.display_name();
|
let buffer_name = view.display_name_with_context(current_table_name);
|
||||||
let buffer_text = format!(" {} ", buffer_name);
|
let buffer_text = format!(" {} ", buffer_name);
|
||||||
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub fn get_view_layer(view: &AppView) -> u8 {
|
|||||||
match view {
|
match view {
|
||||||
AppView::Intro => 1,
|
AppView::Intro => 1,
|
||||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||||
AppView::Form(_) | AppView::Scratch => 3,
|
AppView::Form | AppView::Scratch => 3,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
|||||||
let outcome = save(
|
let outcome = save(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let message = format!("Save successful: {:?}", outcome); // Simple message for now
|
let message = format!("Save successful: {:?}", outcome); // Simple message for now
|
||||||
@@ -40,8 +38,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
|||||||
revert(
|
revert(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
|||||||
action: &str,
|
action: &str,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
match action {
|
match action {
|
||||||
"save" | "revert" => {
|
"save" | "revert" => {
|
||||||
@@ -30,8 +28,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
|||||||
let save_result = save(
|
let save_result = save(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match save_result {
|
match save_result {
|
||||||
@@ -50,8 +46,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
|
|||||||
let revert_result = revert(
|
let revert_result = revert(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match revert_result {
|
match revert_result {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use crate::services::GrpcClient;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crate::components::common::text_editor::TextEditor;
|
use crate::components::common::text_editor::TextEditor;
|
||||||
|
use crate::services::ui_service::UiService;
|
||||||
|
use tui_textarea::CursorMove; // Ensure this import is present
|
||||||
|
|
||||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||||
|
|
||||||
@@ -22,31 +24,231 @@ pub fn handle_add_logic_navigation(
|
|||||||
is_edit_mode: &mut bool,
|
is_edit_mode: &mut bool,
|
||||||
buffer_state: &mut BufferState,
|
buffer_state: &mut BufferState,
|
||||||
grpc_client: GrpcClient,
|
grpc_client: GrpcClient,
|
||||||
save_logic_sender: SaveLogicResultSender,
|
_save_logic_sender: SaveLogicResultSender, // Marked as unused
|
||||||
command_message: &mut String,
|
command_message: &mut String,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
// === AUTOCOMPLETE HANDLING ===
|
||||||
|
if add_logic_state.script_editor_autocomplete_active {
|
||||||
// Handle ONLY Escape to exit fullscreen mode
|
match key_event.code {
|
||||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||||
match add_logic_state.editor_keybinding_mode {
|
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||||
EditorKeybindingMode::Vim => {
|
add_logic_state.script_editor_filter_text.push(c);
|
||||||
if *is_edit_mode {
|
add_logic_state.update_script_editor_suggestions();
|
||||||
// First escape: try to go to Vim Normal mode
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
TextEditor::handle_input(
|
TextEditor::handle_input(
|
||||||
&mut editor_borrow,
|
&mut editor_borrow,
|
||||||
key_event,
|
key_event,
|
||||||
&add_logic_state.editor_keybinding_mode,
|
&add_logic_state.editor_keybinding_mode,
|
||||||
&mut add_logic_state.vim_state,
|
&mut add_logic_state.vim_state,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||||
|
add_logic_state.script_editor_filter_text.pop();
|
||||||
|
add_logic_state.update_script_editor_suggestions();
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||||
|
"Autocomplete: @".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||||
|
let current_cursor = {
|
||||||
|
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||||
|
editor_borrow.cursor()
|
||||||
|
};
|
||||||
|
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if should_deactivate {
|
||||||
|
add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
*command_message = "Autocomplete cancelled".to_string();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||||
|
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||||
|
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||||
|
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||||
|
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||||
|
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||||
|
let prev = if current == 0 {
|
||||||
|
add_logic_state.script_editor_suggestions.len() - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
};
|
||||||
|
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||||
|
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||||
|
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||||
|
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||||
|
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||||
|
|
||||||
|
add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
add_logic_state.has_unsaved_changes = true;
|
||||||
|
|
||||||
|
if let Some(pos) = trigger_pos {
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
|
||||||
|
if suggestion == "sql" {
|
||||||
|
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||||
|
editor_borrow.insert_str("('')");
|
||||||
|
// Move cursor back twice to be between the single quotes
|
||||||
|
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||||
|
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||||
|
*command_message = "Inserted: @sql('')".to_string();
|
||||||
|
} else {
|
||||||
|
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||||
|
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||||
|
|
||||||
|
if is_table_selection {
|
||||||
|
editor_borrow.insert_str(".");
|
||||||
|
let new_cursor = editor_borrow.cursor();
|
||||||
|
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||||
|
|
||||||
|
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||||
|
add_logic_state.script_editor_autocomplete_active = true;
|
||||||
|
add_logic_state.script_editor_filter_text.clear();
|
||||||
|
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||||
|
|
||||||
|
let profile_name = add_logic_state.profile_name.clone();
|
||||||
|
let table_name_for_fetch = suggestion.clone();
|
||||||
|
let mut client_clone = grpc_client.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||||
|
Ok(_columns) => {
|
||||||
|
// Result handled by main UI loop
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||||
|
} else {
|
||||||
|
*command_message = format!("Inserted: {}", suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
*command_message = "Autocomplete cancelled".to_string();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
*command_message = "Autocomplete cancelled".to_string();
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||||
|
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||||
|
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if should_trigger {
|
||||||
|
let cursor_before = {
|
||||||
|
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||||
|
editor_borrow.cursor()
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||||
|
add_logic_state.script_editor_autocomplete_active = true;
|
||||||
|
add_logic_state.script_editor_filter_text.clear();
|
||||||
|
add_logic_state.update_script_editor_suggestions();
|
||||||
|
add_logic_state.has_unsaved_changes = true;
|
||||||
|
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||||
|
match add_logic_state.editor_keybinding_mode {
|
||||||
|
EditorKeybindingMode::Vim => {
|
||||||
|
if *is_edit_mode {
|
||||||
|
{
|
||||||
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
|
TextEditor::handle_input(
|
||||||
|
&mut editor_borrow,
|
||||||
|
key_event,
|
||||||
|
&add_logic_state.editor_keybinding_mode,
|
||||||
|
&mut add_logic_state.vim_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Second escape: exit fullscreen
|
|
||||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||||
app_state.ui.focus_outside_canvas = true;
|
app_state.ui.focus_outside_canvas = true;
|
||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
@@ -58,7 +260,6 @@ pub fn handle_add_logic_navigation(
|
|||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||||
} else {
|
} else {
|
||||||
// Exit fullscreen
|
|
||||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||||
app_state.ui.focus_outside_canvas = true;
|
app_state.ui.focus_outside_canvas = true;
|
||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
@@ -69,27 +270,24 @@ pub fn handle_add_logic_navigation(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALL OTHER KEYS: Pass directly to textarea without any interference
|
let changed = {
|
||||||
let changed = TextEditor::handle_input(
|
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||||
&mut editor_borrow,
|
TextEditor::handle_input(
|
||||||
key_event,
|
&mut editor_borrow,
|
||||||
&add_logic_state.editor_keybinding_mode,
|
key_event,
|
||||||
&mut add_logic_state.vim_state,
|
&add_logic_state.editor_keybinding_mode,
|
||||||
);
|
&mut add_logic_state.vim_state,
|
||||||
if changed {
|
)
|
||||||
add_logic_state.has_unsaved_changes = true;
|
};
|
||||||
|
if changed {
|
||||||
|
add_logic_state.has_unsaved_changes = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update edit mode status for Vim
|
|
||||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
return true; // Always consume the event in fullscreen mode
|
|
||||||
}
|
}
|
||||||
// === END FULLSCREEN ISOLATION ===
|
|
||||||
|
|
||||||
// Regular navigation logic for non-fullscreen elements
|
|
||||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||||
let current_focus = add_logic_state.current_focus;
|
let current_focus = add_logic_state.current_focus;
|
||||||
let mut handled = true;
|
let mut handled = true;
|
||||||
@@ -97,14 +295,11 @@ pub fn handle_add_logic_navigation(
|
|||||||
|
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("exit_table_scroll") => {
|
Some("exit_table_scroll") => {
|
||||||
// This shouldn't happen since we handle InsideScriptContent above
|
|
||||||
handled = false;
|
handled = false;
|
||||||
}
|
}
|
||||||
Some("move_up") => {
|
Some("move_up") => {
|
||||||
match current_focus {
|
match current_focus {
|
||||||
AddLogicFocus::InputLogicName => {
|
AddLogicFocus::InputLogicName => {}
|
||||||
// Stay at top
|
|
||||||
}
|
|
||||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||||
@@ -123,9 +318,7 @@ pub fn handle_add_logic_navigation(
|
|||||||
},
|
},
|
||||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||||
AddLogicFocus::CancelButton => {
|
AddLogicFocus::CancelButton => {}
|
||||||
// Stay at bottom
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
_ => handled = false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,14 +328,14 @@ pub fn handle_add_logic_navigation(
|
|||||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||||
AddLogicFocus::CancelButton => { /* Stay at last */ }
|
AddLogicFocus::CancelButton => { }
|
||||||
_ => handled = false,
|
_ => handled = false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("previous_option") => {
|
Some("previous_option") => {
|
||||||
match current_focus {
|
match current_focus {
|
||||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||||
{ /* Stay at first */ }
|
{ }
|
||||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||||
@@ -175,8 +368,8 @@ pub fn handle_add_logic_navigation(
|
|||||||
match current_focus {
|
match current_focus {
|
||||||
AddLogicFocus::ScriptContentPreview => {
|
AddLogicFocus::ScriptContentPreview => {
|
||||||
new_focus = AddLogicFocus::InsideScriptContent;
|
new_focus = AddLogicFocus::InsideScriptContent;
|
||||||
*is_edit_mode = false; // Start in preview mode
|
*is_edit_mode = false;
|
||||||
app_state.ui.focus_outside_canvas = false; // Script is like canvas
|
app_state.ui.focus_outside_canvas = false;
|
||||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||||
_ => "Enter/Ctrl+E to edit",
|
_ => "Enter/Ctrl+E to edit",
|
||||||
@@ -215,24 +408,33 @@ pub fn handle_add_logic_navigation(
|
|||||||
|
|
||||||
if handled && current_focus != new_focus {
|
if handled && current_focus != new_focus {
|
||||||
add_logic_state.current_focus = new_focus;
|
add_logic_state.current_focus = new_focus;
|
||||||
|
|
||||||
// Set edit mode and canvas focus based on new focus
|
|
||||||
let new_is_canvas_input_focus = matches!(new_focus,
|
let new_is_canvas_input_focus = matches!(new_focus,
|
||||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||||
);
|
);
|
||||||
|
|
||||||
if new_is_canvas_input_focus {
|
if new_is_canvas_input_focus {
|
||||||
// Entering canvas - start in readonly mode
|
|
||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
app_state.ui.focus_outside_canvas = false;
|
||||||
} else {
|
} else {
|
||||||
// Outside canvas
|
|
||||||
app_state.ui.focus_outside_canvas = true;
|
app_state.ui.focus_outside_canvas = true;
|
||||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||||
*is_edit_mode = false;
|
*is_edit_mode = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handled
|
handled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_autocomplete_text(
|
||||||
|
editor: &mut tui_textarea::TextArea,
|
||||||
|
trigger_pos: (usize, usize),
|
||||||
|
filter_len: usize,
|
||||||
|
replacement: &str,
|
||||||
|
) {
|
||||||
|
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||||
|
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||||
|
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||||
|
for _ in 0..filter_len {
|
||||||
|
editor.delete_next_char();
|
||||||
|
}
|
||||||
|
editor.insert_str(replacement);
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,31 +234,34 @@ pub fn handle_admin_navigation(
|
|||||||
admin_state.add_logic_state = AddLogicState {
|
admin_state.add_logic_state = AddLogicState {
|
||||||
profile_name: profile.name.clone(),
|
profile_name: profile.name.clone(),
|
||||||
selected_table_name: Some(table.name.clone()),
|
selected_table_name: Some(table.name.clone()),
|
||||||
// selected_table_id: table.id, // If you have table IDs
|
selected_table_id: Some(table.id), // If you have table IDs
|
||||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||||
current_focus: AddLogicFocus::default(), // Reset focus for the new screen
|
current_focus: AddLogicFocus::default(),
|
||||||
..AddLogicState::default()
|
..AddLogicState::default()
|
||||||
};
|
};
|
||||||
buffer_state.update_history(AppView::AddLogic); // Switch view
|
|
||||||
app_state.ui.focus_outside_canvas = false; // Ensure canvas focus
|
// Store table info for later fetching
|
||||||
|
app_state.pending_table_structure_fetch = Some((
|
||||||
|
profile.name.clone(),
|
||||||
|
table.name.clone()
|
||||||
|
));
|
||||||
|
|
||||||
|
buffer_state.update_history(AppView::AddLogic);
|
||||||
|
app_state.ui.focus_outside_canvas = false;
|
||||||
*command_message = format!(
|
*command_message = format!(
|
||||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||||
table.name, profile.name
|
table.name, profile.name
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// This case should ideally not be reached if indices are managed correctly
|
|
||||||
*command_message = "Error: Selected table data not found.".to_string();
|
*command_message = "Error: Selected table data not found.".to_string();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Profile is selected, but table is not
|
|
||||||
*command_message = "Select a table first!".to_string();
|
*command_message = "Select a table first!".to_string();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// This case should ideally not be reached if p_idx is valid
|
|
||||||
*command_message = "Error: Selected profile data not found.".to_string();
|
*command_message = "Error: Selected profile data not found.".to_string();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Profile is not selected
|
|
||||||
*command_message = "Select a profile first!".to_string();
|
*command_message = "Select a profile first!".to_string();
|
||||||
}
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod components;
|
|||||||
pub mod modes;
|
pub mod modes;
|
||||||
pub mod functions;
|
pub mod functions;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
pub use ui::run_ui;
|
pub use ui::run_ui;
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ pub async fn handle_core_action(
|
|||||||
auth_client: &mut AuthClient,
|
auth_client: &mut AuthClient,
|
||||||
terminal: &mut TerminalCore,
|
terminal: &mut TerminalCore,
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
match action {
|
match action {
|
||||||
"save" => {
|
"save" => {
|
||||||
@@ -36,8 +34,6 @@ pub async fn handle_core_action(
|
|||||||
let save_outcome = form_save(
|
let save_outcome = form_save(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await.context("Register save action failed")?;
|
).await.context("Register save action failed")?;
|
||||||
let message = match save_outcome {
|
let message = match save_outcome {
|
||||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||||
@@ -58,8 +54,6 @@ pub async fn handle_core_action(
|
|||||||
let save_outcome = form_save(
|
let save_outcome = form_save(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await?;
|
).await?;
|
||||||
match save_outcome {
|
match save_outcome {
|
||||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||||
@@ -81,8 +75,6 @@ pub async fn handle_core_action(
|
|||||||
let message = form_revert(
|
let message = form_revert(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await.context("Form revert x action failed")?;
|
).await.context("Form revert x action failed")?;
|
||||||
Ok(EventOutcome::Ok(message))
|
Ok(EventOutcome::Ok(message))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ pub async fn handle_edit_event(
|
|||||||
// TODO: Implement common actions for AddLogic if needed
|
// TODO: Implement common actions for AddLogic if needed
|
||||||
format!("Action '{}' not implemented for Add Logic in edit mode.", action)
|
format!("Action '{}' not implemented for Add Logic in edit mode.", action)
|
||||||
} else { // Assuming Form view
|
} else { // Assuming Form view
|
||||||
let outcome = form_e::execute_common_action(action, form_state, grpc_client, current_position, total_count).await?;
|
let outcome = form_e::execute_common_action(action, form_state, grpc_client).await?;
|
||||||
match outcome {
|
match outcome {
|
||||||
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
|
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
|
||||||
_ => format!("Unexpected outcome from common action: {:?}", outcome),
|
_ => format!("Unexpected outcome from common action: {:?}", outcome),
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ pub async fn handle_read_only_event(
|
|||||||
add_table_state: &mut AddTableState,
|
add_table_state: &mut AddTableState,
|
||||||
add_logic_state: &mut AddLogicState,
|
add_logic_state: &mut AddLogicState,
|
||||||
key_sequence_tracker: &mut KeySequenceTracker,
|
key_sequence_tracker: &mut KeySequenceTracker,
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
command_message: &mut String,
|
command_message: &mut String,
|
||||||
edit_mode_cooldown: &mut bool,
|
edit_mode_cooldown: &mut bool,
|
||||||
@@ -74,12 +72,10 @@ pub async fn handle_read_only_event(
|
|||||||
action,
|
action,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||||
crate::tui::functions::login::handle_action(action).await?
|
crate::tui::functions::login::handle_action(action).await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
add_table_ro::execute_action(
|
add_table_ro::execute_action(
|
||||||
@@ -143,12 +139,10 @@ pub async fn handle_read_only_event(
|
|||||||
action,
|
action,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
|
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
|
||||||
crate::tui::functions::login::handle_action(action).await?
|
crate::tui::functions::login::handle_action(action).await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
add_table_ro::execute_action(
|
add_table_ro::execute_action(
|
||||||
@@ -177,7 +171,7 @@ pub async fn handle_read_only_event(
|
|||||||
key_sequence_tracker,
|
key_sequence_tracker,
|
||||||
command_message,
|
command_message,
|
||||||
).await?
|
).await?
|
||||||
} else if app_state.ui.show_login { // Handle login general actions
|
} else if app_state.ui.show_login {
|
||||||
auth_ro::execute_action(
|
auth_ro::execute_action(
|
||||||
action,
|
action,
|
||||||
app_state,
|
app_state,
|
||||||
@@ -211,8 +205,6 @@ pub async fn handle_read_only_event(
|
|||||||
action,
|
action,
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
ideal_cursor_column,
|
ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -245,7 +237,7 @@ pub async fn handle_read_only_event(
|
|||||||
key_sequence_tracker,
|
key_sequence_tracker,
|
||||||
command_message,
|
command_message,
|
||||||
).await?
|
).await?
|
||||||
} else if app_state.ui.show_login { // Handle login general actions
|
} else if app_state.ui.show_login {
|
||||||
auth_ro::execute_action(
|
auth_ro::execute_action(
|
||||||
action,
|
action,
|
||||||
app_state,
|
app_state,
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ async fn process_command(
|
|||||||
let outcome = save(
|
let outcome = save(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await?;
|
).await?;
|
||||||
let message = match outcome {
|
let message = match outcome {
|
||||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||||
@@ -134,8 +132,6 @@ async fn process_command(
|
|||||||
let message = revert(
|
let message = revert(
|
||||||
form_state,
|
form_state,
|
||||||
grpc_client,
|
grpc_client,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await?;
|
).await?;
|
||||||
command_input.clear();
|
command_input.clear();
|
||||||
Ok(EventOutcome::Ok(message))
|
Ok(EventOutcome::Ok(message))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// src/client/modes/general.rs
|
// src/client/modes/general.rs
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod command_navigation;
|
||||||
|
|||||||
396
client/src/modes/general/command_navigation.rs
Normal file
396
client/src/modes/general/command_navigation.rs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// src/modes/general/command_navigation.rs
|
||||||
|
use crate::config::binds::config::Config;
|
||||||
|
use crate::modes::handlers::event::EventOutcome;
|
||||||
|
use anyhow::Result;
|
||||||
|
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum NavigationType {
|
||||||
|
FindFile,
|
||||||
|
TableTree,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TableDependencyGraph {
|
||||||
|
all_tables: HashSet<String>,
|
||||||
|
dependents_map: HashMap<String, Vec<String>>,
|
||||||
|
root_tables: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableDependencyGraph {
|
||||||
|
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
|
||||||
|
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut all_tables_set: HashSet<String> = HashSet::new();
|
||||||
|
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
|
||||||
|
for profile in &profile_tree.profiles {
|
||||||
|
for table in &profile.tables {
|
||||||
|
all_tables_set.insert(table.name.clone());
|
||||||
|
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
|
||||||
|
|
||||||
|
for dependency_name in &table.depends_on {
|
||||||
|
dependents_map
|
||||||
|
.entry(dependency_name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(table.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_tables: Vec<String> = all_tables_set
|
||||||
|
.iter()
|
||||||
|
.filter(|name| {
|
||||||
|
table_dependencies
|
||||||
|
.get(*name)
|
||||||
|
.map_or(true, |deps| deps.is_empty())
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sorted_root_tables = root_tables;
|
||||||
|
sorted_root_tables.sort();
|
||||||
|
|
||||||
|
for dependents_list in dependents_map.values_mut() {
|
||||||
|
dependents_list.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
all_tables: all_tables_set,
|
||||||
|
dependents_map,
|
||||||
|
root_tables: sorted_root_tables,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
|
||||||
|
if path.is_empty() {
|
||||||
|
return self.root_tables.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
if let Some(last_segment_name) = path_segments.last() {
|
||||||
|
if self.all_tables.contains(*last_segment_name) {
|
||||||
|
return self
|
||||||
|
.dependents_map
|
||||||
|
.get(*last_segment_name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||||
|
pub struct NavigationState {
|
||||||
|
pub active: bool,
|
||||||
|
pub input: String,
|
||||||
|
pub selected_index: Option<usize>,
|
||||||
|
pub filtered_options: Vec<(usize, String)>,
|
||||||
|
pub navigation_type: NavigationType,
|
||||||
|
pub current_path: String,
|
||||||
|
pub graph: Option<TableDependencyGraph>,
|
||||||
|
pub all_options: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavigationState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active: false,
|
||||||
|
input: String::new(),
|
||||||
|
selected_index: None,
|
||||||
|
filtered_options: Vec::new(),
|
||||||
|
navigation_type: NavigationType::FindFile,
|
||||||
|
current_path: String::new(),
|
||||||
|
graph: None,
|
||||||
|
all_options: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
||||||
|
self.active = true;
|
||||||
|
self.navigation_type = NavigationType::FindFile;
|
||||||
|
self.all_options = options;
|
||||||
|
self.input.clear();
|
||||||
|
self.current_path.clear();
|
||||||
|
self.graph = None;
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
|
||||||
|
self.active = true;
|
||||||
|
self.navigation_type = NavigationType::TableTree;
|
||||||
|
self.graph = Some(graph);
|
||||||
|
self.input.clear();
|
||||||
|
self.current_path.clear();
|
||||||
|
self.update_options_for_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deactivate(&mut self) {
|
||||||
|
self.active = false;
|
||||||
|
self.input.clear();
|
||||||
|
self.all_options.clear();
|
||||||
|
self.filtered_options.clear();
|
||||||
|
self.selected_index = None;
|
||||||
|
self.current_path.clear();
|
||||||
|
self.graph = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_char(&mut self, c: char) {
|
||||||
|
match self.navigation_type {
|
||||||
|
NavigationType::FindFile => {
|
||||||
|
self.input.push(c);
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
NavigationType::TableTree => {
|
||||||
|
if c == '/' {
|
||||||
|
if !self.input.is_empty() {
|
||||||
|
if self.current_path.is_empty() {
|
||||||
|
self.current_path = self.input.clone();
|
||||||
|
} else {
|
||||||
|
self.current_path.push('/');
|
||||||
|
self.current_path.push_str(&self.input);
|
||||||
|
}
|
||||||
|
self.input.clear();
|
||||||
|
self.update_options_for_path();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.input.push(c);
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_char(&mut self) {
|
||||||
|
match self.navigation_type {
|
||||||
|
NavigationType::FindFile => {
|
||||||
|
self.input.pop();
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
NavigationType::TableTree => {
|
||||||
|
if self.input.is_empty() {
|
||||||
|
if !self.current_path.is_empty() {
|
||||||
|
if let Some(last_slash_idx) = self.current_path.rfind('/') {
|
||||||
|
self.input = self.current_path[last_slash_idx + 1..].to_string();
|
||||||
|
self.current_path = self.current_path[..last_slash_idx].to_string();
|
||||||
|
} else {
|
||||||
|
self.input = self.current_path.clone();
|
||||||
|
self.current_path.clear();
|
||||||
|
}
|
||||||
|
self.update_options_for_path();
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.input.pop();
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_up(&mut self) {
|
||||||
|
if self.filtered_options.is_empty() {
|
||||||
|
self.selected_index = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_index = match self.selected_index {
|
||||||
|
Some(0) => Some(self.filtered_options.len() - 1),
|
||||||
|
Some(current) => Some(current - 1),
|
||||||
|
None => Some(self.filtered_options.len() - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_down(&mut self) {
|
||||||
|
if self.filtered_options.is_empty() {
|
||||||
|
self.selected_index = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_index = match self.selected_index {
|
||||||
|
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
|
||||||
|
Some(current) => Some(current + 1),
|
||||||
|
None => Some(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_option_str(&self) -> Option<&str> {
|
||||||
|
self.selected_index
|
||||||
|
.and_then(|idx| self.filtered_options.get(idx))
|
||||||
|
.map(|(_, option_str)| option_str.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn autocomplete_selected(&mut self) {
|
||||||
|
if let Some(selected_option_str) = self.get_selected_option_str() {
|
||||||
|
self.input = selected_option_str.to_string();
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_display_input(&self) -> String {
|
||||||
|
match self.navigation_type {
|
||||||
|
NavigationType::FindFile => self.input.clone(),
|
||||||
|
NavigationType::TableTree => {
|
||||||
|
if self.current_path.is_empty() {
|
||||||
|
self.input.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", self.current_path, self.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- START FIX ---
|
||||||
|
pub fn get_selected_value(&self) -> Option<String> {
|
||||||
|
match self.navigation_type {
|
||||||
|
NavigationType::FindFile => {
|
||||||
|
// Return the highlighted option, not the raw input buffer.
|
||||||
|
self.get_selected_option_str().map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
NavigationType::TableTree => {
|
||||||
|
self.get_selected_option_str().map(|selected_name| {
|
||||||
|
if self.current_path.is_empty() {
|
||||||
|
selected_name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", self.current_path, selected_name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
|
fn update_options_for_path(&mut self) {
|
||||||
|
if let NavigationType::TableTree = self.navigation_type {
|
||||||
|
if let Some(graph) = &self.graph {
|
||||||
|
self.all_options = graph.get_dependent_children(&self.current_path);
|
||||||
|
} else {
|
||||||
|
self.all_options.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.update_filtered_options();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_filtered_options(&mut self) {
|
||||||
|
let filter_text = match self.navigation_type {
|
||||||
|
NavigationType::FindFile => &self.input,
|
||||||
|
NavigationType::TableTree => &self.input,
|
||||||
|
}
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if filter_text.is_empty() {
|
||||||
|
self.filtered_options = self
|
||||||
|
.all_options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, opt)| (i, opt.clone()))
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
self.filtered_options = self
|
||||||
|
.all_options
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
|
||||||
|
.map(|(i, opt)| (i, opt.clone()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.filtered_options.is_empty() {
|
||||||
|
self.selected_index = None;
|
||||||
|
} else {
|
||||||
|
self.selected_index = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn handle_command_navigation_event(
|
||||||
|
navigation_state: &mut NavigationState,
|
||||||
|
key: KeyEvent,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<EventOutcome> {
|
||||||
|
if !navigation_state.active {
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
navigation_state.deactivate();
|
||||||
|
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
||||||
|
if navigation_state.input == selected_opt_str {
|
||||||
|
if navigation_state.navigation_type == NavigationType::TableTree {
|
||||||
|
let path_before_nav = navigation_state.current_path.clone();
|
||||||
|
let input_before_nav = navigation_state.input.clone();
|
||||||
|
navigation_state.add_char('/');
|
||||||
|
if !(navigation_state.input.is_empty() &&
|
||||||
|
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
|
||||||
|
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
|
||||||
|
navigation_state.input = input_before_nav;
|
||||||
|
if navigation_state.current_path != path_before_nav {
|
||||||
|
navigation_state.current_path = path_before_nav;
|
||||||
|
}
|
||||||
|
navigation_state.update_options_for_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigation_state.autocomplete_selected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
navigation_state.remove_char();
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
navigation_state.add_char(c);
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||||
|
match action {
|
||||||
|
"move_up" => {
|
||||||
|
navigation_state.move_up();
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
"move_down" => {
|
||||||
|
navigation_state.move_down();
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
"select" => {
|
||||||
|
if let Some(selected_value) = navigation_state.get_selected_value() {
|
||||||
|
let outcome = match navigation_state.navigation_type {
|
||||||
|
// --- START FIX ---
|
||||||
|
NavigationType::FindFile => {
|
||||||
|
// The purpose of this palette is to select a table.
|
||||||
|
// Emit a TableSelected event instead of a generic Ok message.
|
||||||
|
EventOutcome::TableSelected {
|
||||||
|
path: selected_value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
NavigationType::TableTree => {
|
||||||
|
EventOutcome::TableSelected {
|
||||||
|
path: selected_value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigation_state.deactivate();
|
||||||
|
Ok(outcome)
|
||||||
|
} else {
|
||||||
|
Ok(EventOutcome::Ok("No selection".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(EventOutcome::Ok(String::new())),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(EventOutcome::Ok(String::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use crate::state::pages::admin::AdminState;
|
|||||||
use crate::state::pages::canvas_state::CanvasState;
|
use crate::state::pages::canvas_state::CanvasState;
|
||||||
use crate::ui::handlers::context::UiContext;
|
use crate::ui::handlers::context::UiContext;
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
use crate::modes::handlers::event::EventOutcome;
|
||||||
|
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
pub async fn handle_navigation_event(
|
pub async fn handle_navigation_event(
|
||||||
@@ -25,7 +26,13 @@ pub async fn handle_navigation_event(
|
|||||||
command_mode: &mut bool,
|
command_mode: &mut bool,
|
||||||
command_input: &mut String,
|
command_input: &mut String,
|
||||||
command_message: &mut String,
|
command_message: &mut String,
|
||||||
|
navigation_state: &mut NavigationState,
|
||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
|
// Handle command navigation first if active
|
||||||
|
if navigation_state.active {
|
||||||
|
return handle_command_navigation_event(navigation_state, key, config).await;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||||
match action {
|
match action {
|
||||||
"move_up" => {
|
"move_up" => {
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
// src/modes/handlers/event.rs
|
// src/modes/handlers/event.rs
|
||||||
use crossterm::event::Event;
|
|
||||||
use crossterm::cursor::SetCursorStyle;
|
|
||||||
use crate::services::grpc_client::GrpcClient;
|
|
||||||
use crate::services::auth::AuthClient;
|
|
||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::ui::handlers::rat_state::UiStateHandler;
|
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||||
use crate::ui::handlers::context::UiContext;
|
|
||||||
use crate::functions::common::buffer;
|
use crate::functions::common::buffer;
|
||||||
use anyhow::Result;
|
use crate::functions::modes::navigation::add_logic_nav;
|
||||||
use crate::tui::{
|
use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
|
||||||
terminal::core::TerminalCore,
|
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
|
||||||
functions::{
|
use crate::functions::modes::navigation::{add_table_nav, admin_nav};
|
||||||
common::{form::SaveOutcome, login, register},
|
use crate::modes::general::command_navigation::{
|
||||||
},
|
handle_command_navigation_event, NavigationState,
|
||||||
{intro, admin},
|
|
||||||
};
|
};
|
||||||
|
use crate::modes::{
|
||||||
|
canvas::{common_mode, edit, read_only},
|
||||||
|
common::{command_mode, commands::CommandHandler},
|
||||||
|
general::{dialog, navigation},
|
||||||
|
handlers::mode_manager::{AppMode, ModeManager},
|
||||||
|
};
|
||||||
|
use crate::services::auth::AuthClient;
|
||||||
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
app::{
|
app::{
|
||||||
|
buffer::{AppView, BufferState},
|
||||||
highlight::HighlightState,
|
highlight::HighlightState,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
buffer::{AppView, BufferState},
|
|
||||||
},
|
},
|
||||||
pages::{
|
pages::{
|
||||||
auth::{AuthState, LoginState, RegisterState},
|
|
||||||
admin::AdminState,
|
admin::AdminState,
|
||||||
|
auth::{AuthState, LoginState, RegisterState},
|
||||||
canvas_state::CanvasState,
|
canvas_state::CanvasState,
|
||||||
form::FormState,
|
form::FormState,
|
||||||
intro::IntroState,
|
intro::IntroState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use crate::modes::{
|
|
||||||
common::{command_mode, commands::CommandHandler},
|
|
||||||
handlers::mode_manager::{ModeManager, AppMode},
|
|
||||||
canvas::{edit, read_only, common_mode},
|
|
||||||
general::{navigation, dialog},
|
|
||||||
};
|
|
||||||
use crate::functions::modes::navigation::{admin_nav, add_table_nav};
|
|
||||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use crate::tui::functions::common::login::LoginResult;
|
use crate::tui::functions::common::login::LoginResult;
|
||||||
use crate::tui::functions::common::register::RegisterResult;
|
use crate::tui::functions::common::register::RegisterResult;
|
||||||
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
|
use crate::tui::{
|
||||||
use crate::functions::modes::navigation::add_logic_nav::SaveLogicResultSender;
|
functions::common::{form::SaveOutcome, login, register},
|
||||||
use crate::functions::modes::navigation::add_logic_nav;
|
terminal::core::TerminalCore,
|
||||||
|
{admin, intro},
|
||||||
|
};
|
||||||
|
use crate::ui::handlers::context::UiContext;
|
||||||
|
use crate::ui::handlers::rat_state::UiStateHandler;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::cursor::SetCursorStyle;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::{Event, KeyEvent};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum EventOutcome {
|
pub enum EventOutcome {
|
||||||
@@ -50,6 +52,16 @@ pub enum EventOutcome {
|
|||||||
Exit(String),
|
Exit(String),
|
||||||
DataSaved(SaveOutcome, String),
|
DataSaved(SaveOutcome, String),
|
||||||
ButtonSelected { context: UiContext, index: usize },
|
ButtonSelected { context: UiContext, index: usize },
|
||||||
|
TableSelected { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventOutcome {
|
||||||
|
pub fn get_message_if_ok(&self) -> String {
|
||||||
|
match self {
|
||||||
|
EventOutcome::Ok(msg) => msg.clone(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventHandler {
|
pub struct EventHandler {
|
||||||
@@ -66,6 +78,7 @@ pub struct EventHandler {
|
|||||||
pub register_result_sender: mpsc::Sender<RegisterResult>,
|
pub register_result_sender: mpsc::Sender<RegisterResult>,
|
||||||
pub save_table_result_sender: SaveTableResultSender,
|
pub save_table_result_sender: SaveTableResultSender,
|
||||||
pub save_logic_result_sender: SaveLogicResultSender,
|
pub save_logic_result_sender: SaveLogicResultSender,
|
||||||
|
pub navigation_state: NavigationState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHandler {
|
impl EventHandler {
|
||||||
@@ -83,15 +96,25 @@ impl EventHandler {
|
|||||||
highlight_state: HighlightState::Off,
|
highlight_state: HighlightState::Off,
|
||||||
edit_mode_cooldown: false,
|
edit_mode_cooldown: false,
|
||||||
ideal_cursor_column: 0,
|
ideal_cursor_column: 0,
|
||||||
key_sequence_tracker: KeySequenceTracker::new(800),
|
key_sequence_tracker: KeySequenceTracker::new(400),
|
||||||
auth_client: AuthClient::new().await?,
|
auth_client: AuthClient::new().await?,
|
||||||
login_result_sender,
|
login_result_sender,
|
||||||
register_result_sender,
|
register_result_sender,
|
||||||
save_table_result_sender,
|
save_table_result_sender,
|
||||||
save_logic_result_sender,
|
save_logic_result_sender,
|
||||||
|
navigation_state: NavigationState::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_navigation_active(&self) -> bool {
|
||||||
|
self.navigation_state.active
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
||||||
|
self.navigation_state.activate_find_file(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn handle_event(
|
pub async fn handle_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -107,10 +130,26 @@ impl EventHandler {
|
|||||||
admin_state: &mut AdminState,
|
admin_state: &mut AdminState,
|
||||||
buffer_state: &mut BufferState,
|
buffer_state: &mut BufferState,
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
total_count: u64,
|
|
||||||
current_position: &mut u64,
|
|
||||||
) -> Result<EventOutcome> {
|
) -> Result<EventOutcome> {
|
||||||
let current_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
let mut current_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
||||||
|
|
||||||
|
if current_mode == AppMode::General && self.navigation_state.active {
|
||||||
|
if let Event::Key(key_event) = event {
|
||||||
|
let outcome =
|
||||||
|
handle_command_navigation_event(&mut self.navigation_state, key_event, config)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !self.navigation_state.active {
|
||||||
|
self.command_message = outcome.get_message_if_ok();
|
||||||
|
current_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
||||||
|
}
|
||||||
|
app_state.update_mode(current_mode);
|
||||||
|
return Ok(outcome);
|
||||||
|
}
|
||||||
|
app_state.update_mode(current_mode);
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
app_state.update_mode(current_mode);
|
app_state.update_mode(current_mode);
|
||||||
|
|
||||||
let current_view = {
|
let current_view = {
|
||||||
@@ -121,51 +160,39 @@ impl EventHandler {
|
|||||||
else if ui.show_admin { AppView::Admin }
|
else if ui.show_admin { AppView::Admin }
|
||||||
else if ui.show_add_logic { AppView::AddLogic }
|
else if ui.show_add_logic { AppView::AddLogic }
|
||||||
else if ui.show_add_table { AppView::AddTable }
|
else if ui.show_add_table { AppView::AddTable }
|
||||||
else if ui.show_form {
|
else if ui.show_form { AppView::Form }
|
||||||
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
|
|
||||||
AppView::Form(form_name)
|
|
||||||
}
|
|
||||||
else { AppView::Scratch }
|
else { AppView::Scratch }
|
||||||
};
|
};
|
||||||
buffer_state.update_history(current_view);
|
buffer_state.update_history(current_view);
|
||||||
|
|
||||||
if app_state.ui.dialog.dialog_show {
|
if app_state.ui.dialog.dialog_show {
|
||||||
if let Some(dialog_result) = dialog::handle_dialog_event(
|
if let Event::Key(key_event) = event {
|
||||||
&event,
|
if let Some(dialog_result) = dialog::handle_dialog_event(
|
||||||
config,
|
&Event::Key(key_event), config, app_state, login_state,
|
||||||
app_state,
|
register_state, buffer_state, admin_state,
|
||||||
login_state,
|
).await {
|
||||||
register_state,
|
return dialog_result;
|
||||||
buffer_state,
|
}
|
||||||
admin_state,
|
} else if let Event::Resize(_, _) = event {
|
||||||
).await {
|
|
||||||
return dialog_result;
|
|
||||||
}
|
}
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Event::Key(key) = event {
|
if let Event::Key(key_event) = event {
|
||||||
let key_code = key.code;
|
let key_code = key_event.code;
|
||||||
let modifiers = key.modifiers;
|
let modifiers = key_event.modifiers;
|
||||||
|
|
||||||
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
|
if UiStateHandler::toggle_sidebar(&mut app_state.ui, config, key_code, modifiers) {
|
||||||
let message = format!("Sidebar {}",
|
let message = format!("Sidebar {}", if app_state.ui.show_sidebar { "shown" } else { "hidden" });
|
||||||
if app_state.ui.show_sidebar { "shown" } else { "hidden" }
|
|
||||||
);
|
|
||||||
return Ok(EventOutcome::Ok(message));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}
|
}
|
||||||
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
|
if UiStateHandler::toggle_buffer_list(&mut app_state.ui, config, key_code, modifiers) {
|
||||||
let message = format!("Buffer {}",
|
let message = format!("Buffer {}", if app_state.ui.show_buffer_list { "shown" } else { "hidden" });
|
||||||
if app_state.ui.show_buffer_list { "shown" } else { "hidden" }
|
|
||||||
);
|
|
||||||
return Ok(EventOutcome::Ok(message));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
|
if !matches!(current_mode, AppMode::Edit | AppMode::Command) {
|
||||||
if let Some(action) = config.get_action_for_key_in_mode(
|
if let Some(action) = config.get_action_for_key_in_mode(&config.keybindings.global, key_code, modifiers) {
|
||||||
&config.keybindings.global, key_code, modifiers
|
|
||||||
) {
|
|
||||||
match action {
|
match action {
|
||||||
"next_buffer" => {
|
"next_buffer" => {
|
||||||
if buffer::switch_buffer(buffer_state, true) {
|
if buffer::switch_buffer(buffer_state, true) {
|
||||||
@@ -178,7 +205,8 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"close_buffer" => {
|
"close_buffer" => {
|
||||||
let message = buffer_state.close_buffer_with_intro_fallback();
|
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));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -188,170 +216,96 @@ impl EventHandler {
|
|||||||
|
|
||||||
match current_mode {
|
match current_mode {
|
||||||
AppMode::General => {
|
AppMode::General => {
|
||||||
// Prioritize Admin Panel navigation if it's visible
|
if app_state.ui.show_admin && auth_state.role.as_deref() == Some("admin") {
|
||||||
if app_state.ui.show_admin
|
if admin_nav::handle_admin_navigation(key_event, config, app_state, admin_state, buffer_state, &mut self.command_message) {
|
||||||
&& auth_state.role.as_deref() == Some("admin") {
|
|
||||||
if admin_nav::handle_admin_navigation(
|
|
||||||
key,
|
|
||||||
config,
|
|
||||||
app_state,
|
|
||||||
admin_state,
|
|
||||||
buffer_state,
|
|
||||||
&mut self.command_message,
|
|
||||||
) {
|
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- Add Logic Page Navigation ---
|
|
||||||
if app_state.ui.show_add_logic {
|
if app_state.ui.show_add_logic {
|
||||||
let client_clone = grpc_client.clone();
|
let client_clone = grpc_client.clone();
|
||||||
let sender_clone = self.save_logic_result_sender.clone();
|
let sender_clone = self.save_logic_result_sender.clone();
|
||||||
|
|
||||||
if add_logic_nav::handle_add_logic_navigation(
|
if add_logic_nav::handle_add_logic_navigation(
|
||||||
key,
|
key_event, config, app_state, &mut admin_state.add_logic_state,
|
||||||
config,
|
&mut self.is_edit_mode, buffer_state, client_clone, sender_clone, &mut self.command_message,
|
||||||
app_state,
|
|
||||||
&mut admin_state.add_logic_state,
|
|
||||||
&mut self.is_edit_mode,
|
|
||||||
buffer_state,
|
|
||||||
client_clone,
|
|
||||||
sender_clone,
|
|
||||||
&mut self.command_message,
|
|
||||||
) {
|
) {
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Add Table Page Navigation ---
|
|
||||||
if app_state.ui.show_add_table {
|
if app_state.ui.show_add_table {
|
||||||
let client_clone = grpc_client.clone();
|
let client_clone = grpc_client.clone();
|
||||||
let sender_clone = self.save_table_result_sender.clone();
|
let sender_clone = self.save_table_result_sender.clone();
|
||||||
|
|
||||||
if add_table_nav::handle_add_table_navigation(
|
if add_table_nav::handle_add_table_navigation(
|
||||||
key,
|
key_event, config, app_state, &mut admin_state.add_table_state,
|
||||||
config,
|
client_clone, sender_clone, &mut self.command_message,
|
||||||
app_state,
|
|
||||||
&mut admin_state.add_table_state,
|
|
||||||
client_clone,
|
|
||||||
sender_clone,
|
|
||||||
&mut self.command_message,
|
|
||||||
|
|
||||||
) {
|
) {
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nav_outcome = navigation::handle_navigation_event(
|
let nav_outcome = navigation::handle_navigation_event(
|
||||||
key,
|
key_event, config, form_state, app_state, login_state, register_state,
|
||||||
config,
|
intro_state, admin_state, &mut self.command_mode, &mut self.command_input,
|
||||||
form_state,
|
&mut self.command_message, &mut self.navigation_state,
|
||||||
app_state,
|
|
||||||
login_state,
|
|
||||||
register_state,
|
|
||||||
intro_state,
|
|
||||||
admin_state,
|
|
||||||
&mut self.command_mode,
|
|
||||||
&mut self.command_input,
|
|
||||||
&mut self.command_message,
|
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match nav_outcome {
|
match nav_outcome {
|
||||||
Ok(EventOutcome::ButtonSelected { context, index }) => {
|
Ok(EventOutcome::ButtonSelected { context, index }) => {
|
||||||
let message = match context {
|
let message = match context {
|
||||||
UiContext::Intro => {
|
UiContext::Intro => {
|
||||||
intro::handle_intro_selection(app_state, buffer_state, index);
|
intro::handle_intro_selection(app_state, buffer_state, index);
|
||||||
if app_state.ui.show_admin {
|
if app_state.ui.show_admin && !app_state.profile_tree.profiles.is_empty() {
|
||||||
if !app_state.profile_tree.profiles.is_empty() {
|
admin_state.profile_list_state.select(Some(0));
|
||||||
admin_state.profile_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
format!("Intro Option {} selected", index)
|
format!("Intro Option {} selected", index)
|
||||||
}
|
}
|
||||||
UiContext::Login => {
|
UiContext::Login => match index {
|
||||||
let login_action_message = match index {
|
0 => login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone()),
|
||||||
0 => {
|
1 => login::back_to_main(login_state, app_state, buffer_state).await,
|
||||||
login::initiate_login(login_state, app_state, self.auth_client.clone(), self.login_result_sender.clone())
|
_ => "Invalid Login Option".to_string(),
|
||||||
},
|
},
|
||||||
1 => login::back_to_main(login_state, app_state, buffer_state).await,
|
UiContext::Register => match index {
|
||||||
_ => "Invalid Login Option".to_string(),
|
0 => register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone()),
|
||||||
};
|
1 => register::back_to_login(register_state, app_state, buffer_state).await,
|
||||||
login_action_message
|
_ => "Invalid Login Option".to_string(),
|
||||||
}
|
},
|
||||||
UiContext::Register => {
|
|
||||||
let register_action_message = match index {
|
|
||||||
0 => {
|
|
||||||
register::initiate_registration(register_state, app_state, self.auth_client.clone(), self.register_result_sender.clone())
|
|
||||||
},
|
|
||||||
1 => register::back_to_login(register_state, app_state, buffer_state).await,
|
|
||||||
_ => "Invalid Login Option".to_string(),
|
|
||||||
};
|
|
||||||
register_action_message
|
|
||||||
}
|
|
||||||
UiContext::Admin => {
|
UiContext::Admin => {
|
||||||
admin::handle_admin_selection(app_state, admin_state);
|
admin::handle_admin_selection(app_state, admin_state);
|
||||||
format!("Admin Option {} selected", index)
|
format!("Admin Option {} selected", index)
|
||||||
}
|
}
|
||||||
UiContext::Dialog => {
|
UiContext::Dialog => "Internal error: Unexpected dialog state".to_string(),
|
||||||
"Internal error: Unexpected dialog state".to_string()
|
};
|
||||||
}
|
|
||||||
}; // Semicolon added here
|
|
||||||
return Ok(EventOutcome::Ok(message));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}
|
}
|
||||||
other => return other,
|
other => return other,
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
AppMode::ReadOnly => {
|
AppMode::ReadOnly => {
|
||||||
// Check for Linewise highlight first
|
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") && ModeManager::can_enter_highlight_mode(current_mode) {
|
||||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
|
||||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
|
||||||
{
|
|
||||||
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
|
|
||||||
else if app_state.ui.show_register { register_state.current_field() }
|
|
||||||
else { form_state.current_field() };
|
|
||||||
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index };
|
self.highlight_state = HighlightState::Linewise { anchor_line: current_field_index };
|
||||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode") && ModeManager::can_enter_highlight_mode(current_mode) {
|
||||||
// Check for Character-wise highlight
|
let current_field_index = if app_state.ui.show_login { login_state.current_field() } else if app_state.ui.show_register { register_state.current_field() } else { form_state.current_field() };
|
||||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
|
||||||
{
|
|
||||||
let current_field_index = if app_state.ui.show_login { login_state.current_field() }
|
|
||||||
else if app_state.ui.show_register { register_state.current_field() }
|
|
||||||
else { form_state.current_field() };
|
|
||||||
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() }
|
|
||||||
else if app_state.ui.show_register { register_state.current_cursor_pos() }
|
|
||||||
else { form_state.current_cursor_pos() };
|
|
||||||
let anchor = (current_field_index, current_cursor_pos);
|
let anchor = (current_field_index, current_cursor_pos);
|
||||||
self.highlight_state = HighlightState::Characterwise { anchor };
|
self.highlight_state = HighlightState::Characterwise { anchor };
|
||||||
self.command_message = "-- HIGHLIGHT --".to_string();
|
self.command_message = "-- HIGHLIGHT --".to_string();
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before") && ModeManager::can_enter_edit_mode(current_mode) {
|
||||||
// Check for entering edit mode (before cursor)
|
|
||||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before")
|
|
||||||
&& ModeManager::can_enter_edit_mode(current_mode) {
|
|
||||||
self.is_edit_mode = true;
|
self.is_edit_mode = true;
|
||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
self.command_message = "Edit mode".to_string();
|
self.command_message = "Edit mode".to_string();
|
||||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
} else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after") && ModeManager::can_enter_edit_mode(current_mode) {
|
||||||
// Check for entering edit mode (after cursor)
|
let current_input = if app_state.ui.show_login || app_state.ui.show_register { login_state.get_current_input() } else { form_state.get_current_input() };
|
||||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
|
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register { login_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||||
&& ModeManager::can_enter_edit_mode(current_mode) {
|
|
||||||
let current_input = if app_state.ui.show_login || app_state.ui.show_register{
|
|
||||||
login_state.get_current_input()
|
|
||||||
} else {
|
|
||||||
form_state.get_current_input()
|
|
||||||
};
|
|
||||||
let current_cursor_pos = if app_state.ui.show_login || app_state.ui.show_register{
|
|
||||||
login_state.current_cursor_pos()
|
|
||||||
} else {
|
|
||||||
form_state.current_cursor_pos()
|
|
||||||
};
|
|
||||||
|
|
||||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||||
if app_state.ui.show_login || app_state.ui.show_register{
|
if app_state.ui.show_login || app_state.ui.show_register {
|
||||||
login_state.set_current_cursor_pos(current_cursor_pos + 1);
|
login_state.set_current_cursor_pos(current_cursor_pos + 1);
|
||||||
self.ideal_cursor_column = login_state.current_cursor_pos();
|
self.ideal_cursor_column = login_state.current_cursor_pos();
|
||||||
} else {
|
} else {
|
||||||
@@ -365,42 +319,28 @@ impl EventHandler {
|
|||||||
self.command_message = "Edit mode (after cursor)".to_string();
|
self.command_message = "Edit mode (after cursor)".to_string();
|
||||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
} else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode") && ModeManager::can_enter_command_mode(current_mode) {
|
||||||
// Check for entering command mode
|
self.command_mode = true;
|
||||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode")
|
self.command_input.clear();
|
||||||
&& ModeManager::can_enter_command_mode(current_mode) {
|
self.command_message.clear();
|
||||||
self.command_mode = true;
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
self.command_input.clear();
|
|
||||||
self.command_message.clear();
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for common actions (save, quit, etc.) only if no mode change happened
|
|
||||||
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||||
match action {
|
match action {
|
||||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||||
return common_mode::handle_core_action(
|
return common_mode::handle_core_action(
|
||||||
action,
|
action, form_state, auth_state, login_state, register_state,
|
||||||
form_state,
|
grpc_client, &mut self.auth_client, terminal, app_state,
|
||||||
auth_state,
|
|
||||||
login_state,
|
|
||||||
register_state,
|
|
||||||
grpc_client,
|
|
||||||
&mut self.auth_client,
|
|
||||||
terminal,
|
|
||||||
app_state,
|
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await;
|
).await;
|
||||||
},
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no mode change or specific common action handled, delegate to read_only handler
|
|
||||||
let (_should_exit, message) = read_only::handle_read_only_event(
|
let (_should_exit, message) = read_only::handle_read_only_event(
|
||||||
app_state,
|
app_state,
|
||||||
key,
|
key_event,
|
||||||
config,
|
config,
|
||||||
form_state,
|
form_state,
|
||||||
login_state,
|
login_state,
|
||||||
@@ -408,47 +348,43 @@ impl EventHandler {
|
|||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut self.key_sequence_tracker,
|
&mut self.key_sequence_tracker,
|
||||||
current_position,
|
// No more current_position or total_count arguments
|
||||||
total_count,
|
|
||||||
grpc_client,
|
grpc_client,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.edit_mode_cooldown,
|
&mut self.edit_mode_cooldown,
|
||||||
&mut self.ideal_cursor_column,
|
&mut self.ideal_cursor_column,
|
||||||
).await?;
|
)
|
||||||
// Note: handle_read_only_event should ignore mode entry keys internally now
|
.await?;
|
||||||
return Ok(EventOutcome::Ok(message));
|
return Ok(EventOutcome::Ok(message));
|
||||||
}, // End AppMode::ReadOnly
|
}
|
||||||
|
|
||||||
AppMode::Highlight => {
|
AppMode::Highlight => {
|
||||||
// --- Handle Highlight Mode Specific Keys ---
|
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
|
||||||
// 1. Check for Exit first
|
self.highlight_state = HighlightState::Off;
|
||||||
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
|
self.command_message = "Exited highlight mode".to_string();
|
||||||
self.highlight_state = HighlightState::Off;
|
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||||
self.command_message = "Exited highlight mode".to_string();
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
} else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
if let HighlightState::Characterwise { anchor } = self.highlight_state {
|
||||||
}
|
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
|
||||||
// 2. Check for Switch to Linewise
|
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||||
else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
// Only switch if currently characterwise
|
}
|
||||||
if let HighlightState::Characterwise { anchor } = self.highlight_state {
|
|
||||||
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
|
|
||||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
|
||||||
}
|
|
||||||
return Ok(EventOutcome::Ok("".to_string()));
|
return Ok(EventOutcome::Ok("".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_should_exit, message) = read_only::handle_read_only_event(
|
let (_should_exit, message) = read_only::handle_read_only_event(
|
||||||
app_state, key, config, form_state, login_state,
|
app_state,
|
||||||
register_state,
|
key_event,
|
||||||
&mut admin_state.add_table_state,
|
config,
|
||||||
|
form_state,
|
||||||
|
login_state,
|
||||||
|
register_state,
|
||||||
|
&mut admin_state.add_table_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
&mut self.key_sequence_tracker,
|
&mut self.key_sequence_tracker,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
grpc_client,
|
grpc_client,
|
||||||
&mut self.command_message,
|
&mut self.command_message,
|
||||||
&mut self.edit_mode_cooldown,
|
&mut self.edit_mode_cooldown,
|
||||||
&mut self.ideal_cursor_column,
|
&mut self.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
@@ -457,73 +393,35 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppMode::Edit => {
|
AppMode::Edit => {
|
||||||
// First, check for common actions (save, revert, etc.) that apply in Edit mode
|
|
||||||
// These might take precedence or have different behavior than the edit handler
|
|
||||||
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||||
// Handle common actions like save, revert, force_quit, save_and_quit
|
|
||||||
// Ensure these actions return EventOutcome directly if they might exit the app
|
|
||||||
match action {
|
match action {
|
||||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||||
// This call likely returns EventOutcome, handle it directly
|
|
||||||
return common_mode::handle_core_action(
|
return common_mode::handle_core_action(
|
||||||
action,
|
action, form_state, auth_state, login_state, register_state,
|
||||||
form_state,
|
grpc_client, &mut self.auth_client, terminal, app_state,
|
||||||
auth_state,
|
|
||||||
login_state,
|
|
||||||
register_state,
|
|
||||||
grpc_client,
|
|
||||||
&mut self.auth_client,
|
|
||||||
terminal,
|
|
||||||
app_state,
|
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await;
|
).await;
|
||||||
},
|
}
|
||||||
// Handle other common actions if necessary
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
// If a common action was handled but didn't return/exit,
|
|
||||||
// we might want to stop further processing for this key event.
|
|
||||||
// Depending on the action, you might return Ok(EventOutcome::Ok(...)) here.
|
|
||||||
// For now, assume common actions either exit or don't prevent further processing.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no common action took precedence, delegate to the edit-specific handler
|
let mut current_position = form_state.current_position;
|
||||||
|
let total_count = form_state.total_count;
|
||||||
let edit_result = edit::handle_edit_event(
|
let edit_result = edit::handle_edit_event(
|
||||||
key,
|
key_event, config, form_state, login_state, register_state, admin_state,
|
||||||
config,
|
&mut self.ideal_cursor_column, &mut current_position, total_count,
|
||||||
form_state,
|
grpc_client, app_state,
|
||||||
login_state,
|
|
||||||
register_state,
|
|
||||||
admin_state,
|
|
||||||
&mut self.ideal_cursor_column,
|
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
grpc_client,
|
|
||||||
app_state,
|
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match edit_result {
|
match edit_result {
|
||||||
Ok(edit::EditEventOutcome::ExitEditMode) => {
|
Ok(edit::EditEventOutcome::ExitEditMode) => {
|
||||||
// The edit handler signaled to exit the mode
|
|
||||||
self.is_edit_mode = false;
|
self.is_edit_mode = false;
|
||||||
self.edit_mode_cooldown = true;
|
self.edit_mode_cooldown = true;
|
||||||
let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() }
|
let has_changes = if app_state.ui.show_login { login_state.has_unsaved_changes() } else if app_state.ui.show_register { register_state.has_unsaved_changes() } else { form_state.has_unsaved_changes() };
|
||||||
else if app_state.ui.show_register { register_state.has_unsaved_changes() }
|
self.command_message = if has_changes { "Exited edit mode (unsaved changes remain)".to_string() } else { "Read-only mode".to_string() };
|
||||||
else { form_state.has_unsaved_changes() };
|
|
||||||
self.command_message = if has_changes {
|
|
||||||
"Exited edit mode (unsaved changes remain)".to_string()
|
|
||||||
} else {
|
|
||||||
"Read-only mode".to_string()
|
|
||||||
};
|
|
||||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||||
// Adjust cursor position if needed
|
let current_input = if app_state.ui.show_login { login_state.get_current_input() } else if app_state.ui.show_register { register_state.get_current_input() } else { form_state.get_current_input() };
|
||||||
let current_input = if app_state.ui.show_login { login_state.get_current_input() }
|
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() } else if app_state.ui.show_register { register_state.current_cursor_pos() } else { form_state.current_cursor_pos() };
|
||||||
else if app_state.ui.show_register { register_state.get_current_input() }
|
|
||||||
else { form_state.get_current_input() };
|
|
||||||
let current_cursor_pos = if app_state.ui.show_login { login_state.current_cursor_pos() }
|
|
||||||
else if app_state.ui.show_register { register_state.current_cursor_pos() }
|
|
||||||
else { form_state.current_cursor_pos() };
|
|
||||||
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
|
||||||
let new_pos = current_input.len() - 1;
|
let new_pos = current_input.len() - 1;
|
||||||
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
|
let target_state: &mut dyn CanvasState = if app_state.ui.show_login { login_state } else if app_state.ui.show_register { register_state } else { form_state };
|
||||||
@@ -533,48 +431,110 @@ impl EventHandler {
|
|||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
}
|
||||||
Ok(edit::EditEventOutcome::Message(msg)) => {
|
Ok(edit::EditEventOutcome::Message(msg)) => {
|
||||||
// Stay in edit mode, update message if not empty
|
if !msg.is_empty() { self.command_message = msg; }
|
||||||
if !msg.is_empty() {
|
self.key_sequence_tracker.reset();
|
||||||
self.command_message = msg;
|
|
||||||
}
|
|
||||||
self.key_sequence_tracker.reset(); // Reset sequence tracker on successful edit action
|
|
||||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => { return Err(e.into()); }
|
||||||
// Handle error from the edit handler
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, // End AppMode::Edit
|
}
|
||||||
|
|
||||||
AppMode::Command => {
|
AppMode::Command => {
|
||||||
let outcome = command_mode::handle_command_event(
|
if config.is_exit_command_mode(key_code, modifiers) {
|
||||||
key,
|
self.command_input.clear();
|
||||||
config,
|
self.command_message.clear();
|
||||||
app_state,
|
self.command_mode = false;
|
||||||
login_state,
|
self.key_sequence_tracker.reset();
|
||||||
register_state,
|
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
|
||||||
form_state,
|
|
||||||
&mut self.command_input,
|
|
||||||
&mut self.command_message,
|
|
||||||
grpc_client,
|
|
||||||
command_handler,
|
|
||||||
terminal,
|
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
if let EventOutcome::Ok(msg) = &outcome {
|
|
||||||
if msg == "Exited command mode" {
|
|
||||||
self.command_mode = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Ok(outcome);
|
|
||||||
|
if config.is_command_execute(key_code, modifiers) {
|
||||||
|
let mut current_position = form_state.current_position;
|
||||||
|
let total_count = form_state.total_count;
|
||||||
|
let outcome = command_mode::handle_command_event(
|
||||||
|
key_event, config, app_state, login_state, register_state, form_state,
|
||||||
|
&mut self.command_input, &mut self.command_message, grpc_client,
|
||||||
|
command_handler, terminal, &mut current_position, total_count,
|
||||||
|
).await?;
|
||||||
|
form_state.current_position = current_position;
|
||||||
|
self.command_mode = false;
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
let new_mode = ModeManager::derive_mode(app_state, self, admin_state);
|
||||||
|
app_state.update_mode(new_mode);
|
||||||
|
return Ok(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
if key_code == KeyCode::Backspace {
|
||||||
|
self.command_input.pop();
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let KeyCode::Char(c) = key_code {
|
||||||
|
if c == 'f' {
|
||||||
|
self.key_sequence_tracker.add_key(key_code);
|
||||||
|
let sequence = self.key_sequence_tracker.get_sequence();
|
||||||
|
|
||||||
|
if config.matches_key_sequence_generalized(&sequence) == Some("find_file_palette_toggle") {
|
||||||
|
if app_state.ui.show_form || app_state.ui.show_intro {
|
||||||
|
// --- START FIX ---
|
||||||
|
let mut all_table_paths: Vec<String> = app_state
|
||||||
|
.profile_tree
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.flat_map(|profile| {
|
||||||
|
profile.tables.iter().map(move |table| {
|
||||||
|
format!("{}/{}", profile.name, table.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
all_table_paths.sort();
|
||||||
|
|
||||||
|
self.navigation_state.activate_find_file(all_table_paths);
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
|
self.command_mode = false;
|
||||||
|
self.command_input.clear();
|
||||||
|
self.command_message.clear();
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
return Ok(EventOutcome::Ok("Table selection palette activated".to_string()));
|
||||||
|
} else {
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
self.command_input.push('f');
|
||||||
|
if sequence.len() > 1 && sequence[0] == KeyCode::Char('f') {
|
||||||
|
self.command_input.push('f');
|
||||||
|
}
|
||||||
|
self.command_message = "Find File not available in this view.".to_string();
|
||||||
|
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.is_key_sequence_prefix(&sequence) {
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c != 'f' && !self.key_sequence_tracker.current_sequence.is_empty() {
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.command_input.push(c);
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Event::Resize(_, _) = event {
|
||||||
|
return Ok(EventOutcome::Ok("Resized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.edit_mode_cooldown = false;
|
self.edit_mode_cooldown = false;
|
||||||
Ok(EventOutcome::Ok(self.command_message.clone()))
|
Ok(EventOutcome::Ok(self.command_message.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_processed_command(&self, command: &str) -> bool {
|
||||||
|
matches!(command, "w" | "q" | "q!" | "wq" | "r")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ impl ModeManager {
|
|||||||
event_handler: &EventHandler,
|
event_handler: &EventHandler,
|
||||||
admin_state: &AdminState,
|
admin_state: &AdminState,
|
||||||
) -> AppMode {
|
) -> AppMode {
|
||||||
|
if event_handler.navigation_state.active {
|
||||||
|
return AppMode::General;
|
||||||
|
}
|
||||||
|
|
||||||
if event_handler.command_mode {
|
if event_handler.command_mode {
|
||||||
return AppMode::Command;
|
return AppMode::Command;
|
||||||
}
|
}
|
||||||
@@ -78,14 +82,14 @@ impl ModeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mode transition rules
|
// Mode transition rules
|
||||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||||
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode
|
!matches!(current_mode, AppMode::Edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||||
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly
|
matches!(current_mode, AppMode::ReadOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||||
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ pub async fn handle_highlight_event(
|
|||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut admin_state.add_logic_state,
|
&mut admin_state.add_logic_state,
|
||||||
key_sequence_tracker,
|
key_sequence_tracker,
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
grpc_client,
|
grpc_client,
|
||||||
command_message, // Pass the message buffer
|
command_message, // Pass the message buffer
|
||||||
edit_mode_cooldown,
|
edit_mode_cooldown,
|
||||||
|
|||||||
@@ -1,101 +1,200 @@
|
|||||||
// src/services/grpc_client.rs
|
// src/services/grpc_client.rs
|
||||||
|
|
||||||
use tonic::transport::Channel;
|
use tonic::transport::Channel;
|
||||||
use common::proto::multieko2::adresar::adresar_client::AdresarClient;
|
use common::proto::multieko2::common::{CountResponse, Empty};
|
||||||
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
|
|
||||||
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
|
|
||||||
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
|
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
|
||||||
// Import the new request type for table structure
|
use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
|
||||||
use common::proto::multieko2::table_structure::{TableStructureResponse, GetTableStructureRequest};
|
|
||||||
use common::proto::multieko2::table_definition::{
|
use common::proto::multieko2::table_definition::{
|
||||||
table_definition_client::TableDefinitionClient,
|
table_definition_client::TableDefinitionClient,
|
||||||
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse,
|
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
|
||||||
};
|
};
|
||||||
use common::proto::multieko2::table_script::{
|
use common::proto::multieko2::table_script::{
|
||||||
table_script_client::TableScriptClient,
|
table_script_client::TableScriptClient,
|
||||||
PostTableScriptRequest, TableScriptResponse,
|
PostTableScriptRequest, TableScriptResponse,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use common::proto::multieko2::tables_data::{
|
||||||
|
tables_data_client::TablesDataClient,
|
||||||
|
GetTableDataByPositionRequest,
|
||||||
|
GetTableDataResponse,
|
||||||
|
GetTableDataCountRequest,
|
||||||
|
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
|
||||||
|
PutTableDataResponse,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result}; // Added Context
|
||||||
|
use std::collections::HashMap; // NEW
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GrpcClient {
|
pub struct GrpcClient {
|
||||||
adresar_client: AdresarClient<Channel>,
|
|
||||||
table_structure_client: TableStructureServiceClient<Channel>,
|
table_structure_client: TableStructureServiceClient<Channel>,
|
||||||
table_definition_client: TableDefinitionClient<Channel>,
|
table_definition_client: TableDefinitionClient<Channel>,
|
||||||
table_script_client: TableScriptClient<Channel>,
|
table_script_client: TableScriptClient<Channel>,
|
||||||
|
tables_data_client: TablesDataClient<Channel>, // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GrpcClient {
|
impl GrpcClient {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new() -> Result<Self> {
|
||||||
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?;
|
let table_structure_client = TableStructureServiceClient::connect(
|
||||||
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?;
|
"http://[::1]:50051",
|
||||||
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?;
|
)
|
||||||
let table_script_client = TableScriptClient::connect("http://[::1]:50051").await?;
|
.await
|
||||||
|
.context("Failed to connect to TableStructureService")?;
|
||||||
|
let table_definition_client = TableDefinitionClient::connect(
|
||||||
|
"http://[::1]:50051",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to TableDefinitionService")?;
|
||||||
|
let table_script_client =
|
||||||
|
TableScriptClient::connect("http://[::1]:50051")
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to TableScriptService")?;
|
||||||
|
let tables_data_client =
|
||||||
|
TablesDataClient::connect("http://[::1]:50051")
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to TablesDataService")?; // NEW
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
adresar_client,
|
// adresar_client, // REMOVE
|
||||||
table_structure_client,
|
table_structure_client,
|
||||||
table_definition_client,
|
table_definition_client,
|
||||||
table_script_client,
|
table_script_client,
|
||||||
|
tables_data_client, // NEW
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_adresar_count(&mut self) -> Result<u64> {
|
|
||||||
let request = tonic::Request::new(Empty::default());
|
|
||||||
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
|
|
||||||
Ok(response.count as u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse> {
|
|
||||||
let request = tonic::Request::new(PositionRequest { position: position as i64 });
|
|
||||||
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
|
|
||||||
let request = tonic::Request::new(request);
|
|
||||||
let response = self.adresar_client.post_adresar(request).await?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
|
|
||||||
let request = tonic::Request::new(request);
|
|
||||||
let response = self.adresar_client.put_adresar(request).await?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updated get_table_structure method
|
|
||||||
pub async fn get_table_structure(
|
pub async fn get_table_structure(
|
||||||
&mut self,
|
&mut self,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
) -> Result<TableStructureResponse> {
|
) -> Result<TableStructureResponse> {
|
||||||
// Create the new request type
|
|
||||||
let grpc_request = GetTableStructureRequest {
|
let grpc_request = GetTableStructureRequest {
|
||||||
profile_name,
|
profile_name,
|
||||||
table_name,
|
table_name,
|
||||||
};
|
};
|
||||||
let request = tonic::Request::new(grpc_request);
|
let request = tonic::Request::new(grpc_request);
|
||||||
// Call the new gRPC method
|
let response = self
|
||||||
let response = self.table_structure_client.get_table_structure(request).await?;
|
.table_structure_client
|
||||||
|
.get_table_structure(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC GetTableStructure call failed")?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse> {
|
pub async fn get_profile_tree(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<ProfileTreeResponse> {
|
||||||
let request = tonic::Request::new(Empty::default());
|
let request = tonic::Request::new(Empty::default());
|
||||||
let response = self.table_definition_client.get_profile_tree(request).await?;
|
let response = self
|
||||||
|
.table_definition_client
|
||||||
|
.get_profile_tree(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC GetProfileTree call failed")?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_table_definition(&mut self, request: PostTableDefinitionRequest) -> Result<TableDefinitionResponse> {
|
pub async fn post_table_definition(
|
||||||
|
&mut self,
|
||||||
|
request: PostTableDefinitionRequest,
|
||||||
|
) -> Result<TableDefinitionResponse> {
|
||||||
let tonic_request = tonic::Request::new(request);
|
let tonic_request = tonic::Request::new(request);
|
||||||
let response = self.table_definition_client.post_table_definition(tonic_request).await?;
|
let response = self
|
||||||
|
.table_definition_client
|
||||||
|
.post_table_definition(tonic_request)
|
||||||
|
.await
|
||||||
|
.context("gRPC PostTableDefinition call failed")?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_table_script(&mut self, request: PostTableScriptRequest) -> Result<TableScriptResponse> {
|
pub async fn post_table_script(
|
||||||
|
&mut self,
|
||||||
|
request: PostTableScriptRequest,
|
||||||
|
) -> Result<TableScriptResponse> {
|
||||||
let tonic_request = tonic::Request::new(request);
|
let tonic_request = tonic::Request::new(request);
|
||||||
let response = self.table_script_client.post_table_script(tonic_request).await?;
|
let response = self
|
||||||
|
.table_script_client
|
||||||
|
.post_table_script(tonic_request)
|
||||||
|
.await
|
||||||
|
.context("gRPC PostTableScript call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW Methods for TablesData service
|
||||||
|
pub async fn get_table_data_count(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let grpc_request = GetTableDataCountRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.get_table_data_count(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC GetTableDataCount call failed")?;
|
||||||
|
Ok(response.into_inner().count as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_table_data_by_position(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<GetTableDataResponse> {
|
||||||
|
let grpc_request = GetTableDataByPositionRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.get_table_data_by_position(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC GetTableDataByPosition call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_table_data(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
data: HashMap<String, String>,
|
||||||
|
) -> Result<PostTableDataResponse> {
|
||||||
|
let grpc_request = PostTableDataRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.post_table_data(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC PostTableData call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_table_data(
|
||||||
|
&mut self,
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
id: i64,
|
||||||
|
data: HashMap<String, String>,
|
||||||
|
) -> Result<PutTableDataResponse> {
|
||||||
|
let grpc_request = PutTableDataRequest {
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let request = tonic::Request::new(grpc_request);
|
||||||
|
let response = self
|
||||||
|
.tables_data_client
|
||||||
|
.put_table_data(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC PutTableData call failed")?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,118 +3,240 @@
|
|||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::tui::functions::common::form::SaveOutcome;
|
use crate::tui::functions::common::form::SaveOutcome;
|
||||||
|
use crate::state::pages::add_logic::AddLogicState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
|
use crate::utils::columns::filter_user_columns;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
pub struct UiService;
|
pub struct UiService;
|
||||||
|
|
||||||
impl UiService {
|
impl UiService {
|
||||||
pub async fn initialize_app_state(
|
pub async fn initialize_add_logic_table_data(
|
||||||
|
grpc_client: &mut GrpcClient,
|
||||||
|
add_logic_state: &mut AddLogicState,
|
||||||
|
profile_tree: &common::proto::multieko2::table_definition::ProfileTreeResponse,
|
||||||
|
) -> Result<String> {
|
||||||
|
let profile_name_clone_opt = Some(add_logic_state.profile_name.clone());
|
||||||
|
let table_name_opt_clone = add_logic_state.selected_table_name.clone();
|
||||||
|
|
||||||
|
// Collect table names from SAME profile only
|
||||||
|
let same_profile_table_names: Vec<String> = profile_tree.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.name == add_logic_state.profile_name)
|
||||||
|
.map(|profile| profile.tables.iter().map(|table| table.name.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Set same profile table names for autocomplete
|
||||||
|
add_logic_state.set_same_profile_table_names(same_profile_table_names.clone());
|
||||||
|
|
||||||
|
if let (Some(profile_name_clone), Some(table_name_clone)) = (profile_name_clone_opt, table_name_opt_clone) {
|
||||||
|
match grpc_client.get_table_structure(profile_name_clone.clone(), table_name_clone.clone()).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let column_names: Vec<String> = response.columns
|
||||||
|
.into_iter()
|
||||||
|
.map(|col| col.name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
add_logic_state.set_table_columns(column_names.clone());
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Loaded {} columns for table '{}' and {} tables from profile '{}'",
|
||||||
|
column_names.len(),
|
||||||
|
table_name_clone,
|
||||||
|
same_profile_table_names.len(),
|
||||||
|
add_logic_state.profile_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to fetch table structure for {}.{}: {}",
|
||||||
|
profile_name_clone,
|
||||||
|
table_name_clone,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(format!(
|
||||||
|
"Warning: Could not load table structure for '{}'. Autocomplete will use {} tables from profile '{}'.",
|
||||||
|
table_name_clone,
|
||||||
|
same_profile_table_names.len(),
|
||||||
|
add_logic_state.profile_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"No table selected for Add Logic. Loaded {} tables from profile '{}' for autocomplete.",
|
||||||
|
same_profile_table_names.len(),
|
||||||
|
add_logic_state.profile_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches columns for a specific table (used for table.column autocomplete)
|
||||||
|
pub async fn fetch_columns_for_table(
|
||||||
|
grpc_client: &mut GrpcClient,
|
||||||
|
profile_name: &str,
|
||||||
|
table_name: &str,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
match grpc_client.get_table_structure(profile_name.to_string(), table_name.to_string()).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let column_names: Vec<String> = response.columns
|
||||||
|
.into_iter()
|
||||||
|
.map(|col| col.name)
|
||||||
|
.collect();
|
||||||
|
Ok(filter_user_columns(column_names))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize_app_state_and_form(
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
) -> Result<Vec<String>> {
|
) -> Result<(String, String, Vec<String>)> {
|
||||||
// Fetch profile tree
|
let profile_tree = grpc_client
|
||||||
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?;
|
.get_profile_tree()
|
||||||
|
.await
|
||||||
|
.context("Failed to get profile tree")?;
|
||||||
app_state.profile_tree = profile_tree;
|
app_state.profile_tree = profile_tree;
|
||||||
|
|
||||||
// TODO for general tables and not hardcoded
|
// Determine initial table to load (e.g., first table of first profile, or a default)
|
||||||
let default_profile_name = "default".to_string();
|
let initial_profile_name = app_state
|
||||||
let default_table_name = "2025_customer".to_string();
|
.profile_tree
|
||||||
|
.profiles
|
||||||
|
.first()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
|
let initial_table_name = app_state
|
||||||
|
.profile_tree
|
||||||
|
.profiles
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
|
||||||
|
.unwrap_or_else(|| "2025_company_data1".to_string()); // Fallback if no tables
|
||||||
|
|
||||||
|
app_state.set_current_view_table(
|
||||||
|
initial_profile_name.clone(),
|
||||||
|
initial_table_name.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch table structure for the default table
|
|
||||||
let table_structure = grpc_client
|
let table_structure = grpc_client
|
||||||
.get_table_structure(default_profile_name, default_table_name)
|
.get_table_structure(
|
||||||
|
initial_profile_name.clone(),
|
||||||
|
initial_table_name.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.context("Failed to get initial table structure")?;
|
.context(format!(
|
||||||
|
"Failed to get initial table structure for {}.{}",
|
||||||
|
initial_profile_name, initial_table_name
|
||||||
|
))?;
|
||||||
|
|
||||||
// Extract the column names from the response
|
|
||||||
let column_names: Vec<String> = table_structure
|
let column_names: Vec<String> = table_structure
|
||||||
.columns
|
.columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|col| col.name.clone())
|
.map(|col| col.name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(column_names)
|
let filtered_columns = filter_user_columns(column_names);
|
||||||
|
|
||||||
|
Ok((initial_profile_name, initial_table_name, filtered_columns))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn initialize_adresar_count(
|
pub async fn fetch_and_set_table_count(
|
||||||
|
grpc_client: &mut GrpcClient,
|
||||||
|
form_state: &mut FormState,
|
||||||
|
) -> Result<()> {
|
||||||
|
let total_count = grpc_client
|
||||||
|
.get_table_data_count(
|
||||||
|
form_state.profile_name.clone(),
|
||||||
|
form_state.table_name.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context(format!(
|
||||||
|
"Failed to get count for table {}.{}",
|
||||||
|
form_state.profile_name, form_state.table_name
|
||||||
|
))?;
|
||||||
|
form_state.total_count = total_count;
|
||||||
|
|
||||||
|
if total_count > 0 {
|
||||||
|
form_state.current_position = total_count;
|
||||||
|
} else {
|
||||||
|
form_state.current_position = 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_table_data_by_position(
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
app_state: &mut AppState,
|
|
||||||
) -> Result<()> {
|
|
||||||
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar count")?;
|
|
||||||
app_state.update_total_count(total_count);
|
|
||||||
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_adresar_count(
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
) -> Result<()> {
|
|
||||||
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar by position")?;
|
|
||||||
app_state.update_total_count(total_count);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_adresar_by_position(
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
_app_state: &mut AppState,
|
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
position: u64,
|
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
match grpc_client.get_adresar_by_position(position).await {
|
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
return Ok(format!(
|
||||||
|
"New entry mode for table {}.{}",
|
||||||
|
form_state.profile_name, form_state.table_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if form_state.total_count == 0 && form_state.current_position == 1 {
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
return Ok(format!(
|
||||||
|
"New entry mode for empty table {}.{}",
|
||||||
|
form_state.profile_name, form_state.table_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match grpc_client
|
||||||
|
.get_table_data_by_position(
|
||||||
|
form_state.profile_name.clone(),
|
||||||
|
form_state.table_name.clone(),
|
||||||
|
form_state.current_position as i32,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Set the ID properly
|
// FIX: Pass the current position as the second argument
|
||||||
form_state.id = response.id;
|
form_state.update_from_response(&response.data, form_state.current_position);
|
||||||
|
Ok(format!(
|
||||||
// Update form values dynamically
|
"Loaded entry {}/{} for table {}.{}",
|
||||||
form_state.values = vec![
|
form_state.current_position,
|
||||||
response.firma,
|
form_state.total_count,
|
||||||
response.kz,
|
form_state.profile_name,
|
||||||
response.drc,
|
form_state.table_name
|
||||||
response.ulica,
|
))
|
||||||
response.psc,
|
|
||||||
response.mesto,
|
|
||||||
response.stat,
|
|
||||||
response.banka,
|
|
||||||
response.ucet,
|
|
||||||
response.skladm,
|
|
||||||
response.ico,
|
|
||||||
response.kontakt,
|
|
||||||
response.telefon,
|
|
||||||
response.skladu,
|
|
||||||
response.fax,
|
|
||||||
];
|
|
||||||
|
|
||||||
form_state.has_unsaved_changes = false;
|
|
||||||
Ok(format!("Loaded entry {}", position))
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
Ok(format!("Error loading entry: {}", e))
|
tracing::error!(
|
||||||
|
"Error loading entry {} for table {}.{}: {}",
|
||||||
|
form_state.current_position,
|
||||||
|
form_state.profile_name,
|
||||||
|
form_state.table_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Error loading entry {}: {}",
|
||||||
|
form_state.current_position,
|
||||||
|
e
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the consequences of a save operation, like updating counts.
|
|
||||||
pub async fn handle_save_outcome(
|
pub async fn handle_save_outcome(
|
||||||
save_outcome: SaveOutcome,
|
save_outcome: SaveOutcome,
|
||||||
grpc_client: &mut GrpcClient,
|
_grpc_client: &mut GrpcClient,
|
||||||
app_state: &mut AppState,
|
_app_state: &mut AppState,
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match save_outcome {
|
match save_outcome {
|
||||||
SaveOutcome::CreatedNew(new_id) => {
|
SaveOutcome::CreatedNew(new_id) => {
|
||||||
// A new record was created, update the count!
|
form_state.id = new_id;
|
||||||
UiService::update_adresar_count(grpc_client, app_state).await?;
|
|
||||||
// Navigate to the new record (now that count is updated)
|
|
||||||
app_state.update_current_position(app_state.total_count);
|
|
||||||
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it)
|
|
||||||
}
|
}
|
||||||
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
|
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
|
||||||
// No count update needed for these outcomes
|
// No action needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ pub enum AppView {
|
|||||||
Admin,
|
Admin,
|
||||||
AddTable,
|
AddTable,
|
||||||
AddLogic,
|
AddLogic,
|
||||||
Form(String),
|
Form,
|
||||||
Scratch,
|
Scratch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppView {
|
impl AppView {
|
||||||
|
/// Returns the display name for the view.
|
||||||
|
/// For Form, pass the current table name to get dynamic naming.
|
||||||
pub fn display_name(&self) -> &str {
|
pub fn display_name(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
AppView::Intro => "Intro",
|
AppView::Intro => "Intro",
|
||||||
@@ -21,10 +23,22 @@ impl AppView {
|
|||||||
AppView::Admin => "Admin_Panel",
|
AppView::Admin => "Admin_Panel",
|
||||||
AppView::AddTable => "Add_Table",
|
AppView::AddTable => "Add_Table",
|
||||||
AppView::AddLogic => "Add_Logic",
|
AppView::AddLogic => "Add_Logic",
|
||||||
AppView::Form(name) => name.as_str(),
|
AppView::Form => "Form",
|
||||||
AppView::Scratch => "*scratch*",
|
AppView::Scratch => "*scratch*",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the display name with dynamic context (for Form buffers)
|
||||||
|
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
|
||||||
|
match self {
|
||||||
|
AppView::Form => {
|
||||||
|
current_table_name
|
||||||
|
.unwrap_or("Data Form")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
_ => self.display_name().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -66,11 +80,8 @@ impl BufferState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current_index = self.active_index;
|
let current_index = self.active_index;
|
||||||
if matches!(self.history.get(current_index), Some(AppView::Intro)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.history.remove(current_index);
|
self.history.remove(current_index);
|
||||||
|
|
||||||
if self.history.is_empty() {
|
if self.history.is_empty() {
|
||||||
self.history.push(AppView::Intro);
|
self.history.push(AppView::Intro);
|
||||||
self.active_index = 0;
|
self.active_index = 0;
|
||||||
@@ -80,25 +91,29 @@ impl BufferState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn close_buffer_with_intro_fallback(&mut self) -> String {
|
pub fn close_buffer_with_intro_fallback(&mut self, current_table_name: Option<&str>) -> String {
|
||||||
let current_view_cloned = self.get_active_view().cloned();
|
let current_view_cloned = self.get_active_view().cloned();
|
||||||
|
|
||||||
if let Some(AppView::Intro) = current_view_cloned {
|
if let Some(AppView::Intro) = current_view_cloned {
|
||||||
return "Cannot close intro buffer".to_string();
|
if self.history.len() == 1 {
|
||||||
|
self.close_active_buffer();
|
||||||
|
return "Intro buffer reset".to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let closed_name = current_view_cloned
|
let closed_name = current_view_cloned
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|v| v.display_name().to_string())
|
.map(|v| v.display_name_with_context(current_table_name))
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
if self.close_active_buffer() {
|
if self.close_active_buffer() {
|
||||||
if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) {
|
if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) {
|
||||||
format!("Closed '{}' - returned to intro", closed_name)
|
format!("Closed '{}' - returned to Intro", closed_name)
|
||||||
} else {
|
} else {
|
||||||
format!("Closed '{}'", closed_name)
|
format!("Closed '{}'", closed_name)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
format!("Cannot close buffer: {}", closed_name)
|
format!("Buffer '{}' could not be closed", closed_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
use common::proto::multieko2::table_definition::ProfileTreeResponse;
|
||||||
use crate::modes::handlers::mode_manager::AppMode;
|
use crate::modes::handlers::mode_manager::AppMode;
|
||||||
use crate::ui::handlers::context::{DialogPurpose, UiContext};
|
use crate::ui::handlers::context::DialogPurpose;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
pub struct DialogState {
|
pub struct DialogState {
|
||||||
@@ -33,15 +33,20 @@ pub struct UiState {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
// Core editor state
|
// Core editor state
|
||||||
pub current_dir: String,
|
pub current_dir: String,
|
||||||
pub total_count: u64,
|
|
||||||
pub current_position: u64,
|
|
||||||
pub profile_tree: ProfileTreeResponse,
|
pub profile_tree: ProfileTreeResponse,
|
||||||
pub selected_profile: Option<String>,
|
pub selected_profile: Option<String>,
|
||||||
pub current_mode: AppMode,
|
pub current_mode: AppMode,
|
||||||
|
pub current_view_profile_name: Option<String>,
|
||||||
|
pub current_view_table_name: Option<String>,
|
||||||
|
|
||||||
pub focused_button_index: usize,
|
pub focused_button_index: usize,
|
||||||
|
pub pending_table_structure_fetch: Option<(String, String)>,
|
||||||
|
|
||||||
// UI preferences
|
// UI preferences
|
||||||
pub ui: UiState,
|
pub ui: UiState,
|
||||||
|
|
||||||
|
#[cfg(feature = "ui-debug")]
|
||||||
|
pub debug_info: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -51,28 +56,28 @@ impl AppState {
|
|||||||
.to_string();
|
.to_string();
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
current_dir,
|
current_dir,
|
||||||
total_count: 0,
|
|
||||||
current_position: 0,
|
|
||||||
profile_tree: ProfileTreeResponse::default(),
|
profile_tree: ProfileTreeResponse::default(),
|
||||||
selected_profile: None,
|
selected_profile: None,
|
||||||
|
current_view_profile_name: None,
|
||||||
|
current_view_table_name: None,
|
||||||
current_mode: AppMode::General,
|
current_mode: AppMode::General,
|
||||||
focused_button_index: 0,
|
focused_button_index: 0,
|
||||||
|
pending_table_structure_fetch: None,
|
||||||
ui: UiState::default(),
|
ui: UiState::default(),
|
||||||
|
|
||||||
|
#[cfg(feature = "ui-debug")]
|
||||||
|
debug_info: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing methods remain unchanged
|
|
||||||
pub fn update_total_count(&mut self, total_count: u64) {
|
|
||||||
self.total_count = total_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_current_position(&mut self, current_position: u64) {
|
|
||||||
self.current_position = current_position;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_mode(&mut self, mode: AppMode) {
|
pub fn update_mode(&mut self, mode: AppMode) {
|
||||||
self.current_mode = mode;
|
self.current_mode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
|
||||||
|
self.current_view_profile_name = Some(profile_name);
|
||||||
|
self.current_view_table_name = Some(table_name);
|
||||||
|
}
|
||||||
|
|
||||||
// Add dialog helper methods
|
// Add dialog helper methods
|
||||||
/// Shows a dialog with the given title, message, and buttons.
|
/// Shows a dialog with the given title, message, and buttons.
|
||||||
@@ -134,6 +139,7 @@ impl AppState {
|
|||||||
self.ui.dialog.dialog_active_button_index = 0;
|
self.ui.dialog.dialog_active_button_index = 0;
|
||||||
self.ui.dialog.purpose = None;
|
self.ui.dialog.purpose = None;
|
||||||
self.ui.focus_outside_canvas = false;
|
self.ui.focus_outside_canvas = false;
|
||||||
|
self.ui.dialog.is_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the active button index, wrapping around if necessary.
|
/// Sets the active button index, wrapping around if necessary.
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ pub struct AddLogicState {
|
|||||||
pub show_target_column_suggestions: bool,
|
pub show_target_column_suggestions: bool,
|
||||||
pub selected_target_column_suggestion_index: Option<usize>,
|
pub selected_target_column_suggestion_index: Option<usize>,
|
||||||
pub in_target_column_suggestion_mode: bool,
|
pub in_target_column_suggestion_mode: bool,
|
||||||
|
|
||||||
|
// Script Editor Autocomplete
|
||||||
|
pub script_editor_autocomplete_active: bool,
|
||||||
|
pub script_editor_suggestions: Vec<String>,
|
||||||
|
pub script_editor_selected_suggestion_index: Option<usize>,
|
||||||
|
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
||||||
|
pub all_table_names: Vec<String>,
|
||||||
|
pub script_editor_filter_text: String,
|
||||||
|
|
||||||
|
// New fields for same-profile table names and column autocomplete
|
||||||
|
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||||
|
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddLogicState {
|
impl AddLogicState {
|
||||||
@@ -69,6 +81,16 @@ impl AddLogicState {
|
|||||||
show_target_column_suggestions: false,
|
show_target_column_suggestions: false,
|
||||||
selected_target_column_suggestion_index: None,
|
selected_target_column_suggestion_index: None,
|
||||||
in_target_column_suggestion_mode: false,
|
in_target_column_suggestion_mode: false,
|
||||||
|
|
||||||
|
script_editor_autocomplete_active: false,
|
||||||
|
script_editor_suggestions: Vec::new(),
|
||||||
|
script_editor_selected_suggestion_index: None,
|
||||||
|
script_editor_trigger_position: None,
|
||||||
|
all_table_names: Vec::new(),
|
||||||
|
script_editor_filter_text: String::new(),
|
||||||
|
|
||||||
|
same_profile_table_names: Vec::new(),
|
||||||
|
script_editor_awaiting_column_autocomplete: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,16 +119,10 @@ impl AddLogicState {
|
|||||||
|
|
||||||
self.show_target_column_suggestions = !self.target_column_suggestions.is_empty();
|
self.show_target_column_suggestions = !self.target_column_suggestions.is_empty();
|
||||||
if self.show_target_column_suggestions {
|
if self.show_target_column_suggestions {
|
||||||
// If suggestions are shown, ensure a selection (usually the first)
|
|
||||||
// or maintain current if it's still valid.
|
|
||||||
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
|
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
|
||||||
if selected_idx >= self.target_column_suggestions.len() {
|
if selected_idx >= self.target_column_suggestions.len() {
|
||||||
self.selected_target_column_suggestion_index = Some(0);
|
self.selected_target_column_suggestion_index = Some(0);
|
||||||
}
|
}
|
||||||
// If the previously selected item is no longer in the filtered list, reset.
|
|
||||||
// This is a bit more complex to check perfectly without iterating again.
|
|
||||||
// For now, just ensuring it's within bounds is a good start.
|
|
||||||
// A more robust way would be to check if the string at selected_idx still matches.
|
|
||||||
} else {
|
} else {
|
||||||
self.selected_target_column_suggestion_index = Some(0);
|
self.selected_target_column_suggestion_index = Some(0);
|
||||||
}
|
}
|
||||||
@@ -114,6 +130,101 @@ impl AddLogicState {
|
|||||||
self.selected_target_column_suggestion_index = None;
|
self.selected_target_column_suggestion_index = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates script editor suggestions based on current filter text
|
||||||
|
pub fn update_script_editor_suggestions(&mut self) {
|
||||||
|
let mut suggestions = vec!["sql".to_string()];
|
||||||
|
|
||||||
|
if self.selected_table_name.is_some() {
|
||||||
|
suggestions.extend(self.table_columns_for_suggestions.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_selected_table_name = self.selected_table_name.as_deref();
|
||||||
|
suggestions.extend(
|
||||||
|
self.same_profile_table_names
|
||||||
|
.iter()
|
||||||
|
.filter(|tn| Some(tn.as_str()) != current_selected_table_name)
|
||||||
|
.cloned()
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.script_editor_filter_text.is_empty() {
|
||||||
|
self.script_editor_suggestions = suggestions;
|
||||||
|
} else {
|
||||||
|
let filter_lower = self.script_editor_filter_text.to_lowercase();
|
||||||
|
self.script_editor_suggestions = suggestions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|suggestion| suggestion.to_lowercase().contains(&filter_lower))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selection index
|
||||||
|
if self.script_editor_suggestions.is_empty() {
|
||||||
|
self.script_editor_selected_suggestion_index = None;
|
||||||
|
self.script_editor_autocomplete_active = false;
|
||||||
|
} else if let Some(selected_idx) = self.script_editor_selected_suggestion_index {
|
||||||
|
if selected_idx >= self.script_editor_suggestions.len() {
|
||||||
|
self.script_editor_selected_suggestion_index = Some(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.script_editor_selected_suggestion_index = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a suggestion is a table name (for triggering column autocomplete)
|
||||||
|
pub fn is_table_name_suggestion(&self, suggestion: &str) -> bool {
|
||||||
|
// Not "sql"
|
||||||
|
if suggestion == "sql" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.table_columns_for_suggestions.contains(&suggestion.to_string()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.same_profile_table_names.contains(&suggestion.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets table columns for autocomplete suggestions
|
||||||
|
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
||||||
|
self.table_columns_for_suggestions = columns.clone();
|
||||||
|
if !columns.is_empty() {
|
||||||
|
self.update_target_column_suggestions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets all available table names for autocomplete suggestions
|
||||||
|
pub fn set_all_table_names(&mut self, table_names: Vec<String>) {
|
||||||
|
self.all_table_names = table_names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets table names from the same profile for autocomplete suggestions
|
||||||
|
pub fn set_same_profile_table_names(&mut self, table_names: Vec<String>) {
|
||||||
|
self.same_profile_table_names = table_names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers waiting for column autocomplete for a specific table
|
||||||
|
pub fn trigger_column_autocomplete_for_table(&mut self, table_name: String) {
|
||||||
|
self.script_editor_awaiting_column_autocomplete = Some(table_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates autocomplete with columns for a specific table
|
||||||
|
pub fn set_columns_for_table_autocomplete(&mut self, columns: Vec<String>) {
|
||||||
|
self.script_editor_suggestions = columns;
|
||||||
|
self.script_editor_selected_suggestion_index = if self.script_editor_suggestions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
self.script_editor_autocomplete_active = !self.script_editor_suggestions.is_empty();
|
||||||
|
self.script_editor_awaiting_column_autocomplete = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivates script editor autocomplete and clears related state
|
||||||
|
pub fn deactivate_script_editor_autocomplete(&mut self) {
|
||||||
|
self.script_editor_autocomplete_active = false;
|
||||||
|
self.script_editor_suggestions.clear();
|
||||||
|
self.script_editor_selected_suggestion_index = None;
|
||||||
|
self.script_editor_trigger_position = None;
|
||||||
|
self.script_editor_filter_text.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AddLogicState {
|
impl Default for AddLogicState {
|
||||||
@@ -180,10 +291,9 @@ impl CanvasState for AddLogicState {
|
|||||||
0 => AddLogicFocus::InputLogicName,
|
0 => AddLogicFocus::InputLogicName,
|
||||||
1 => AddLogicFocus::InputTargetColumn,
|
1 => AddLogicFocus::InputTargetColumn,
|
||||||
2 => AddLogicFocus::InputDescription,
|
2 => AddLogicFocus::InputDescription,
|
||||||
_ => return, // Or handle error/default
|
_ => return,
|
||||||
};
|
};
|
||||||
if self.current_focus != new_focus {
|
if self.current_focus != new_focus {
|
||||||
// If changing field, exit suggestion mode for target column
|
|
||||||
if self.current_focus == AddLogicFocus::InputTargetColumn {
|
if self.current_focus == AddLogicFocus::InputTargetColumn {
|
||||||
self.in_target_column_suggestion_mode = false;
|
self.in_target_column_suggestion_mode = false;
|
||||||
self.show_target_column_suggestions = false;
|
self.show_target_column_suggestions = false;
|
||||||
@@ -199,12 +309,10 @@ impl CanvasState for AddLogicState {
|
|||||||
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
|
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
|
||||||
}
|
}
|
||||||
AddLogicFocus::InputTargetColumn => {
|
AddLogicFocus::InputTargetColumn => {
|
||||||
self.target_column_cursor_pos =
|
self.target_column_cursor_pos = pos.min(self.target_column_input.len());
|
||||||
pos.min(self.target_column_input.len());
|
|
||||||
}
|
}
|
||||||
AddLogicFocus::InputDescription => {
|
AddLogicFocus::InputDescription => {
|
||||||
self.description_cursor_pos =
|
self.description_cursor_pos = pos.min(self.description_input.len());
|
||||||
pos.min(self.description_input.len());
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -215,7 +323,7 @@ impl CanvasState for AddLogicState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
fn get_suggestions(&self) -> Option<&[String]> {
|
||||||
if self.current_field() == 1 // Target Column field index
|
if self.current_field() == 1
|
||||||
&& self.in_target_column_suggestion_mode
|
&& self.in_target_column_suggestion_mode
|
||||||
&& self.show_target_column_suggestions
|
&& self.show_target_column_suggestions
|
||||||
{
|
{
|
||||||
@@ -226,7 +334,7 @@ impl CanvasState for AddLogicState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||||
if self.current_field() == 1 // Target Column field index
|
if self.current_field() == 1
|
||||||
&& self.in_target_column_suggestion_mode
|
&& self.in_target_column_suggestion_mode
|
||||||
&& self.show_target_column_suggestions
|
&& self.show_target_column_suggestions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// src/state/pages/form.rs
|
// src/state/pages/form.rs
|
||||||
|
|
||||||
|
use std::collections::HashMap; // NEW
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -7,7 +9,13 @@ use crate::state::pages::canvas_state::CanvasState;
|
|||||||
|
|
||||||
pub struct FormState {
|
pub struct FormState {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub fields: Vec<String>,
|
// NEW fields for dynamic table context
|
||||||
|
pub profile_name: String,
|
||||||
|
pub table_name: String,
|
||||||
|
pub total_count: u64,
|
||||||
|
pub current_position: u64, // 1-based index, 0 or total_count + 1 for new entry
|
||||||
|
|
||||||
|
pub fields: Vec<String>, // Already dynamic, which is good
|
||||||
pub values: Vec<String>,
|
pub values: Vec<String>,
|
||||||
pub current_field: usize,
|
pub current_field: usize,
|
||||||
pub has_unsaved_changes: bool,
|
pub has_unsaved_changes: bool,
|
||||||
@@ -15,11 +23,22 @@ pub struct FormState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FormState {
|
impl FormState {
|
||||||
/// Create a new FormState with dynamic fields.
|
/// Creates a new, empty FormState for a given table.
|
||||||
pub fn new(fields: Vec<String>) -> Self {
|
/// The position defaults to 1, representing either the first record
|
||||||
let values = vec![String::new(); fields.len()]; // Initialize values for each field
|
/// or the position for a new entry if the table is empty.
|
||||||
|
pub fn new(
|
||||||
|
profile_name: String,
|
||||||
|
table_name: String,
|
||||||
|
fields: Vec<String>,
|
||||||
|
) -> Self {
|
||||||
|
let values = vec![String::new(); fields.len()];
|
||||||
FormState {
|
FormState {
|
||||||
id: 0,
|
id: 0, // Default to 0, indicating a new or unloaded record
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
total_count: 0, // Will be fetched after initialization
|
||||||
|
// FIX: Default to 1. A position of 0 is an invalid state.
|
||||||
|
current_position: 1,
|
||||||
fields,
|
fields,
|
||||||
values,
|
values,
|
||||||
current_field: 0,
|
current_field: 0,
|
||||||
@@ -35,31 +54,41 @@ impl FormState {
|
|||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_edit_mode: bool,
|
is_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
total_count: u64,
|
|
||||||
current_position: u64,
|
|
||||||
) {
|
) {
|
||||||
let fields: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect();
|
let fields_str_slice: Vec<&str> =
|
||||||
let values: Vec<&String> = self.values.iter().collect();
|
self.fields.iter().map(|s| s.as_str()).collect();
|
||||||
|
let values_str_slice: Vec<&String> = self.values.iter().collect();
|
||||||
|
|
||||||
crate::components::form::form::render_form(
|
crate::components::form::form::render_form(
|
||||||
f,
|
f,
|
||||||
area,
|
area,
|
||||||
self,
|
self, // Pass self as CanvasState
|
||||||
&fields,
|
&fields_str_slice,
|
||||||
&self.current_field,
|
&self.current_field,
|
||||||
&values,
|
&values_str_slice,
|
||||||
|
&self.table_name,
|
||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_edit_mode,
|
||||||
highlight_state,
|
highlight_state,
|
||||||
total_count,
|
self.total_count,
|
||||||
current_position,
|
self.current_position,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the form to a state for creating a new entry.
|
||||||
|
/// It clears all values and sets the position to be one after the last record.
|
||||||
pub fn reset_to_empty(&mut self) {
|
pub fn reset_to_empty(&mut self) {
|
||||||
self.id = 0; // Reset ID to 0 for new entries
|
self.id = 0;
|
||||||
self.values.iter_mut().for_each(|v| v.clear()); // Clear all values
|
self.values.iter_mut().for_each(|v| v.clear());
|
||||||
|
self.current_field = 0;
|
||||||
|
self.current_cursor_pos = 0;
|
||||||
self.has_unsaved_changes = false;
|
self.has_unsaved_changes = false;
|
||||||
|
// Set the position for a new entry.
|
||||||
|
if self.total_count > 0 {
|
||||||
|
self.current_position = self.total_count + 1;
|
||||||
|
} else {
|
||||||
|
self.current_position = 1; // If table is empty, new record is at position 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_input(&self) -> &str {
|
pub fn get_current_input(&self) -> &str {
|
||||||
@@ -75,15 +104,47 @@ impl FormState {
|
|||||||
.expect("Invalid current_field index")
|
.expect("Invalid current_field index")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_from_response(&mut self, response: common::proto::multieko2::adresar::AdresarResponse) {
|
/// Updates the form's values from a data response and sets its position.
|
||||||
self.id = response.id;
|
/// This is the single source of truth for populating the form after a data fetch.
|
||||||
self.values = vec![
|
pub fn update_from_response(
|
||||||
response.firma, response.kz, response.drc,
|
&mut self,
|
||||||
response.ulica, response.psc, response.mesto,
|
response_data: &HashMap<String, String>,
|
||||||
response.stat, response.banka, response.ucet,
|
// FIX: Add new_position to make this method authoritative.
|
||||||
response.skladm, response.ico, response.kontakt,
|
new_position: u64,
|
||||||
response.telefon, response.skladu, response.fax,
|
) {
|
||||||
];
|
// Create a new vector for the values, ensuring they are in the correct order.
|
||||||
|
self.values = self.fields.iter().map(|field_from_schema| {
|
||||||
|
// For each field from our schema, find the corresponding key in the
|
||||||
|
// response data by doing a case-insensitive comparison.
|
||||||
|
response_data
|
||||||
|
.iter()
|
||||||
|
.find(|(key_from_data, _)| key_from_data.eq_ignore_ascii_case(field_from_schema))
|
||||||
|
.map(|(_, value)| value.clone()) // If found, clone its value.
|
||||||
|
.unwrap_or_default() // If not found, use an empty string.
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Now, do the same case-insensitive lookup for the 'id' field.
|
||||||
|
let id_str_opt = response_data
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
|
||||||
|
.map(|(_, v)| v);
|
||||||
|
|
||||||
|
if let Some(id_str) = id_str_opt {
|
||||||
|
if let Ok(parsed_id) = id_str.parse::<i64>() {
|
||||||
|
self.id = parsed_id;
|
||||||
|
} else {
|
||||||
|
tracing::error!( "Failed to parse 'id' field '{}' for table {}.{}", id_str, self.profile_name, self.table_name);
|
||||||
|
self.id = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.id = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Set the position from the provided parameter.
|
||||||
|
self.current_position = new_position;
|
||||||
|
self.has_unsaved_changes = false;
|
||||||
|
self.current_field = 0;
|
||||||
|
self.current_cursor_pos = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,31 +166,26 @@ impl CanvasState for FormState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
fn get_current_input(&self) -> &str {
|
||||||
self.values
|
// Re-use the struct's own method
|
||||||
.get(self.current_field)
|
FormState::get_current_input(self)
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
fn get_current_input_mut(&mut self) -> &mut String {
|
||||||
self.values
|
// Re-use the struct's own method
|
||||||
.get_mut(self.current_field)
|
FormState::get_current_input_mut(self)
|
||||||
.expect("Invalid current_field index")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
fn fields(&self) -> Vec<&str> {
|
||||||
self.fields.iter().map(|s| s.as_str()).collect()
|
self.fields.iter().map(|s| s.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Implement the setter methods ---
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
if index < self.fields.len() { // Basic bounds check
|
if index < self.fields.len() {
|
||||||
self.current_field = index;
|
self.current_field = index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||||
// Optional: Add validation based on current input length if needed
|
|
||||||
self.current_cursor_pos = pos;
|
self.current_cursor_pos = pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +193,11 @@ impl CanvasState for FormState {
|
|||||||
self.has_unsaved_changes = changed;
|
self.has_unsaved_changes = changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Autocomplete Support (Not Used for FormState) ---
|
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
fn get_suggestions(&self) -> Option<&[String]> {
|
||||||
None // FormState doesn't provide suggestions
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||||
None // FormState doesn't have selected suggestions
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/tui/functions/common/add_table.rs
|
// src/tui/functions/common/add_table.rs
|
||||||
use crate::state::pages::add_table::{
|
use crate::state::pages::add_table::{
|
||||||
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition, LinkDefinition,
|
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
|
||||||
};
|
};
|
||||||
use crate::services::GrpcClient;
|
use crate::services::GrpcClient;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
|||||||
@@ -2,114 +2,130 @@
|
|||||||
|
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest};
|
use anyhow::{Context, Result}; // Added Context
|
||||||
use anyhow::Result;
|
use std::collections::HashMap; // NEW
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SaveOutcome {
|
pub enum SaveOutcome {
|
||||||
NoChange, // Nothing needed saving
|
NoChange,
|
||||||
UpdatedExisting, // An existing record was updated
|
UpdatedExisting,
|
||||||
CreatedNew(i64), // A new record was created (include its new ID)
|
CreatedNew(i64), // Keep the ID
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared logic for saving the current form state
|
// MODIFIED save function
|
||||||
pub async fn save(
|
pub async fn save(
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
current_position: &mut u64,
|
) -> Result<SaveOutcome> {
|
||||||
total_count: u64,
|
|
||||||
) -> Result<SaveOutcome> { // <-- Return SaveOutcome
|
|
||||||
if !form_state.has_unsaved_changes {
|
if !form_state.has_unsaved_changes {
|
||||||
return Ok(SaveOutcome::NoChange); // Early exit if no changes
|
return Ok(SaveOutcome::NoChange);
|
||||||
}
|
}
|
||||||
let is_new = *current_position == total_count + 1;
|
|
||||||
|
|
||||||
let outcome = if is_new {
|
let data_map: HashMap<String, String> = form_state.fields.iter()
|
||||||
let post_request = PostAdresarRequest {
|
.zip(form_state.values.iter())
|
||||||
firma: form_state.values[0].clone(),
|
.map(|(field, value)| (field.clone(), value.clone()))
|
||||||
kz: form_state.values[1].clone(),
|
.collect();
|
||||||
drc: form_state.values[2].clone(),
|
|
||||||
ulica: form_state.values[3].clone(),
|
let outcome: SaveOutcome;
|
||||||
psc: form_state.values[4].clone(),
|
|
||||||
mesto: form_state.values[5].clone(),
|
let is_new_entry = form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) ;
|
||||||
stat: form_state.values[6].clone(),
|
|
||||||
banka: form_state.values[7].clone(),
|
|
||||||
ucet: form_state.values[8].clone(),
|
if is_new_entry {
|
||||||
skladm: form_state.values[9].clone(),
|
let response = grpc_client
|
||||||
ico: form_state.values[10].clone(),
|
.post_table_data(
|
||||||
kontakt: form_state.values[11].clone(),
|
form_state.profile_name.clone(),
|
||||||
telefon: form_state.values[12].clone(),
|
form_state.table_name.clone(),
|
||||||
skladu: form_state.values[13].clone(),
|
data_map,
|
||||||
fax: form_state.values[14].clone(),
|
)
|
||||||
};
|
.await
|
||||||
let response = grpc_client.post_adresar(post_request).await?;
|
.context("Failed to post new table data")?;
|
||||||
let new_id = response.into_inner().id;
|
|
||||||
form_state.id = new_id;
|
if response.success {
|
||||||
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID
|
form_state.id = response.inserted_id;
|
||||||
|
// After creating a new entry, total_count increases, and current_position becomes this new total_count
|
||||||
|
form_state.total_count += 1;
|
||||||
|
form_state.current_position = form_state.total_count;
|
||||||
|
outcome = SaveOutcome::CreatedNew(response.inserted_id);
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Server failed to insert data: {}",
|
||||||
|
response.message
|
||||||
|
));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let put_request = PutAdresarRequest {
|
// This assumes form_state.id is valid for an existing record
|
||||||
id: form_state.id,
|
if form_state.id == 0 {
|
||||||
firma: form_state.values[0].clone(),
|
return Err(anyhow::anyhow!(
|
||||||
kz: form_state.values[1].clone(),
|
"Cannot update record: ID is 0, but not classified as new entry."
|
||||||
drc: form_state.values[2].clone(),
|
));
|
||||||
ulica: form_state.values[3].clone(),
|
}
|
||||||
psc: form_state.values[4].clone(),
|
let response = grpc_client
|
||||||
mesto: form_state.values[5].clone(),
|
.put_table_data(
|
||||||
stat: form_state.values[6].clone(),
|
form_state.profile_name.clone(),
|
||||||
banka: form_state.values[7].clone(),
|
form_state.table_name.clone(),
|
||||||
ucet: form_state.values[8].clone(),
|
form_state.id,
|
||||||
skladm: form_state.values[9].clone(),
|
data_map,
|
||||||
ico: form_state.values[10].clone(),
|
)
|
||||||
kontakt: form_state.values[11].clone(),
|
.await
|
||||||
telefon: form_state.values[12].clone(),
|
.context("Failed to put (update) table data")?;
|
||||||
skladu: form_state.values[13].clone(),
|
|
||||||
fax: form_state.values[14].clone(),
|
if response.success {
|
||||||
};
|
outcome = SaveOutcome::UpdatedExisting;
|
||||||
let _ = grpc_client.put_adresar(put_request).await?;
|
} else {
|
||||||
SaveOutcome::UpdatedExisting
|
return Err(anyhow::anyhow!(
|
||||||
};
|
"Server failed to update data: {}",
|
||||||
|
response.message
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
form_state.has_unsaved_changes = false;
|
form_state.has_unsaved_changes = false;
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discard changes since last save
|
|
||||||
pub async fn revert(
|
pub async fn revert(
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState, // Takes &mut FormState to update it
|
||||||
grpc_client: &mut GrpcClient,
|
grpc_client: &mut GrpcClient,
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let is_new = *current_position == total_count + 1;
|
if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
|
||||||
|
let old_total_count = form_state.total_count; // Preserve for correct new position
|
||||||
if is_new {
|
form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
|
||||||
// Clear all fields for new entries
|
form_state.total_count = old_total_count; // Restore total_count
|
||||||
form_state.values.iter_mut().for_each(|v| *v = String::new());
|
if form_state.total_count > 0 { // Correctly set current_position for new
|
||||||
form_state.has_unsaved_changes = false;
|
form_state.current_position = form_state.total_count + 1;
|
||||||
|
} else {
|
||||||
|
form_state.current_position = 1;
|
||||||
|
}
|
||||||
return Ok("New entry cleared".to_string());
|
return Ok("New entry cleared".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = grpc_client.get_adresar_by_position(*current_position).await?;
|
if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
|
||||||
|
if form_state.total_count > 0 {
|
||||||
|
form_state.current_position = 1;
|
||||||
|
} else {
|
||||||
|
// No records to revert to, effectively a new entry state.
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
return Ok("No saved data to revert to; form cleared.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update form fields with saved values
|
let response = grpc_client
|
||||||
form_state.values = vec![
|
.get_table_data_by_position(
|
||||||
data.firma,
|
form_state.profile_name.clone(),
|
||||||
data.kz,
|
form_state.table_name.clone(),
|
||||||
data.drc,
|
form_state.current_position as i32,
|
||||||
data.ulica,
|
)
|
||||||
data.psc,
|
.await
|
||||||
data.mesto,
|
.context(format!(
|
||||||
data.stat,
|
"Failed to get table data by position {} for table {}.{}",
|
||||||
data.banka,
|
form_state.current_position,
|
||||||
data.ucet,
|
form_state.profile_name,
|
||||||
data.skladm,
|
form_state.table_name
|
||||||
data.ico,
|
))?;
|
||||||
data.kontakt,
|
|
||||||
data.telefon,
|
|
||||||
data.skladu,
|
|
||||||
data.fax,
|
|
||||||
];
|
|
||||||
|
|
||||||
form_state.has_unsaved_changes = false;
|
// FIX: Pass the current position as the second argument
|
||||||
|
form_state.update_from_response(&response.data, form_state.current_position);
|
||||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
// src/tui/functions/form.rs
|
// src/tui/functions/form.rs
|
||||||
|
use crate::state::pages::canvas_state::CanvasState;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
pub async fn handle_action(
|
pub async fn handle_action(
|
||||||
action: &str,
|
action: &str,
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
grpc_client: &mut GrpcClient,
|
_grpc_client: &mut GrpcClient,
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
// TODO store unsaved changes without deleting form state values
|
|
||||||
// First check for unsaved changes in both cases
|
|
||||||
if form_state.has_unsaved_changes() {
|
if form_state.has_unsaved_changes() {
|
||||||
return Ok(
|
return Ok(
|
||||||
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
||||||
@@ -21,71 +17,29 @@ pub async fn handle_action(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let total_count = form_state.total_count;
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
"previous_entry" => {
|
"previous_entry" => {
|
||||||
let new_position = current_position.saturating_sub(1);
|
// Only decrement if the current position is greater than the first record.
|
||||||
if new_position >= 1 {
|
// This prevents wrapping from 1 to total_count.
|
||||||
*current_position = new_position;
|
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
|
||||||
let response = grpc_client.get_adresar_by_position(*current_position).await?;
|
if form_state.current_position > 1 {
|
||||||
|
form_state.current_position -= 1;
|
||||||
// Direct field assignments
|
*ideal_cursor_column = 0;
|
||||||
form_state.id = response.id;
|
|
||||||
form_state.values = vec![
|
|
||||||
response.firma, response.kz, response.drc,
|
|
||||||
response.ulica, response.psc, response.mesto,
|
|
||||||
response.stat, response.banka, response.ucet,
|
|
||||||
response.skladm, response.ico, response.kontakt,
|
|
||||||
response.telefon, response.skladu, response.fax,
|
|
||||||
];
|
|
||||||
|
|
||||||
let current_input = form_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1
|
|
||||||
} else { 0 };
|
|
||||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
|
||||||
form_state.has_unsaved_changes = false;
|
|
||||||
|
|
||||||
Ok(format!("Loaded form entry {}", *current_position))
|
|
||||||
} else {
|
|
||||||
Ok("Already at first form entry".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"next_entry" => {
|
"next_entry" => {
|
||||||
if *current_position <= total_count {
|
// Only increment if the current position is not yet at the "New Entry" stage.
|
||||||
*current_position += 1;
|
// The "New Entry" position is total_count + 1.
|
||||||
if *current_position <= total_count {
|
// This allows moving from the last record to "New Entry", but stops there.
|
||||||
let response = grpc_client.get_adresar_by_position(*current_position).await?;
|
if form_state.current_position <= total_count {
|
||||||
|
form_state.current_position += 1;
|
||||||
// Direct field assignments
|
*ideal_cursor_column = 0;
|
||||||
form_state.id = response.id;
|
|
||||||
form_state.values = vec![
|
|
||||||
response.firma, response.kz, response.drc,
|
|
||||||
response.ulica, response.psc, response.mesto,
|
|
||||||
response.stat, response.banka, response.ucet,
|
|
||||||
response.skladm, response.ico, response.kontakt,
|
|
||||||
response.telefon, response.skladu, response.fax,
|
|
||||||
];
|
|
||||||
|
|
||||||
let current_input = form_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1
|
|
||||||
} else { 0 };
|
|
||||||
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
|
|
||||||
form_state.has_unsaved_changes = false;
|
|
||||||
|
|
||||||
Ok(format!("Loaded form entry {}", *current_position))
|
|
||||||
} else {
|
|
||||||
form_state.reset_to_empty();
|
|
||||||
form_state.current_field = 0;
|
|
||||||
form_state.current_cursor_pos = 0;
|
|
||||||
*ideal_cursor_column = 0;
|
|
||||||
Ok("New form entry mode".into())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok("Already at last entry".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(anyhow!("Unknown form action: {}", action))
|
_ => return Err(anyhow!("Unknown form action: {}", action)),
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ pub fn handle_intro_selection(
|
|||||||
index: usize,
|
index: usize,
|
||||||
) {
|
) {
|
||||||
let target_view = match index {
|
let target_view = match index {
|
||||||
0 => {
|
0 => AppView::Form,
|
||||||
let form_name = app_state.selected_profile.clone().unwrap_or_else(|| "Data Form".to_string());
|
|
||||||
AppView::Form(form_name)
|
|
||||||
}
|
|
||||||
1 => AppView::Admin,
|
1 => AppView::Admin,
|
||||||
2 => AppView::Login,
|
2 => AppView::Login,
|
||||||
3 => AppView::Register,
|
3 => AppView::Register,
|
||||||
|
|||||||
@@ -21,4 +21,3 @@ pub enum DialogPurpose {
|
|||||||
// TODO in the future:
|
// TODO in the future:
|
||||||
// ConfirmQuit,
|
// ConfirmQuit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/ui/handlers/rat_state.rs
|
// client/src/ui/handlers/rat_state.rs
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::state::app::state::UiState;
|
use crate::state::app::state::UiState;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/ui/handlers/render.rs
|
// client/src/ui/handlers/render.rs
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
render_background,
|
render_background,
|
||||||
@@ -11,10 +11,14 @@ use crate::components::{
|
|||||||
admin::render_add_table,
|
admin::render_add_table,
|
||||||
admin::add_logic::render_add_logic,
|
admin::add_logic::render_add_logic,
|
||||||
auth::{login::render_login, register::render_register},
|
auth::{login::render_login, register::render_register},
|
||||||
|
common::find_file_palette,
|
||||||
};
|
};
|
||||||
use crate::config::colors::themes::Theme;
|
use crate::config::colors::themes::Theme;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::{
|
||||||
use ratatui::Frame;
|
layout::{Constraint, Direction, Layout},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::state::pages::canvas_state::CanvasState;
|
||||||
use crate::state::pages::form::FormState;
|
use crate::state::pages::form::FormState;
|
||||||
use crate::state::pages::auth::AuthState;
|
use crate::state::pages::auth::AuthState;
|
||||||
use crate::state::pages::auth::LoginState;
|
use crate::state::pages::auth::LoginState;
|
||||||
@@ -24,7 +28,9 @@ use crate::state::app::buffer::BufferState;
|
|||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::admin::AdminState;
|
use crate::state::pages::admin::AdminState;
|
||||||
use crate::state::app::highlight::HighlightState;
|
use crate::state::app::highlight::HighlightState;
|
||||||
|
use crate::modes::general::command_navigation::NavigationState;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn render_ui(
|
pub fn render_ui(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
form_state: &mut FormState,
|
form_state: &mut FormState,
|
||||||
@@ -35,175 +41,175 @@ pub fn render_ui(
|
|||||||
admin_state: &mut AdminState,
|
admin_state: &mut AdminState,
|
||||||
buffer_state: &BufferState,
|
buffer_state: &BufferState,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
is_edit_mode: bool,
|
is_event_handler_edit_mode: bool,
|
||||||
highlight_state: &HighlightState,
|
highlight_state: &HighlightState,
|
||||||
total_count: u64,
|
event_handler_command_input: &str,
|
||||||
current_position: u64,
|
event_handler_command_mode_active: bool,
|
||||||
|
event_handler_command_message: &str,
|
||||||
|
navigation_state: &NavigationState,
|
||||||
current_dir: &str,
|
current_dir: &str,
|
||||||
command_input: &str,
|
|
||||||
command_mode: bool,
|
|
||||||
command_message: &str,
|
|
||||||
current_fps: f64,
|
current_fps: f64,
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
) {
|
) {
|
||||||
render_background(f, f.area(), theme);
|
render_background(f, f.area(), theme);
|
||||||
|
|
||||||
// Adjust layout based on whether buffer list is shown
|
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
||||||
let constraints = if app_state.ui.show_buffer_list {
|
|
||||||
vec![
|
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(1)];
|
||||||
Constraint::Length(1), // Buffer list
|
|
||||||
Constraint::Min(1), // Main content
|
let command_palette_area_height = if navigation_state.active {
|
||||||
Constraint::Length(1), // Status line
|
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
||||||
Constraint::Length(1), // Command line
|
} else if event_handler_command_mode_active {
|
||||||
]
|
1
|
||||||
} else {
|
} else {
|
||||||
vec![
|
0 // Neither is active
|
||||||
Constraint::Min(1), // Main content
|
|
||||||
Constraint::Length(1), // Status line (no buffer list)
|
|
||||||
Constraint::Length(1), // Command line
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let root = Layout::default()
|
if command_palette_area_height > 0 {
|
||||||
|
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut main_layout_constraints = vec![Constraint::Min(1)];
|
||||||
|
if app_state.ui.show_buffer_list {
|
||||||
|
main_layout_constraints.insert(0, Constraint::Length(1));
|
||||||
|
}
|
||||||
|
main_layout_constraints.extend(bottom_area_constraints);
|
||||||
|
|
||||||
|
|
||||||
|
let root_chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(constraints)
|
.constraints(main_layout_constraints)
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
let mut buffer_list_area = None;
|
let mut chunk_idx = 0;
|
||||||
let main_content_area;
|
let buffer_list_area = if app_state.ui.show_buffer_list {
|
||||||
let status_line_area;
|
let area = Some(root_chunks[chunk_idx]);
|
||||||
let command_line_area;
|
chunk_idx += 1;
|
||||||
|
area
|
||||||
// Assign areas based on layout
|
|
||||||
if app_state.ui.show_buffer_list {
|
|
||||||
buffer_list_area = Some(root[0]);
|
|
||||||
main_content_area = root[1];
|
|
||||||
status_line_area = root[2];
|
|
||||||
command_line_area = root[3];
|
|
||||||
} else {
|
} else {
|
||||||
main_content_area = root[0];
|
None
|
||||||
status_line_area = root[1];
|
};
|
||||||
command_line_area = root[2];
|
|
||||||
}
|
let main_content_area = root_chunks[chunk_idx];
|
||||||
|
chunk_idx += 1;
|
||||||
|
|
||||||
|
let status_line_area = root_chunks[chunk_idx];
|
||||||
|
chunk_idx += 1;
|
||||||
|
|
||||||
|
let command_render_area = if command_palette_area_height > 0 {
|
||||||
|
if root_chunks.len() > chunk_idx {
|
||||||
|
Some(root_chunks[chunk_idx])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
if app_state.ui.show_intro {
|
if app_state.ui.show_intro {
|
||||||
render_intro(f, intro_state, main_content_area, theme);
|
render_intro(f, intro_state, main_content_area, theme);
|
||||||
} else if app_state.ui.show_register {
|
} else if app_state.ui.show_register {
|
||||||
render_register(
|
render_register(
|
||||||
f,
|
f, main_content_area, theme, register_state, app_state,
|
||||||
main_content_area,
|
register_state.current_field() < 4,
|
||||||
theme,
|
|
||||||
register_state,
|
|
||||||
app_state,
|
|
||||||
register_state.current_field < 4,
|
|
||||||
highlight_state,
|
highlight_state,
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
render_add_table(
|
render_add_table(
|
||||||
f,
|
f, main_content_area, theme, app_state, &mut admin_state.add_table_state,
|
||||||
main_content_area,
|
is_event_handler_edit_mode,
|
||||||
theme,
|
|
||||||
app_state,
|
|
||||||
&mut admin_state.add_table_state,
|
|
||||||
login_state.current_field < 3,
|
|
||||||
highlight_state,
|
highlight_state,
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_add_logic {
|
} else if app_state.ui.show_add_logic {
|
||||||
render_add_logic(
|
render_add_logic(
|
||||||
f,
|
f, main_content_area, theme, app_state, &mut admin_state.add_logic_state,
|
||||||
main_content_area,
|
is_event_handler_edit_mode, highlight_state,
|
||||||
theme,
|
|
||||||
app_state,
|
|
||||||
&mut admin_state.add_logic_state,
|
|
||||||
is_edit_mode, // Pass the general edit mode status
|
|
||||||
highlight_state,
|
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_login {
|
} else if app_state.ui.show_login {
|
||||||
render_login(
|
render_login(
|
||||||
f,
|
f, main_content_area, theme, login_state, app_state,
|
||||||
main_content_area,
|
login_state.current_field() < 2,
|
||||||
theme,
|
|
||||||
login_state,
|
|
||||||
app_state,
|
|
||||||
login_state.current_field < 2,
|
|
||||||
highlight_state,
|
highlight_state,
|
||||||
);
|
);
|
||||||
} else if app_state.ui.show_admin {
|
} else if app_state.ui.show_admin {
|
||||||
crate::components::admin::admin_panel::render_admin_panel(
|
crate::components::admin::admin_panel::render_admin_panel(
|
||||||
f,
|
f, app_state, auth_state, admin_state, main_content_area, theme,
|
||||||
app_state,
|
&app_state.profile_tree, &app_state.selected_profile,
|
||||||
auth_state,
|
|
||||||
admin_state,
|
|
||||||
main_content_area,
|
|
||||||
theme,
|
|
||||||
&app_state.profile_tree,
|
|
||||||
&app_state.selected_profile,
|
|
||||||
);
|
|
||||||
} else if app_state.ui.show_form {
|
|
||||||
let (sidebar_area, form_area) = calculate_sidebar_layout(
|
|
||||||
app_state.ui.show_sidebar,
|
|
||||||
main_content_area
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
} else if app_state.ui.show_form {
|
||||||
|
let (sidebar_area, form_actual_area) = calculate_sidebar_layout(
|
||||||
|
app_state.ui.show_sidebar, main_content_area
|
||||||
|
);
|
||||||
if let Some(sidebar_rect) = sidebar_area {
|
if let Some(sidebar_rect) = sidebar_area {
|
||||||
sidebar::render_sidebar(
|
sidebar::render_sidebar(
|
||||||
f,
|
f, sidebar_rect, theme, &app_state.profile_tree, &app_state.selected_profile
|
||||||
sidebar_rect,
|
|
||||||
theme,
|
|
||||||
&app_state.profile_tree,
|
|
||||||
&app_state.selected_profile
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let available_width = form_actual_area.width;
|
||||||
// This change makes the form stay stationary when toggling sidebar
|
let form_render_area = if available_width >= 80 {
|
||||||
let available_width = form_area.width;
|
Layout::default().direction(Direction::Horizontal)
|
||||||
let form_constraint = if available_width >= 80 {
|
.constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
|
||||||
// Use main_content_area for centering when enough space
|
.split(form_actual_area)[1]
|
||||||
Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(80),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.split(main_content_area)[1]
|
|
||||||
} else {
|
} else {
|
||||||
// Use form_area (post sidebar) when limited space
|
Layout::default().direction(Direction::Horizontal)
|
||||||
Layout::default()
|
.constraints([Constraint::Min(0), Constraint::Length(available_width), Constraint::Min(0)])
|
||||||
.direction(Direction::Horizontal)
|
.split(form_actual_area)[1]
|
||||||
.constraints([
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(80.min(available_width)),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.split(form_area)[1]
|
|
||||||
};
|
};
|
||||||
|
let fields_vec: Vec<&str> = form_state.fields.iter().map(AsRef::as_ref).collect();
|
||||||
|
let values_vec: Vec<&String> = form_state.values.iter().collect();
|
||||||
|
|
||||||
// Convert fields to &[&str] and values to &[&String]
|
// --- START FIX ---
|
||||||
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect();
|
// Add the missing `&form_state.table_name` argument to this function call.
|
||||||
let values: Vec<&String> = form_state.values.iter().collect();
|
|
||||||
|
|
||||||
render_form(
|
render_form(
|
||||||
f,
|
f,
|
||||||
form_constraint,
|
form_render_area,
|
||||||
form_state,
|
form_state,
|
||||||
&fields,
|
&fields_vec,
|
||||||
&form_state.current_field,
|
&form_state.current_field,
|
||||||
&values,
|
&values_vec,
|
||||||
|
&form_state.table_name, // <-- THIS ARGUMENT WAS MISSING
|
||||||
theme,
|
theme,
|
||||||
is_edit_mode,
|
is_event_handler_edit_mode,
|
||||||
highlight_state,
|
highlight_state,
|
||||||
total_count,
|
form_state.total_count,
|
||||||
current_position,
|
form_state.current_position,
|
||||||
);
|
);
|
||||||
|
// --- END FIX ---
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render buffer list if enabled and area is available
|
|
||||||
if let Some(area) = buffer_list_area {
|
if let Some(area) = buffer_list_area {
|
||||||
if app_state.ui.show_buffer_list {
|
render_buffer_list(f, area, theme, buffer_state, app_state);
|
||||||
render_buffer_list(f, area, theme, buffer_state);
|
}
|
||||||
|
|
||||||
|
render_status_line(
|
||||||
|
f,
|
||||||
|
status_line_area,
|
||||||
|
current_dir,
|
||||||
|
theme,
|
||||||
|
is_event_handler_edit_mode,
|
||||||
|
current_fps,
|
||||||
|
app_state,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(palette_or_command_area) = command_render_area { // Use the calculated area
|
||||||
|
if navigation_state.active {
|
||||||
|
find_file_palette::render_find_file_palette(
|
||||||
|
f,
|
||||||
|
palette_or_command_area, // Use the correct area
|
||||||
|
theme,
|
||||||
|
navigation_state, // Pass the navigation_state directly
|
||||||
|
);
|
||||||
|
} else if event_handler_command_mode_active {
|
||||||
|
render_command_line(
|
||||||
|
f,
|
||||||
|
palette_or_command_area, // Use the correct area
|
||||||
|
event_handler_command_input,
|
||||||
|
true, // Assuming it's always active when this branch is hit
|
||||||
|
theme,
|
||||||
|
event_handler_command_message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);
|
|
||||||
render_command_line(f, command_line_area, command_input, command_mode, theme, command_message);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,42 +23,37 @@ use crate::tui::terminal::{EventReader, TerminalCore};
|
|||||||
use crate::ui::handlers::render::render_ui;
|
use crate::ui::handlers::render::render_ui;
|
||||||
use crate::tui::functions::common::login::LoginResult;
|
use crate::tui::functions::common::login::LoginResult;
|
||||||
use crate::tui::functions::common::register::RegisterResult;
|
use crate::tui::functions::common::register::RegisterResult;
|
||||||
use crate::tui::functions::common::add_table::handle_save_table_action;
|
use crate::ui::handlers::context::DialogPurpose;
|
||||||
use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
|
|
||||||
use crate::ui::handlers::context::{DialogPurpose, UiContext};
|
|
||||||
use crate::tui::functions::common::login;
|
use crate::tui::functions::common::login;
|
||||||
use crate::tui::functions::common::register;
|
use crate::tui::functions::common::register;
|
||||||
|
use crate::utils::columns::filter_user_columns;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
use crossterm::event as crossterm_event;
|
use crossterm::event as crossterm_event;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
|
||||||
pub async fn run_ui() -> Result<()> {
|
pub async fn run_ui() -> Result<()> {
|
||||||
let config = Config::load().context("Failed to load configuration")?;
|
let config = Config::load().context("Failed to load configuration")?;
|
||||||
let theme = Theme::from_str(&config.colors.theme);
|
let theme = Theme::from_str(&config.colors.theme);
|
||||||
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
|
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
|
||||||
let mut grpc_client = GrpcClient::new().await?;
|
let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
|
||||||
let mut command_handler = CommandHandler::new();
|
let mut command_handler = CommandHandler::new();
|
||||||
|
|
||||||
// --- Channel for Login Results ---
|
let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
|
||||||
let (login_result_sender, mut login_result_receiver) =
|
let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
|
||||||
mpsc::channel::<LoginResult>(1);
|
let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
|
||||||
let (register_result_sender, mut register_result_receiver) =
|
let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
|
||||||
mpsc::channel::<RegisterResult>(1);
|
|
||||||
let (save_table_result_sender, mut save_table_result_receiver) =
|
|
||||||
mpsc::channel::<Result<String>>(1);
|
|
||||||
let (save_logic_result_sender, mut save_logic_result_receiver) =
|
|
||||||
mpsc::channel::<Result<String>>(1);
|
|
||||||
|
|
||||||
let mut event_handler = EventHandler::new(
|
let mut event_handler = EventHandler::new(
|
||||||
login_result_sender.clone(),
|
login_result_sender.clone(),
|
||||||
register_result_sender.clone(),
|
register_result_sender.clone(),
|
||||||
save_table_result_sender.clone(),
|
save_table_result_sender.clone(),
|
||||||
save_logic_result_sender.clone(),
|
save_logic_result_sender.clone(),
|
||||||
).await.context("Failed to create event handler")?;
|
)
|
||||||
|
.await
|
||||||
|
.context("Failed to create event handler")?;
|
||||||
let event_reader = EventReader::new();
|
let event_reader = EventReader::new();
|
||||||
|
|
||||||
let mut auth_state = AuthState::default();
|
let mut auth_state = AuthState::default();
|
||||||
@@ -69,12 +64,9 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
let mut buffer_state = BufferState::default();
|
let mut buffer_state = BufferState::default();
|
||||||
let mut app_state = AppState::new().context("Failed to create initial app state")?;
|
let mut app_state = AppState::new().context("Failed to create initial app state")?;
|
||||||
|
|
||||||
// --- DATA: Load auth data from file at startup ---
|
|
||||||
let mut auto_logged_in = false;
|
let mut auto_logged_in = false;
|
||||||
match load_auth_data() {
|
match load_auth_data() {
|
||||||
Ok(Some(stored_data)) => {
|
Ok(Some(stored_data)) => {
|
||||||
// TODO: Optionally validate token with server here
|
|
||||||
// For now, assume valid if successfully loaded
|
|
||||||
auth_state.auth_token = Some(stored_data.access_token);
|
auth_state.auth_token = Some(stored_data.access_token);
|
||||||
auth_state.user_id = Some(stored_data.user_id);
|
auth_state.user_id = Some(stored_data.user_id);
|
||||||
auth_state.role = Some(stored_data.role);
|
auth_state.role = Some(stored_data.role);
|
||||||
@@ -89,37 +81,168 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
error!("Failed to load auth data: {}", e);
|
error!("Failed to load auth data: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- END DATA ---
|
|
||||||
|
|
||||||
// Initialize app state with profile tree and table structure
|
let (initial_profile, initial_table, initial_columns_from_service) =
|
||||||
let column_names =
|
UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
|
||||||
UiService::initialize_app_state(&mut grpc_client, &mut app_state)
|
.await
|
||||||
.await.context("Failed to initialize app state from UI service")?;
|
.context("Failed to initialize app state and form")?;
|
||||||
let mut form_state = FormState::new(column_names);
|
|
||||||
|
|
||||||
// Fetch the total count of Adresar entries
|
let filtered_columns = filter_user_columns(initial_columns_from_service);
|
||||||
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?;
|
|
||||||
form_state.reset_to_empty();
|
let mut form_state = FormState::new(
|
||||||
|
initial_profile.clone(),
|
||||||
|
initial_table.clone(),
|
||||||
|
filtered_columns,
|
||||||
|
);
|
||||||
|
|
||||||
|
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
|
||||||
|
.await
|
||||||
|
.context(format!(
|
||||||
|
"Failed to fetch initial count for table {}.{}",
|
||||||
|
initial_profile, initial_table
|
||||||
|
))?;
|
||||||
|
|
||||||
|
if form_state.total_count > 0 {
|
||||||
|
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||||
|
event_handler.command_message = format!("Error loading initial data: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
}
|
||||||
|
|
||||||
// --- DATA2: Adjust initial view based on auth status ---
|
|
||||||
if auto_logged_in {
|
if auto_logged_in {
|
||||||
// User is auto-logged in, go to main app view
|
buffer_state.history = vec![AppView::Form];
|
||||||
buffer_state.history = vec![AppView::Form("Adresar".to_string())];
|
|
||||||
buffer_state.active_index = 0;
|
buffer_state.active_index = 0;
|
||||||
info!("Initial view set to Form due to auto-login.");
|
info!("Initial view set to Form due to auto-login.");
|
||||||
}
|
}
|
||||||
// If not auto-logged in, BufferState default (Intro) will be used
|
|
||||||
// --- END DATA2 ---
|
|
||||||
|
|
||||||
// --- FPS Calculation State ---
|
|
||||||
let mut last_frame_time = Instant::now();
|
let mut last_frame_time = Instant::now();
|
||||||
let mut current_fps = 0.0;
|
let mut current_fps = 0.0;
|
||||||
let mut needs_redraw = true;
|
let mut needs_redraw = true;
|
||||||
|
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
|
||||||
|
let mut prev_view_table_name = app_state.current_view_table_name.clone();
|
||||||
|
let mut table_just_switched = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// --- Synchronize UI View from Active Buffer ---
|
let position_before_event = form_state.current_position;
|
||||||
|
let mut event_processed = false;
|
||||||
|
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
|
||||||
|
let event = event_reader.read_event().context("Failed to read terminal event")?;
|
||||||
|
event_processed = true;
|
||||||
|
let event_outcome_result = event_handler.handle_event(
|
||||||
|
event,
|
||||||
|
&config,
|
||||||
|
&mut terminal,
|
||||||
|
&mut grpc_client,
|
||||||
|
&mut command_handler,
|
||||||
|
&mut form_state,
|
||||||
|
&mut auth_state,
|
||||||
|
&mut login_state,
|
||||||
|
&mut register_state,
|
||||||
|
&mut intro_state,
|
||||||
|
&mut admin_state,
|
||||||
|
&mut buffer_state,
|
||||||
|
&mut app_state,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let mut should_exit = false;
|
||||||
|
match event_outcome_result {
|
||||||
|
Ok(outcome) => match outcome {
|
||||||
|
EventOutcome::Ok(message) => {
|
||||||
|
if !message.is_empty() {
|
||||||
|
event_handler.command_message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventOutcome::Exit(message) => {
|
||||||
|
event_handler.command_message = message;
|
||||||
|
should_exit = true;
|
||||||
|
}
|
||||||
|
EventOutcome::DataSaved(save_outcome, message) => {
|
||||||
|
event_handler.command_message = message;
|
||||||
|
if let Err(e) = UiService::handle_save_outcome(
|
||||||
|
save_outcome,
|
||||||
|
&mut grpc_client,
|
||||||
|
&mut app_state,
|
||||||
|
&mut form_state,
|
||||||
|
).await {
|
||||||
|
event_handler.command_message =
|
||||||
|
format!("Error handling save outcome: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventOutcome::ButtonSelected { .. } => {}
|
||||||
|
EventOutcome::TableSelected { path } => {
|
||||||
|
let parts: Vec<&str> = path.split('/').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let profile_name = parts[0].to_string();
|
||||||
|
let table_name = parts[1].to_string();
|
||||||
|
|
||||||
|
app_state.set_current_view_table(profile_name, table_name);
|
||||||
|
buffer_state.update_history(AppView::Form);
|
||||||
|
event_handler.command_message = format!("Loading table: {}", path);
|
||||||
|
} else {
|
||||||
|
event_handler.command_message = format!("Invalid table path: {}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
event_handler.command_message = format!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if should_exit {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match login_result_receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
error!("Login result channel disconnected unexpectedly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match register_result_receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
error!("Register result channel disconnected unexpectedly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match save_table_result_receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
app_state.hide_dialog();
|
||||||
|
match result {
|
||||||
|
Ok(ref success_message) => {
|
||||||
|
app_state.show_dialog(
|
||||||
|
"Save Successful",
|
||||||
|
success_message,
|
||||||
|
vec!["OK".to_string()],
|
||||||
|
DialogPurpose::SaveTableSuccess,
|
||||||
|
);
|
||||||
|
admin_state.add_table_state.has_unsaved_changes = false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
event_handler.command_message = format!("Save failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
error!("Save table result channel disconnected unexpectedly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(active_view) = buffer_state.get_active_view() {
|
if let Some(active_view) = buffer_state.get_active_view() {
|
||||||
// Reset all flags first
|
|
||||||
app_state.ui.show_intro = false;
|
app_state.ui.show_intro = false;
|
||||||
app_state.ui.show_login = false;
|
app_state.ui.show_login = false;
|
||||||
app_state.ui.show_register = false;
|
app_state.ui.show_register = false;
|
||||||
@@ -142,38 +265,263 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app_state.ui.show_admin = true; // <<< RESTORE THIS
|
app_state.ui.show_admin = true;
|
||||||
let profile_names = app_state.profile_tree.profiles.iter() // <<< RESTORE THIS
|
let profile_names = app_state.profile_tree.profiles.iter()
|
||||||
.map(|p| p.name.clone()) // <<< RESTORE THIS
|
.map(|p| p.name.clone())
|
||||||
.collect(); // <<< RESTORE THIS
|
.collect();
|
||||||
admin_state.set_profiles(profile_names);
|
admin_state.set_profiles(profile_names);
|
||||||
|
|
||||||
// Only reset to ProfilesPane if not already in a specific admin sub-focus
|
if admin_state.current_focus == AdminFocus::default() ||
|
||||||
if admin_state.current_focus == AdminFocus::default() ||
|
!matches!(admin_state.current_focus,
|
||||||
!matches!(admin_state.current_focus,
|
|
||||||
AdminFocus::InsideProfilesList |
|
AdminFocus::InsideProfilesList |
|
||||||
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
||||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
|
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
|
||||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||||
}
|
}
|
||||||
// Pre-select first profile item for visual consistency, but '>' won't show until 'select'
|
|
||||||
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
|
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
|
||||||
admin_state.profile_list_state.select(Some(0));
|
admin_state.profile_list_state.select(Some(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppView::AddTable => app_state.ui.show_add_table = true,
|
AppView::AddTable => app_state.ui.show_add_table = true,
|
||||||
AppView::AddLogic => app_state.ui.show_add_logic = true,
|
AppView::AddLogic => app_state.ui.show_add_logic = true,
|
||||||
AppView::Form(_) => app_state.ui.show_form = true,
|
AppView::Form => app_state.ui.show_form = true,
|
||||||
AppView::Scratch => {} // Or show a scratchpad component
|
AppView::Scratch => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- End Synchronization ---
|
|
||||||
|
|
||||||
// --- 3. Draw UI ---
|
if app_state.ui.show_form {
|
||||||
// Draw the current state *first*. This ensures the loading dialog
|
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||||
// set in the *previous* iteration gets rendered before the pending
|
let current_view_table = app_state.current_view_table_name.clone();
|
||||||
// action check below.
|
|
||||||
if needs_redraw {
|
if prev_view_profile_name != current_view_profile
|
||||||
|
|| prev_view_table_name != current_view_table
|
||||||
|
{
|
||||||
|
if let (Some(prof_name), Some(tbl_name)) =
|
||||||
|
(current_view_profile.as_ref(), current_view_table.as_ref())
|
||||||
|
{
|
||||||
|
app_state.show_loading_dialog(
|
||||||
|
"Loading Table",
|
||||||
|
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
|
||||||
|
);
|
||||||
|
needs_redraw = true;
|
||||||
|
|
||||||
|
match grpc_client
|
||||||
|
.get_table_structure(prof_name.clone(), tbl_name.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(structure_response) => {
|
||||||
|
let new_columns: Vec<String> = structure_response
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let filtered_columns = filter_user_columns(new_columns);
|
||||||
|
form_state = FormState::new(
|
||||||
|
prof_name.clone(),
|
||||||
|
tbl_name.clone(),
|
||||||
|
filtered_columns,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||||
|
&mut grpc_client,
|
||||||
|
&mut form_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
app_state.update_dialog_content(
|
||||||
|
&format!("Error fetching count: {}", e),
|
||||||
|
vec!["OK".to_string()],
|
||||||
|
DialogPurpose::LoginFailed,
|
||||||
|
);
|
||||||
|
} else if form_state.total_count > 0 {
|
||||||
|
if let Err(e) = UiService::load_table_data_by_position(
|
||||||
|
&mut grpc_client,
|
||||||
|
&mut form_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
app_state.update_dialog_content(
|
||||||
|
&format!("Error loading data: {}", e),
|
||||||
|
vec!["OK".to_string()],
|
||||||
|
DialogPurpose::LoginFailed,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app_state.hide_dialog();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
app_state.hide_dialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_view_profile_name = current_view_profile;
|
||||||
|
prev_view_table_name = current_view_table;
|
||||||
|
table_just_switched = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app_state.update_dialog_content(
|
||||||
|
&format!("Error fetching table structure: {}", e),
|
||||||
|
vec!["OK".to_string()],
|
||||||
|
DialogPurpose::LoginFailed,
|
||||||
|
);
|
||||||
|
app_state.current_view_profile_name =
|
||||||
|
prev_view_profile_name.clone();
|
||||||
|
app_state.current_view_table_name =
|
||||||
|
prev_view_table_name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||||
|
if app_state.ui.show_add_logic {
|
||||||
|
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||||
|
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
|
||||||
|
info!("Fetching table structure for {}.{}", profile_name, table_name);
|
||||||
|
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||||
|
&mut grpc_client,
|
||||||
|
&mut admin_state.add_logic_state,
|
||||||
|
&app_state.profile_tree,
|
||||||
|
).await.unwrap_or_else(|e| {
|
||||||
|
error!("Error initializing add_logic_table_data: {}", e);
|
||||||
|
format!("Error fetching table structure: {}", e)
|
||||||
|
});
|
||||||
|
|
||||||
|
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||||
|
info!("{}", fetch_message);
|
||||||
|
} else {
|
||||||
|
event_handler.command_message = fetch_message;
|
||||||
|
}
|
||||||
|
needs_redraw = true;
|
||||||
|
} else {
|
||||||
|
error!(
|
||||||
|
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but add_logic_state is for {}.{:?}",
|
||||||
|
profile_name, table_name,
|
||||||
|
admin_state.add_logic_state.profile_name,
|
||||||
|
admin_state.add_logic_state.selected_table_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
|
||||||
|
profile_name, table_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
|
||||||
|
if app_state.ui.show_add_logic {
|
||||||
|
let profile_name = admin_state.add_logic_state.profile_name.clone();
|
||||||
|
|
||||||
|
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
|
||||||
|
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
|
||||||
|
Ok(columns) => {
|
||||||
|
admin_state.add_logic_state.set_columns_for_table_autocomplete(columns.clone());
|
||||||
|
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||||
|
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||||
|
admin_state.add_logic_state.script_editor_awaiting_column_autocomplete = None;
|
||||||
|
admin_state.add_logic_state.deactivate_script_editor_autocomplete();
|
||||||
|
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let position_changed = form_state.current_position != position_before_event;
|
||||||
|
let mut position_logic_needs_redraw = false;
|
||||||
|
|
||||||
|
if app_state.ui.show_form && !table_just_switched {
|
||||||
|
if position_changed && !event_handler.is_edit_mode {
|
||||||
|
position_logic_needs_redraw = true;
|
||||||
|
|
||||||
|
if form_state.current_position > form_state.total_count {
|
||||||
|
form_state.reset_to_empty();
|
||||||
|
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||||
|
} else {
|
||||||
|
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||||
|
Ok(load_message) => {
|
||||||
|
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
|
||||||
|
event_handler.command_message = load_message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
event_handler.command_message = format!("Error loading data: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_input_after_load_str = form_state.get_current_input();
|
||||||
|
let current_input_len_after_load = current_input_after_load_str.chars().count();
|
||||||
|
let max_cursor_pos = if current_input_len_after_load > 0 {
|
||||||
|
current_input_len_after_load.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||||
|
|
||||||
|
} else if !position_changed && !event_handler.is_edit_mode {
|
||||||
|
let current_input_str = form_state.get_current_input();
|
||||||
|
let current_input_len = current_input_str.chars().count();
|
||||||
|
let max_cursor_pos = if current_input_len > 0 {
|
||||||
|
current_input_len.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||||
|
}
|
||||||
|
} else if app_state.ui.show_register {
|
||||||
|
if !event_handler.is_edit_mode {
|
||||||
|
let current_input = register_state.get_current_input();
|
||||||
|
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||||
|
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||||
|
}
|
||||||
|
} else if app_state.ui.show_login {
|
||||||
|
if !event_handler.is_edit_mode {
|
||||||
|
let current_input = login_state.get_current_input();
|
||||||
|
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||||
|
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if position_logic_needs_redraw {
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app_state.ui.dialog.is_loading {
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ui-debug")]
|
||||||
|
{
|
||||||
|
app_state.debug_info = format!(
|
||||||
|
"Redraw -> event: {}, needs_redraw: {}, pos_changed: {}",
|
||||||
|
event_processed, needs_redraw, position_changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_processed || needs_redraw || position_changed {
|
||||||
|
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
|
||||||
|
match current_mode {
|
||||||
|
AppMode::Edit => { terminal.show_cursor()?; }
|
||||||
|
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
||||||
|
AppMode::ReadOnly => {
|
||||||
|
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
||||||
|
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
||||||
|
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
||||||
|
}
|
||||||
|
AppMode::General => {
|
||||||
|
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
||||||
|
else { terminal.hide_cursor()?; }
|
||||||
|
}
|
||||||
|
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
||||||
|
}
|
||||||
|
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
render_ui(
|
render_ui(
|
||||||
f,
|
f,
|
||||||
@@ -185,14 +533,13 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
&mut admin_state,
|
&mut admin_state,
|
||||||
&buffer_state,
|
&buffer_state,
|
||||||
&theme,
|
&theme,
|
||||||
event_handler.is_edit_mode, // Use event_handler's state
|
event_handler.is_edit_mode,
|
||||||
&event_handler.highlight_state,
|
&event_handler.highlight_state,
|
||||||
app_state.total_count,
|
|
||||||
app_state.current_position,
|
|
||||||
&app_state.current_dir,
|
|
||||||
&event_handler.command_input,
|
&event_handler.command_input,
|
||||||
event_handler.command_mode,
|
event_handler.command_mode,
|
||||||
&event_handler.command_message,
|
&event_handler.command_message,
|
||||||
|
&event_handler.navigation_state,
|
||||||
|
&app_state.current_dir,
|
||||||
current_fps,
|
current_fps,
|
||||||
&app_state,
|
&app_state,
|
||||||
);
|
);
|
||||||
@@ -200,275 +547,13 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
needs_redraw = false;
|
needs_redraw = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cursor Visibility Logic ---
|
|
||||||
// (Keep existing cursor logic here - depends on state drawn above)
|
|
||||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
|
|
||||||
match current_mode {
|
|
||||||
AppMode::Edit => { terminal.show_cursor()?; }
|
|
||||||
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
|
||||||
AppMode::ReadOnly => {
|
|
||||||
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
|
||||||
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
|
||||||
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
|
||||||
}
|
|
||||||
AppMode::General => {
|
|
||||||
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
|
||||||
else { terminal.hide_cursor()?; }
|
|
||||||
}
|
|
||||||
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
|
||||||
}
|
|
||||||
// --- End Cursor Visibility Logic ---
|
|
||||||
|
|
||||||
let total_count = app_state.total_count;
|
|
||||||
let mut current_position = app_state.current_position;
|
|
||||||
let position_before_event = current_position;
|
|
||||||
// --- Determine if redraw is needed based on active login ---
|
|
||||||
// Always redraw if the loading dialog is currently showing.
|
|
||||||
if app_state.ui.dialog.is_loading {
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 1. Handle Terminal Events ---
|
|
||||||
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
|
|
||||||
let mut event_processed = false;
|
|
||||||
// Poll for events *after* drawing and checking pending actions
|
|
||||||
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
|
|
||||||
let event = event_reader.read_event().context("Failed to read terminal event")?;
|
|
||||||
event_processed = true; // Mark that we received and will process an event
|
|
||||||
event_outcome_result = event_handler.handle_event(
|
|
||||||
event,
|
|
||||||
&config,
|
|
||||||
&mut terminal,
|
|
||||||
&mut grpc_client,
|
|
||||||
&mut command_handler,
|
|
||||||
&mut form_state,
|
|
||||||
&mut auth_state,
|
|
||||||
&mut login_state,
|
|
||||||
&mut register_state,
|
|
||||||
&mut intro_state,
|
|
||||||
&mut admin_state,
|
|
||||||
&mut buffer_state,
|
|
||||||
&mut app_state,
|
|
||||||
total_count,
|
|
||||||
&mut current_position,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if event_processed {
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update position based on handler's modification
|
|
||||||
// This happens *after* the event is handled
|
|
||||||
app_state.current_position = current_position;
|
|
||||||
|
|
||||||
// --- Check for Login Results from Channel ---
|
|
||||||
match login_result_receiver.try_recv() {
|
|
||||||
Ok(result) => {
|
|
||||||
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
|
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
error!("Login result channel disconnected unexpectedly.");
|
|
||||||
// Optionally show an error dialog here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Check for Register Results from Channel ---
|
|
||||||
match register_result_receiver.try_recv() {
|
|
||||||
Ok(result) => {
|
|
||||||
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
|
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
error!("Register result channel disconnected unexpectedly.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- Check for Save Table Results ---
|
|
||||||
match save_table_result_receiver.try_recv() {
|
|
||||||
Ok(result) => {
|
|
||||||
app_state.hide_dialog(); // Hide loading indicator
|
|
||||||
match result {
|
|
||||||
Ok(ref success_message) => {
|
|
||||||
app_state.show_dialog(
|
|
||||||
"Save Successful",
|
|
||||||
success_message,
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::SaveTableSuccess,
|
|
||||||
);
|
|
||||||
admin_state.add_table_state.has_unsaved_changes = false;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
event_handler.command_message = format!("Save failed: {}", e);
|
|
||||||
// Optionally show an error dialog instead of just command message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TryRecvError::Empty) => {} // No message
|
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
error!("Save table result channel disconnected unexpectedly.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Centralized Consequence Handling ---
|
|
||||||
let mut should_exit = false;
|
|
||||||
match event_outcome_result {
|
|
||||||
Ok(outcome) => match outcome {
|
|
||||||
EventOutcome::Ok(message) => {
|
|
||||||
if !message.is_empty() {
|
|
||||||
// Update command message only if event handling produced one
|
|
||||||
// Avoid overwriting messages potentially set by pending actions
|
|
||||||
// event_handler.command_message = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EventOutcome::Exit(message) => {
|
|
||||||
event_handler.command_message = message;
|
|
||||||
should_exit = true;
|
|
||||||
}
|
|
||||||
EventOutcome::DataSaved(save_outcome, message) => {
|
|
||||||
event_handler.command_message = message; // Show save status
|
|
||||||
if let Err(e) = UiService::handle_save_outcome(
|
|
||||||
save_outcome,
|
|
||||||
&mut grpc_client,
|
|
||||||
&mut app_state,
|
|
||||||
&mut form_state,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
event_handler.command_message =
|
|
||||||
format!("Error handling save outcome: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EventOutcome::ButtonSelected { context: _, index: _ } => {
|
|
||||||
// This case should ideally be fully handled within handle_event
|
|
||||||
// If initiate_login was called, it returned early.
|
|
||||||
// If not, the message was set and returned via Ok(message).
|
|
||||||
// Log if necessary, but likely no action needed here.
|
|
||||||
// log::warn!("ButtonSelected outcome reached main loop unexpectedly.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
event_handler.command_message = format!("Error: {}", e);
|
|
||||||
}
|
|
||||||
} // --- End Consequence Handling ---
|
|
||||||
|
|
||||||
// --- Position Change Handling (after outcome processing and pending actions) ---
|
|
||||||
let position_changed = app_state.current_position != position_before_event;
|
|
||||||
let current_total_count = app_state.total_count;
|
|
||||||
let mut position_logic_needs_redraw = false;
|
|
||||||
if app_state.ui.show_form {
|
|
||||||
if position_changed && !event_handler.is_edit_mode {
|
|
||||||
let current_input = form_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1 // Limit to last character in readonly mode
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
form_state.current_cursor_pos =
|
|
||||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
|
||||||
position_logic_needs_redraw = true;
|
|
||||||
|
|
||||||
// Ensure position never exceeds total_count + 1
|
|
||||||
if app_state.current_position > current_total_count + 1 {
|
|
||||||
app_state.current_position = current_total_count + 1;
|
|
||||||
}
|
|
||||||
if app_state.current_position > current_total_count {
|
|
||||||
// New entry - reset form
|
|
||||||
form_state.reset_to_empty();
|
|
||||||
form_state.current_field = 0;
|
|
||||||
} else if app_state.current_position >= 1
|
|
||||||
&& app_state.current_position <= current_total_count
|
|
||||||
{
|
|
||||||
// Existing entry - load data
|
|
||||||
let current_position_to_load = app_state.current_position; // Use a copy
|
|
||||||
let load_message = UiService::load_adresar_by_position(
|
|
||||||
&mut grpc_client,
|
|
||||||
&mut app_state, // Pass app_state mutably if needed by the service
|
|
||||||
&mut form_state,
|
|
||||||
current_position_to_load,
|
|
||||||
)
|
|
||||||
.await.with_context(|| format!("Failed to load adresar by position: {}", current_position_to_load))?;
|
|
||||||
|
|
||||||
let current_input = form_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !event_handler.is_edit_mode
|
|
||||||
&& !current_input.is_empty()
|
|
||||||
{
|
|
||||||
current_input.len() - 1 // In readonly mode, limit to last character
|
|
||||||
} else {
|
|
||||||
current_input.len()
|
|
||||||
};
|
|
||||||
form_state.current_cursor_pos = event_handler
|
|
||||||
.ideal_cursor_column
|
|
||||||
.min(max_cursor_pos);
|
|
||||||
// Don't overwrite message from handle_event if load_message is simple success
|
|
||||||
if !load_message.starts_with("Loaded entry")
|
|
||||||
|| event_handler.command_message.is_empty()
|
|
||||||
{
|
|
||||||
event_handler.command_message = load_message;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Invalid position (e.g., 0) - reset to first entry or new entry mode
|
|
||||||
app_state.current_position =
|
|
||||||
1.min(current_total_count + 1); // Go to 1 or new entry if empty
|
|
||||||
if app_state.current_position > total_count {
|
|
||||||
form_state.reset_to_empty();
|
|
||||||
form_state.current_field = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !position_changed && !event_handler.is_edit_mode {
|
|
||||||
// If position didn't change but we are in read-only, just adjust cursor
|
|
||||||
let current_input = form_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
form_state.current_cursor_pos =
|
|
||||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
|
||||||
}
|
|
||||||
} else if app_state.ui.show_register {
|
|
||||||
if !event_handler.is_edit_mode {
|
|
||||||
let current_input = register_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
|
||||||
}
|
|
||||||
} else if app_state.ui.show_login {
|
|
||||||
if !event_handler.is_edit_mode {
|
|
||||||
let current_input = login_state.get_current_input();
|
|
||||||
let max_cursor_pos = if !current_input.is_empty() {
|
|
||||||
current_input.len() - 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if position_logic_needs_redraw {
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
// --- End Position Change Handling ---
|
|
||||||
|
|
||||||
// Check exit condition *after* all processing for the iteration
|
|
||||||
if should_exit {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FPS Calculation ---
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let frame_duration = now.duration_since(last_frame_time);
|
let frame_duration = now.duration_since(last_frame_time);
|
||||||
last_frame_time = now;
|
last_frame_time = now;
|
||||||
if frame_duration.as_secs_f64() > 1e-6 {
|
if frame_duration.as_secs_f64() > 1e-6 {
|
||||||
current_fps = 1.0 / frame_duration.as_secs_f64();
|
current_fps = 1.0 / frame_duration.as_secs_f64();
|
||||||
}
|
}
|
||||||
} // End main loop
|
|
||||||
}
|
|
||||||
|
|
||||||
|
table_just_switched = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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/table_definition.proto",
|
||||||
"proto/tables_data.proto",
|
"proto/tables_data.proto",
|
||||||
"proto/table_script.proto",
|
"proto/table_script.proto",
|
||||||
|
"proto/search.proto",
|
||||||
],
|
],
|
||||||
&["proto"],
|
&["proto"],
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
20
common/proto/search.proto
Normal file
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 {
|
pub mod table_script {
|
||||||
include!("proto/multieko2.table_script.rs");
|
include!("proto/multieko2.table_script.rs");
|
||||||
}
|
}
|
||||||
|
pub mod search {
|
||||||
|
include!("proto/multieko2.search.rs");
|
||||||
|
}
|
||||||
pub const FILE_DESCRIPTOR_SET: &[u8] =
|
pub const FILE_DESCRIPTOR_SET: &[u8] =
|
||||||
include_bytes!("proto/descriptor.bin");
|
include_bytes!("proto/descriptor.bin");
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
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]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
search = { path = "../search" }
|
||||||
|
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tantivy = { workspace = true }
|
||||||
chrono = { version = "0.4.40", features = ["serde"] }
|
chrono = { version = "0.4.40", features = ["serde"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
prost = "0.13.5"
|
prost = "0.13.5"
|
||||||
|
|||||||
3
server/migrations/20250528110810_create_gen_schema.sql
Normal file
3
server/migrations/20250528110810_create_gen_schema.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS gen;
|
||||||
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
|
// src/lib.rs
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod indexer;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod adresar;
|
pub mod adresar;
|
||||||
pub mod uctovnictvo;
|
pub mod uctovnictvo;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
use tonic::transport::Server;
|
use tonic::transport::Server;
|
||||||
use tonic_reflection::server::Builder as ReflectionBuilder;
|
use tonic_reflection::server::Builder as ReflectionBuilder;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use crate::indexer::{indexer_task, IndexCommand};
|
||||||
|
|
||||||
use common::proto::multieko2::FILE_DESCRIPTOR_SET;
|
use common::proto::multieko2::FILE_DESCRIPTOR_SET;
|
||||||
use crate::server::services::{
|
use crate::server::services::{
|
||||||
AdresarService,
|
AdresarService,
|
||||||
@@ -21,22 +24,35 @@ use common::proto::multieko2::{
|
|||||||
table_script::table_script_server::TableScriptServer,
|
table_script::table_script_server::TableScriptServer,
|
||||||
auth::auth_service_server::AuthServiceServer
|
auth::auth_service_server::AuthServiceServer
|
||||||
};
|
};
|
||||||
|
use search::{SearcherService, SearcherServer};
|
||||||
|
|
||||||
pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize JWT for authentication
|
// Initialize JWT for authentication
|
||||||
crate::auth::logic::jwt::init_jwt()?;
|
crate::auth::logic::jwt::init_jwt()?;
|
||||||
|
|
||||||
let addr = "[::1]:50051".parse()?;
|
let addr = "[::1]:50051".parse()?;
|
||||||
|
println!("Unified Server listening on {}", addr);
|
||||||
|
|
||||||
|
// 1. Create the MPSC channel for indexer commands
|
||||||
|
let (indexer_tx, indexer_rx) = mpsc::channel::<IndexCommand>(100); // Buffer of 100 messages
|
||||||
|
|
||||||
|
// 2. Spawn the background indexer task
|
||||||
|
let indexer_pool = db_pool.clone();
|
||||||
|
tokio::spawn(indexer_task(indexer_pool, indexer_rx));
|
||||||
|
|
||||||
let reflection_service = ReflectionBuilder::configure()
|
let reflection_service = ReflectionBuilder::configure()
|
||||||
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
||||||
.build_v1()?;
|
.build_v1()?;
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services, passing the indexer sender to the relevant ones
|
||||||
let table_definition_service = TableDefinitionService { db_pool: db_pool.clone() };
|
let table_definition_service = TableDefinitionService { db_pool: db_pool.clone() };
|
||||||
let tables_data_service = TablesDataService { db_pool: db_pool.clone() };
|
let tables_data_service = TablesDataService {
|
||||||
|
db_pool: db_pool.clone(),
|
||||||
|
indexer_tx: indexer_tx.clone(), // Pass the sender
|
||||||
|
};
|
||||||
let table_script_service = TableScriptService { db_pool: db_pool.clone() };
|
let table_script_service = TableScriptService { db_pool: db_pool.clone() };
|
||||||
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
|
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
|
||||||
|
let search_service = SearcherService;
|
||||||
|
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.add_service(AdresarServer::new(AdresarService { db_pool: db_pool.clone() }))
|
.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(TablesDataServer::new(tables_data_service))
|
||||||
.add_service(TableScriptServer::new(table_script_service))
|
.add_service(TableScriptServer::new(table_script_service))
|
||||||
.add_service(AuthServiceServer::new(auth_service))
|
.add_service(AuthServiceServer::new(auth_service))
|
||||||
|
.add_service(SearcherServer::new(search_service))
|
||||||
.add_service(reflection_service)
|
.add_service(reflection_service)
|
||||||
.serve(addr)
|
.serve(addr)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
// src/server/services/tables_data_service.rs
|
// src/server/services/tables_data_service.rs
|
||||||
|
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
// Add these imports
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use crate::indexer::IndexCommand;
|
||||||
|
|
||||||
use common::proto::multieko2::tables_data::tables_data_server::TablesData;
|
use common::proto::multieko2::tables_data::tables_data_server::TablesData;
|
||||||
use common::proto::multieko2::common::CountResponse;
|
use common::proto::multieko2::common::CountResponse;
|
||||||
use common::proto::multieko2::tables_data::{
|
use common::proto::multieko2::tables_data::{
|
||||||
@@ -15,6 +20,8 @@ use sqlx::PgPool;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TablesDataService {
|
pub struct TablesDataService {
|
||||||
pub db_pool: PgPool,
|
pub db_pool: PgPool,
|
||||||
|
// MODIFIED: Add the sender field
|
||||||
|
pub indexer_tx: mpsc::Sender<IndexCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
@@ -24,25 +31,34 @@ impl TablesData for TablesDataService {
|
|||||||
request: Request<PostTableDataRequest>,
|
request: Request<PostTableDataRequest>,
|
||||||
) -> Result<Response<PostTableDataResponse>, Status> {
|
) -> Result<Response<PostTableDataResponse>, Status> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
let response = post_table_data(&self.db_pool, request).await?;
|
// MODIFIED: Pass the indexer_tx to the handler
|
||||||
|
let response = post_table_data(
|
||||||
|
&self.db_pool,
|
||||||
|
request,
|
||||||
|
&self.indexer_tx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Response::new(response))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the new method implementation
|
// You will later apply the same pattern to put_table_data...
|
||||||
async fn put_table_data(
|
async fn put_table_data(
|
||||||
&self,
|
&self,
|
||||||
request: Request<PutTableDataRequest>,
|
request: Request<PutTableDataRequest>,
|
||||||
) -> Result<Response<PutTableDataResponse>, Status> {
|
) -> Result<Response<PutTableDataResponse>, Status> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
|
// TODO: Update put_table_data handler to accept and use indexer_tx
|
||||||
let response = put_table_data(&self.db_pool, request).await?;
|
let response = put_table_data(&self.db_pool, request).await?;
|
||||||
Ok(Response::new(response))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ...and delete_table_data
|
||||||
async fn delete_table_data(
|
async fn delete_table_data(
|
||||||
&self,
|
&self,
|
||||||
request: Request<DeleteTableDataRequest>,
|
request: Request<DeleteTableDataRequest>,
|
||||||
) -> Result<Response<DeleteTableDataResponse>, Status> {
|
) -> Result<Response<DeleteTableDataResponse>, Status> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
|
// TODO: Update delete_table_data handler to accept and use indexer_tx
|
||||||
let response = delete_table_data(&self.db_pool, request).await?;
|
let response = delete_table_data(&self.db_pool, request).await?;
|
||||||
Ok(Response::new(response))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
// src/shared/mod.rs
|
// src/shared/mod.rs
|
||||||
pub mod date_utils;
|
pub mod date_utils;
|
||||||
|
pub mod schema_qualifier;
|
||||||
|
|||||||
34
server/src/shared/schema_qualifier.rs
Normal file
34
server/src/shared/schema_qualifier.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/shared/schema_qualifier.rs
|
||||||
|
use tonic::Status;
|
||||||
|
|
||||||
|
/// Qualifies table names with the appropriate schema
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - Tables created via PostTableDefinition (dynamically created tables) are in 'gen' schema
|
||||||
|
/// - System tables (like users, profiles) remain in 'public' schema
|
||||||
|
pub fn qualify_table_name(table_name: &str) -> String {
|
||||||
|
// Check if table matches the pattern of dynamically created tables (e.g., 2025_something)
|
||||||
|
if table_name.starts_with(|c: char| c.is_ascii_digit()) && table_name.contains('_') {
|
||||||
|
format!("gen.\"{}\"", table_name)
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", table_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualifies table names for data operations
|
||||||
|
pub fn qualify_table_name_for_data(table_name: &str) -> Result<String, Status> {
|
||||||
|
Ok(qualify_table_name(table_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_qualify_table_name() {
|
||||||
|
assert_eq!(qualify_table_name("2025_test_schema3"), "gen.\"2025_test_schema3\"");
|
||||||
|
assert_eq!(qualify_table_name("users"), "\"users\"");
|
||||||
|
assert_eq!(qualify_table_name("profiles"), "\"profiles\"");
|
||||||
|
assert_eq!(qualify_table_name("adresar"), "\"adresar\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// src/table_definition/handlers/post_table_definition.rs
|
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
use sqlx::{PgPool, Transaction, Postgres};
|
use sqlx::{PgPool, Transaction, Postgres};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
||||||
|
|
||||||
|
const GENERATED_SCHEMA_NAME: &str = "gen";
|
||||||
|
|
||||||
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
||||||
("text", "TEXT"),
|
("text", "TEXT"),
|
||||||
("psc", "TEXT"),
|
("psc", "TEXT"),
|
||||||
@@ -27,7 +28,6 @@ fn sanitize_table_name(s: &str) -> String {
|
|||||||
let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
||||||
.trim()
|
.trim()
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
format!("{}_{}", year, cleaned)
|
format!("{}_{}", year, cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,33 +45,46 @@ fn map_field_type(field_type: &str) -> Result<&str, Status> {
|
|||||||
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
|
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invalid_table_name(table_name: &str) -> bool {
|
||||||
|
table_name.ends_with("_id") ||
|
||||||
|
table_name == "id" ||
|
||||||
|
table_name == "deleted" ||
|
||||||
|
table_name == "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_table_definition(
|
pub async fn post_table_definition(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
request: PostTableDefinitionRequest, // Removed `mut` since it's not needed here
|
request: PostTableDefinitionRequest,
|
||||||
) -> Result<TableDefinitionResponse, Status> {
|
) -> Result<TableDefinitionResponse, Status> {
|
||||||
// Validate and sanitize table name
|
let base_name = sanitize_table_name(&request.table_name);
|
||||||
let table_name = sanitize_table_name(&request.table_name);
|
let user_part_cleaned = request.table_name
|
||||||
if !is_valid_identifier(&request.table_name) {
|
.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
||||||
return Err(Status::invalid_argument("Invalid table name"));
|
.trim_matches('_')
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
// New validation check
|
||||||
|
if is_invalid_table_name(&user_part_cleaned) {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user_part_cleaned.is_empty() && !is_valid_identifier(&user_part_cleaned) {
|
||||||
|
return Err(Status::invalid_argument("Invalid table name"));
|
||||||
|
} else if user_part_cleaned.is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Table name cannot be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction to ensure atomicity
|
|
||||||
let mut tx = db_pool.begin().await
|
let mut tx = db_pool.begin().await
|
||||||
.map_err(|e| Status::internal(format!("Failed to start transaction: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Failed to start transaction: {}", e)))?;
|
||||||
|
|
||||||
// Execute all database operations within the transaction
|
match execute_table_definition(&mut tx, request, base_name).await {
|
||||||
let result = execute_table_definition(&mut tx, request, table_name).await;
|
|
||||||
|
|
||||||
// Commit or rollback based on the result
|
|
||||||
match result {
|
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Commit the transaction
|
|
||||||
tx.commit().await
|
tx.commit().await
|
||||||
.map_err(|e| Status::internal(format!("Failed to commit transaction: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Failed to commit transaction: {}", e)))?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Explicitly roll back the transaction (optional but good for clarity)
|
|
||||||
let _ = tx.rollback().await;
|
let _ = tx.rollback().await;
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
@@ -83,7 +96,6 @@ async fn execute_table_definition(
|
|||||||
mut request: PostTableDefinitionRequest,
|
mut request: PostTableDefinitionRequest,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
) -> Result<TableDefinitionResponse, Status> {
|
) -> Result<TableDefinitionResponse, Status> {
|
||||||
// Lookup or create profile
|
|
||||||
let profile = sqlx::query!(
|
let profile = sqlx::query!(
|
||||||
"INSERT INTO profiles (name) VALUES ($1)
|
"INSERT INTO profiles (name) VALUES ($1)
|
||||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
@@ -94,7 +106,6 @@ async fn execute_table_definition(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| Status::internal(format!("Profile error: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Profile error: {}", e)))?;
|
||||||
|
|
||||||
// Process table links
|
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
for link in request.links.drain(..) {
|
for link in request.links.drain(..) {
|
||||||
let linked_table = sqlx::query!(
|
let linked_table = sqlx::query!(
|
||||||
@@ -114,7 +125,6 @@ async fn execute_table_definition(
|
|||||||
links.push((linked_id, link.required));
|
links.push((linked_id, link.required));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process columns
|
|
||||||
let mut columns = Vec::new();
|
let mut columns = Vec::new();
|
||||||
for col_def in request.columns.drain(..) {
|
for col_def in request.columns.drain(..) {
|
||||||
let col_name = sanitize_identifier(&col_def.name);
|
let col_name = sanitize_identifier(&col_def.name);
|
||||||
@@ -125,20 +135,20 @@ async fn execute_table_definition(
|
|||||||
columns.push(format!("\"{}\" {}", col_name, sql_type));
|
columns.push(format!("\"{}\" {}", col_name, sql_type));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process indexes
|
|
||||||
let mut indexes = Vec::new();
|
let mut indexes = Vec::new();
|
||||||
for idx in request.indexes.drain(..) {
|
for idx in request.indexes.drain(..) {
|
||||||
let idx_name = sanitize_identifier(&idx);
|
let idx_name = sanitize_identifier(&idx);
|
||||||
if !is_valid_identifier(&idx) {
|
if !is_valid_identifier(&idx) {
|
||||||
return Err(Status::invalid_argument(format!("Invalid index name: {}", idx)));
|
return Err(Status::invalid_argument(format!("Invalid index name: {}", idx)));
|
||||||
}
|
}
|
||||||
|
if !columns.iter().any(|c| c.starts_with(&format!("\"{}\"", idx_name))) {
|
||||||
|
return Err(Status::invalid_argument(format!("Index column {} not found", idx_name)));
|
||||||
|
}
|
||||||
indexes.push(idx_name);
|
indexes.push(idx_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SQL with multiple links
|
|
||||||
let (create_sql, index_sql) = generate_table_sql(tx, &table_name, &columns, &indexes, &links).await?;
|
let (create_sql, index_sql) = generate_table_sql(tx, &table_name, &columns, &indexes, &links).await?;
|
||||||
|
|
||||||
// Store main table definition
|
|
||||||
let table_def = sqlx::query!(
|
let table_def = sqlx::query!(
|
||||||
r#"INSERT INTO table_definitions
|
r#"INSERT INTO table_definitions
|
||||||
(profile_id, table_name, columns, indexes)
|
(profile_id, table_name, columns, indexes)
|
||||||
@@ -160,7 +170,6 @@ async fn execute_table_definition(
|
|||||||
Status::internal(format!("Database error: {}", e))
|
Status::internal(format!("Database error: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Store relationships
|
|
||||||
for (linked_id, is_required) in links {
|
for (linked_id, is_required) in links {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO table_definition_links
|
"INSERT INTO table_definition_links
|
||||||
@@ -175,7 +184,6 @@ async fn execute_table_definition(
|
|||||||
.map_err(|e| Status::internal(format!("Failed to save link: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Failed to save link: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute generated SQL within the transaction
|
|
||||||
sqlx::query(&create_sql)
|
sqlx::query(&create_sql)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
@@ -201,60 +209,60 @@ async fn generate_table_sql(
|
|||||||
indexes: &[String],
|
indexes: &[String],
|
||||||
links: &[(i64, bool)],
|
links: &[(i64, bool)],
|
||||||
) -> Result<(String, Vec<String>), Status> {
|
) -> Result<(String, Vec<String>), Status> {
|
||||||
|
let qualified_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, table_name);
|
||||||
|
|
||||||
let mut system_columns = vec![
|
let mut system_columns = vec![
|
||||||
"id BIGSERIAL PRIMARY KEY".to_string(),
|
"id BIGSERIAL PRIMARY KEY".to_string(),
|
||||||
"deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(),
|
"deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add foreign key columns
|
|
||||||
let mut link_info = Vec::new();
|
|
||||||
for (linked_id, required) in links {
|
for (linked_id, required) in links {
|
||||||
let linked_table = get_table_name_by_id(tx, *linked_id).await?;
|
let linked_table = get_table_name_by_id(tx, *linked_id).await?;
|
||||||
|
let qualified_linked_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, linked_table);
|
||||||
// Extract base name after year prefix
|
|
||||||
let base_name = linked_table.split_once('_')
|
let base_name = linked_table.split_once('_')
|
||||||
.map(|(_, rest)| rest)
|
.map(|(_, rest)| rest)
|
||||||
.unwrap_or(&linked_table)
|
.unwrap_or(&linked_table)
|
||||||
.to_string();
|
.to_string();
|
||||||
let null_clause = if *required { "NOT NULL" } else { "" };
|
let null_clause = if *required { "NOT NULL" } else { "" };
|
||||||
|
|
||||||
system_columns.push(
|
system_columns.push(
|
||||||
format!("\"{0}_id\" BIGINT {1} REFERENCES \"{2}\"(id)",
|
format!("\"{0}_id\" BIGINT {1} REFERENCES {2}(id)",
|
||||||
base_name, null_clause, linked_table
|
base_name, null_clause, qualified_linked_table
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
link_info.push((base_name, linked_table));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine all columns
|
|
||||||
let all_columns = system_columns
|
let all_columns = system_columns
|
||||||
.iter()
|
.iter()
|
||||||
.chain(columns.iter())
|
.chain(columns.iter())
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Build CREATE TABLE statement
|
|
||||||
let create_sql = format!(
|
let create_sql = format!(
|
||||||
"CREATE TABLE \"{}\" (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)",
|
"CREATE TABLE {} (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)",
|
||||||
table_name,
|
qualified_table,
|
||||||
all_columns.join(",\n ")
|
all_columns.join(",\n ")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate indexes
|
let mut all_indexes = Vec::new();
|
||||||
let mut system_indexes = Vec::new();
|
for (linked_id, _) in links {
|
||||||
for (base_name, _) in &link_info {
|
let linked_table = get_table_name_by_id(tx, *linked_id).await?;
|
||||||
system_indexes.push(format!(
|
let base_name = linked_table.split_once('_')
|
||||||
"CREATE INDEX idx_{}_{}_fk ON \"{}\" (\"{}_id\")",
|
.map(|(_, rest)| rest)
|
||||||
table_name, base_name, table_name, base_name
|
.unwrap_or(&linked_table)
|
||||||
|
.to_string();
|
||||||
|
all_indexes.push(format!(
|
||||||
|
"CREATE INDEX \"idx_{}_{}_fk\" ON {} (\"{}_id\")",
|
||||||
|
table_name, base_name, qualified_table, base_name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let all_indexes = system_indexes
|
for idx in indexes {
|
||||||
.into_iter()
|
all_indexes.push(format!(
|
||||||
.chain(indexes.iter().map(|idx| {
|
"CREATE INDEX \"idx_{}_{}\" ON {} (\"{}\")",
|
||||||
format!("CREATE INDEX idx_{}_{} ON \"{}\" (\"{}\")",
|
table_name, idx, qualified_table, idx
|
||||||
table_name, idx, table_name, idx)
|
));
|
||||||
}))
|
}
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok((create_sql, all_indexes))
|
Ok((create_sql, all_indexes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use common::proto::multieko2::table_structure::{
|
use common::proto::multieko2::table_structure::{
|
||||||
GetTableStructureRequest, TableColumn, TableStructureResponse,
|
GetTableStructureRequest, TableColumn, TableStructureResponse,
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::PgPool;
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
|
|
||||||
// Helper struct to map query results
|
// Helper struct to map query results
|
||||||
@@ -19,8 +19,8 @@ pub async fn get_table_structure(
|
|||||||
request: GetTableStructureRequest,
|
request: GetTableStructureRequest,
|
||||||
) -> Result<TableStructureResponse, Status> {
|
) -> Result<TableStructureResponse, Status> {
|
||||||
let profile_name = request.profile_name;
|
let profile_name = request.profile_name;
|
||||||
let table_name = request.table_name; // This should be the full table name, e.g., "2025_adresar6"
|
let table_name = request.table_name;
|
||||||
let table_schema = "public"; // Assuming tables are in the 'public' schema
|
let table_schema = "gen";
|
||||||
|
|
||||||
// 1. Validate Profile
|
// 1. Validate Profile
|
||||||
let profile = sqlx::query!(
|
let profile = sqlx::query!(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use common::proto::multieko2::tables_data::{DeleteTableDataRequest, DeleteTableDataResponse};
|
use common::proto::multieko2::tables_data::{DeleteTableDataRequest, DeleteTableDataResponse};
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
|
||||||
|
|
||||||
pub async fn delete_table_data(
|
pub async fn delete_table_data(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
@@ -36,20 +37,37 @@ pub async fn delete_table_data(
|
|||||||
return Err(Status::not_found("Table not found in profile"));
|
return Err(Status::not_found("Table not found in profile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform soft delete
|
// Qualify table name with schema
|
||||||
|
let qualified_table = qualify_table_name_for_data(&request.table_name)?;
|
||||||
|
|
||||||
|
// Perform soft delete using qualified table name
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"UPDATE \"{}\"
|
"UPDATE {}
|
||||||
SET deleted = true
|
SET deleted = true
|
||||||
WHERE id = $1 AND deleted = false",
|
WHERE id = $1 AND deleted = false",
|
||||||
request.table_name
|
qualified_table
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows_affected = sqlx::query(&query)
|
let result = sqlx::query(&query)
|
||||||
.bind(request.record_id)
|
.bind(request.record_id)
|
||||||
.execute(db_pool)
|
.execute(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| Status::internal(format!("Delete operation failed: {}", e)))?
|
|
||||||
.rows_affected();
|
let rows_affected = match result {
|
||||||
|
Ok(result) => result.rows_affected(),
|
||||||
|
Err(e) => {
|
||||||
|
// Handle "relation does not exist" error specifically
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||||
|
request.table_name, qualified_table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(Status::internal(format!("Delete operation failed: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(DeleteTableDataResponse {
|
Ok(DeleteTableDataResponse {
|
||||||
success: rows_affected > 0,
|
success: rows_affected > 0,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use tonic::Status;
|
|||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse};
|
use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse};
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
|
||||||
|
|
||||||
pub async fn get_table_data(
|
pub async fn get_table_data(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
@@ -55,7 +56,6 @@ pub async fn get_table_data(
|
|||||||
let system_columns = vec![
|
let system_columns = vec![
|
||||||
("id".to_string(), "BIGINT".to_string()),
|
("id".to_string(), "BIGINT".to_string()),
|
||||||
("deleted".to_string(), "BOOLEAN".to_string()),
|
("deleted".to_string(), "BOOLEAN".to_string()),
|
||||||
("firma".to_string(), "TEXT".to_string()),
|
|
||||||
];
|
];
|
||||||
let all_columns: Vec<(String, String)> = system_columns
|
let all_columns: Vec<(String, String)> = system_columns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -69,20 +69,36 @@ pub async fn get_table_data(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
// Qualify table name with schema
|
||||||
|
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {} FROM \"{}\" WHERE id = $1 AND deleted = false",
|
"SELECT {} FROM {} WHERE id = $1 AND deleted = false",
|
||||||
columns_clause, table_name
|
columns_clause, qualified_table
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute query
|
// Execute query with enhanced error handling
|
||||||
let row = sqlx::query(&sql)
|
let row_result = sqlx::query(&sql)
|
||||||
.bind(record_id)
|
.bind(record_id)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| match e {
|
|
||||||
sqlx::Error::RowNotFound => Status::not_found("Record not found"),
|
let row = match row_result {
|
||||||
_ => Status::internal(format!("Database error: {}", e)),
|
Ok(row) => row,
|
||||||
})?;
|
Err(sqlx::Error::RowNotFound) => return Err(Status::not_found("Record not found")),
|
||||||
|
Err(e) => {
|
||||||
|
// Handle "relation does not exist" error specifically
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||||
|
table_name, qualified_table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(Status::internal(format!("Database error: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Build response data
|
// Build response data
|
||||||
let mut data = HashMap::new();
|
let mut data = HashMap::new();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use common::proto::multieko2::tables_data::{
|
|||||||
GetTableDataByPositionRequest, GetTableDataRequest, GetTableDataResponse
|
GetTableDataByPositionRequest, GetTableDataRequest, GetTableDataResponse
|
||||||
};
|
};
|
||||||
use super::get_table_data;
|
use super::get_table_data;
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
|
||||||
|
|
||||||
pub async fn get_table_data_by_position(
|
pub async fn get_table_data_by_position(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
@@ -27,39 +28,55 @@ pub async fn get_table_data_by_position(
|
|||||||
|
|
||||||
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
|
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
|
||||||
|
|
||||||
let table_exists = sqlx::query!(
|
let table_exists = sqlx::query_scalar!(
|
||||||
r#"SELECT EXISTS(
|
r#"SELECT EXISTS(
|
||||||
SELECT 1 FROM table_definitions
|
SELECT 1 FROM table_definitions
|
||||||
WHERE profile_id = $1 AND table_name = $2
|
WHERE profile_id = $1 AND table_name = $2
|
||||||
)"#,
|
) AS "exists!""#,
|
||||||
profile_id,
|
profile_id,
|
||||||
table_name
|
table_name
|
||||||
)
|
)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?
|
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?;
|
||||||
.exists
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !table_exists {
|
if !table_exists {
|
||||||
return Err(Status::not_found("Table not found"));
|
return Err(Status::not_found("Table not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: i64 = sqlx::query_scalar(
|
// Qualify table name with schema
|
||||||
|
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||||
|
|
||||||
|
let id_result = sqlx::query_scalar(
|
||||||
&format!(
|
&format!(
|
||||||
r#"SELECT id FROM "{}"
|
r#"SELECT id FROM {}
|
||||||
WHERE deleted = FALSE
|
WHERE deleted = FALSE
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
OFFSET $1
|
OFFSET $1
|
||||||
LIMIT 1"#,
|
LIMIT 1"#,
|
||||||
table_name
|
qualified_table
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.bind(request.position - 1)
|
.bind(request.position - 1)
|
||||||
.fetch_optional(db_pool)
|
.fetch_optional(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| Status::internal(format!("Position query failed: {}", e)))?
|
|
||||||
.ok_or_else(|| Status::not_found("Position out of bounds"))?;
|
let id: i64 = match id_result {
|
||||||
|
Ok(Some(id)) => id,
|
||||||
|
Ok(None) => return Err(Status::not_found("Position out of bounds")),
|
||||||
|
Err(e) => {
|
||||||
|
// Handle "relation does not exist" error specifically
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||||
|
table_name, qualified_table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(Status::internal(format!("Position query failed: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
get_table_data(
|
get_table_data(
|
||||||
db_pool,
|
db_pool,
|
||||||
|
|||||||
@@ -3,59 +3,93 @@ use tonic::Status;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use common::proto::multieko2::common::CountResponse;
|
use common::proto::multieko2::common::CountResponse;
|
||||||
use common::proto::multieko2::tables_data::GetTableDataCountRequest;
|
use common::proto::multieko2::tables_data::GetTableDataCountRequest;
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data; // 1. IMPORT THE FUNCTION
|
||||||
|
|
||||||
pub async fn get_table_data_count(
|
pub async fn get_table_data_count(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
request: GetTableDataCountRequest,
|
request: GetTableDataCountRequest,
|
||||||
) -> Result<CountResponse, Status> {
|
) -> Result<CountResponse, Status> {
|
||||||
let profile_name = request.profile_name;
|
// We still need to verify that the table is logically defined for the profile.
|
||||||
let table_name = request.table_name;
|
// The schema qualifier handles *how* to access it physically, but this check
|
||||||
|
// ensures the request is valid in the context of the application's definitions.
|
||||||
// Lookup profile
|
|
||||||
let profile = sqlx::query!(
|
let profile = sqlx::query!(
|
||||||
"SELECT id FROM profiles WHERE name = $1",
|
"SELECT id FROM profiles WHERE name = $1",
|
||||||
profile_name
|
request.profile_name
|
||||||
)
|
)
|
||||||
.fetch_optional(db_pool)
|
.fetch_optional(db_pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::internal(format!("Profile lookup error: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Profile lookup error for '{}': {}", request.profile_name, e)))?;
|
||||||
|
|
||||||
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
|
let profile_id = match profile {
|
||||||
|
Some(p) => p.id,
|
||||||
|
None => return Err(Status::not_found(format!("Profile '{}' not found", request.profile_name))),
|
||||||
|
};
|
||||||
|
|
||||||
// Verify table exists and belongs to profile
|
let table_defined_for_profile = sqlx::query_scalar!(
|
||||||
let table_exists = sqlx::query!(
|
|
||||||
r#"SELECT EXISTS(
|
r#"SELECT EXISTS(
|
||||||
SELECT 1 FROM table_definitions
|
SELECT 1 FROM table_definitions
|
||||||
WHERE profile_id = $1 AND table_name = $2
|
WHERE profile_id = $1 AND table_name = $2
|
||||||
)"#,
|
) AS "exists!" "#, // Added AS "exists!" for clarity with sqlx macro
|
||||||
profile_id,
|
profile_id,
|
||||||
table_name
|
request.table_name
|
||||||
)
|
)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?
|
.map_err(|e| Status::internal(format!("Table definition verification error for '{}.{}': {}", request.profile_name, request.table_name, e)))?;
|
||||||
.exists
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !table_exists {
|
if !table_defined_for_profile {
|
||||||
return Err(Status::not_found("Table not found"));
|
// If the table isn't even defined for this profile in table_definitions,
|
||||||
|
// it's an error, regardless of whether a physical table with that name exists somewhere.
|
||||||
|
return Err(Status::not_found(format!(
|
||||||
|
"Table '{}' is not defined for profile '{}'",
|
||||||
|
request.table_name, request.profile_name
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get count of non-deleted records
|
// 2. QUALIFY THE TABLE NAME using the imported function
|
||||||
let query = format!(
|
let qualified_table_name = qualify_table_name_for_data(&request.table_name)?;
|
||||||
|
|
||||||
|
// 3. USE THE QUALIFIED NAME in the SQL query
|
||||||
|
let query_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) AS count
|
SELECT COUNT(*) AS count
|
||||||
FROM "{}"
|
FROM {}
|
||||||
WHERE deleted = FALSE
|
WHERE deleted = FALSE
|
||||||
"#,
|
"#,
|
||||||
table_name
|
qualified_table_name // Use the schema-qualified name here
|
||||||
);
|
);
|
||||||
|
|
||||||
let count: i64 = sqlx::query_scalar::<_, Option<i64>>(&query)
|
// The rest of the logic remains largely the same, but error messages can be more specific.
|
||||||
|
let count_result = sqlx::query_scalar::<_, Option<i64>>(&query_sql)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| Status::internal(format!("Count query failed: {}", e)))?
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
Ok(CountResponse { count })
|
match count_result {
|
||||||
|
Ok(Some(count_val)) => Ok(CountResponse { count: count_val }),
|
||||||
|
Ok(None) => {
|
||||||
|
// This case should ideally not be reached with COUNT(*),
|
||||||
|
// as it always returns a row, even if the count is 0.
|
||||||
|
// If it does, it might indicate an issue or an empty table if the query was different.
|
||||||
|
// For COUNT(*), a 0 count is expected if no non-deleted rows.
|
||||||
|
Ok(CountResponse { count: 0 })
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Check if the error is "relation does not exist" (PostgreSQL error code 42P01)
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
// This means the table (e.g., gen."2025_test_schema3") does not physically exist,
|
||||||
|
// even though it was defined in table_definitions. This is an inconsistency.
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}.",
|
||||||
|
request.table_name, qualified_table_name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other errors, provide a general message.
|
||||||
|
Err(Status::internal(format!(
|
||||||
|
"Count query failed for table {}: {}",
|
||||||
|
qualified_table_name, e
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// src/tables_data/handlers/post_table_data.rs
|
// src/tables_data/handlers/post_table_data.rs
|
||||||
|
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
use sqlx::{PgPool, Arguments};
|
use sqlx::{PgPool, Arguments};
|
||||||
use sqlx::postgres::PgArguments;
|
use sqlx::postgres::PgArguments;
|
||||||
@@ -6,31 +7,28 @@ use chrono::{DateTime, Utc};
|
|||||||
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
|
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data;
|
||||||
|
|
||||||
use crate::steel::server::execution::{self, Value};
|
use crate::steel::server::execution::{self, Value};
|
||||||
use crate::steel::server::functions::SteelContext;
|
use crate::steel::server::functions::SteelContext;
|
||||||
|
|
||||||
|
// Add these imports
|
||||||
|
use crate::indexer::{IndexCommand, IndexCommandData};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
// MODIFIED: Function signature now accepts the indexer sender
|
||||||
pub async fn post_table_data(
|
pub async fn post_table_data(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
request: PostTableDataRequest,
|
request: PostTableDataRequest,
|
||||||
|
indexer_tx: &mpsc::Sender<IndexCommand>,
|
||||||
) -> Result<PostTableDataResponse, Status> {
|
) -> Result<PostTableDataResponse, Status> {
|
||||||
let profile_name = request.profile_name;
|
let profile_name = request.profile_name;
|
||||||
let table_name = request.table_name;
|
let table_name = request.table_name;
|
||||||
let mut data = HashMap::new();
|
let mut data = HashMap::new();
|
||||||
|
|
||||||
// Process and validate all data values
|
|
||||||
for (key, value) in request.data {
|
for (key, value) in request.data {
|
||||||
let trimmed = value.trim().to_string();
|
data.insert(key, value.trim().to_string());
|
||||||
|
|
||||||
// Handle specially - it cannot be empty
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(Status::invalid_argument("Firma cannot be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add trimmed non-empty values to data map
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
data.insert(key, trimmed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup profile
|
// Lookup profile
|
||||||
@@ -97,7 +95,7 @@ pub async fn post_table_data(
|
|||||||
// Validate all data columns
|
// Validate all data columns
|
||||||
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
||||||
for key in data.keys() {
|
for key in data.keys() {
|
||||||
if !system_columns_set.contains(key.as_str()) &&
|
if !system_columns_set.contains(key.as_str()) &&
|
||||||
!user_columns.contains(&&key.to_string()) {
|
!user_columns.contains(&&key.to_string()) {
|
||||||
return Err(Status::invalid_argument(format!("Invalid column: {}", key)));
|
return Err(Status::invalid_argument(format!("Invalid column: {}", key)));
|
||||||
}
|
}
|
||||||
@@ -123,13 +121,12 @@ pub async fn post_table_data(
|
|||||||
|
|
||||||
// Create execution context
|
// Create execution context
|
||||||
let context = SteelContext {
|
let context = SteelContext {
|
||||||
current_table: table_name.clone(),
|
current_table: table_name.clone(), // Keep base name for scripts
|
||||||
profile_id,
|
profile_id,
|
||||||
row_data: data.clone(),
|
row_data: data.clone(),
|
||||||
db_pool: Arc::new(db_pool.clone()),
|
db_pool: Arc::new(db_pool.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Execute validation script
|
// Execute validation script
|
||||||
let script_result = execution::execute_script(
|
let script_result = execution::execute_script(
|
||||||
script_record.script,
|
script_record.script,
|
||||||
@@ -220,17 +217,51 @@ pub async fn post_table_data(
|
|||||||
return Err(Status::invalid_argument("No valid columns to insert"));
|
return Err(Status::invalid_argument("No valid columns to insert"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qualify table name with schema
|
||||||
|
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING id",
|
"INSERT INTO {} ({}) VALUES ({}) RETURNING id",
|
||||||
table_name,
|
qualified_table,
|
||||||
columns_list.join(", "),
|
columns_list.join(", "),
|
||||||
placeholders.join(", ")
|
placeholders.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
let inserted_id: i64 = sqlx::query_scalar_with(&sql, params)
|
// Execute query with enhanced error handling
|
||||||
|
let result = sqlx::query_scalar_with::<_, i64, _>(&sql, params)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| Status::internal(format!("Insert failed: {}", e)))?;
|
|
||||||
|
let inserted_id = match result {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
// Handle "relation does not exist" error specifically
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||||
|
table_name, qualified_table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(Status::internal(format!("Insert failed: {}", e)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// After a successful insert, send a command to the indexer.
|
||||||
|
let command = IndexCommand::AddOrUpdate(IndexCommandData {
|
||||||
|
table_name: table_name.clone(),
|
||||||
|
row_id: inserted_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = indexer_tx.send(command).await {
|
||||||
|
// If sending fails, the DB is updated but the index will be stale.
|
||||||
|
// This is a critical situation to log and monitor.
|
||||||
|
error!(
|
||||||
|
"CRITICAL: DB insert for table '{}' (id: {}) succeeded but failed to queue for indexing: {}. Search index is now inconsistent.",
|
||||||
|
table_name, inserted_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(PostTableDataResponse {
|
Ok(PostTableDataResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use sqlx::postgres::PgArguments;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse};
|
use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
|
||||||
|
|
||||||
pub async fn put_table_data(
|
pub async fn put_table_data(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
@@ -13,20 +14,17 @@ pub async fn put_table_data(
|
|||||||
let profile_name = request.profile_name;
|
let profile_name = request.profile_name;
|
||||||
let table_name = request.table_name;
|
let table_name = request.table_name;
|
||||||
let record_id = request.id;
|
let record_id = request.id;
|
||||||
|
|
||||||
// Preprocess and validate data
|
// Preprocess and validate data
|
||||||
let mut processed_data = HashMap::new();
|
let mut processed_data = HashMap::new();
|
||||||
let mut null_fields = Vec::new();
|
let mut null_fields = Vec::new();
|
||||||
|
|
||||||
|
// CORRECTED: Generic handling for all fields.
|
||||||
|
// Any field with an empty string will be added to the null_fields list.
|
||||||
|
// The special, hardcoded logic for "firma" has been removed.
|
||||||
for (key, value) in request.data {
|
for (key, value) in request.data {
|
||||||
let trimmed = value.trim().to_string();
|
let trimmed = value.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
if key == "firma" && trimmed.is_empty() {
|
|
||||||
return Err(Status::invalid_argument("Firma cannot be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store fields that should be set to NULL
|
|
||||||
if key != "firma" && trimmed.is_empty() {
|
|
||||||
null_fields.push(key);
|
null_fields.push(key);
|
||||||
} else {
|
} else {
|
||||||
processed_data.insert(key, trimmed);
|
processed_data.insert(key, trimmed);
|
||||||
@@ -72,8 +70,9 @@ pub async fn put_table_data(
|
|||||||
columns.push((name, sql_type));
|
columns.push((name, sql_type));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate system columns
|
// CORRECTED: "firma" is not a system column.
|
||||||
let system_columns = ["firma", "deleted"];
|
// It should be treated as a user-defined column.
|
||||||
|
let system_columns = ["deleted"];
|
||||||
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
||||||
|
|
||||||
// Validate input columns
|
// Validate input columns
|
||||||
@@ -90,9 +89,11 @@ pub async fn put_table_data(
|
|||||||
|
|
||||||
// Add data parameters for non-empty fields
|
// Add data parameters for non-empty fields
|
||||||
for (col, value) in &processed_data {
|
for (col, value) in &processed_data {
|
||||||
|
// CORRECTED: The logic for "firma" is removed from this match.
|
||||||
|
// It will now fall through to the `else` block and have its type
|
||||||
|
// correctly looked up from the `columns` vector.
|
||||||
let sql_type = if system_columns.contains(&col.as_str()) {
|
let sql_type = if system_columns.contains(&col.as_str()) {
|
||||||
match col.as_str() {
|
match col.as_str() {
|
||||||
"firma" => "TEXT",
|
|
||||||
"deleted" => "BOOLEAN",
|
"deleted" => "BOOLEAN",
|
||||||
_ => return Err(Status::invalid_argument("Invalid system column")),
|
_ => return Err(Status::invalid_argument("Invalid system column")),
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,6 @@ pub async fn put_table_data(
|
|||||||
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
|
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO strong testing by user pick in the future
|
|
||||||
match sql_type {
|
match sql_type {
|
||||||
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
|
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
|
||||||
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(")
|
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(")
|
||||||
@@ -129,6 +129,13 @@ pub async fn put_table_data(
|
|||||||
params.add(dt.with_timezone(&Utc))
|
params.add(dt.with_timezone(&Utc))
|
||||||
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
|
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
|
||||||
},
|
},
|
||||||
|
// ADDED: BIGINT handling for completeness, if needed for other columns.
|
||||||
|
"BIGINT" => {
|
||||||
|
let val = value.parse::<i64>()
|
||||||
|
.map_err(|_| Status::invalid_argument(format!("Invalid integer for {}", col)))?;
|
||||||
|
params.add(val)
|
||||||
|
.map_err(|e| Status::internal(format!("Failed to add integer parameter for {}: {}", col, e)))?;
|
||||||
|
},
|
||||||
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
|
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,25 +161,39 @@ pub async fn put_table_data(
|
|||||||
params.add(record_id)
|
params.add(record_id)
|
||||||
.map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?;
|
||||||
|
|
||||||
|
// Qualify table name with schema
|
||||||
|
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||||
|
|
||||||
let set_clause = set_clauses.join(", ");
|
let set_clause = set_clauses.join(", ");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"UPDATE \"{}\" SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
|
"UPDATE {} SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
|
||||||
table_name,
|
qualified_table,
|
||||||
set_clause,
|
set_clause,
|
||||||
param_idx
|
param_idx
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = sqlx::query_scalar_with::<Postgres, i64, _>(&sql, params)
|
let result = sqlx::query_scalar_with::<Postgres, i64, _>(&sql, params)
|
||||||
.fetch_optional(db_pool)
|
.fetch_optional(db_pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| Status::internal(format!("Update failed: {}", e)))?;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some(updated_id) => Ok(PutTableDataResponse {
|
Ok(Some(updated_id)) => Ok(PutTableDataResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Data updated successfully".into(),
|
message: "Data updated successfully".into(),
|
||||||
updated_id,
|
updated_id,
|
||||||
}),
|
}),
|
||||||
None => Err(Status::not_found("Record not found or already deleted")),
|
Ok(None) => Err(Status::not_found("Record not found or already deleted")),
|
||||||
|
Err(e) => {
|
||||||
|
// Handle "relation does not exist" error specifically
|
||||||
|
if let Some(db_err) = e.as_database_error() {
|
||||||
|
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||||
|
return Err(Status::internal(format!(
|
||||||
|
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||||
|
table_name, qualified_table
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Status::internal(format!("Update failed: {}", e)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user