├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── media └── synctui.GIF ├── rustfmt.toml └── src ├── config.rs ├── error.rs ├── lib.rs ├── main.rs └── tui ├── app.rs ├── input.rs ├── mod.rs ├── pages ├── devices.rs ├── folders.rs ├── id.rs └── pending.rs ├── popup.rs ├── state.rs └── ui.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build-and-test: 11 | name: Build, Test, Lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install Rust 18 | uses: dtolnay/rust-toolchain@stable 19 | with: 20 | components: clippy, rustfmt 21 | 22 | - name: Cache cargo registry 23 | uses: actions/cache@v4 24 | with: 25 | path: | 26 | ~/.cargo/registry 27 | ~/.cargo/git 28 | target 29 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 30 | 31 | - name: cargo check 32 | run: cargo check --workspace --all-targets 33 | 34 | - name: cargo test 35 | run: cargo test --workspace 36 | 37 | - name: cargo clippy 38 | run: cargo clippy --workspace --all-targets --all-features -- -D warnings 39 | 40 | - name: cargo fmt 41 | run: cargo fmt --all -- --check 42 | 43 | - name: cargo doc 44 | run: cargo doc --workspace --no-deps --document-private-items 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /log.txt 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "allocator-api2" 31 | version = "0.2.21" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 34 | 35 | [[package]] 36 | name = "android-tzdata" 37 | version = "0.1.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 40 | 41 | [[package]] 42 | name = "android_system_properties" 43 | version = "0.1.5" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 46 | dependencies = [ 47 | "libc", 48 | ] 49 | 50 | [[package]] 51 | name = "anstream" 52 | version = "0.6.18" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 55 | dependencies = [ 56 | "anstyle", 57 | "anstyle-parse", 58 | "anstyle-query", 59 | "anstyle-wincon", 60 | "colorchoice", 61 | "is_terminal_polyfill", 62 | "utf8parse", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle" 67 | version = "1.0.10" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 70 | 71 | [[package]] 72 | name = "anstyle-parse" 73 | version = "0.2.6" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 76 | dependencies = [ 77 | "utf8parse", 78 | ] 79 | 80 | [[package]] 81 | name = "anstyle-query" 82 | version = "1.1.2" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 85 | dependencies = [ 86 | "windows-sys 0.59.0", 87 | ] 88 | 89 | [[package]] 90 | name = "anstyle-wincon" 91 | version = "3.0.7" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 94 | dependencies = [ 95 | "anstyle", 96 | "once_cell", 97 | "windows-sys 0.59.0", 98 | ] 99 | 100 | [[package]] 101 | name = "atomic-waker" 102 | version = "1.1.2" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 105 | 106 | [[package]] 107 | name = "autocfg" 108 | version = "1.4.0" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 111 | 112 | [[package]] 113 | name = "backtrace" 114 | version = "0.3.75" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 117 | dependencies = [ 118 | "addr2line", 119 | "cfg-if", 120 | "libc", 121 | "miniz_oxide", 122 | "object", 123 | "rustc-demangle", 124 | "windows-targets 0.52.6", 125 | ] 126 | 127 | [[package]] 128 | name = "base64" 129 | version = "0.22.1" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 132 | 133 | [[package]] 134 | name = "bitflags" 135 | version = "2.9.1" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 138 | 139 | [[package]] 140 | name = "bumpalo" 141 | version = "3.17.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 144 | 145 | [[package]] 146 | name = "bytes" 147 | version = "1.10.1" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 150 | 151 | [[package]] 152 | name = "cassowary" 153 | version = "0.3.0" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 156 | 157 | [[package]] 158 | name = "castaway" 159 | version = "0.2.3" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 162 | dependencies = [ 163 | "rustversion", 164 | ] 165 | 166 | [[package]] 167 | name = "cc" 168 | version = "1.2.23" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" 171 | dependencies = [ 172 | "shlex", 173 | ] 174 | 175 | [[package]] 176 | name = "cfg-if" 177 | version = "1.0.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 180 | 181 | [[package]] 182 | name = "chrono" 183 | version = "0.4.41" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 186 | dependencies = [ 187 | "android-tzdata", 188 | "iana-time-zone", 189 | "js-sys", 190 | "num-traits", 191 | "serde", 192 | "wasm-bindgen", 193 | "windows-link", 194 | ] 195 | 196 | [[package]] 197 | name = "clap" 198 | version = "4.5.38" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" 201 | dependencies = [ 202 | "clap_builder", 203 | "clap_derive", 204 | ] 205 | 206 | [[package]] 207 | name = "clap_builder" 208 | version = "4.5.38" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" 211 | dependencies = [ 212 | "anstream", 213 | "anstyle", 214 | "clap_lex", 215 | "strsim", 216 | ] 217 | 218 | [[package]] 219 | name = "clap_derive" 220 | version = "4.5.32" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 223 | dependencies = [ 224 | "heck", 225 | "proc-macro2", 226 | "quote", 227 | "syn", 228 | ] 229 | 230 | [[package]] 231 | name = "clap_lex" 232 | version = "0.7.4" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 235 | 236 | [[package]] 237 | name = "color-eyre" 238 | version = "0.6.4" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "e6e1761c0e16f8883bbbb8ce5990867f4f06bf11a0253da6495a04ce4b6ef0ec" 241 | dependencies = [ 242 | "backtrace", 243 | "color-spantrace", 244 | "eyre", 245 | "indenter", 246 | "once_cell", 247 | "owo-colors", 248 | "tracing-error", 249 | ] 250 | 251 | [[package]] 252 | name = "color-spantrace" 253 | version = "0.2.2" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "2ddd8d5bfda1e11a501d0a7303f3bfed9aa632ebdb859be40d0fd70478ed70d5" 256 | dependencies = [ 257 | "once_cell", 258 | "owo-colors", 259 | "tracing-core", 260 | "tracing-error", 261 | ] 262 | 263 | [[package]] 264 | name = "colorchoice" 265 | version = "1.0.3" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 268 | 269 | [[package]] 270 | name = "compact_str" 271 | version = "0.8.1" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 274 | dependencies = [ 275 | "castaway", 276 | "cfg-if", 277 | "itoa", 278 | "rustversion", 279 | "ryu", 280 | "static_assertions", 281 | ] 282 | 283 | [[package]] 284 | name = "core-foundation" 285 | version = "0.9.4" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 288 | dependencies = [ 289 | "core-foundation-sys", 290 | "libc", 291 | ] 292 | 293 | [[package]] 294 | name = "core-foundation-sys" 295 | version = "0.8.7" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 298 | 299 | [[package]] 300 | name = "crossterm" 301 | version = "0.28.1" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 304 | dependencies = [ 305 | "bitflags", 306 | "crossterm_winapi", 307 | "futures-core", 308 | "mio", 309 | "parking_lot", 310 | "rustix 0.38.44", 311 | "signal-hook", 312 | "signal-hook-mio", 313 | "winapi", 314 | ] 315 | 316 | [[package]] 317 | name = "crossterm_winapi" 318 | version = "0.9.1" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 321 | dependencies = [ 322 | "winapi", 323 | ] 324 | 325 | [[package]] 326 | name = "darling" 327 | version = "0.20.11" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 330 | dependencies = [ 331 | "darling_core", 332 | "darling_macro", 333 | ] 334 | 335 | [[package]] 336 | name = "darling_core" 337 | version = "0.20.11" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 340 | dependencies = [ 341 | "fnv", 342 | "ident_case", 343 | "proc-macro2", 344 | "quote", 345 | "strsim", 346 | "syn", 347 | ] 348 | 349 | [[package]] 350 | name = "darling_macro" 351 | version = "0.20.11" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 354 | dependencies = [ 355 | "darling_core", 356 | "quote", 357 | "syn", 358 | ] 359 | 360 | [[package]] 361 | name = "dirs" 362 | version = "6.0.0" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 365 | dependencies = [ 366 | "dirs-sys", 367 | ] 368 | 369 | [[package]] 370 | name = "dirs-sys" 371 | version = "0.5.0" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 374 | dependencies = [ 375 | "libc", 376 | "option-ext", 377 | "redox_users", 378 | "windows-sys 0.59.0", 379 | ] 380 | 381 | [[package]] 382 | name = "displaydoc" 383 | version = "0.2.5" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 386 | dependencies = [ 387 | "proc-macro2", 388 | "quote", 389 | "syn", 390 | ] 391 | 392 | [[package]] 393 | name = "either" 394 | version = "1.15.0" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 397 | 398 | [[package]] 399 | name = "encoding_rs" 400 | version = "0.8.35" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 403 | dependencies = [ 404 | "cfg-if", 405 | ] 406 | 407 | [[package]] 408 | name = "env_filter" 409 | version = "0.1.3" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 412 | dependencies = [ 413 | "log", 414 | "regex", 415 | ] 416 | 417 | [[package]] 418 | name = "env_logger" 419 | version = "0.11.8" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" 422 | dependencies = [ 423 | "anstream", 424 | "anstyle", 425 | "env_filter", 426 | "jiff", 427 | "log", 428 | ] 429 | 430 | [[package]] 431 | name = "equivalent" 432 | version = "1.0.2" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 435 | 436 | [[package]] 437 | name = "errno" 438 | version = "0.3.12" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" 441 | dependencies = [ 442 | "libc", 443 | "windows-sys 0.59.0", 444 | ] 445 | 446 | [[package]] 447 | name = "eyre" 448 | version = "0.6.12" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 451 | dependencies = [ 452 | "indenter", 453 | "once_cell", 454 | ] 455 | 456 | [[package]] 457 | name = "fastrand" 458 | version = "2.3.0" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 461 | 462 | [[package]] 463 | name = "fnv" 464 | version = "1.0.7" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 467 | 468 | [[package]] 469 | name = "foldhash" 470 | version = "0.1.5" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 473 | 474 | [[package]] 475 | name = "foreign-types" 476 | version = "0.3.2" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 479 | dependencies = [ 480 | "foreign-types-shared", 481 | ] 482 | 483 | [[package]] 484 | name = "foreign-types-shared" 485 | version = "0.1.1" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 488 | 489 | [[package]] 490 | name = "form_urlencoded" 491 | version = "1.2.1" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 494 | dependencies = [ 495 | "percent-encoding", 496 | ] 497 | 498 | [[package]] 499 | name = "futures" 500 | version = "0.3.31" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 503 | dependencies = [ 504 | "futures-channel", 505 | "futures-core", 506 | "futures-executor", 507 | "futures-io", 508 | "futures-sink", 509 | "futures-task", 510 | "futures-util", 511 | ] 512 | 513 | [[package]] 514 | name = "futures-channel" 515 | version = "0.3.31" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 518 | dependencies = [ 519 | "futures-core", 520 | "futures-sink", 521 | ] 522 | 523 | [[package]] 524 | name = "futures-core" 525 | version = "0.3.31" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 528 | 529 | [[package]] 530 | name = "futures-executor" 531 | version = "0.3.31" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 534 | dependencies = [ 535 | "futures-core", 536 | "futures-task", 537 | "futures-util", 538 | ] 539 | 540 | [[package]] 541 | name = "futures-io" 542 | version = "0.3.31" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 545 | 546 | [[package]] 547 | name = "futures-macro" 548 | version = "0.3.31" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 551 | dependencies = [ 552 | "proc-macro2", 553 | "quote", 554 | "syn", 555 | ] 556 | 557 | [[package]] 558 | name = "futures-sink" 559 | version = "0.3.31" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 562 | 563 | [[package]] 564 | name = "futures-task" 565 | version = "0.3.31" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 568 | 569 | [[package]] 570 | name = "futures-util" 571 | version = "0.3.31" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 574 | dependencies = [ 575 | "futures-channel", 576 | "futures-core", 577 | "futures-io", 578 | "futures-macro", 579 | "futures-sink", 580 | "futures-task", 581 | "memchr", 582 | "pin-project-lite", 583 | "pin-utils", 584 | "slab", 585 | ] 586 | 587 | [[package]] 588 | name = "getrandom" 589 | version = "0.2.16" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 592 | dependencies = [ 593 | "cfg-if", 594 | "libc", 595 | "wasi 0.11.0+wasi-snapshot-preview1", 596 | ] 597 | 598 | [[package]] 599 | name = "getrandom" 600 | version = "0.3.3" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 603 | dependencies = [ 604 | "cfg-if", 605 | "libc", 606 | "r-efi", 607 | "wasi 0.14.2+wasi-0.2.4", 608 | ] 609 | 610 | [[package]] 611 | name = "gimli" 612 | version = "0.31.1" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 615 | 616 | [[package]] 617 | name = "h2" 618 | version = "0.4.10" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" 621 | dependencies = [ 622 | "atomic-waker", 623 | "bytes", 624 | "fnv", 625 | "futures-core", 626 | "futures-sink", 627 | "http", 628 | "indexmap", 629 | "slab", 630 | "tokio", 631 | "tokio-util", 632 | "tracing", 633 | ] 634 | 635 | [[package]] 636 | name = "hashbrown" 637 | version = "0.15.3" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" 640 | dependencies = [ 641 | "allocator-api2", 642 | "equivalent", 643 | "foldhash", 644 | ] 645 | 646 | [[package]] 647 | name = "heck" 648 | version = "0.5.0" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 651 | 652 | [[package]] 653 | name = "http" 654 | version = "1.3.1" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 657 | dependencies = [ 658 | "bytes", 659 | "fnv", 660 | "itoa", 661 | ] 662 | 663 | [[package]] 664 | name = "http-body" 665 | version = "1.0.1" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 668 | dependencies = [ 669 | "bytes", 670 | "http", 671 | ] 672 | 673 | [[package]] 674 | name = "http-body-util" 675 | version = "0.1.3" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 678 | dependencies = [ 679 | "bytes", 680 | "futures-core", 681 | "http", 682 | "http-body", 683 | "pin-project-lite", 684 | ] 685 | 686 | [[package]] 687 | name = "httparse" 688 | version = "1.10.1" 689 | source = "registry+https://github.com/rust-lang/crates.io-index" 690 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 691 | 692 | [[package]] 693 | name = "hyper" 694 | version = "1.6.0" 695 | source = "registry+https://github.com/rust-lang/crates.io-index" 696 | checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 697 | dependencies = [ 698 | "bytes", 699 | "futures-channel", 700 | "futures-util", 701 | "h2", 702 | "http", 703 | "http-body", 704 | "httparse", 705 | "itoa", 706 | "pin-project-lite", 707 | "smallvec", 708 | "tokio", 709 | "want", 710 | ] 711 | 712 | [[package]] 713 | name = "hyper-rustls" 714 | version = "0.27.5" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" 717 | dependencies = [ 718 | "futures-util", 719 | "http", 720 | "hyper", 721 | "hyper-util", 722 | "rustls", 723 | "rustls-pki-types", 724 | "tokio", 725 | "tokio-rustls", 726 | "tower-service", 727 | ] 728 | 729 | [[package]] 730 | name = "hyper-tls" 731 | version = "0.6.0" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 734 | dependencies = [ 735 | "bytes", 736 | "http-body-util", 737 | "hyper", 738 | "hyper-util", 739 | "native-tls", 740 | "tokio", 741 | "tokio-native-tls", 742 | "tower-service", 743 | ] 744 | 745 | [[package]] 746 | name = "hyper-util" 747 | version = "0.1.11" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" 750 | dependencies = [ 751 | "bytes", 752 | "futures-channel", 753 | "futures-util", 754 | "http", 755 | "http-body", 756 | "hyper", 757 | "libc", 758 | "pin-project-lite", 759 | "socket2", 760 | "tokio", 761 | "tower-service", 762 | "tracing", 763 | ] 764 | 765 | [[package]] 766 | name = "iana-time-zone" 767 | version = "0.1.63" 768 | source = "registry+https://github.com/rust-lang/crates.io-index" 769 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 770 | dependencies = [ 771 | "android_system_properties", 772 | "core-foundation-sys", 773 | "iana-time-zone-haiku", 774 | "js-sys", 775 | "log", 776 | "wasm-bindgen", 777 | "windows-core", 778 | ] 779 | 780 | [[package]] 781 | name = "iana-time-zone-haiku" 782 | version = "0.1.2" 783 | source = "registry+https://github.com/rust-lang/crates.io-index" 784 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 785 | dependencies = [ 786 | "cc", 787 | ] 788 | 789 | [[package]] 790 | name = "icu_collections" 791 | version = "2.0.0" 792 | source = "registry+https://github.com/rust-lang/crates.io-index" 793 | checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" 794 | dependencies = [ 795 | "displaydoc", 796 | "potential_utf", 797 | "yoke", 798 | "zerofrom", 799 | "zerovec", 800 | ] 801 | 802 | [[package]] 803 | name = "icu_locale_core" 804 | version = "2.0.0" 805 | source = "registry+https://github.com/rust-lang/crates.io-index" 806 | checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" 807 | dependencies = [ 808 | "displaydoc", 809 | "litemap", 810 | "tinystr", 811 | "writeable", 812 | "zerovec", 813 | ] 814 | 815 | [[package]] 816 | name = "icu_normalizer" 817 | version = "2.0.0" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" 820 | dependencies = [ 821 | "displaydoc", 822 | "icu_collections", 823 | "icu_normalizer_data", 824 | "icu_properties", 825 | "icu_provider", 826 | "smallvec", 827 | "zerovec", 828 | ] 829 | 830 | [[package]] 831 | name = "icu_normalizer_data" 832 | version = "2.0.0" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" 835 | 836 | [[package]] 837 | name = "icu_properties" 838 | version = "2.0.0" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" 841 | dependencies = [ 842 | "displaydoc", 843 | "icu_collections", 844 | "icu_locale_core", 845 | "icu_properties_data", 846 | "icu_provider", 847 | "potential_utf", 848 | "zerotrie", 849 | "zerovec", 850 | ] 851 | 852 | [[package]] 853 | name = "icu_properties_data" 854 | version = "2.0.0" 855 | source = "registry+https://github.com/rust-lang/crates.io-index" 856 | checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" 857 | 858 | [[package]] 859 | name = "icu_provider" 860 | version = "2.0.0" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" 863 | dependencies = [ 864 | "displaydoc", 865 | "icu_locale_core", 866 | "stable_deref_trait", 867 | "tinystr", 868 | "writeable", 869 | "yoke", 870 | "zerofrom", 871 | "zerotrie", 872 | "zerovec", 873 | ] 874 | 875 | [[package]] 876 | name = "ident_case" 877 | version = "1.0.1" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 880 | 881 | [[package]] 882 | name = "idna" 883 | version = "1.0.3" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 886 | dependencies = [ 887 | "idna_adapter", 888 | "smallvec", 889 | "utf8_iter", 890 | ] 891 | 892 | [[package]] 893 | name = "idna_adapter" 894 | version = "1.2.1" 895 | source = "registry+https://github.com/rust-lang/crates.io-index" 896 | checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 897 | dependencies = [ 898 | "icu_normalizer", 899 | "icu_properties", 900 | ] 901 | 902 | [[package]] 903 | name = "indenter" 904 | version = "0.3.3" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 907 | 908 | [[package]] 909 | name = "indexmap" 910 | version = "2.9.0" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 913 | dependencies = [ 914 | "equivalent", 915 | "hashbrown", 916 | ] 917 | 918 | [[package]] 919 | name = "indoc" 920 | version = "2.0.6" 921 | source = "registry+https://github.com/rust-lang/crates.io-index" 922 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 923 | 924 | [[package]] 925 | name = "instability" 926 | version = "0.3.7" 927 | source = "registry+https://github.com/rust-lang/crates.io-index" 928 | checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" 929 | dependencies = [ 930 | "darling", 931 | "indoc", 932 | "proc-macro2", 933 | "quote", 934 | "syn", 935 | ] 936 | 937 | [[package]] 938 | name = "ipnet" 939 | version = "2.11.0" 940 | source = "registry+https://github.com/rust-lang/crates.io-index" 941 | checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 942 | 943 | [[package]] 944 | name = "is_terminal_polyfill" 945 | version = "1.70.1" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 948 | 949 | [[package]] 950 | name = "itertools" 951 | version = "0.13.0" 952 | source = "registry+https://github.com/rust-lang/crates.io-index" 953 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 954 | dependencies = [ 955 | "either", 956 | ] 957 | 958 | [[package]] 959 | name = "itoa" 960 | version = "1.0.15" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 963 | 964 | [[package]] 965 | name = "jiff" 966 | version = "0.2.13" 967 | source = "registry+https://github.com/rust-lang/crates.io-index" 968 | checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806" 969 | dependencies = [ 970 | "jiff-static", 971 | "log", 972 | "portable-atomic", 973 | "portable-atomic-util", 974 | "serde", 975 | ] 976 | 977 | [[package]] 978 | name = "jiff-static" 979 | version = "0.2.13" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48" 982 | dependencies = [ 983 | "proc-macro2", 984 | "quote", 985 | "syn", 986 | ] 987 | 988 | [[package]] 989 | name = "js-sys" 990 | version = "0.3.77" 991 | source = "registry+https://github.com/rust-lang/crates.io-index" 992 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 993 | dependencies = [ 994 | "once_cell", 995 | "wasm-bindgen", 996 | ] 997 | 998 | [[package]] 999 | name = "lazy_static" 1000 | version = "1.5.0" 1001 | source = "registry+https://github.com/rust-lang/crates.io-index" 1002 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1003 | 1004 | [[package]] 1005 | name = "libc" 1006 | version = "0.2.172" 1007 | source = "registry+https://github.com/rust-lang/crates.io-index" 1008 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 1009 | 1010 | [[package]] 1011 | name = "libredox" 1012 | version = "0.1.3" 1013 | source = "registry+https://github.com/rust-lang/crates.io-index" 1014 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 1015 | dependencies = [ 1016 | "bitflags", 1017 | "libc", 1018 | ] 1019 | 1020 | [[package]] 1021 | name = "linux-raw-sys" 1022 | version = "0.4.15" 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" 1024 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1025 | 1026 | [[package]] 1027 | name = "linux-raw-sys" 1028 | version = "0.9.4" 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" 1030 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 1031 | 1032 | [[package]] 1033 | name = "litemap" 1034 | version = "0.8.0" 1035 | source = "registry+https://github.com/rust-lang/crates.io-index" 1036 | checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" 1037 | 1038 | [[package]] 1039 | name = "lock_api" 1040 | version = "0.4.12" 1041 | source = "registry+https://github.com/rust-lang/crates.io-index" 1042 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 1043 | dependencies = [ 1044 | "autocfg", 1045 | "scopeguard", 1046 | ] 1047 | 1048 | [[package]] 1049 | name = "log" 1050 | version = "0.4.27" 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" 1052 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 1053 | 1054 | [[package]] 1055 | name = "lru" 1056 | version = "0.12.5" 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" 1058 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1059 | dependencies = [ 1060 | "hashbrown", 1061 | ] 1062 | 1063 | [[package]] 1064 | name = "memchr" 1065 | version = "2.7.4" 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" 1067 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1068 | 1069 | [[package]] 1070 | name = "mime" 1071 | version = "0.3.17" 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" 1073 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1074 | 1075 | [[package]] 1076 | name = "miniz_oxide" 1077 | version = "0.8.8" 1078 | source = "registry+https://github.com/rust-lang/crates.io-index" 1079 | checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 1080 | dependencies = [ 1081 | "adler2", 1082 | ] 1083 | 1084 | [[package]] 1085 | name = "mio" 1086 | version = "1.0.3" 1087 | source = "registry+https://github.com/rust-lang/crates.io-index" 1088 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 1089 | dependencies = [ 1090 | "libc", 1091 | "log", 1092 | "wasi 0.11.0+wasi-snapshot-preview1", 1093 | "windows-sys 0.52.0", 1094 | ] 1095 | 1096 | [[package]] 1097 | name = "native-tls" 1098 | version = "0.2.14" 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" 1100 | checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 1101 | dependencies = [ 1102 | "libc", 1103 | "log", 1104 | "openssl", 1105 | "openssl-probe", 1106 | "openssl-sys", 1107 | "schannel", 1108 | "security-framework", 1109 | "security-framework-sys", 1110 | "tempfile", 1111 | ] 1112 | 1113 | [[package]] 1114 | name = "num-traits" 1115 | version = "0.2.19" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1118 | dependencies = [ 1119 | "autocfg", 1120 | ] 1121 | 1122 | [[package]] 1123 | name = "object" 1124 | version = "0.36.7" 1125 | source = "registry+https://github.com/rust-lang/crates.io-index" 1126 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 1127 | dependencies = [ 1128 | "memchr", 1129 | ] 1130 | 1131 | [[package]] 1132 | name = "once_cell" 1133 | version = "1.21.3" 1134 | source = "registry+https://github.com/rust-lang/crates.io-index" 1135 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1136 | 1137 | [[package]] 1138 | name = "openssl" 1139 | version = "0.10.72" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" 1142 | dependencies = [ 1143 | "bitflags", 1144 | "cfg-if", 1145 | "foreign-types", 1146 | "libc", 1147 | "once_cell", 1148 | "openssl-macros", 1149 | "openssl-sys", 1150 | ] 1151 | 1152 | [[package]] 1153 | name = "openssl-macros" 1154 | version = "0.1.1" 1155 | source = "registry+https://github.com/rust-lang/crates.io-index" 1156 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 1157 | dependencies = [ 1158 | "proc-macro2", 1159 | "quote", 1160 | "syn", 1161 | ] 1162 | 1163 | [[package]] 1164 | name = "openssl-probe" 1165 | version = "0.1.6" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1168 | 1169 | [[package]] 1170 | name = "openssl-sys" 1171 | version = "0.9.108" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" 1174 | dependencies = [ 1175 | "cc", 1176 | "libc", 1177 | "pkg-config", 1178 | "vcpkg", 1179 | ] 1180 | 1181 | [[package]] 1182 | name = "option-ext" 1183 | version = "0.2.0" 1184 | source = "registry+https://github.com/rust-lang/crates.io-index" 1185 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1186 | 1187 | [[package]] 1188 | name = "owo-colors" 1189 | version = "4.2.1" 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" 1191 | checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" 1192 | 1193 | [[package]] 1194 | name = "parking_lot" 1195 | version = "0.12.3" 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" 1197 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 1198 | dependencies = [ 1199 | "lock_api", 1200 | "parking_lot_core", 1201 | ] 1202 | 1203 | [[package]] 1204 | name = "parking_lot_core" 1205 | version = "0.9.10" 1206 | source = "registry+https://github.com/rust-lang/crates.io-index" 1207 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 1208 | dependencies = [ 1209 | "cfg-if", 1210 | "libc", 1211 | "redox_syscall", 1212 | "smallvec", 1213 | "windows-targets 0.52.6", 1214 | ] 1215 | 1216 | [[package]] 1217 | name = "paste" 1218 | version = "1.0.15" 1219 | source = "registry+https://github.com/rust-lang/crates.io-index" 1220 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1221 | 1222 | [[package]] 1223 | name = "percent-encoding" 1224 | version = "2.3.1" 1225 | source = "registry+https://github.com/rust-lang/crates.io-index" 1226 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1227 | 1228 | [[package]] 1229 | name = "pin-project-lite" 1230 | version = "0.2.16" 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" 1232 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1233 | 1234 | [[package]] 1235 | name = "pin-utils" 1236 | version = "0.1.0" 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" 1238 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1239 | 1240 | [[package]] 1241 | name = "pkg-config" 1242 | version = "0.3.32" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1245 | 1246 | [[package]] 1247 | name = "portable-atomic" 1248 | version = "1.11.0" 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" 1250 | checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 1251 | 1252 | [[package]] 1253 | name = "portable-atomic-util" 1254 | version = "0.2.4" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 1257 | dependencies = [ 1258 | "portable-atomic", 1259 | ] 1260 | 1261 | [[package]] 1262 | name = "potential_utf" 1263 | version = "0.1.2" 1264 | source = "registry+https://github.com/rust-lang/crates.io-index" 1265 | checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" 1266 | dependencies = [ 1267 | "zerovec", 1268 | ] 1269 | 1270 | [[package]] 1271 | name = "proc-macro2" 1272 | version = "1.0.95" 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" 1274 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 1275 | dependencies = [ 1276 | "unicode-ident", 1277 | ] 1278 | 1279 | [[package]] 1280 | name = "qrcode" 1281 | version = "0.14.1" 1282 | source = "registry+https://github.com/rust-lang/crates.io-index" 1283 | checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" 1284 | 1285 | [[package]] 1286 | name = "quote" 1287 | version = "1.0.40" 1288 | source = "registry+https://github.com/rust-lang/crates.io-index" 1289 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 1290 | dependencies = [ 1291 | "proc-macro2", 1292 | ] 1293 | 1294 | [[package]] 1295 | name = "r-efi" 1296 | version = "5.2.0" 1297 | source = "registry+https://github.com/rust-lang/crates.io-index" 1298 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1299 | 1300 | [[package]] 1301 | name = "ratatui" 1302 | version = "0.29.0" 1303 | source = "registry+https://github.com/rust-lang/crates.io-index" 1304 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 1305 | dependencies = [ 1306 | "bitflags", 1307 | "cassowary", 1308 | "compact_str", 1309 | "crossterm", 1310 | "indoc", 1311 | "instability", 1312 | "itertools", 1313 | "lru", 1314 | "paste", 1315 | "strum 0.26.3", 1316 | "unicode-segmentation", 1317 | "unicode-truncate", 1318 | "unicode-width 0.2.0", 1319 | ] 1320 | 1321 | [[package]] 1322 | name = "redox_syscall" 1323 | version = "0.5.12" 1324 | source = "registry+https://github.com/rust-lang/crates.io-index" 1325 | checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" 1326 | dependencies = [ 1327 | "bitflags", 1328 | ] 1329 | 1330 | [[package]] 1331 | name = "redox_users" 1332 | version = "0.5.0" 1333 | source = "registry+https://github.com/rust-lang/crates.io-index" 1334 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 1335 | dependencies = [ 1336 | "getrandom 0.2.16", 1337 | "libredox", 1338 | "thiserror", 1339 | ] 1340 | 1341 | [[package]] 1342 | name = "regex" 1343 | version = "1.11.1" 1344 | source = "registry+https://github.com/rust-lang/crates.io-index" 1345 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1346 | dependencies = [ 1347 | "aho-corasick", 1348 | "memchr", 1349 | "regex-automata", 1350 | "regex-syntax", 1351 | ] 1352 | 1353 | [[package]] 1354 | name = "regex-automata" 1355 | version = "0.4.9" 1356 | source = "registry+https://github.com/rust-lang/crates.io-index" 1357 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1358 | dependencies = [ 1359 | "aho-corasick", 1360 | "memchr", 1361 | "regex-syntax", 1362 | ] 1363 | 1364 | [[package]] 1365 | name = "regex-syntax" 1366 | version = "0.8.5" 1367 | source = "registry+https://github.com/rust-lang/crates.io-index" 1368 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1369 | 1370 | [[package]] 1371 | name = "reqwest" 1372 | version = "0.12.15" 1373 | source = "registry+https://github.com/rust-lang/crates.io-index" 1374 | checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" 1375 | dependencies = [ 1376 | "base64", 1377 | "bytes", 1378 | "encoding_rs", 1379 | "futures-core", 1380 | "futures-util", 1381 | "h2", 1382 | "http", 1383 | "http-body", 1384 | "http-body-util", 1385 | "hyper", 1386 | "hyper-rustls", 1387 | "hyper-tls", 1388 | "hyper-util", 1389 | "ipnet", 1390 | "js-sys", 1391 | "log", 1392 | "mime", 1393 | "native-tls", 1394 | "once_cell", 1395 | "percent-encoding", 1396 | "pin-project-lite", 1397 | "rustls-pemfile", 1398 | "serde", 1399 | "serde_json", 1400 | "serde_urlencoded", 1401 | "sync_wrapper", 1402 | "system-configuration", 1403 | "tokio", 1404 | "tokio-native-tls", 1405 | "tower", 1406 | "tower-service", 1407 | "url", 1408 | "wasm-bindgen", 1409 | "wasm-bindgen-futures", 1410 | "web-sys", 1411 | "windows-registry", 1412 | ] 1413 | 1414 | [[package]] 1415 | name = "ring" 1416 | version = "0.17.14" 1417 | source = "registry+https://github.com/rust-lang/crates.io-index" 1418 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 1419 | dependencies = [ 1420 | "cc", 1421 | "cfg-if", 1422 | "getrandom 0.2.16", 1423 | "libc", 1424 | "untrusted", 1425 | "windows-sys 0.52.0", 1426 | ] 1427 | 1428 | [[package]] 1429 | name = "rustc-demangle" 1430 | version = "0.1.24" 1431 | source = "registry+https://github.com/rust-lang/crates.io-index" 1432 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 1433 | 1434 | [[package]] 1435 | name = "rustix" 1436 | version = "0.38.44" 1437 | source = "registry+https://github.com/rust-lang/crates.io-index" 1438 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1439 | dependencies = [ 1440 | "bitflags", 1441 | "errno", 1442 | "libc", 1443 | "linux-raw-sys 0.4.15", 1444 | "windows-sys 0.59.0", 1445 | ] 1446 | 1447 | [[package]] 1448 | name = "rustix" 1449 | version = "1.0.7" 1450 | source = "registry+https://github.com/rust-lang/crates.io-index" 1451 | checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" 1452 | dependencies = [ 1453 | "bitflags", 1454 | "errno", 1455 | "libc", 1456 | "linux-raw-sys 0.9.4", 1457 | "windows-sys 0.59.0", 1458 | ] 1459 | 1460 | [[package]] 1461 | name = "rustls" 1462 | version = "0.23.27" 1463 | source = "registry+https://github.com/rust-lang/crates.io-index" 1464 | checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" 1465 | dependencies = [ 1466 | "once_cell", 1467 | "rustls-pki-types", 1468 | "rustls-webpki", 1469 | "subtle", 1470 | "zeroize", 1471 | ] 1472 | 1473 | [[package]] 1474 | name = "rustls-pemfile" 1475 | version = "2.2.0" 1476 | source = "registry+https://github.com/rust-lang/crates.io-index" 1477 | checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 1478 | dependencies = [ 1479 | "rustls-pki-types", 1480 | ] 1481 | 1482 | [[package]] 1483 | name = "rustls-pki-types" 1484 | version = "1.12.0" 1485 | source = "registry+https://github.com/rust-lang/crates.io-index" 1486 | checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 1487 | dependencies = [ 1488 | "zeroize", 1489 | ] 1490 | 1491 | [[package]] 1492 | name = "rustls-webpki" 1493 | version = "0.103.3" 1494 | source = "registry+https://github.com/rust-lang/crates.io-index" 1495 | checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 1496 | dependencies = [ 1497 | "ring", 1498 | "rustls-pki-types", 1499 | "untrusted", 1500 | ] 1501 | 1502 | [[package]] 1503 | name = "rustversion" 1504 | version = "1.0.20" 1505 | source = "registry+https://github.com/rust-lang/crates.io-index" 1506 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1507 | 1508 | [[package]] 1509 | name = "ryu" 1510 | version = "1.0.20" 1511 | source = "registry+https://github.com/rust-lang/crates.io-index" 1512 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1513 | 1514 | [[package]] 1515 | name = "schannel" 1516 | version = "0.1.27" 1517 | source = "registry+https://github.com/rust-lang/crates.io-index" 1518 | checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 1519 | dependencies = [ 1520 | "windows-sys 0.59.0", 1521 | ] 1522 | 1523 | [[package]] 1524 | name = "scopeguard" 1525 | version = "1.2.0" 1526 | source = "registry+https://github.com/rust-lang/crates.io-index" 1527 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1528 | 1529 | [[package]] 1530 | name = "security-framework" 1531 | version = "2.11.1" 1532 | source = "registry+https://github.com/rust-lang/crates.io-index" 1533 | checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 1534 | dependencies = [ 1535 | "bitflags", 1536 | "core-foundation", 1537 | "core-foundation-sys", 1538 | "libc", 1539 | "security-framework-sys", 1540 | ] 1541 | 1542 | [[package]] 1543 | name = "security-framework-sys" 1544 | version = "2.14.0" 1545 | source = "registry+https://github.com/rust-lang/crates.io-index" 1546 | checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 1547 | dependencies = [ 1548 | "core-foundation-sys", 1549 | "libc", 1550 | ] 1551 | 1552 | [[package]] 1553 | name = "serde" 1554 | version = "1.0.219" 1555 | source = "registry+https://github.com/rust-lang/crates.io-index" 1556 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1557 | dependencies = [ 1558 | "serde_derive", 1559 | ] 1560 | 1561 | [[package]] 1562 | name = "serde_derive" 1563 | version = "1.0.219" 1564 | source = "registry+https://github.com/rust-lang/crates.io-index" 1565 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1566 | dependencies = [ 1567 | "proc-macro2", 1568 | "quote", 1569 | "syn", 1570 | ] 1571 | 1572 | [[package]] 1573 | name = "serde_json" 1574 | version = "1.0.140" 1575 | source = "registry+https://github.com/rust-lang/crates.io-index" 1576 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1577 | dependencies = [ 1578 | "itoa", 1579 | "memchr", 1580 | "ryu", 1581 | "serde", 1582 | ] 1583 | 1584 | [[package]] 1585 | name = "serde_spanned" 1586 | version = "0.6.8" 1587 | source = "registry+https://github.com/rust-lang/crates.io-index" 1588 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 1589 | dependencies = [ 1590 | "serde", 1591 | ] 1592 | 1593 | [[package]] 1594 | name = "serde_urlencoded" 1595 | version = "0.7.1" 1596 | source = "registry+https://github.com/rust-lang/crates.io-index" 1597 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1598 | dependencies = [ 1599 | "form_urlencoded", 1600 | "itoa", 1601 | "ryu", 1602 | "serde", 1603 | ] 1604 | 1605 | [[package]] 1606 | name = "sharded-slab" 1607 | version = "0.1.7" 1608 | source = "registry+https://github.com/rust-lang/crates.io-index" 1609 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1610 | dependencies = [ 1611 | "lazy_static", 1612 | ] 1613 | 1614 | [[package]] 1615 | name = "shlex" 1616 | version = "1.3.0" 1617 | source = "registry+https://github.com/rust-lang/crates.io-index" 1618 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1619 | 1620 | [[package]] 1621 | name = "signal-hook" 1622 | version = "0.3.18" 1623 | source = "registry+https://github.com/rust-lang/crates.io-index" 1624 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1625 | dependencies = [ 1626 | "libc", 1627 | "signal-hook-registry", 1628 | ] 1629 | 1630 | [[package]] 1631 | name = "signal-hook-mio" 1632 | version = "0.2.4" 1633 | source = "registry+https://github.com/rust-lang/crates.io-index" 1634 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 1635 | dependencies = [ 1636 | "libc", 1637 | "mio", 1638 | "signal-hook", 1639 | ] 1640 | 1641 | [[package]] 1642 | name = "signal-hook-registry" 1643 | version = "1.4.5" 1644 | source = "registry+https://github.com/rust-lang/crates.io-index" 1645 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 1646 | dependencies = [ 1647 | "libc", 1648 | ] 1649 | 1650 | [[package]] 1651 | name = "slab" 1652 | version = "0.4.9" 1653 | source = "registry+https://github.com/rust-lang/crates.io-index" 1654 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 1655 | dependencies = [ 1656 | "autocfg", 1657 | ] 1658 | 1659 | [[package]] 1660 | name = "smallvec" 1661 | version = "1.15.0" 1662 | source = "registry+https://github.com/rust-lang/crates.io-index" 1663 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 1664 | 1665 | [[package]] 1666 | name = "socket2" 1667 | version = "0.5.9" 1668 | source = "registry+https://github.com/rust-lang/crates.io-index" 1669 | checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" 1670 | dependencies = [ 1671 | "libc", 1672 | "windows-sys 0.52.0", 1673 | ] 1674 | 1675 | [[package]] 1676 | name = "stable_deref_trait" 1677 | version = "1.2.0" 1678 | source = "registry+https://github.com/rust-lang/crates.io-index" 1679 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 1680 | 1681 | [[package]] 1682 | name = "static_assertions" 1683 | version = "1.1.0" 1684 | source = "registry+https://github.com/rust-lang/crates.io-index" 1685 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1686 | 1687 | [[package]] 1688 | name = "strsim" 1689 | version = "0.11.1" 1690 | source = "registry+https://github.com/rust-lang/crates.io-index" 1691 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1692 | 1693 | [[package]] 1694 | name = "strum" 1695 | version = "0.26.3" 1696 | source = "registry+https://github.com/rust-lang/crates.io-index" 1697 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1698 | dependencies = [ 1699 | "strum_macros 0.26.4", 1700 | ] 1701 | 1702 | [[package]] 1703 | name = "strum" 1704 | version = "0.27.1" 1705 | source = "registry+https://github.com/rust-lang/crates.io-index" 1706 | checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" 1707 | dependencies = [ 1708 | "strum_macros 0.27.1", 1709 | ] 1710 | 1711 | [[package]] 1712 | name = "strum_macros" 1713 | version = "0.26.4" 1714 | source = "registry+https://github.com/rust-lang/crates.io-index" 1715 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1716 | dependencies = [ 1717 | "heck", 1718 | "proc-macro2", 1719 | "quote", 1720 | "rustversion", 1721 | "syn", 1722 | ] 1723 | 1724 | [[package]] 1725 | name = "strum_macros" 1726 | version = "0.27.1" 1727 | source = "registry+https://github.com/rust-lang/crates.io-index" 1728 | checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" 1729 | dependencies = [ 1730 | "heck", 1731 | "proc-macro2", 1732 | "quote", 1733 | "rustversion", 1734 | "syn", 1735 | ] 1736 | 1737 | [[package]] 1738 | name = "subtle" 1739 | version = "2.6.1" 1740 | source = "registry+https://github.com/rust-lang/crates.io-index" 1741 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1742 | 1743 | [[package]] 1744 | name = "syn" 1745 | version = "2.0.101" 1746 | source = "registry+https://github.com/rust-lang/crates.io-index" 1747 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 1748 | dependencies = [ 1749 | "proc-macro2", 1750 | "quote", 1751 | "unicode-ident", 1752 | ] 1753 | 1754 | [[package]] 1755 | name = "sync_wrapper" 1756 | version = "1.0.2" 1757 | source = "registry+https://github.com/rust-lang/crates.io-index" 1758 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1759 | dependencies = [ 1760 | "futures-core", 1761 | ] 1762 | 1763 | [[package]] 1764 | name = "syncthing-macros" 1765 | version = "0.1.0-alpha.1" 1766 | source = "git+https://github.com/hertelukas/syncthing-rs.git#1447c9dd225f0bb84fd4ea233f62fed5d94cbfca" 1767 | dependencies = [ 1768 | "proc-macro2", 1769 | "quote", 1770 | "serde", 1771 | "syn", 1772 | ] 1773 | 1774 | [[package]] 1775 | name = "syncthing-rs" 1776 | version = "0.1.0-alpha.2" 1777 | source = "git+https://github.com/hertelukas/syncthing-rs.git#1447c9dd225f0bb84fd4ea233f62fed5d94cbfca" 1778 | dependencies = [ 1779 | "chrono", 1780 | "log", 1781 | "reqwest", 1782 | "serde", 1783 | "syncthing-macros", 1784 | "thiserror", 1785 | "tokio", 1786 | ] 1787 | 1788 | [[package]] 1789 | name = "synctui" 1790 | version = "0.1.0" 1791 | dependencies = [ 1792 | "chrono", 1793 | "clap", 1794 | "color-eyre", 1795 | "crossterm", 1796 | "dirs", 1797 | "env_logger", 1798 | "futures", 1799 | "log", 1800 | "qrcode", 1801 | "ratatui", 1802 | "reqwest", 1803 | "serde", 1804 | "serde_json", 1805 | "strum 0.27.1", 1806 | "syncthing-rs", 1807 | "thiserror", 1808 | "tokio", 1809 | "toml", 1810 | "tui-qrcode", 1811 | ] 1812 | 1813 | [[package]] 1814 | name = "synstructure" 1815 | version = "0.13.2" 1816 | source = "registry+https://github.com/rust-lang/crates.io-index" 1817 | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1818 | dependencies = [ 1819 | "proc-macro2", 1820 | "quote", 1821 | "syn", 1822 | ] 1823 | 1824 | [[package]] 1825 | name = "system-configuration" 1826 | version = "0.6.1" 1827 | source = "registry+https://github.com/rust-lang/crates.io-index" 1828 | checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 1829 | dependencies = [ 1830 | "bitflags", 1831 | "core-foundation", 1832 | "system-configuration-sys", 1833 | ] 1834 | 1835 | [[package]] 1836 | name = "system-configuration-sys" 1837 | version = "0.6.0" 1838 | source = "registry+https://github.com/rust-lang/crates.io-index" 1839 | checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 1840 | dependencies = [ 1841 | "core-foundation-sys", 1842 | "libc", 1843 | ] 1844 | 1845 | [[package]] 1846 | name = "tempfile" 1847 | version = "3.20.0" 1848 | source = "registry+https://github.com/rust-lang/crates.io-index" 1849 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 1850 | dependencies = [ 1851 | "fastrand", 1852 | "getrandom 0.3.3", 1853 | "once_cell", 1854 | "rustix 1.0.7", 1855 | "windows-sys 0.59.0", 1856 | ] 1857 | 1858 | [[package]] 1859 | name = "thiserror" 1860 | version = "2.0.12" 1861 | source = "registry+https://github.com/rust-lang/crates.io-index" 1862 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1863 | dependencies = [ 1864 | "thiserror-impl", 1865 | ] 1866 | 1867 | [[package]] 1868 | name = "thiserror-impl" 1869 | version = "2.0.12" 1870 | source = "registry+https://github.com/rust-lang/crates.io-index" 1871 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1872 | dependencies = [ 1873 | "proc-macro2", 1874 | "quote", 1875 | "syn", 1876 | ] 1877 | 1878 | [[package]] 1879 | name = "thread_local" 1880 | version = "1.1.8" 1881 | source = "registry+https://github.com/rust-lang/crates.io-index" 1882 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 1883 | dependencies = [ 1884 | "cfg-if", 1885 | "once_cell", 1886 | ] 1887 | 1888 | [[package]] 1889 | name = "tinystr" 1890 | version = "0.8.1" 1891 | source = "registry+https://github.com/rust-lang/crates.io-index" 1892 | checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" 1893 | dependencies = [ 1894 | "displaydoc", 1895 | "zerovec", 1896 | ] 1897 | 1898 | [[package]] 1899 | name = "tokio" 1900 | version = "1.45.0" 1901 | source = "registry+https://github.com/rust-lang/crates.io-index" 1902 | checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" 1903 | dependencies = [ 1904 | "backtrace", 1905 | "bytes", 1906 | "libc", 1907 | "mio", 1908 | "parking_lot", 1909 | "pin-project-lite", 1910 | "signal-hook-registry", 1911 | "socket2", 1912 | "tokio-macros", 1913 | "windows-sys 0.52.0", 1914 | ] 1915 | 1916 | [[package]] 1917 | name = "tokio-macros" 1918 | version = "2.5.0" 1919 | source = "registry+https://github.com/rust-lang/crates.io-index" 1920 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 1921 | dependencies = [ 1922 | "proc-macro2", 1923 | "quote", 1924 | "syn", 1925 | ] 1926 | 1927 | [[package]] 1928 | name = "tokio-native-tls" 1929 | version = "0.3.1" 1930 | source = "registry+https://github.com/rust-lang/crates.io-index" 1931 | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 1932 | dependencies = [ 1933 | "native-tls", 1934 | "tokio", 1935 | ] 1936 | 1937 | [[package]] 1938 | name = "tokio-rustls" 1939 | version = "0.26.2" 1940 | source = "registry+https://github.com/rust-lang/crates.io-index" 1941 | checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 1942 | dependencies = [ 1943 | "rustls", 1944 | "tokio", 1945 | ] 1946 | 1947 | [[package]] 1948 | name = "tokio-util" 1949 | version = "0.7.15" 1950 | source = "registry+https://github.com/rust-lang/crates.io-index" 1951 | checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" 1952 | dependencies = [ 1953 | "bytes", 1954 | "futures-core", 1955 | "futures-sink", 1956 | "pin-project-lite", 1957 | "tokio", 1958 | ] 1959 | 1960 | [[package]] 1961 | name = "toml" 1962 | version = "0.8.22" 1963 | source = "registry+https://github.com/rust-lang/crates.io-index" 1964 | checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" 1965 | dependencies = [ 1966 | "serde", 1967 | "serde_spanned", 1968 | "toml_datetime", 1969 | "toml_edit", 1970 | ] 1971 | 1972 | [[package]] 1973 | name = "toml_datetime" 1974 | version = "0.6.9" 1975 | source = "registry+https://github.com/rust-lang/crates.io-index" 1976 | checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" 1977 | dependencies = [ 1978 | "serde", 1979 | ] 1980 | 1981 | [[package]] 1982 | name = "toml_edit" 1983 | version = "0.22.26" 1984 | source = "registry+https://github.com/rust-lang/crates.io-index" 1985 | checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" 1986 | dependencies = [ 1987 | "indexmap", 1988 | "serde", 1989 | "serde_spanned", 1990 | "toml_datetime", 1991 | "toml_write", 1992 | "winnow", 1993 | ] 1994 | 1995 | [[package]] 1996 | name = "toml_write" 1997 | version = "0.1.1" 1998 | source = "registry+https://github.com/rust-lang/crates.io-index" 1999 | checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" 2000 | 2001 | [[package]] 2002 | name = "tower" 2003 | version = "0.5.2" 2004 | source = "registry+https://github.com/rust-lang/crates.io-index" 2005 | checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 2006 | dependencies = [ 2007 | "futures-core", 2008 | "futures-util", 2009 | "pin-project-lite", 2010 | "sync_wrapper", 2011 | "tokio", 2012 | "tower-layer", 2013 | "tower-service", 2014 | ] 2015 | 2016 | [[package]] 2017 | name = "tower-layer" 2018 | version = "0.3.3" 2019 | source = "registry+https://github.com/rust-lang/crates.io-index" 2020 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 2021 | 2022 | [[package]] 2023 | name = "tower-service" 2024 | version = "0.3.3" 2025 | source = "registry+https://github.com/rust-lang/crates.io-index" 2026 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 2027 | 2028 | [[package]] 2029 | name = "tracing" 2030 | version = "0.1.41" 2031 | source = "registry+https://github.com/rust-lang/crates.io-index" 2032 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 2033 | dependencies = [ 2034 | "pin-project-lite", 2035 | "tracing-core", 2036 | ] 2037 | 2038 | [[package]] 2039 | name = "tracing-core" 2040 | version = "0.1.33" 2041 | source = "registry+https://github.com/rust-lang/crates.io-index" 2042 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 2043 | dependencies = [ 2044 | "once_cell", 2045 | "valuable", 2046 | ] 2047 | 2048 | [[package]] 2049 | name = "tracing-error" 2050 | version = "0.2.1" 2051 | source = "registry+https://github.com/rust-lang/crates.io-index" 2052 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 2053 | dependencies = [ 2054 | "tracing", 2055 | "tracing-subscriber", 2056 | ] 2057 | 2058 | [[package]] 2059 | name = "tracing-subscriber" 2060 | version = "0.3.19" 2061 | source = "registry+https://github.com/rust-lang/crates.io-index" 2062 | checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2063 | dependencies = [ 2064 | "sharded-slab", 2065 | "thread_local", 2066 | "tracing-core", 2067 | ] 2068 | 2069 | [[package]] 2070 | name = "try-lock" 2071 | version = "0.2.5" 2072 | source = "registry+https://github.com/rust-lang/crates.io-index" 2073 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2074 | 2075 | [[package]] 2076 | name = "tui-qrcode" 2077 | version = "0.1.1" 2078 | source = "git+https://github.com/joshka/tui-widgets.git#59a01f78286ff7bf6c40dd32c4d8df934131f240" 2079 | dependencies = [ 2080 | "color-eyre", 2081 | "qrcode", 2082 | "ratatui", 2083 | ] 2084 | 2085 | [[package]] 2086 | name = "unicode-ident" 2087 | version = "1.0.18" 2088 | source = "registry+https://github.com/rust-lang/crates.io-index" 2089 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 2090 | 2091 | [[package]] 2092 | name = "unicode-segmentation" 2093 | version = "1.12.0" 2094 | source = "registry+https://github.com/rust-lang/crates.io-index" 2095 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 2096 | 2097 | [[package]] 2098 | name = "unicode-truncate" 2099 | version = "1.1.0" 2100 | source = "registry+https://github.com/rust-lang/crates.io-index" 2101 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 2102 | dependencies = [ 2103 | "itertools", 2104 | "unicode-segmentation", 2105 | "unicode-width 0.1.14", 2106 | ] 2107 | 2108 | [[package]] 2109 | name = "unicode-width" 2110 | version = "0.1.14" 2111 | source = "registry+https://github.com/rust-lang/crates.io-index" 2112 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 2113 | 2114 | [[package]] 2115 | name = "unicode-width" 2116 | version = "0.2.0" 2117 | source = "registry+https://github.com/rust-lang/crates.io-index" 2118 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 2119 | 2120 | [[package]] 2121 | name = "untrusted" 2122 | version = "0.9.0" 2123 | source = "registry+https://github.com/rust-lang/crates.io-index" 2124 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 2125 | 2126 | [[package]] 2127 | name = "url" 2128 | version = "2.5.4" 2129 | source = "registry+https://github.com/rust-lang/crates.io-index" 2130 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 2131 | dependencies = [ 2132 | "form_urlencoded", 2133 | "idna", 2134 | "percent-encoding", 2135 | ] 2136 | 2137 | [[package]] 2138 | name = "utf8_iter" 2139 | version = "1.0.4" 2140 | source = "registry+https://github.com/rust-lang/crates.io-index" 2141 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2142 | 2143 | [[package]] 2144 | name = "utf8parse" 2145 | version = "0.2.2" 2146 | source = "registry+https://github.com/rust-lang/crates.io-index" 2147 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2148 | 2149 | [[package]] 2150 | name = "valuable" 2151 | version = "0.1.1" 2152 | source = "registry+https://github.com/rust-lang/crates.io-index" 2153 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2154 | 2155 | [[package]] 2156 | name = "vcpkg" 2157 | version = "0.2.15" 2158 | source = "registry+https://github.com/rust-lang/crates.io-index" 2159 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2160 | 2161 | [[package]] 2162 | name = "want" 2163 | version = "0.3.1" 2164 | source = "registry+https://github.com/rust-lang/crates.io-index" 2165 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 2166 | dependencies = [ 2167 | "try-lock", 2168 | ] 2169 | 2170 | [[package]] 2171 | name = "wasi" 2172 | version = "0.11.0+wasi-snapshot-preview1" 2173 | source = "registry+https://github.com/rust-lang/crates.io-index" 2174 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 2175 | 2176 | [[package]] 2177 | name = "wasi" 2178 | version = "0.14.2+wasi-0.2.4" 2179 | source = "registry+https://github.com/rust-lang/crates.io-index" 2180 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 2181 | dependencies = [ 2182 | "wit-bindgen-rt", 2183 | ] 2184 | 2185 | [[package]] 2186 | name = "wasm-bindgen" 2187 | version = "0.2.100" 2188 | source = "registry+https://github.com/rust-lang/crates.io-index" 2189 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 2190 | dependencies = [ 2191 | "cfg-if", 2192 | "once_cell", 2193 | "rustversion", 2194 | "wasm-bindgen-macro", 2195 | ] 2196 | 2197 | [[package]] 2198 | name = "wasm-bindgen-backend" 2199 | version = "0.2.100" 2200 | source = "registry+https://github.com/rust-lang/crates.io-index" 2201 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 2202 | dependencies = [ 2203 | "bumpalo", 2204 | "log", 2205 | "proc-macro2", 2206 | "quote", 2207 | "syn", 2208 | "wasm-bindgen-shared", 2209 | ] 2210 | 2211 | [[package]] 2212 | name = "wasm-bindgen-futures" 2213 | version = "0.4.50" 2214 | source = "registry+https://github.com/rust-lang/crates.io-index" 2215 | checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 2216 | dependencies = [ 2217 | "cfg-if", 2218 | "js-sys", 2219 | "once_cell", 2220 | "wasm-bindgen", 2221 | "web-sys", 2222 | ] 2223 | 2224 | [[package]] 2225 | name = "wasm-bindgen-macro" 2226 | version = "0.2.100" 2227 | source = "registry+https://github.com/rust-lang/crates.io-index" 2228 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 2229 | dependencies = [ 2230 | "quote", 2231 | "wasm-bindgen-macro-support", 2232 | ] 2233 | 2234 | [[package]] 2235 | name = "wasm-bindgen-macro-support" 2236 | version = "0.2.100" 2237 | source = "registry+https://github.com/rust-lang/crates.io-index" 2238 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 2239 | dependencies = [ 2240 | "proc-macro2", 2241 | "quote", 2242 | "syn", 2243 | "wasm-bindgen-backend", 2244 | "wasm-bindgen-shared", 2245 | ] 2246 | 2247 | [[package]] 2248 | name = "wasm-bindgen-shared" 2249 | version = "0.2.100" 2250 | source = "registry+https://github.com/rust-lang/crates.io-index" 2251 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 2252 | dependencies = [ 2253 | "unicode-ident", 2254 | ] 2255 | 2256 | [[package]] 2257 | name = "web-sys" 2258 | version = "0.3.77" 2259 | source = "registry+https://github.com/rust-lang/crates.io-index" 2260 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2261 | dependencies = [ 2262 | "js-sys", 2263 | "wasm-bindgen", 2264 | ] 2265 | 2266 | [[package]] 2267 | name = "winapi" 2268 | version = "0.3.9" 2269 | source = "registry+https://github.com/rust-lang/crates.io-index" 2270 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2271 | dependencies = [ 2272 | "winapi-i686-pc-windows-gnu", 2273 | "winapi-x86_64-pc-windows-gnu", 2274 | ] 2275 | 2276 | [[package]] 2277 | name = "winapi-i686-pc-windows-gnu" 2278 | version = "0.4.0" 2279 | source = "registry+https://github.com/rust-lang/crates.io-index" 2280 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2281 | 2282 | [[package]] 2283 | name = "winapi-x86_64-pc-windows-gnu" 2284 | version = "0.4.0" 2285 | source = "registry+https://github.com/rust-lang/crates.io-index" 2286 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2287 | 2288 | [[package]] 2289 | name = "windows-core" 2290 | version = "0.61.1" 2291 | source = "registry+https://github.com/rust-lang/crates.io-index" 2292 | checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" 2293 | dependencies = [ 2294 | "windows-implement", 2295 | "windows-interface", 2296 | "windows-link", 2297 | "windows-result", 2298 | "windows-strings 0.4.1", 2299 | ] 2300 | 2301 | [[package]] 2302 | name = "windows-implement" 2303 | version = "0.60.0" 2304 | source = "registry+https://github.com/rust-lang/crates.io-index" 2305 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 2306 | dependencies = [ 2307 | "proc-macro2", 2308 | "quote", 2309 | "syn", 2310 | ] 2311 | 2312 | [[package]] 2313 | name = "windows-interface" 2314 | version = "0.59.1" 2315 | source = "registry+https://github.com/rust-lang/crates.io-index" 2316 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 2317 | dependencies = [ 2318 | "proc-macro2", 2319 | "quote", 2320 | "syn", 2321 | ] 2322 | 2323 | [[package]] 2324 | name = "windows-link" 2325 | version = "0.1.1" 2326 | source = "registry+https://github.com/rust-lang/crates.io-index" 2327 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 2328 | 2329 | [[package]] 2330 | name = "windows-registry" 2331 | version = "0.4.0" 2332 | source = "registry+https://github.com/rust-lang/crates.io-index" 2333 | checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" 2334 | dependencies = [ 2335 | "windows-result", 2336 | "windows-strings 0.3.1", 2337 | "windows-targets 0.53.0", 2338 | ] 2339 | 2340 | [[package]] 2341 | name = "windows-result" 2342 | version = "0.3.3" 2343 | source = "registry+https://github.com/rust-lang/crates.io-index" 2344 | checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" 2345 | dependencies = [ 2346 | "windows-link", 2347 | ] 2348 | 2349 | [[package]] 2350 | name = "windows-strings" 2351 | version = "0.3.1" 2352 | source = "registry+https://github.com/rust-lang/crates.io-index" 2353 | checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" 2354 | dependencies = [ 2355 | "windows-link", 2356 | ] 2357 | 2358 | [[package]] 2359 | name = "windows-strings" 2360 | version = "0.4.1" 2361 | source = "registry+https://github.com/rust-lang/crates.io-index" 2362 | checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" 2363 | dependencies = [ 2364 | "windows-link", 2365 | ] 2366 | 2367 | [[package]] 2368 | name = "windows-sys" 2369 | version = "0.52.0" 2370 | source = "registry+https://github.com/rust-lang/crates.io-index" 2371 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 2372 | dependencies = [ 2373 | "windows-targets 0.52.6", 2374 | ] 2375 | 2376 | [[package]] 2377 | name = "windows-sys" 2378 | version = "0.59.0" 2379 | source = "registry+https://github.com/rust-lang/crates.io-index" 2380 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2381 | dependencies = [ 2382 | "windows-targets 0.52.6", 2383 | ] 2384 | 2385 | [[package]] 2386 | name = "windows-targets" 2387 | version = "0.52.6" 2388 | source = "registry+https://github.com/rust-lang/crates.io-index" 2389 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 2390 | dependencies = [ 2391 | "windows_aarch64_gnullvm 0.52.6", 2392 | "windows_aarch64_msvc 0.52.6", 2393 | "windows_i686_gnu 0.52.6", 2394 | "windows_i686_gnullvm 0.52.6", 2395 | "windows_i686_msvc 0.52.6", 2396 | "windows_x86_64_gnu 0.52.6", 2397 | "windows_x86_64_gnullvm 0.52.6", 2398 | "windows_x86_64_msvc 0.52.6", 2399 | ] 2400 | 2401 | [[package]] 2402 | name = "windows-targets" 2403 | version = "0.53.0" 2404 | source = "registry+https://github.com/rust-lang/crates.io-index" 2405 | checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" 2406 | dependencies = [ 2407 | "windows_aarch64_gnullvm 0.53.0", 2408 | "windows_aarch64_msvc 0.53.0", 2409 | "windows_i686_gnu 0.53.0", 2410 | "windows_i686_gnullvm 0.53.0", 2411 | "windows_i686_msvc 0.53.0", 2412 | "windows_x86_64_gnu 0.53.0", 2413 | "windows_x86_64_gnullvm 0.53.0", 2414 | "windows_x86_64_msvc 0.53.0", 2415 | ] 2416 | 2417 | [[package]] 2418 | name = "windows_aarch64_gnullvm" 2419 | version = "0.52.6" 2420 | source = "registry+https://github.com/rust-lang/crates.io-index" 2421 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 2422 | 2423 | [[package]] 2424 | name = "windows_aarch64_gnullvm" 2425 | version = "0.53.0" 2426 | source = "registry+https://github.com/rust-lang/crates.io-index" 2427 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 2428 | 2429 | [[package]] 2430 | name = "windows_aarch64_msvc" 2431 | version = "0.52.6" 2432 | source = "registry+https://github.com/rust-lang/crates.io-index" 2433 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 2434 | 2435 | [[package]] 2436 | name = "windows_aarch64_msvc" 2437 | version = "0.53.0" 2438 | source = "registry+https://github.com/rust-lang/crates.io-index" 2439 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 2440 | 2441 | [[package]] 2442 | name = "windows_i686_gnu" 2443 | version = "0.52.6" 2444 | source = "registry+https://github.com/rust-lang/crates.io-index" 2445 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2446 | 2447 | [[package]] 2448 | name = "windows_i686_gnu" 2449 | version = "0.53.0" 2450 | source = "registry+https://github.com/rust-lang/crates.io-index" 2451 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 2452 | 2453 | [[package]] 2454 | name = "windows_i686_gnullvm" 2455 | version = "0.52.6" 2456 | source = "registry+https://github.com/rust-lang/crates.io-index" 2457 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2458 | 2459 | [[package]] 2460 | name = "windows_i686_gnullvm" 2461 | version = "0.53.0" 2462 | source = "registry+https://github.com/rust-lang/crates.io-index" 2463 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 2464 | 2465 | [[package]] 2466 | name = "windows_i686_msvc" 2467 | version = "0.52.6" 2468 | source = "registry+https://github.com/rust-lang/crates.io-index" 2469 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 2470 | 2471 | [[package]] 2472 | name = "windows_i686_msvc" 2473 | version = "0.53.0" 2474 | source = "registry+https://github.com/rust-lang/crates.io-index" 2475 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 2476 | 2477 | [[package]] 2478 | name = "windows_x86_64_gnu" 2479 | version = "0.52.6" 2480 | source = "registry+https://github.com/rust-lang/crates.io-index" 2481 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2482 | 2483 | [[package]] 2484 | name = "windows_x86_64_gnu" 2485 | version = "0.53.0" 2486 | source = "registry+https://github.com/rust-lang/crates.io-index" 2487 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 2488 | 2489 | [[package]] 2490 | name = "windows_x86_64_gnullvm" 2491 | version = "0.52.6" 2492 | source = "registry+https://github.com/rust-lang/crates.io-index" 2493 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2494 | 2495 | [[package]] 2496 | name = "windows_x86_64_gnullvm" 2497 | version = "0.53.0" 2498 | source = "registry+https://github.com/rust-lang/crates.io-index" 2499 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 2500 | 2501 | [[package]] 2502 | name = "windows_x86_64_msvc" 2503 | version = "0.52.6" 2504 | source = "registry+https://github.com/rust-lang/crates.io-index" 2505 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2506 | 2507 | [[package]] 2508 | name = "windows_x86_64_msvc" 2509 | version = "0.53.0" 2510 | source = "registry+https://github.com/rust-lang/crates.io-index" 2511 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 2512 | 2513 | [[package]] 2514 | name = "winnow" 2515 | version = "0.7.10" 2516 | source = "registry+https://github.com/rust-lang/crates.io-index" 2517 | checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" 2518 | dependencies = [ 2519 | "memchr", 2520 | ] 2521 | 2522 | [[package]] 2523 | name = "wit-bindgen-rt" 2524 | version = "0.39.0" 2525 | source = "registry+https://github.com/rust-lang/crates.io-index" 2526 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 2527 | dependencies = [ 2528 | "bitflags", 2529 | ] 2530 | 2531 | [[package]] 2532 | name = "writeable" 2533 | version = "0.6.1" 2534 | source = "registry+https://github.com/rust-lang/crates.io-index" 2535 | checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" 2536 | 2537 | [[package]] 2538 | name = "yoke" 2539 | version = "0.8.0" 2540 | source = "registry+https://github.com/rust-lang/crates.io-index" 2541 | checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" 2542 | dependencies = [ 2543 | "serde", 2544 | "stable_deref_trait", 2545 | "yoke-derive", 2546 | "zerofrom", 2547 | ] 2548 | 2549 | [[package]] 2550 | name = "yoke-derive" 2551 | version = "0.8.0" 2552 | source = "registry+https://github.com/rust-lang/crates.io-index" 2553 | checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" 2554 | dependencies = [ 2555 | "proc-macro2", 2556 | "quote", 2557 | "syn", 2558 | "synstructure", 2559 | ] 2560 | 2561 | [[package]] 2562 | name = "zerofrom" 2563 | version = "0.1.6" 2564 | source = "registry+https://github.com/rust-lang/crates.io-index" 2565 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2566 | dependencies = [ 2567 | "zerofrom-derive", 2568 | ] 2569 | 2570 | [[package]] 2571 | name = "zerofrom-derive" 2572 | version = "0.1.6" 2573 | source = "registry+https://github.com/rust-lang/crates.io-index" 2574 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2575 | dependencies = [ 2576 | "proc-macro2", 2577 | "quote", 2578 | "syn", 2579 | "synstructure", 2580 | ] 2581 | 2582 | [[package]] 2583 | name = "zeroize" 2584 | version = "1.8.1" 2585 | source = "registry+https://github.com/rust-lang/crates.io-index" 2586 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 2587 | 2588 | [[package]] 2589 | name = "zerotrie" 2590 | version = "0.2.2" 2591 | source = "registry+https://github.com/rust-lang/crates.io-index" 2592 | checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" 2593 | dependencies = [ 2594 | "displaydoc", 2595 | "yoke", 2596 | "zerofrom", 2597 | ] 2598 | 2599 | [[package]] 2600 | name = "zerovec" 2601 | version = "0.11.2" 2602 | source = "registry+https://github.com/rust-lang/crates.io-index" 2603 | checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" 2604 | dependencies = [ 2605 | "yoke", 2606 | "zerofrom", 2607 | "zerovec-derive", 2608 | ] 2609 | 2610 | [[package]] 2611 | name = "zerovec-derive" 2612 | version = "0.11.1" 2613 | source = "registry+https://github.com/rust-lang/crates.io-index" 2614 | checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 2615 | dependencies = [ 2616 | "proc-macro2", 2617 | "quote", 2618 | "syn", 2619 | ] 2620 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "synctui" 3 | version = "0.1.0" 4 | edition = "2024" 5 | publish = false 6 | 7 | [dependencies] 8 | chrono = { version = "0.4.40", features = ["serde"] } 9 | clap = { version = "4.5.32", features = ["derive"] } 10 | color-eyre = "0.6.3" 11 | crossterm = { version = "0.28.1", features = ["event-stream"] } 12 | dirs = "6.0.0" 13 | env_logger = "0.11.7" 14 | futures = "0.3.31" 15 | log = "0.4.26" 16 | qrcode = { version = "0.14.1", default-features = false } 17 | ratatui = "0.29.0" 18 | reqwest = { version = "0.12.12", features = ["json"] } 19 | serde = { version = "1.0.219", features = ["derive"] } 20 | serde_json = "1.0.140" 21 | strum = { version = "0.27.1", features = ["derive"] } 22 | syncthing-rs = { git = "https://github.com/hertelukas/syncthing-rs.git" } 23 | thiserror = "2.0.12" 24 | tokio = { version = "1", features = ["full"] } 25 | toml = "0.8.20" 26 | tui-qrcode = { git = "https://github.com/joshka/tui-widgets.git", default-features = false } 27 | 28 | [profile.release] 29 | lto = true 30 | codegen-units = 1 31 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hertelukas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Synctui 2 | 3 | [![GitHub License](https://img.shields.io/github/license/hertelukas/synctui)](./LICENSE-MIT) 4 | [![CI](https://github.com/hertelukas/synctui/workflows/CI/badge.svg)](https://github.com/hertelukas/synctui/actions?query=workflow%3ACI) 5 | 6 | > [!WARNING] 7 | > Synctui is under active development. Everything that works, should be correct, 8 | > but it's not heavily tested yet. 9 | 10 | > [!NOTE] 11 | > - Not affiliated with the Syncthing Foundation. 12 | > - Contributions are welcome! 13 | 14 | --- 15 | 16 | ![synctui demo](media/synctui.GIF) 17 | 18 | **Synctui** lets you control [Syncthing](https://syncthing.net) from your terminal — no need to open a browser. Perfect for headless setups like servers or Raspberry Pis. Skip the port forwarding and get syncing. 19 | 20 | It already supports most essential features, so you can manage devices and folders pretty comfortably. That said, don’t uninstall the Syncthing GUI just yet — advanced features are still on the roadmap. 21 | 22 | ## 🚀 Installation 23 | 1. **Install Rust and Cargo** (if you haven't already): 24 | ``` bash 25 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 26 | ``` 27 | 28 | 2. **Install Synctui** 29 | ```bash 30 | cargo install --git https://github.com/hertelukas/synctui 31 | ``` 32 | 33 | 3. **Configure Synctui:** 34 | 35 | Create a config.toml in your system's default config directory. 36 | On Linux, for example: 37 | 38 | ``` bash 39 | ~/.config/synctui/config.toml 40 | ``` 41 | 42 | With this content: 43 | ``` toml 44 | api-key="your-api-key" 45 | ``` 46 | 47 | To find your API key (on Linux): 48 | 49 | ``` bash 50 | cat ~/.config/syncthing/config.xml | grep apikey 51 | ``` 52 | 53 | 4. **Run the app:** 54 | 55 | ``` bash 56 | synctui 57 | ``` 58 | 59 | ## 📌 Roadmap 60 | - [x] Accept incoming devices 61 | - [x] Accept incoming folders 62 | - [x] Create and share new folders 63 | - [x] Show device ID as a QR code 64 | - [x] Modify/delete folders 65 | - [x] Modify/delete devices 66 | - [ ] Ignore folders/devices 67 | - [ ] Live sync status & updates (WIP) 68 | - [ ] Support for encrypted endpoints 69 | - [ ] Add new devices (currently, they have to add you) 70 | -------------------------------------------------------------------------------- /media/synctui.GIF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hertelukas/synctui/dd1eb9949cb2bff0841c8fe9bfa949d00cdcc4d0/media/synctui.GIF -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition="2024" 2 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::read_to_string, path::PathBuf}; 2 | 3 | use color_eyre::eyre; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::AppError; 7 | 8 | #[derive(Debug, Deserialize, Serialize)] 9 | pub struct AppConfig { 10 | #[serde(rename = "api-key")] 11 | pub api_key: String, 12 | } 13 | 14 | impl AppConfig { 15 | pub fn load(path_arg: Option) -> eyre::Result 16 | where 17 | T: Into, 18 | { 19 | let effective_path: PathBuf = match path_arg { 20 | Some(p) => p.into(), 21 | None => { 22 | let base_config_dir = 23 | dirs::config_dir().ok_or(AppError::DefaultConfigDirNotFound)?; 24 | base_config_dir.join("synctui").join("config.toml") 25 | } 26 | }; 27 | 28 | let config_content = 29 | read_to_string(&effective_path).map_err(|io_error| AppError::ConfigReadError { 30 | path: effective_path.clone(), 31 | source: io_error, 32 | })?; 33 | 34 | let config_struct: Self = 35 | toml::from_str(&config_content).map_err(|toml_error| AppError::ConfigParseError { 36 | path: effective_path, 37 | source: toml_error, 38 | })?; 39 | 40 | Ok(config_struct) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::tui::state::Reload; 4 | 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum AppError { 7 | #[error( 8 | "Could not determine the system's default configuration directory. This is needed to locate 'synctui/config.toml'." 9 | )] 10 | DefaultConfigDirNotFound, 11 | 12 | #[error("Failed to read configuration file from '{path}'")] 13 | ConfigReadError { 14 | path: PathBuf, 15 | #[source] 16 | source: std::io::Error, 17 | }, 18 | 19 | #[error("Failed to parse TOML configuration from '{path}'")] 20 | ConfigParseError { 21 | path: PathBuf, 22 | #[source] 23 | source: toml::de::Error, 24 | }, 25 | #[error(transparent)] 26 | APIError(#[from] reqwest::Error), 27 | #[error("syncthing ID header not set")] 28 | SyncthingIDError, 29 | #[error(transparent)] 30 | ToStrError(#[from] reqwest::header::ToStrError), 31 | #[error("folder ID already exists")] 32 | DuplicateFolderID, 33 | #[error(transparent)] 34 | SendUnitError(#[from] tokio::sync::mpsc::error::SendError<()>), 35 | #[error(transparent)] 36 | SendReloadError(#[from] tokio::sync::mpsc::error::SendError), 37 | #[error("folder not found")] 38 | UnknownFolder, 39 | #[error("device not found")] 40 | UnknownDevice, 41 | #[error("syncthing API error")] 42 | SyncthingError(#[from] syncthing_rs::error::Error), 43 | } 44 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | pub use config::AppConfig; 3 | 4 | mod error; 5 | pub use error::AppError; 6 | 7 | mod tui; 8 | pub use tui::start; 9 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Parser; 4 | use color_eyre::eyre::{self, Context}; 5 | use serde::Serialize; 6 | use syncthing_rs::Client; 7 | use synctui::{AppConfig, start}; 8 | use tokio::{sync::broadcast, task}; 9 | 10 | #[derive(clap::ValueEnum, Clone, Debug, Serialize, Default)] 11 | enum LevelFilter { 12 | Off, 13 | Error, 14 | Warn, 15 | Info, 16 | #[default] 17 | Debug, 18 | Trace, 19 | } 20 | 21 | impl From for log::LevelFilter { 22 | fn from(val: LevelFilter) -> Self { 23 | match val { 24 | LevelFilter::Off => log::LevelFilter::Off, 25 | LevelFilter::Error => log::LevelFilter::Error, 26 | LevelFilter::Warn => log::LevelFilter::Warn, 27 | LevelFilter::Info => log::LevelFilter::Info, 28 | LevelFilter::Debug => log::LevelFilter::Debug, 29 | LevelFilter::Trace => log::LevelFilter::Trace, 30 | } 31 | } 32 | } 33 | 34 | /// CLI wrapper around the syncthing API 35 | #[derive(Parser, Debug)] 36 | #[command(version, about, long_about = None)] 37 | struct Args { 38 | /// Syncthing API key 39 | #[arg(short, long)] 40 | api_key: Option, 41 | 42 | /// Run only as CLI, do not start TUI 43 | #[arg(long)] 44 | cli: bool, 45 | 46 | /// Provide custom config path 47 | #[arg(short, long)] 48 | config: Option, 49 | 50 | /// Set log level 51 | #[arg(short, long)] 52 | log_level: Option, 53 | 54 | /// Set path of log file 55 | #[arg(long, requires = "log_level")] 56 | log_file: Option, 57 | } 58 | 59 | fn default_log_file_path() -> Option { 60 | dirs::cache_dir().map(|mut path| { 61 | path.push("synctui"); 62 | path.push("log.txt"); 63 | path 64 | }) 65 | } 66 | 67 | fn setup_logging(path: PathBuf, level: log::LevelFilter) -> eyre::Result<()> { 68 | if let Some(parent_dir) = path.parent() { 69 | if !parent_dir.as_os_str().is_empty() { 70 | std::fs::create_dir_all(parent_dir).wrap_err_with(|| { 71 | format!( 72 | "Failed to create parent directory '{}' while preparing log file", 73 | parent_dir.display() 74 | ) 75 | })?; 76 | } 77 | } 78 | 79 | let target_file = std::fs::File::create(&path) 80 | .wrap_err_with(|| format!("Failed to create log file at '{}'", path.display()))?; 81 | let target = Box::new(target_file); 82 | 83 | env_logger::Builder::new() 84 | .target(env_logger::Target::Pipe(target)) 85 | .filter(None, level) 86 | .init(); 87 | 88 | Ok(()) 89 | } 90 | 91 | #[tokio::main] 92 | async fn main() -> eyre::Result<()> { 93 | color_eyre::install()?; 94 | let args = Args::parse(); 95 | let level = args.log_level; 96 | if let Some(level) = level { 97 | let path = args.log_file 98 | .or_else(default_log_file_path) 99 | .ok_or_else(|| eyre::eyre!("Failed to determine a log file path: No path specified via --log-file and could not determine a default path."))?; 100 | 101 | setup_logging(path, level.into())?; 102 | } 103 | let api_key = { 104 | match args.api_key { 105 | Some(key) => key, 106 | None => AppConfig::load(args.config)?.api_key, 107 | } 108 | }; 109 | 110 | let client = Client::builder(&api_key).build()?; 111 | 112 | if args.cli { 113 | client.ping().await?; 114 | client.get_configuration().await?; 115 | 116 | let (tx_event, mut rx_event) = broadcast::channel(1); 117 | 118 | task::spawn(async move { 119 | if let Err(error) = client.get_events(tx_event, false).await { 120 | println!("Error: {error:?}"); 121 | } 122 | }); 123 | 124 | task::spawn(async move { 125 | while let Ok(event) = rx_event.recv().await { 126 | println!("{:#?}", event); 127 | } 128 | }) 129 | .await?; 130 | } else { 131 | start(client).await?; 132 | } 133 | 134 | Ok(()) 135 | } 136 | -------------------------------------------------------------------------------- /src/tui/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use log::{debug, warn}; 4 | use strum::IntoEnumIterator; 5 | use syncthing_rs::{ 6 | Client, 7 | types::{ 8 | config::NewFolderConfiguration, 9 | events::{Event, EventType}, 10 | }, 11 | }; 12 | use tokio::sync::{broadcast, mpsc}; 13 | 14 | use crate::{AppError, tui::state::State}; 15 | 16 | use super::{ 17 | input::Message, 18 | pages::PendingPageState, 19 | popup::{ 20 | DevicePopup, FolderPopup, NewFolderPopup, PendingDevicePopup, PendingShareFolderPopup, 21 | Popup, 22 | }, 23 | state::Reload, 24 | }; 25 | 26 | #[derive(Default, Debug, strum::EnumIter, PartialEq)] 27 | pub enum CurrentScreen { 28 | #[default] 29 | Folders, 30 | Devices, 31 | Pending, 32 | ID, 33 | } 34 | 35 | /// VIM modes 36 | #[derive(Debug, Clone, PartialEq)] 37 | pub enum CurrentMode { 38 | Insert, 39 | Normal, 40 | } 41 | 42 | impl std::fmt::Display for CurrentMode { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | match self { 45 | Self::Insert => write!(f, "I"), 46 | Self::Normal => write!(f, "N"), 47 | } 48 | } 49 | } 50 | 51 | impl TryFrom for CurrentScreen { 52 | type Error = (); 53 | 54 | fn try_from(v: u32) -> Result { 55 | if let Some((_, screen)) = CurrentScreen::iter() 56 | .enumerate() 57 | .find(|(i, _)| i + 1 == (v as usize)) 58 | { 59 | Ok(screen) 60 | } else { 61 | Err(()) 62 | } 63 | } 64 | } 65 | 66 | /// Tracks current state of application 67 | #[derive(Debug)] 68 | pub struct App { 69 | rerender_tx: mpsc::Sender, 70 | pub running: bool, 71 | pub current_screen: CurrentScreen, 72 | pub state: State, 73 | pub selected_folder: Option, 74 | pub selected_device: Option, 75 | pub pending_state: PendingPageState, 76 | pub mode: Arc>, 77 | pub popup: Option>, 78 | } 79 | 80 | impl App { 81 | pub fn new(client: Client, rerender_tx: mpsc::Sender) -> Self { 82 | let app = App { 83 | rerender_tx, 84 | running: true, 85 | current_screen: CurrentScreen::default(), 86 | state: State::new(client.clone()), 87 | selected_folder: None, 88 | selected_device: None, 89 | pending_state: PendingPageState::default(), 90 | mode: Arc::new(Mutex::new(CurrentMode::Normal)), 91 | popup: None, 92 | }; 93 | 94 | // React to events 95 | let rerender_tx = app.rerender_tx.clone(); 96 | let event_rx = app.state.subscribe_to_events(); 97 | tokio::spawn(async move { Self::handle_event(event_rx, rerender_tx).await }); 98 | 99 | // Start listen to changes to the config and rerender based on them 100 | let rerender_tx = app.rerender_tx.clone(); 101 | let config_rx = app.state.subscribe_to_config(); 102 | tokio::spawn(async move { Self::handle_rerender(config_rx, rerender_tx).await }); 103 | 104 | // TODO maybe reload state here again, as the state might already have fully 105 | // been fully initialized while we were setting up the listeners 106 | 107 | app 108 | } 109 | 110 | /// Runs in the background and reacts to Syncthing events. 111 | async fn handle_event( 112 | mut event_rx: broadcast::Receiver, 113 | rerender_tx: mpsc::Sender, 114 | ) { 115 | while let Ok(event) = event_rx.recv().await { 116 | debug!("Received event: {:?}", event); 117 | match event.ty { 118 | EventType::PendingDevicesChanged { 119 | ref added, 120 | ref removed, 121 | } => { 122 | if let Some(added) = added { 123 | if let Some(first) = added.first() { 124 | if let Err(e) = rerender_tx 125 | .send(Message::NewPendingDevice(first.device_id.clone())) 126 | .await 127 | { 128 | warn!( 129 | "failed to send rerender message with new popup about new pending device: {:?}", 130 | e 131 | ); 132 | // Don't set an error, as this is not really mission critical 133 | } 134 | } 135 | } 136 | if let Some(_removed) = removed { 137 | // TODO close popup if we have one with a removed device opened 138 | } 139 | } 140 | EventType::PendingFoldersChanged { 141 | ref added, 142 | ref removed, 143 | } => { 144 | if let Some(added) = added { 145 | if let Some(first) = added.first() { 146 | if let Err(e) = rerender_tx 147 | .send(Message::NewPendingFolder { 148 | folder_label: first.folder_label.clone(), 149 | folder_id: first.folder_id.clone(), 150 | device_id: first.device_id.clone(), 151 | }) 152 | .await 153 | { 154 | warn!( 155 | "failed to send rerender message with new popup about new pending folder-share: {:?}", 156 | e 157 | ); 158 | } 159 | } 160 | } 161 | if let Some(_removed) = removed { 162 | // TODO close popup if we have one with a removed folder opened 163 | } 164 | } 165 | _ => {} 166 | } 167 | } 168 | } 169 | 170 | /// Listens to config changes and just initiates a rerender of the UI 171 | async fn handle_rerender( 172 | mut reload_rx: broadcast::Receiver<()>, 173 | rerender_tx: mpsc::Sender, 174 | ) { 175 | while reload_rx.recv().await.is_ok() { 176 | rerender_tx.send(Message::None).await.unwrap(); 177 | } 178 | unreachable!("the config sender should never have been dropped") 179 | } 180 | 181 | fn update_folders(&mut self, msg: Message) -> Option { 182 | match msg { 183 | Message::Down => { 184 | let len = self.state.read(|state| state.get_folders().len()); 185 | if len == 0 { 186 | return None; 187 | } 188 | if let Some(highlighted_folder) = self.selected_folder { 189 | self.selected_folder = Some((highlighted_folder + 1) % len) 190 | } else { 191 | self.selected_folder = Some(0); 192 | } 193 | } 194 | Message::Up => { 195 | let len = self.state.read(|state| state.get_folders().len()); 196 | if len == 0 { 197 | return None; 198 | } 199 | 200 | if let Some(highlighted_folder) = self.selected_folder { 201 | self.selected_folder = Some((highlighted_folder + len - 1) % len) 202 | } else { 203 | self.selected_folder = Some(len - 1); 204 | } 205 | } 206 | Message::Add => { 207 | self.popup = Some(Box::new(NewFolderPopup::new( 208 | self.mode.clone(), 209 | self.state.clone(), 210 | ))); 211 | } 212 | Message::Select => { 213 | if let Some(highlighted_folder) = self.selected_folder { 214 | self.state.read(|state| { 215 | if let Some(folder) = state.get_folders().get(highlighted_folder) { 216 | self.popup = Some(Box::new(FolderPopup::new( 217 | folder.config.clone(), 218 | self.mode.clone(), 219 | ))) 220 | } 221 | }) 222 | } 223 | } 224 | _ => {} 225 | }; 226 | None 227 | } 228 | 229 | fn update_devices(&mut self, msg: Message) -> Option { 230 | let len = self.state.read(|state| state.get_other_devices().len()); 231 | match msg { 232 | Message::Down => { 233 | if len == 0 { 234 | return None; 235 | } 236 | 237 | if let Some(highlighted_device) = self.selected_device { 238 | self.selected_device = Some((highlighted_device + 1) % len) 239 | } else { 240 | self.selected_device = Some(0) 241 | } 242 | } 243 | Message::Up => { 244 | if len == 0 { 245 | return None; 246 | } 247 | if let Some(highlighted_device) = self.selected_device { 248 | self.selected_device = Some((highlighted_device + len - 1) % len) 249 | } else { 250 | self.selected_device = Some(len - 1); 251 | } 252 | } 253 | Message::Select => { 254 | if let Some(highlighted_device) = self.selected_device { 255 | self.state.read(|state| { 256 | if let Some(device) = state.get_other_devices().get(highlighted_device) { 257 | self.popup = Some(Box::new(DevicePopup::new( 258 | device.config.clone(), 259 | self.mode.clone(), 260 | ))) 261 | } 262 | }) 263 | } 264 | } 265 | _ => {} 266 | }; 267 | None 268 | } 269 | 270 | fn update_pending(&mut self, msg: Message) -> Option { 271 | let devices_len = self.state.read(|state| state.get_pending_devices().len()); 272 | 273 | let folders_len = self.state.read(|state| state.get_pending_folders().len()); 274 | 275 | self.pending_state.update(&msg, devices_len, folders_len); 276 | if matches!(msg, Message::Select) { 277 | // Device Popup 278 | if let Some(index) = self.pending_state.device_selected() { 279 | self.state.read(|state| { 280 | if let Some(device) = state.get_pending_devices().get(index) { 281 | self.popup = Some(Box::new(PendingDevicePopup::new( 282 | device.get_device_id().clone(), 283 | ))) 284 | } 285 | }); 286 | }; 287 | // Folder Popup 288 | if let Some(index) = self.pending_state.folder_selected() { 289 | self.state.read(|state| { 290 | if let Some((device_id, folder)) = state.get_pending_folders().get(index) { 291 | // Only need to share, folder exists already locally 292 | if state.get_folder(folder.get_id()).is_ok() { 293 | self.popup = Some(Box::new(PendingShareFolderPopup::new( 294 | folder.get_id().to_string(), 295 | device_id.to_string(), 296 | ))) 297 | } else { 298 | self.popup = Some(Box::new(NewFolderPopup::new_from_device( 299 | folder.get_label().clone().unwrap_or("".to_string()), 300 | folder.get_id().to_string(), 301 | device_id.to_string(), 302 | self.mode.clone(), 303 | self.state.clone(), 304 | ))) 305 | } 306 | } 307 | }); 308 | } 309 | }; 310 | None 311 | } 312 | 313 | fn handle_new_folder(&mut self, folder: NewFolderConfiguration) -> Option { 314 | // Raise an error if we have a duplicate id. 315 | // Probably, this should also be done in the state 316 | if self 317 | .state 318 | .read(|state| state.get_folder(folder.get_id()).is_ok()) 319 | { 320 | self.state.set_error(AppError::DuplicateFolderID); 321 | return None; 322 | } 323 | 324 | // TODO maybe check that path is valid 325 | self.state.add_foler(folder); 326 | None 327 | } 328 | 329 | pub fn update(&mut self, msg: Message) -> Option { 330 | // Mode switches and popup results take always priority 331 | match msg { 332 | Message::Insert => *self.mode.lock().unwrap() = CurrentMode::Insert, 333 | Message::Normal => *self.mode.lock().unwrap() = CurrentMode::Normal, 334 | Message::NewFolder(folder) => { 335 | self.popup = None; 336 | return self.handle_new_folder(*folder); 337 | } 338 | Message::AcceptDevice(ref device) => { 339 | self.popup = None; 340 | self.state.accept_device(device); 341 | } 342 | Message::IgnoreDevice(_) => { 343 | self.popup = None; 344 | todo!("add device to ignore list"); 345 | } 346 | Message::DismissDevice(ref device_id) => { 347 | self.popup = None; 348 | self.state.dismiss_device(device_id); 349 | } 350 | Message::ShareFolder { 351 | ref folder_id, 352 | ref device_id, 353 | } => { 354 | self.popup = None; 355 | self.state.share_folder(folder_id, device_id); 356 | } 357 | Message::DismissFolder { 358 | ref folder_id, 359 | ref device_id, 360 | } => { 361 | self.popup = None; 362 | self.state.dismiss_folder(folder_id, device_id); 363 | } 364 | Message::EditFolder(ref folder) => { 365 | self.popup = None; 366 | self.state.edit_folder(*folder.clone()); 367 | } 368 | Message::RemoveFolder(ref folder_id) => { 369 | self.popup = None; 370 | self.state.remove_folder(folder_id); 371 | } 372 | Message::EditDevice(ref device) => { 373 | self.popup = None; 374 | self.state.edit_device(*device.clone()); 375 | } 376 | Message::RemoveDevice(ref device_id) => { 377 | self.popup = None; 378 | self.state.remove_device(device_id); 379 | } 380 | _ => {} 381 | } 382 | 383 | // Then, we handle popups if one exists 384 | if let Some(popup) = self.popup.as_mut() { 385 | if let Some(msg) = popup.update(msg, self.state.clone()) { 386 | match msg { 387 | Message::Quit => self.popup = None, 388 | // All other messages from the popup are handles in the next 389 | // iteration, normally. This allows for greater flexibility 390 | _ => return Some(msg), 391 | } 392 | } 393 | return None; 394 | }; 395 | 396 | // If there is none, handle global messages 397 | match msg { 398 | Message::Quit => { 399 | self.running = false; 400 | return None; 401 | } 402 | Message::Number(i) => { 403 | if let Ok(screen) = CurrentScreen::try_from(i) { 404 | self.current_screen = screen; 405 | return None; 406 | } 407 | } 408 | Message::Reload => { 409 | self.state.reload(Reload::Configuration); 410 | } 411 | Message::NewPendingDevice(ref device) => { 412 | self.popup = Some(Box::new(PendingDevicePopup::new(device.clone()))); 413 | } 414 | Message::NewPendingFolder { 415 | ref folder_label, 416 | ref folder_id, 417 | ref device_id, 418 | } => { 419 | // Folder already exists on our machine, just share 420 | if self.state.read(|state| state.get_folder(folder_id).is_ok()) { 421 | self.popup = Some(Box::new(PendingShareFolderPopup::new( 422 | folder_id.clone(), 423 | device_id.to_string(), 424 | ))) 425 | } else { 426 | self.popup = Some(Box::new(NewFolderPopup::new_from_device( 427 | folder_label, 428 | folder_id, 429 | device_id, 430 | self.mode.clone(), 431 | self.state.clone(), 432 | ))) 433 | } 434 | } 435 | _ => {} 436 | } 437 | 438 | // Handle screen specific keys 439 | match self.current_screen { 440 | CurrentScreen::Folders => self.update_folders(msg), 441 | CurrentScreen::Devices => self.update_devices(msg), 442 | CurrentScreen::Pending => self.update_pending(msg), 443 | _ => None, 444 | } 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /src/tui/input.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyModifiers; 2 | use futures::StreamExt; 3 | use log::debug; 4 | use ratatui::crossterm::{ 5 | self, 6 | event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind}, 7 | }; 8 | use syncthing_rs::types::config::{ 9 | DeviceConfiguration, FolderConfiguration, NewFolderConfiguration, 10 | }; 11 | 12 | use super::app::CurrentMode; 13 | 14 | #[derive(Clone, Debug, PartialEq)] 15 | pub enum Message { 16 | // Vim 17 | Insert, 18 | Normal, 19 | // Input 20 | Character(char), 21 | Backspace, 22 | // Navigation 23 | Number(u32), 24 | FocusNext, 25 | FocusBack, 26 | // Movement 27 | Up, 28 | Down, 29 | Right, 30 | Left, 31 | // General 32 | Add, 33 | Quit, 34 | Reload, 35 | Select, 36 | Submit, 37 | // Popups 38 | // NewFolder 39 | NewFolder(Box), 40 | // PendingDevice 41 | NewPendingDevice(String), 42 | AcceptDevice(String), 43 | IgnoreDevice(String), 44 | DismissDevice(String), 45 | // PendingFolder 46 | NewPendingFolder { 47 | folder_label: String, 48 | folder_id: String, 49 | device_id: String, 50 | }, 51 | ShareFolder { 52 | folder_id: String, 53 | device_id: String, 54 | }, 55 | DismissFolder { 56 | folder_id: String, 57 | device_id: String, 58 | }, 59 | // Folder 60 | EditFolder(Box), 61 | RemoveFolder(String), 62 | // Device 63 | EditDevice(Box), 64 | RemoveDevice(String), 65 | None, 66 | } 67 | 68 | pub fn handler(key_event: KeyEvent, mode: CurrentMode) -> Message { 69 | if mode == CurrentMode::Normal { 70 | match key_event.code { 71 | KeyCode::Char('r') => Message::Reload, 72 | KeyCode::Char('q') => Message::Quit, 73 | KeyCode::Char('j') | KeyCode::Down => Message::Down, 74 | KeyCode::Char('k') | KeyCode::Up => Message::Up, 75 | KeyCode::Char('l') | KeyCode::Right => Message::Right, 76 | KeyCode::Char('h') | KeyCode::Left => Message::Left, 77 | KeyCode::Char('i') => Message::Insert, 78 | KeyCode::Char('+') | KeyCode::Char('o') => Message::Add, 79 | KeyCode::Enter => { 80 | if key_event.modifiers.contains(KeyModifiers::SHIFT) { 81 | // BUG this does not work on Linux and Mac 82 | Message::Submit 83 | } else { 84 | Message::Select 85 | } 86 | } 87 | KeyCode::Tab => Message::FocusNext, 88 | KeyCode::BackTab => Message::FocusBack, 89 | KeyCode::Char(a) => { 90 | if let Some(a) = a.to_digit(10) { 91 | Message::Number(a) 92 | } else { 93 | Message::None 94 | } 95 | } 96 | _ => Message::None, 97 | } 98 | } else { 99 | match key_event.code { 100 | KeyCode::Char('+') => Message::Add, 101 | KeyCode::Char(a) => Message::Character(a), 102 | KeyCode::Backspace => Message::Backspace, 103 | KeyCode::Down => Message::Down, 104 | KeyCode::Up => Message::Up, 105 | KeyCode::Right => Message::Right, 106 | KeyCode::Left => Message::Left, 107 | KeyCode::Esc => Message::Normal, 108 | KeyCode::Enter => { 109 | if key_event.modifiers.contains(KeyModifiers::SHIFT) { 110 | // BUG this does not work on Linux and Mac 111 | Message::Submit 112 | } else { 113 | Message::Select 114 | } 115 | } 116 | KeyCode::Tab => Message::FocusNext, 117 | KeyCode::BackTab => Message::FocusBack, 118 | _ => Message::None, 119 | } 120 | } 121 | } 122 | 123 | #[derive(Debug)] 124 | pub enum Event { 125 | Key(crossterm::event::KeyEvent), 126 | } 127 | 128 | pub struct EventHandler { 129 | rx: tokio::sync::mpsc::UnboundedReceiver, 130 | } 131 | 132 | impl EventHandler { 133 | pub fn new() -> Self { 134 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); 135 | let _tx = tx.clone(); 136 | tokio::spawn(async move { 137 | let mut reader = crossterm::event::EventStream::new(); 138 | loop { 139 | let event = reader.next().await; 140 | if let Some(Ok(CrosstermEvent::Key(key))) = event { 141 | if key.kind == KeyEventKind::Press { 142 | debug!("got key {key:?} - sending"); 143 | tx.send(Event::Key(key)).unwrap(); 144 | } 145 | } 146 | } 147 | }); 148 | EventHandler { rx } 149 | } 150 | 151 | pub async fn next(&mut self) -> Option { 152 | self.rx.recv().await 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | use input::{EventHandler, Message}; 2 | use log::debug; 3 | use std::io; 4 | use syncthing_rs::Client; 5 | use tokio::sync::mpsc::{self, Receiver}; 6 | use ui::ui; 7 | 8 | use app::{App, CurrentMode}; 9 | use color_eyre::eyre; 10 | use ratatui::{ 11 | Terminal, 12 | crossterm::{ 13 | event::{DisableMouseCapture, EnableMouseCapture}, 14 | execute, 15 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 16 | }, 17 | prelude::{Backend, CrosstermBackend}, 18 | }; 19 | 20 | mod app; 21 | mod input; 22 | mod popup; 23 | pub mod state; 24 | mod ui; 25 | 26 | mod pages { 27 | mod folders; 28 | pub use folders::FoldersPage; 29 | mod devices; 30 | pub use devices::DevicesPage; 31 | mod id; 32 | pub use id::IDPage; 33 | mod pending; 34 | pub use pending::PendingPage; 35 | pub use pending::PendingPageState; 36 | } 37 | 38 | pub async fn start(client: Client) -> eyre::Result<()> { 39 | init_panic_hook(); 40 | 41 | // Setup terminal 42 | let mut terminal = init_tui()?; 43 | terminal.clear()?; 44 | 45 | let (reload_tx, reload_rx) = mpsc::channel(10); 46 | 47 | let mut app = App::new(client, reload_tx); 48 | let _ = run(&mut terminal, &mut app, reload_rx).await; 49 | 50 | //restore terminal 51 | restore_tui()?; 52 | terminal.show_cursor()?; 53 | 54 | Ok(()) 55 | } 56 | 57 | /// Overwrits the default panic hook by first 58 | /// trying to restore our terminal 59 | fn init_panic_hook() { 60 | let original_hook = std::panic::take_hook(); 61 | std::panic::set_hook(Box::new(move |panic_info| { 62 | // Ignore errors, as we are already panicing 63 | let _ = restore_tui(); 64 | original_hook(panic_info); 65 | })); 66 | } 67 | 68 | fn init_tui() -> io::Result> { 69 | enable_raw_mode()?; 70 | execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 71 | Terminal::new(CrosstermBackend::new(io::stdout())) 72 | } 73 | 74 | fn restore_tui() -> io::Result<()> { 75 | disable_raw_mode()?; 76 | execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 77 | Ok(()) 78 | } 79 | 80 | async fn run( 81 | terminal: &mut Terminal, 82 | app: &mut App, 83 | mut reload_rx: Receiver, 84 | ) -> Result<(), std::io::Error> { 85 | let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); 86 | 87 | let mode_handle = app.mode.clone(); 88 | 89 | tokio::spawn(async move { 90 | let mut event = EventHandler::new(); 91 | loop { 92 | let event = event.next().await; 93 | if let Some(input::Event::Key(k)) = event { 94 | let mode: CurrentMode = { mode_handle.lock().unwrap().clone() }; 95 | msg_tx.send(input::handler(k, mode)).unwrap() 96 | }; 97 | } 98 | }); 99 | 100 | while app.running { 101 | debug!("drawing new frame"); 102 | terminal.draw(|f| ui(f, app))?; 103 | 104 | tokio::select! { 105 | mut msg = msg_rx.recv() => { 106 | while let Some(m) = msg { 107 | msg = app.update(m); 108 | } 109 | }, 110 | mut msg = reload_rx.recv() => { 111 | while let Some(m) = msg { 112 | msg = app.update(m); 113 | } 114 | } 115 | } 116 | } 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /src/tui/pages/devices.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout}, 3 | style::{Color, Style, Stylize}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget, Widget}, 6 | }; 7 | 8 | use crate::tui::app::App; 9 | 10 | pub struct DevicesPage<'a> { 11 | app: &'a App, 12 | } 13 | 14 | impl<'a> DevicesPage<'a> { 15 | pub fn new(app: &'a App) -> Self { 16 | Self { app } 17 | } 18 | } 19 | 20 | impl Widget for DevicesPage<'_> { 21 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { 22 | (&self).render(area, buf); 23 | } 24 | } 25 | 26 | impl Widget for &DevicesPage<'_> { 27 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 28 | where 29 | Self: Sized, 30 | { 31 | let chunks = Layout::default() 32 | .direction(Direction::Horizontal) 33 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 34 | .split(area); 35 | 36 | let list: Vec<_> = self.app.state.read(|state| { 37 | state 38 | .get_other_devices() 39 | .iter() 40 | .map(|d| (d.config.name.clone(), d.connected.clone())) 41 | .collect() 42 | }); 43 | 44 | let max = list 45 | .iter() 46 | .max_by(|x, y| x.0.char_indices().count().cmp(&y.0.char_indices().count())) 47 | .map_or(0, |f| f.0.char_indices().count()); 48 | 49 | let list: Vec<_> = list 50 | .iter() 51 | .map(|(name, online)| { 52 | let online_span = match online { 53 | crate::tui::state::DeviceStatus::UpToDate => { 54 | Span::styled("[Up to Date]", Style::default().green().bold()) 55 | } 56 | crate::tui::state::DeviceStatus::Syncing(completion) => Span::styled( 57 | format!("[Syncing ({:.0}%)]", completion), 58 | Style::default().blue().bold(), 59 | ), 60 | crate::tui::state::DeviceStatus::Disconnected => { 61 | Span::styled("[Disconnected]", Style::default().red()) 62 | } 63 | }; 64 | 65 | let spacing = (max + 2) - name.char_indices().count(); 66 | Line::from(vec![ 67 | Span::raw(name), 68 | Span::raw(" ".repeat(spacing)), 69 | online_span, 70 | ]) 71 | }) 72 | .collect(); 73 | 74 | let list = List::new(list).highlight_style(Style::new().bg(Color::DarkGray)); 75 | let mut list_state = ListState::default().with_selected(self.app.selected_device); 76 | 77 | StatefulWidget::render(list, chunks[0], buf, &mut list_state); 78 | 79 | if let Some(device_index) = self.app.selected_device { 80 | self.app.state.read(|state| { 81 | if let Some(device) = state.get_other_devices().get(device_index) { 82 | let block = Block::default() 83 | .title_top( 84 | Line::from(format!("| {} |", device.config.name)) 85 | .centered() 86 | .bold(), 87 | ) 88 | .borders(Borders::ALL); 89 | 90 | // Device information 91 | let mut device_info = Vec::::new(); 92 | device_info.push(ListItem::new(Line::from(vec![ 93 | Span::raw(" "), 94 | Span::styled("ID", Style::default().bold()), 95 | Span::raw(format!(" : {}", device.config.device_id)), 96 | ]))); 97 | device_info.push(ListItem::new(Line::from(""))); 98 | 99 | let device_folders = state.get_device_folders(&device.config.device_id).len(); 100 | let s_suffix = if device_folders == 1 { "" } else { "s" }; 101 | 102 | device_info.push(ListItem::new(Line::from(vec![ 103 | Span::raw(" "), 104 | Span::styled("Sharing", Style::default().bold()), 105 | Span::raw(" : "), 106 | Span::styled(format!("{}", device_folders), Style::default().bold()), 107 | Span::raw(format!(" Folder{}", s_suffix)), 108 | ]))); 109 | 110 | for i in 0..device_folders { 111 | if let Some(folder) = 112 | state.get_device_folders(&device.config.device_id).get(i) 113 | { 114 | let ident = if i < device_folders - 1 { 115 | "├─" 116 | } else { 117 | "└─" 118 | }; 119 | device_info.push(ListItem::new(Line::from(format!( 120 | " {} {}", 121 | ident, folder.config.label 122 | )))); 123 | } 124 | } 125 | 126 | let inner_area = block.inner(chunks[1]); 127 | block.render(chunks[1], buf); 128 | 129 | let list = List::new(device_info); 130 | Widget::render(list, inner_area, buf); 131 | } 132 | }) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/tui/pages/folders.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout}, 3 | style::{Color, Style, Stylize}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget, Widget}, 6 | }; 7 | 8 | use crate::tui::app::App; 9 | 10 | pub struct FoldersPage<'a> { 11 | app: &'a App, 12 | } 13 | 14 | impl<'a> FoldersPage<'a> { 15 | pub fn new(app: &'a App) -> Self { 16 | Self { app } 17 | } 18 | } 19 | 20 | impl Widget for FoldersPage<'_> { 21 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { 22 | (&self).render(area, buf); 23 | } 24 | } 25 | 26 | impl Widget for &FoldersPage<'_> { 27 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { 28 | let chunks = Layout::default() 29 | .direction(Direction::Horizontal) 30 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 31 | .split(area); 32 | 33 | let list: Vec<_> = self.app.state.read(|state| { 34 | state 35 | .get_folders() 36 | .iter() 37 | .map(|f| (f.config.label.clone(), f.completion)) 38 | .collect() 39 | }); 40 | 41 | let max = list 42 | .iter() 43 | .max_by(|x, y| x.0.char_indices().count().cmp(&y.0.char_indices().count())) 44 | .map_or(0, |f| f.0.char_indices().count()); 45 | 46 | let list: Vec<_> = list 47 | .iter() 48 | .map(|(label, completion)| { 49 | let online_span = if *completion == 100.0 { 50 | Span::styled("[Up to Date]", Style::default().green().bold()) 51 | } else { 52 | Span::styled(format!("[{:.0}%]", completion), Style::default().red()) 53 | }; 54 | 55 | let spacing = (max + 2) - label.char_indices().count(); 56 | Line::from(vec![ 57 | Span::raw(label), 58 | Span::raw(" ".repeat(spacing)), 59 | online_span, 60 | ]) 61 | }) 62 | .collect(); 63 | 64 | let list = List::new(list).highlight_style(Style::new().bg(Color::DarkGray)); 65 | 66 | let mut list_state = ListState::default().with_selected(self.app.selected_folder); 67 | 68 | StatefulWidget::render(list, chunks[0], buf, &mut list_state); 69 | 70 | if let Some(folder_index) = self.app.selected_folder { 71 | self.app.state.read(|state| { 72 | if let Some(folder) = state.get_folders().get(folder_index) { 73 | let block = Block::default() 74 | .title_top( 75 | Line::from(format!("| {} |", folder.config.label)) 76 | .centered() 77 | .bold(), 78 | ) 79 | .borders(Borders::ALL); 80 | // Folder information 81 | let mut folder_info = Vec::::new(); 82 | folder_info.push(ListItem::new(Line::from(vec![ 83 | Span::raw(" "), 84 | Span::styled("ID", Style::default().bold()), 85 | Span::raw(format!(" : {}", folder.config.id)), 86 | ]))); 87 | folder_info.push(ListItem::new(Line::from(vec![ 88 | Span::raw(" "), 89 | Span::styled("Path", Style::default().bold()), 90 | Span::raw(format!(" : {}", folder.config.path)), 91 | ]))); 92 | folder_info.push(ListItem::new(Line::from(""))); 93 | 94 | let folder_sharer = folder.get_sharer_excluded(&state.id).len(); 95 | let s_suffix = if folder_sharer == 1 { "" } else { "s" }; 96 | 97 | folder_info.push(ListItem::new(Line::from(vec![ 98 | Span::raw(" "), 99 | Span::styled("Shared with", Style::default().bold()), 100 | Span::raw(" : "), 101 | Span::styled(format!("{}", folder_sharer), Style::default().bold()), 102 | Span::raw(format!(" Device{}", s_suffix)), 103 | ]))); 104 | 105 | for i in 0..folder_sharer { 106 | if let Some(device_id) = folder.get_sharer_excluded(&state.id).get(i) { 107 | let ident = if i < folder_sharer - 1 { 108 | "├─" 109 | } else { 110 | "└─" 111 | }; 112 | if let Ok(device) = state.get_device(device_id) { 113 | folder_info.push(ListItem::new(Line::from(format!( 114 | " {} {}", 115 | ident, device.config.name 116 | )))); 117 | } 118 | } 119 | } 120 | let inner_area = block.inner(chunks[1]); 121 | block.render(chunks[1], buf); 122 | let list = List::new(folder_info); 123 | Widget::render(list, inner_area, buf); 124 | } 125 | }); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/tui/pages/id.rs: -------------------------------------------------------------------------------- 1 | use qrcode::QrCode; 2 | use ratatui::{ 3 | layout::{Constraint, Flex, Layout, Rect}, 4 | text::Text, 5 | widgets::Widget, 6 | }; 7 | use tui_qrcode::QrCodeWidget; 8 | 9 | pub struct IDPage { 10 | id: String, 11 | } 12 | 13 | impl IDPage { 14 | pub fn new(id: impl Into) -> Self { 15 | Self { id: id.into() } 16 | } 17 | } 18 | 19 | fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { 20 | let [area] = Layout::horizontal([horizontal]) 21 | .flex(Flex::Center) 22 | .areas(area); 23 | let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); 24 | area 25 | } 26 | 27 | impl Widget for IDPage { 28 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 29 | where 30 | Self: Sized, 31 | { 32 | // TODO do error handling - e.g., just don't show QR code 33 | let qr_code = QrCode::new(&self.id).expect("could not generate QR code"); 34 | let widget = QrCodeWidget::new(qr_code); 35 | 36 | let mut qr_area = center( 37 | area, 38 | Constraint::Length(widget.size(area).width), 39 | Constraint::Length(widget.size(area).height), 40 | ); 41 | qr_area.y -= 1; 42 | 43 | let text = Text::raw(self.id); 44 | let [mut text_area] = Layout::horizontal([Constraint::Length(text.width() as u16)]) 45 | .flex(Flex::Center) 46 | .areas(area); 47 | text_area.y = qr_area.y + qr_area.height; 48 | widget.render(qr_area, buf); 49 | text.render(text_area, buf); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tui/pages/pending.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout}, 3 | style::{Color, Style, Stylize}, 4 | text::{Line, Span}, 5 | widgets::{Block, List, ListState, StatefulWidget}, 6 | }; 7 | 8 | use ratatui::widgets::Widget; 9 | 10 | use crate::tui::{app::App, input::Message}; 11 | 12 | pub struct PendingPage<'a> { 13 | app: &'a App, 14 | } 15 | 16 | #[derive(Debug)] 17 | pub struct PendingPageState { 18 | devices_focused: bool, 19 | focused_device: Option, 20 | focused_folder: Option, 21 | } 22 | 23 | impl Default for PendingPageState { 24 | fn default() -> Self { 25 | Self { 26 | devices_focused: true, 27 | focused_device: Default::default(), 28 | focused_folder: Default::default(), 29 | } 30 | } 31 | } 32 | 33 | impl PendingPageState { 34 | pub fn device_selected(&self) -> Option { 35 | if self.devices_focused { 36 | self.focused_device 37 | } else { 38 | None 39 | } 40 | } 41 | 42 | pub fn folder_selected(&self) -> Option { 43 | if !self.devices_focused { 44 | self.focused_folder 45 | } else { 46 | None 47 | } 48 | } 49 | 50 | pub fn update(&mut self, msg: &Message, total_devices: usize, total_folders: usize) { 51 | match msg { 52 | Message::Left | Message::Right | Message::FocusNext | Message::FocusBack => { 53 | self.devices_focused = !self.devices_focused; 54 | } 55 | _ => {} 56 | } 57 | 58 | // Focus nothing if we have no pending 59 | if total_devices == 0 && total_folders == 0 { 60 | self.focused_device = None; 61 | self.focused_folder = None; 62 | return; 63 | } 64 | // Force focus if one is 0 65 | if total_devices == 0 { 66 | self.devices_focused = false; 67 | } 68 | if total_folders == 0 { 69 | self.devices_focused = true; 70 | } 71 | 72 | match msg { 73 | Message::Down => { 74 | if self.devices_focused { 75 | if let Some(i) = self.focused_device { 76 | self.focused_device = Some((i + 1) % total_devices); 77 | } else { 78 | self.focused_device = Some(0); 79 | self.focused_folder = Some(self.focused_folder.unwrap_or(0)) 80 | } 81 | } else if let Some(i) = self.focused_folder { 82 | self.focused_folder = Some((i + 1) % total_folders); 83 | } else { 84 | self.focused_folder = Some(0); 85 | self.focused_device = Some(self.focused_device.unwrap_or(0)) 86 | } 87 | } 88 | Message::Up => { 89 | if self.devices_focused { 90 | if let Some(i) = self.focused_device { 91 | self.focused_device = Some((i + total_devices - 1) % total_devices); 92 | } else { 93 | self.focused_device = Some(total_devices - 1); 94 | self.focused_folder = 95 | Some(self.focused_folder.unwrap_or(total_folders - 1)); 96 | } 97 | } else if let Some(i) = self.focused_folder { 98 | self.focused_folder = Some((i + total_folders - 1) % total_folders); 99 | } else { 100 | self.focused_folder = Some(total_folders - 1); 101 | self.focused_device = Some(self.focused_device.unwrap_or(total_devices - 1)); 102 | } 103 | } 104 | _ => {} 105 | } 106 | } 107 | } 108 | 109 | impl<'a> PendingPage<'a> { 110 | pub fn new(app: &'a App) -> Self { 111 | Self { app } 112 | } 113 | } 114 | 115 | impl Widget for PendingPage<'_> { 116 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 117 | where 118 | Self: Sized, 119 | { 120 | (&self).render(area, buf); 121 | } 122 | } 123 | 124 | impl Widget for &PendingPage<'_> { 125 | fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 126 | where 127 | Self: Sized, 128 | { 129 | let chunks = Layout::default() 130 | .direction(Direction::Horizontal) 131 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 132 | .split(area); 133 | 134 | // Devices 135 | let devices_list: Vec<_> = self.app.state.read(|state| { 136 | state 137 | .get_pending_devices() 138 | .iter() 139 | .map(|d| { 140 | d.get_name() 141 | .clone() 142 | .unwrap_or("".to_string()) 143 | .clone() 144 | }) 145 | .collect() 146 | }); 147 | 148 | let devices_list = List::new(devices_list) 149 | .block(Block::default().title(Span::styled("Pending Devices", Style::new().bold()))) 150 | .highlight_style(Style::new().bg(Color::DarkGray)); 151 | 152 | let mut devices_list_state = 153 | ListState::default().with_selected(self.app.pending_state.device_selected()); 154 | 155 | StatefulWidget::render(devices_list, chunks[0], buf, &mut devices_list_state); 156 | 157 | // Folders 158 | let folders_list: Vec<_> = self.app.state.read(|state| { 159 | state 160 | .get_pending_folders() 161 | .iter() 162 | .map(|(device_id, folder)| { 163 | let device_name = match state.get_device(device_id) { 164 | Ok(d) => &d.config.name, 165 | Err(_) => "", 166 | }; 167 | 168 | let text = match state.get_folder(folder.get_id()) { 169 | Ok(_) => "[Share]", 170 | Err(_) => "[Add]", 171 | }; 172 | let label = folder 173 | .get_label() 174 | .clone() 175 | .unwrap_or("".to_string()); 176 | Line::from(format!( 177 | "{} \"{}\" ({}) - {}", 178 | text, 179 | label, 180 | folder.get_id(), 181 | device_name 182 | )) 183 | }) 184 | .collect() 185 | }); 186 | 187 | let folders_list = List::new(folders_list) 188 | .block(Block::default().title(Span::styled("Pending Folders", Style::new().bold()))) 189 | .highlight_style(Style::new().bg(Color::DarkGray)); 190 | 191 | let mut folders_list_state = 192 | ListState::default().with_selected(self.app.pending_state.folder_selected()); 193 | 194 | StatefulWidget::render(folders_list, chunks[1], buf, &mut folders_list_state); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/tui/popup.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | sync::{Arc, Mutex}, 4 | }; 5 | 6 | use ratatui::{ 7 | Frame, 8 | layout::{Constraint, Direction, Layout, Margin, Position, Rect}, 9 | style::{Color, Style, Stylize}, 10 | text::{Line, Span}, 11 | widgets::{Block, Borders, Clear, List, ListState, Paragraph, StatefulWidget, Widget}, 12 | }; 13 | use strum::IntoEnumIterator; 14 | use syncthing_rs::types::config::{ 15 | DeviceConfiguration, FolderConfiguration, FolderDeviceConfiguration, NewFolderConfiguration, 16 | }; 17 | 18 | use super::{app::CurrentMode, input::Message}; 19 | 20 | use crate::tui::state::State; 21 | 22 | pub trait Popup: std::fmt::Debug { 23 | /// Updates the state of the popup. If Some(Quit) is returned, the popup gets destroyed 24 | fn update(&mut self, msg: Message, state: State) -> Option; 25 | fn render(&self, frame: &mut Frame, state: State); 26 | fn create_popup_block(&self, title: String) -> Block { 27 | Block::default() 28 | .title_top(Line::from(format!("| {} |", title)).centered().bold()) 29 | .borders(Borders::ALL) 30 | } 31 | } 32 | 33 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 34 | // Adapted from https://ratatui.rs/tutorials/json-editor/ui/ 35 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 36 | // Cut the given rectangle into three vertical pieces 37 | let popup_layout = Layout::default() 38 | .direction(Direction::Vertical) 39 | .constraints([ 40 | Constraint::Percentage((100 - percent_y) / 2), 41 | Constraint::Percentage(percent_y), 42 | Constraint::Percentage((100 - percent_y) / 2), 43 | ]) 44 | .split(r); 45 | 46 | // Then cut the middle vertical piece into three width-wise pieces 47 | Layout::default() 48 | .direction(Direction::Horizontal) 49 | .constraints([ 50 | Constraint::Percentage((100 - percent_x) / 2), 51 | Constraint::Percentage(percent_x), 52 | Constraint::Percentage((100 - percent_x) / 2), 53 | ]) 54 | .split(popup_layout[1])[1] // Return the middle chunk 55 | } 56 | 57 | #[derive(Default, Debug)] 58 | struct TextBox { 59 | text: String, 60 | index: usize, 61 | } 62 | 63 | // This impl is heavily inspired (copied) by https://ratatui.rs/examples/apps/user_input/ 64 | impl TextBox { 65 | fn move_cursor_left(&mut self) { 66 | let cursor_moved_left = self.index.saturating_sub(1); 67 | self.index = self.clamp_cursor(cursor_moved_left); 68 | } 69 | 70 | fn move_cursor_right(&mut self) { 71 | let cursor_moved_right = self.index.saturating_add(1); 72 | self.index = self.clamp_cursor(cursor_moved_right); 73 | } 74 | 75 | pub fn enter_char(&mut self, new_char: char) { 76 | let index = self.byte_index(); 77 | self.text.insert(index, new_char); 78 | self.move_cursor_right(); 79 | } 80 | 81 | /// Returns the byte index based on the character position. 82 | /// 83 | /// Since each character in a string can be contain multiple bytes, it's necessary to calculate 84 | /// the byte index based on the index of the character. 85 | fn byte_index(&self) -> usize { 86 | self.text 87 | .char_indices() 88 | .map(|(i, _)| i) 89 | .nth(self.index) 90 | .unwrap_or(self.text.len()) 91 | } 92 | 93 | pub fn delete_char(&mut self) { 94 | let is_not_cursor_leftmost = self.index != 0; 95 | if is_not_cursor_leftmost { 96 | // Method "remove" is not used on the saved text for deleting the selected char. 97 | // Reason: Using remove on String works on bytes instead of the chars. 98 | // Using remove would require special care because of char boundaries. 99 | 100 | let current_index = self.index; 101 | let from_left_to_current_index = current_index - 1; 102 | 103 | // Getting all characters before the selected character. 104 | let before_char_to_delete = self.text.chars().take(from_left_to_current_index); 105 | // Getting all characters after selected character. 106 | let after_char_to_delete = self.text.chars().skip(current_index); 107 | 108 | // Put all characters together except the selected one. 109 | // By leaving the selected one out, it is forgotten and therefore deleted. 110 | self.text = before_char_to_delete.chain(after_char_to_delete).collect(); 111 | self.move_cursor_left(); 112 | } 113 | } 114 | 115 | fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { 116 | new_cursor_pos.clamp(0, self.text.chars().count()) 117 | } 118 | 119 | fn as_paragraph<'a>(&'a self, title: &'a str, style: Style) -> Paragraph<'a> { 120 | Paragraph::new(self.text.as_str()) 121 | .style(style) 122 | .block(Block::bordered().title(title)) 123 | } 124 | } 125 | 126 | impl From for TextBox { 127 | fn from(value: String) -> Self { 128 | let index = value.chars().count(); 129 | Self { text: value, index } 130 | } 131 | } 132 | 133 | #[derive(Debug)] 134 | pub struct NewFolderPopup { 135 | id_input: TextBox, 136 | label_input: TextBox, 137 | path_input: TextBox, 138 | focus: NewFolderFocus, 139 | mode: Arc>, 140 | state: State, 141 | selected_devices: HashSet, 142 | } 143 | 144 | #[derive(Default, Debug, PartialEq, Eq)] 145 | enum NewFolderFocus { 146 | #[default] 147 | Path, 148 | Label, 149 | Id, 150 | Device(usize), 151 | SubmitButton, 152 | } 153 | 154 | impl NewFolderFocus { 155 | fn is_input(&self) -> bool { 156 | !matches!(self, Self::Device(_) | Self::SubmitButton) 157 | } 158 | } 159 | 160 | impl NewFolderPopup { 161 | pub fn new(mode: Arc>, state: State) -> Self { 162 | Self { 163 | id_input: TextBox::default(), 164 | label_input: TextBox::default(), 165 | path_input: TextBox::default(), 166 | focus: NewFolderFocus::default(), 167 | mode, 168 | state, 169 | selected_devices: HashSet::new(), 170 | } 171 | } 172 | 173 | /// This can be used if accepting a folder from another device 174 | pub fn new_from_device( 175 | folder_label: impl Into, 176 | folder_id: impl Into, 177 | device_id: impl Into, 178 | mode: Arc>, 179 | state: State, 180 | ) -> Self { 181 | let mut selected_devices = HashSet::new(); 182 | selected_devices.insert(device_id.into()); 183 | Self { 184 | id_input: folder_id.into().into(), 185 | label_input: folder_label.into().into(), 186 | path_input: TextBox::default(), 187 | focus: NewFolderFocus::default(), 188 | mode, 189 | state, 190 | selected_devices, 191 | } 192 | } 193 | 194 | fn select_next(&mut self) { 195 | let devices_len = self.state.read(|state| state.get_other_devices().len()); 196 | match self.focus { 197 | NewFolderFocus::Path => self.focus = NewFolderFocus::Label, 198 | NewFolderFocus::Label => self.focus = NewFolderFocus::Id, 199 | NewFolderFocus::Id => { 200 | if devices_len > 0 { 201 | self.focus = NewFolderFocus::Device(0); 202 | } else { 203 | self.focus = NewFolderFocus::SubmitButton; 204 | } 205 | } 206 | NewFolderFocus::Device(i) => { 207 | if i + 1 < devices_len { 208 | self.focus = NewFolderFocus::Device(i + 1); 209 | } else { 210 | self.focus = NewFolderFocus::SubmitButton; 211 | } 212 | } 213 | _ => {} 214 | }; 215 | } 216 | 217 | fn select_prev(&mut self) { 218 | match self.focus { 219 | NewFolderFocus::Id => self.focus = NewFolderFocus::Label, 220 | NewFolderFocus::Label => self.focus = NewFolderFocus::Path, 221 | NewFolderFocus::Device(i) => { 222 | if i == 0 { 223 | self.focus = NewFolderFocus::Id; 224 | } else { 225 | self.focus = NewFolderFocus::Device(i - 1); 226 | } 227 | } 228 | NewFolderFocus::SubmitButton => { 229 | let devices_len = self.state.read(|state| state.get_other_devices().len()); 230 | if devices_len > 0 { 231 | self.focus = NewFolderFocus::Device(devices_len - 1); 232 | } else { 233 | self.focus = NewFolderFocus::Id; 234 | } 235 | } 236 | _ => {} 237 | }; 238 | } 239 | fn submit(&mut self) -> Option { 240 | *self.mode.lock().unwrap() = CurrentMode::Normal; 241 | let devices: Vec = self 242 | .selected_devices 243 | .iter() 244 | .map(|d| FolderDeviceConfiguration { 245 | device_id: d.to_string(), 246 | introduced_by: "".to_string(), 247 | encryption_password: "".to_string(), 248 | }) 249 | .collect(); 250 | Some(Message::NewFolder(Box::new( 251 | NewFolderConfiguration::new(self.id_input.text.clone(), self.path_input.text.clone()) 252 | .label(self.label_input.text.clone()) 253 | .devices(devices), 254 | ))) 255 | } 256 | } 257 | 258 | impl Popup for NewFolderPopup { 259 | fn update(&mut self, msg: Message, _: State) -> Option { 260 | let input = match self.focus { 261 | NewFolderFocus::Id => Some(&mut self.id_input), 262 | NewFolderFocus::Label => Some(&mut self.label_input), 263 | NewFolderFocus::Path => Some(&mut self.path_input), 264 | _ => None, 265 | }; 266 | 267 | if let Some(input) = input { 268 | match msg { 269 | Message::Character(c) => input.enter_char(c), 270 | Message::Backspace => input.delete_char(), 271 | Message::Left => input.move_cursor_left(), 272 | Message::Right => input.move_cursor_right(), 273 | _ => {} 274 | } 275 | } 276 | 277 | match msg { 278 | Message::Quit => return Some(Message::Quit), 279 | Message::FocusNext | Message::Down => self.select_next(), 280 | Message::FocusBack | Message::Up => self.select_prev(), 281 | Message::Left => { 282 | if let NewFolderFocus::Device(i) = self.focus { 283 | if i > 0 { 284 | self.select_prev(); 285 | } 286 | } 287 | } 288 | Message::Right => { 289 | if let NewFolderFocus::Device(_) = self.focus { 290 | self.select_next(); 291 | } 292 | } 293 | Message::Select => match self.focus { 294 | NewFolderFocus::SubmitButton => return self.submit(), 295 | NewFolderFocus::Device(i) => { 296 | if let Some(device_id) = self.state.read(|state| { 297 | state 298 | .get_other_devices() 299 | .get(i) 300 | .map(|d| d.config.device_id.clone()) 301 | }) { 302 | if self.selected_devices.contains(&device_id) { 303 | self.selected_devices.remove(&device_id); 304 | } else { 305 | self.selected_devices.insert(device_id); 306 | } 307 | } 308 | } 309 | _ => self.select_next(), 310 | }, 311 | Message::Submit => return self.submit(), 312 | _ => {} 313 | }; 314 | None 315 | } 316 | 317 | fn render(&self, frame: &mut Frame, _state: State) { 318 | let block = self.create_popup_block("New Folder".to_string()); 319 | let vertical = Layout::vertical([ 320 | Constraint::Length(1), 321 | Constraint::Length(3), 322 | Constraint::Length(3), 323 | Constraint::Length(3), 324 | Constraint::Length(2), 325 | Constraint::Length(1), 326 | ]); 327 | 328 | let area = centered_rect(50, 50, frame.area()); 329 | Clear.render(area, frame.buffer_mut()); 330 | let [_, path_area, label_area, id_area, devices_area, submit_area] = 331 | vertical.areas(area.inner(Margin { 332 | horizontal: 1, 333 | vertical: 1, 334 | })); 335 | 336 | let path_input = Paragraph::new(self.path_input.text.as_str()) 337 | .style(match self.focus { 338 | NewFolderFocus::Path => Style::default().fg(Color::Blue), 339 | _ => Style::default(), 340 | }) 341 | .block(Block::bordered().title("Path")); 342 | 343 | let label_input = Paragraph::new(self.label_input.text.as_str()) 344 | .style(match self.focus { 345 | NewFolderFocus::Label => Style::default().fg(Color::Blue), 346 | _ => Style::default(), 347 | }) 348 | .block(Block::bordered().title("Label")); 349 | 350 | let id_input = Paragraph::new(self.id_input.text.as_str()) 351 | .style(match self.focus { 352 | // TODO check if valid (unique) and if not, make red 353 | NewFolderFocus::Id => Style::default().fg(Color::Blue), 354 | _ => Style::default(), 355 | }) 356 | .block(Block::bordered().title("ID")); 357 | 358 | let devices_line: Line = self.state.read(|state| { 359 | state 360 | .get_other_devices() 361 | .iter() 362 | .enumerate() 363 | .map(|(i, device)| { 364 | let style = if self.focus == NewFolderFocus::Device(i) { 365 | Style::new().fg(Color::Blue) 366 | } else { 367 | Style::new() 368 | }; 369 | let selected_char = if self.selected_devices.contains(&device.config.device_id) 370 | { 371 | "✓" 372 | } else { 373 | "☐" 374 | }; 375 | Span::styled( 376 | format!("| {} {} ", selected_char, device.config.name.clone()), 377 | style, 378 | ) 379 | }) 380 | .collect() 381 | }); 382 | 383 | let devices_select = Paragraph::new(devices_line); 384 | 385 | let submit = Paragraph::new(Span::styled( 386 | "Submit", 387 | match self.focus { 388 | NewFolderFocus::SubmitButton => Style::default().bg(Color::DarkGray), 389 | _ => Style::default(), 390 | }, 391 | )); 392 | 393 | // Show cursors 394 | if *self.mode.lock().unwrap() == CurrentMode::Insert { 395 | let (cursor_area, index) = match self.focus { 396 | NewFolderFocus::Path => (path_area, self.path_input.index), 397 | NewFolderFocus::Id => (id_area, self.id_input.index), 398 | NewFolderFocus::Label => (label_area, self.label_input.index), 399 | _ => (area, 0), 400 | }; 401 | if self.focus.is_input() { 402 | frame.set_cursor_position(Position::new( 403 | cursor_area.x + index as u16 + 1, 404 | cursor_area.y + 1, 405 | )); 406 | } 407 | } 408 | 409 | frame.render_widget(block, area); 410 | frame.render_widget(path_input, path_area); 411 | frame.render_widget(label_input, label_area); 412 | frame.render_widget(id_input, id_area); 413 | frame.render_widget(devices_select, devices_area); 414 | frame.render_widget(submit, submit_area); 415 | } 416 | } 417 | 418 | #[derive(Debug)] 419 | pub struct PendingDevicePopup { 420 | device_id: String, 421 | focus: PendingFocus, 422 | } 423 | 424 | #[derive(Debug, Default, PartialEq, Eq)] 425 | enum PendingFocus { 426 | #[default] 427 | Accept, 428 | Ignore, 429 | Dismiss, 430 | } 431 | 432 | impl PendingFocus { 433 | fn next(&mut self) { 434 | match self { 435 | PendingFocus::Accept => *self = PendingFocus::Ignore, 436 | PendingFocus::Ignore => *self = PendingFocus::Dismiss, 437 | PendingFocus::Dismiss => {} 438 | } 439 | } 440 | 441 | fn prev(&mut self) { 442 | match self { 443 | PendingFocus::Accept => {} 444 | PendingFocus::Ignore => *self = PendingFocus::Accept, 445 | PendingFocus::Dismiss => *self = PendingFocus::Ignore, 446 | } 447 | } 448 | } 449 | 450 | impl PendingDevicePopup { 451 | pub fn new(device_id: String) -> Self { 452 | Self { 453 | device_id, 454 | focus: PendingFocus::default(), 455 | } 456 | } 457 | 458 | fn submit(&self) -> Option { 459 | match self.focus { 460 | PendingFocus::Accept => Some(Message::AcceptDevice(self.device_id.clone())), 461 | PendingFocus::Ignore => Some(Message::IgnoreDevice(self.device_id.clone())), 462 | PendingFocus::Dismiss => Some(Message::DismissDevice(self.device_id.clone())), 463 | } 464 | } 465 | } 466 | 467 | impl Popup for PendingDevicePopup { 468 | fn update(&mut self, msg: Message, _state: State) -> Option { 469 | match msg { 470 | Message::Quit => return Some(Message::Quit), 471 | Message::FocusNext | Message::Right => self.focus.next(), 472 | Message::FocusBack | Message::Left => self.focus.prev(), 473 | Message::Select | Message::Submit => return self.submit(), 474 | _ => {} 475 | }; 476 | None 477 | } 478 | 479 | fn render(&self, frame: &mut Frame, _state: State) { 480 | let block = self.create_popup_block("Pending Device".to_string()); 481 | let vertical = Layout::vertical([Constraint::Length(2), Constraint::Length(1)]); 482 | 483 | let area = centered_rect(50, 50, frame.area()); 484 | Clear.render(area, frame.buffer_mut()); 485 | let [message_area, buttons_area] = vertical.areas(area.inner(Margin { 486 | horizontal: 1, 487 | vertical: 1, 488 | })); 489 | // TODO use state to load device name 490 | let line = Line::from(format!("Device {} wants to connect.", self.device_id)); 491 | 492 | let selected_style = Style::new().bg(Color::DarkGray); 493 | 494 | let buttons_line: Line = vec![ 495 | Span::styled( 496 | "Accept", 497 | if matches!(self.focus, PendingFocus::Accept) { 498 | selected_style 499 | } else { 500 | Style::new() 501 | }, 502 | ), 503 | Span::raw(" "), 504 | Span::styled( 505 | "Ignore", 506 | if matches!(self.focus, PendingFocus::Ignore) { 507 | selected_style 508 | } else { 509 | Style::new() 510 | }, 511 | ), 512 | Span::raw(" "), 513 | Span::styled( 514 | "Dismiss", 515 | if matches!(self.focus, PendingFocus::Dismiss) { 516 | selected_style 517 | } else { 518 | Style::new() 519 | }, 520 | ), 521 | ] 522 | .into(); 523 | 524 | frame.render_widget(block, area); 525 | frame.render_widget(line, message_area); 526 | frame.render_widget(buttons_line, buttons_area); 527 | } 528 | } 529 | 530 | /// Popup to share an already existing folder with a new device 531 | #[derive(Debug)] 532 | pub struct PendingShareFolderPopup { 533 | folder_id: String, 534 | device_id: String, 535 | focus: PendingFocus, 536 | } 537 | 538 | impl PendingShareFolderPopup { 539 | pub fn new(folder_id: String, device_id: String) -> Self { 540 | Self { 541 | folder_id, 542 | device_id, 543 | focus: PendingFocus::default(), 544 | } 545 | } 546 | 547 | fn submit(&self) -> Option { 548 | match self.focus { 549 | PendingFocus::Accept => Some(Message::ShareFolder { 550 | folder_id: self.folder_id.clone(), 551 | device_id: self.device_id.clone(), 552 | }), 553 | PendingFocus::Ignore => todo!(), 554 | PendingFocus::Dismiss => Some(Message::DismissFolder { 555 | folder_id: self.folder_id.clone(), 556 | device_id: self.device_id.clone(), 557 | }), 558 | } 559 | } 560 | } 561 | 562 | impl Popup for PendingShareFolderPopup { 563 | fn update(&mut self, msg: Message, _state: State) -> Option { 564 | match msg { 565 | Message::Quit => return Some(Message::Quit), 566 | Message::FocusNext | Message::Right => self.focus.next(), 567 | Message::FocusBack | Message::Left => self.focus.prev(), 568 | Message::Select | Message::Submit => return self.submit(), 569 | _ => {} 570 | }; 571 | None 572 | } 573 | 574 | fn render(&self, frame: &mut Frame, state: State) { 575 | let block = self.create_popup_block("Share Folder".to_string()); 576 | let vertical = Layout::vertical([Constraint::Length(2), Constraint::Length(1)]); 577 | 578 | let area = centered_rect(50, 50, frame.area()); 579 | Clear.render(area, frame.buffer_mut()); 580 | let [message_area, buttons_area] = vertical.areas(area.inner(Margin { 581 | horizontal: 1, 582 | vertical: 1, 583 | })); 584 | let line = state.read(|state| { 585 | // TODO maybe show device label too 586 | let folder = state 587 | .get_folder(&self.folder_id) 588 | .expect("folder to be shared does not exist on this device"); 589 | Line::from(format!( 590 | "Share {} ({}) with {}", 591 | folder.config.label, folder.config.id, self.device_id 592 | )) 593 | }); 594 | let selected_style = Style::new().bg(Color::DarkGray); 595 | 596 | let buttons_line: Line = vec![ 597 | Span::styled( 598 | "Share", 599 | if matches!(self.focus, PendingFocus::Accept) { 600 | selected_style 601 | } else { 602 | Style::new() 603 | }, 604 | ), 605 | Span::raw(" "), 606 | Span::styled( 607 | "Ignore", 608 | if matches!(self.focus, PendingFocus::Ignore) { 609 | selected_style 610 | } else { 611 | Style::new() 612 | }, 613 | ), 614 | Span::raw(" "), 615 | Span::styled( 616 | "Dismiss", 617 | if matches!(self.focus, PendingFocus::Dismiss) { 618 | selected_style 619 | } else { 620 | Style::new() 621 | }, 622 | ), 623 | ] 624 | .into(); 625 | 626 | frame.render_widget(block, area); 627 | frame.render_widget(line, message_area); 628 | frame.render_widget(buttons_line, buttons_area); 629 | } 630 | } 631 | 632 | /// Popup representing a folder 633 | #[derive(Debug)] 634 | pub struct FolderPopup { 635 | folder: FolderConfiguration, 636 | id: TextBox, 637 | label: TextBox, 638 | path: TextBox, 639 | devices: Vec, 640 | selected_device: Option, 641 | focus: FolderFocus, 642 | general_focus: FolderGeneralFocus, 643 | mode: Arc>, 644 | } 645 | 646 | #[derive(Debug, Default, strum::EnumIter, PartialEq, Eq)] 647 | enum FolderFocus { 648 | #[default] 649 | General, 650 | Sharing, 651 | } 652 | 653 | impl TryFrom for FolderFocus { 654 | type Error = (); 655 | 656 | fn try_from(v: u32) -> Result { 657 | if let Some((_, screen)) = FolderFocus::iter() 658 | .enumerate() 659 | .find(|(i, _)| i + 1 == (v as usize)) 660 | { 661 | Ok(screen) 662 | } else { 663 | Err(()) 664 | } 665 | } 666 | } 667 | 668 | #[derive(Debug, Default, PartialEq, Eq)] 669 | enum FolderGeneralFocus { 670 | #[default] 671 | Label, 672 | ID, 673 | Path, 674 | Submit, 675 | Remove, 676 | } 677 | 678 | impl FolderGeneralFocus { 679 | fn next(&mut self) { 680 | match self { 681 | FolderGeneralFocus::Label => *self = FolderGeneralFocus::ID, 682 | FolderGeneralFocus::ID => *self = FolderGeneralFocus::Path, 683 | FolderGeneralFocus::Path => *self = FolderGeneralFocus::Submit, 684 | FolderGeneralFocus::Submit => *self = FolderGeneralFocus::Remove, 685 | FolderGeneralFocus::Remove => {} 686 | } 687 | } 688 | 689 | fn prev(&mut self) { 690 | match self { 691 | FolderGeneralFocus::Label => {} 692 | FolderGeneralFocus::ID => *self = FolderGeneralFocus::Label, 693 | FolderGeneralFocus::Path => *self = FolderGeneralFocus::ID, 694 | FolderGeneralFocus::Submit => *self = FolderGeneralFocus::Path, 695 | FolderGeneralFocus::Remove => *self = FolderGeneralFocus::Submit, 696 | } 697 | } 698 | } 699 | 700 | impl FolderPopup { 701 | pub fn new(folder: FolderConfiguration, mode: Arc>) -> Self { 702 | let devices = folder.devices.to_vec(); 703 | Self { 704 | folder: folder.clone(), 705 | id: folder.id.into(), 706 | label: folder.label.into(), 707 | path: folder.path.into(), 708 | devices, 709 | selected_device: None, 710 | focus: FolderFocus::default(), 711 | general_focus: FolderGeneralFocus::default(), 712 | mode, 713 | } 714 | } 715 | 716 | fn submit(&mut self) -> Option { 717 | if self.folder.id != self.id.text { 718 | // TODO this is currently unsafe as a potentially different folder 719 | // is edited, so don't do anything 720 | return None; 721 | } 722 | 723 | self.folder.path = self.path.text.clone(); 724 | self.folder.label = self.label.text.clone(); 725 | 726 | self.folder.devices = self.devices.clone(); 727 | 728 | Some(Message::EditFolder(Box::new(self.folder.clone()))) 729 | } 730 | 731 | fn remove(&self) -> Option { 732 | Some(Message::RemoveFolder(self.folder.id.clone())) 733 | } 734 | } 735 | 736 | impl Popup for FolderPopup { 737 | fn update(&mut self, msg: Message, state: State) -> Option { 738 | match msg { 739 | Message::Quit => return Some(Message::Quit), 740 | Message::Number(i) => { 741 | if let Ok(focus) = FolderFocus::try_from(i) { 742 | self.focus = focus; 743 | } 744 | } 745 | _ => {} 746 | } 747 | 748 | match self.focus { 749 | FolderFocus::General => { 750 | let input = match self.general_focus { 751 | FolderGeneralFocus::Label => Some(&mut self.label), 752 | FolderGeneralFocus::ID => Some(&mut self.id), 753 | FolderGeneralFocus::Path => Some(&mut self.path), 754 | _ => None, 755 | }; 756 | 757 | match msg { 758 | Message::FocusNext | Message::Down => self.general_focus.next(), 759 | Message::FocusBack | Message::Up => self.general_focus.prev(), 760 | Message::Character(c) => { 761 | if let Some(input) = input { 762 | input.enter_char(c); 763 | } 764 | } 765 | Message::Backspace => { 766 | if let Some(input) = input { 767 | input.delete_char(); 768 | } 769 | } 770 | Message::Left => { 771 | if let Some(input) = input { 772 | input.move_cursor_left(); 773 | } else if matches!(self.general_focus, FolderGeneralFocus::Remove) { 774 | self.general_focus.prev(); 775 | } 776 | } 777 | Message::Right => { 778 | if let Some(input) = input { 779 | input.move_cursor_right(); 780 | } else if matches!(self.general_focus, FolderGeneralFocus::Submit) { 781 | self.general_focus.next(); 782 | } 783 | } 784 | Message::Select => match self.general_focus { 785 | FolderGeneralFocus::Submit => return self.submit(), 786 | FolderGeneralFocus::Remove => return self.remove(), 787 | _ => {} 788 | }, 789 | _ => {} 790 | } 791 | } 792 | FolderFocus::Sharing => { 793 | let len = state.read(|state| state.get_other_devices().len()); 794 | match msg { 795 | Message::FocusNext | Message::Down => { 796 | if len == 0 { 797 | return None; 798 | } 799 | if let Some(selected_device) = self.selected_device { 800 | self.selected_device = Some((selected_device + 1) % len); 801 | } else { 802 | self.selected_device = Some(0) 803 | } 804 | } 805 | Message::FocusBack | Message::Up => { 806 | if len == 0 { 807 | return None; 808 | } 809 | if let Some(selected_device) = self.selected_device { 810 | self.selected_device = Some((selected_device + len - 1) % len); 811 | } else { 812 | self.selected_device = Some(len - 1) 813 | } 814 | } 815 | Message::Select => { 816 | if let Some(selected_device) = self.selected_device { 817 | if let Some(selected_device_id) = state.read(|state| { 818 | state 819 | .get_other_devices() 820 | .get(selected_device) 821 | .map(|device| device.config.device_id.clone()) 822 | }) { 823 | match self 824 | .devices 825 | .iter() 826 | .position(|d| d.device_id == selected_device_id) 827 | { 828 | Some(index) => { 829 | self.devices.remove(index); 830 | } 831 | // TODO support passwords 832 | None => self.devices.push(FolderDeviceConfiguration { 833 | device_id: selected_device_id, 834 | introduced_by: "".to_string(), 835 | encryption_password: "".to_string(), 836 | }), 837 | } 838 | } 839 | } 840 | } 841 | _ => {} 842 | } 843 | } 844 | } 845 | 846 | None 847 | } 848 | 849 | fn render(&self, frame: &mut Frame, state: State) { 850 | let block = self.create_popup_block(format!("Edit Folder ({})", self.folder.label)); 851 | 852 | let mut bottom_string = FolderFocus::iter() 853 | .enumerate() 854 | .map(|(i, focus)| { 855 | Span::styled( 856 | format!("| ({}) {:?} ", i + 1, focus), 857 | if focus == self.focus { 858 | Style::default().bold() 859 | } else { 860 | Style::default() 861 | }, 862 | ) 863 | }) 864 | .collect::>(); 865 | bottom_string.push("|".into()); 866 | let block = block.title_bottom(bottom_string); 867 | 868 | let area = centered_rect(75, 75, frame.area()); 869 | Clear.render(area, frame.buffer_mut()); 870 | 871 | match self.focus { 872 | FolderFocus::General => { 873 | let vertical = Layout::vertical([ 874 | Constraint::Length(3), 875 | Constraint::Length(3), 876 | Constraint::Length(3), 877 | Constraint::Length(1), 878 | ]); 879 | let [label_area, id_area, path_area, buttons_area] = 880 | vertical.areas(area.inner(Margin { 881 | horizontal: 2, 882 | vertical: 2, 883 | })); 884 | 885 | let focused_style = Style::default().fg(Color::Blue); 886 | 887 | let label_paragraph = self.label.as_paragraph( 888 | "Label", 889 | if self.general_focus == FolderGeneralFocus::Label { 890 | focused_style 891 | } else { 892 | Style::default() 893 | }, 894 | ); 895 | 896 | let id_paragraph = self.id.as_paragraph( 897 | "ID", 898 | if self.general_focus == FolderGeneralFocus::ID { 899 | focused_style 900 | } else { 901 | Style::default() 902 | }, 903 | ); 904 | 905 | let path_paragraph = self.path.as_paragraph( 906 | "Path", 907 | if self.general_focus == FolderGeneralFocus::Path { 908 | focused_style 909 | } else { 910 | Style::default() 911 | }, 912 | ); 913 | 914 | let submit = Span::styled( 915 | "Submit", 916 | match self.general_focus { 917 | FolderGeneralFocus::Submit => Style::default().bg(Color::DarkGray), 918 | _ => Style::default(), 919 | }, 920 | ); 921 | let remove = Span::styled( 922 | "Remove", 923 | match self.general_focus { 924 | FolderGeneralFocus::Remove => Style::default().bg(Color::DarkGray), 925 | _ => Style::default(), 926 | }, 927 | ); 928 | 929 | let buttons: Line = vec![submit, Span::raw(" "), remove].into(); 930 | 931 | // Show cursor 932 | if *self.mode.lock().unwrap() == CurrentMode::Insert { 933 | let (cursor_area, index) = match self.general_focus { 934 | FolderGeneralFocus::Label => (label_area, self.label.index), 935 | FolderGeneralFocus::ID => (id_area, self.id.index), 936 | FolderGeneralFocus::Path => (path_area, self.path.index), 937 | _ => (area, 0), 938 | }; 939 | if self.general_focus != FolderGeneralFocus::Submit 940 | && self.general_focus != FolderGeneralFocus::Remove 941 | { 942 | frame.set_cursor_position(Position::new( 943 | cursor_area.x + index as u16 + 1, 944 | cursor_area.y + 1, 945 | )); 946 | } 947 | } 948 | 949 | frame.render_widget(label_paragraph, label_area); 950 | frame.render_widget(id_paragraph, id_area); 951 | frame.render_widget(path_paragraph, path_area); 952 | frame.render_widget(buttons, buttons_area); 953 | } 954 | FolderFocus::Sharing => state.read(|state| { 955 | let lines: Vec<_> = state 956 | .get_other_devices() 957 | .iter() 958 | .map(|device| { 959 | let selected_char = if self 960 | .devices 961 | .iter() 962 | .any(|d| d.device_id == device.config.device_id) 963 | { 964 | "✓" 965 | } else { 966 | "☐" 967 | }; 968 | Span::raw(format!("{} {}", selected_char, device.config.name)) 969 | }) 970 | .collect(); 971 | 972 | let list = List::new(lines).highlight_style(Style::new().bg(Color::DarkGray)); 973 | let mut list_state = ListState::default().with_selected(self.selected_device); 974 | 975 | let area = area.inner(Margin { 976 | horizontal: 2, 977 | vertical: 2, 978 | }); 979 | 980 | StatefulWidget::render(list, area, frame.buffer_mut(), &mut list_state); 981 | }), 982 | } 983 | 984 | frame.render_widget(block, area); 985 | } 986 | } 987 | 988 | /// Popup representing a device 989 | #[derive(Debug)] 990 | pub struct DevicePopup { 991 | device: DeviceConfiguration, 992 | id: TextBox, 993 | name: TextBox, 994 | focus: DeviceFocus, 995 | mode: Arc>, 996 | } 997 | 998 | #[derive(Debug, Default, PartialEq, Eq)] 999 | enum DeviceFocus { 1000 | #[default] 1001 | Name, 1002 | Submit, 1003 | Remove, 1004 | } 1005 | 1006 | impl DeviceFocus { 1007 | fn next(&mut self) { 1008 | match self { 1009 | DeviceFocus::Name => *self = DeviceFocus::Submit, 1010 | DeviceFocus::Submit => *self = DeviceFocus::Remove, 1011 | DeviceFocus::Remove => {} 1012 | } 1013 | } 1014 | 1015 | fn prev(&mut self) { 1016 | match self { 1017 | DeviceFocus::Name => {} 1018 | DeviceFocus::Submit => *self = DeviceFocus::Name, 1019 | DeviceFocus::Remove => *self = DeviceFocus::Submit, 1020 | } 1021 | } 1022 | } 1023 | 1024 | impl DevicePopup { 1025 | pub fn new(device: DeviceConfiguration, mode: Arc>) -> Self { 1026 | let id = device.device_id.clone().into(); 1027 | let name = device.name.clone().into(); 1028 | Self { 1029 | device, 1030 | id, 1031 | name, 1032 | focus: DeviceFocus::default(), 1033 | mode, 1034 | } 1035 | } 1036 | 1037 | fn submit(&mut self) -> Option { 1038 | self.device.name = self.name.text.clone(); 1039 | 1040 | Some(Message::EditDevice(Box::new(self.device.clone()))) 1041 | } 1042 | 1043 | fn remove(&self) -> Option { 1044 | Some(Message::RemoveDevice(self.device.device_id.clone())) 1045 | } 1046 | } 1047 | 1048 | impl Popup for DevicePopup { 1049 | fn update(&mut self, msg: Message, _state: State) -> Option { 1050 | match msg { 1051 | Message::Quit => return Some(Message::Quit), 1052 | Message::FocusNext | Message::Down => self.focus.next(), 1053 | Message::FocusBack | Message::Up => self.focus.prev(), 1054 | Message::Left => match self.focus { 1055 | DeviceFocus::Name => self.name.move_cursor_left(), 1056 | DeviceFocus::Submit => {} 1057 | DeviceFocus::Remove => self.focus.prev(), 1058 | }, 1059 | Message::Right => match self.focus { 1060 | DeviceFocus::Name => self.name.move_cursor_right(), 1061 | DeviceFocus::Submit => self.focus.next(), 1062 | DeviceFocus::Remove => {} 1063 | }, 1064 | Message::Character(c) => { 1065 | if matches!(self.focus, DeviceFocus::Name) { 1066 | self.name.enter_char(c); 1067 | } 1068 | } 1069 | Message::Backspace => { 1070 | if matches!(self.focus, DeviceFocus::Name) { 1071 | self.name.delete_char(); 1072 | } 1073 | } 1074 | Message::Select => match self.focus { 1075 | DeviceFocus::Name => {} 1076 | DeviceFocus::Submit => return self.submit(), 1077 | DeviceFocus::Remove => return self.remove(), 1078 | }, 1079 | _ => {} 1080 | } 1081 | 1082 | None 1083 | } 1084 | 1085 | fn render(&self, frame: &mut Frame, _state: State) { 1086 | let block = self.create_popup_block(format!("Edit Device ({})", self.device.name)); 1087 | 1088 | let area = centered_rect(50, 50, frame.area()); 1089 | Clear.render(area, frame.buffer_mut()); 1090 | 1091 | let vertical = Layout::vertical([ 1092 | Constraint::Length(3), 1093 | Constraint::Length(3), 1094 | Constraint::Length(1), 1095 | ]); 1096 | let [id_area, name_area, buttons_area] = vertical.areas(area.inner(Margin { 1097 | horizontal: 2, 1098 | vertical: 2, 1099 | })); 1100 | 1101 | let focused_style = Style::default().fg(Color::Blue); 1102 | 1103 | let id_paragraph = self.id.as_paragraph("ID", Style::default()); 1104 | 1105 | let name_paragraph = self.name.as_paragraph( 1106 | "Name", 1107 | if self.focus == DeviceFocus::Name { 1108 | focused_style 1109 | } else { 1110 | Style::default() 1111 | }, 1112 | ); 1113 | 1114 | let submit = Span::styled( 1115 | "Submit", 1116 | match self.focus { 1117 | DeviceFocus::Submit => Style::default().bg(Color::DarkGray), 1118 | _ => Style::default(), 1119 | }, 1120 | ); 1121 | let remove = Span::styled( 1122 | "Remove", 1123 | match self.focus { 1124 | DeviceFocus::Remove => Style::default().bg(Color::DarkGray), 1125 | _ => Style::default(), 1126 | }, 1127 | ); 1128 | 1129 | let buttons: Line = vec![submit, Span::raw(" "), remove].into(); 1130 | 1131 | // Show cursor 1132 | if *self.mode.lock().unwrap() == CurrentMode::Insert 1133 | && matches!(self.focus, DeviceFocus::Name) 1134 | { 1135 | frame.set_cursor_position(Position::new( 1136 | name_area.x + (self.name.index as u16) + 1, 1137 | name_area.y + 1, 1138 | )); 1139 | } 1140 | 1141 | frame.render_widget(id_paragraph, id_area); 1142 | frame.render_widget(name_paragraph, name_area); 1143 | frame.render_widget(buttons, buttons_area); 1144 | frame.render_widget(block, area); 1145 | } 1146 | } 1147 | -------------------------------------------------------------------------------- /src/tui/state.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::sync::RwLock; 3 | 4 | use color_eyre::eyre; 5 | use syncthing_rs::Client; 6 | use syncthing_rs::types as api; 7 | use syncthing_rs::types::config::DeviceConfiguration; 8 | use syncthing_rs::types::config::FolderConfiguration; 9 | use syncthing_rs::types::config::FolderDeviceConfiguration; 10 | use syncthing_rs::types::config::NewDeviceConfiguration; 11 | use syncthing_rs::types::config::NewFolderConfiguration; 12 | use syncthing_rs::types::events::EventType; 13 | use tokio::sync::broadcast; 14 | use tokio::sync::mpsc; 15 | 16 | use crate::AppError; 17 | 18 | #[derive(Clone, Debug)] 19 | pub enum Reload { 20 | ID, 21 | Configuration, 22 | PendingDevices, 23 | PendingFolders, 24 | Connections, 25 | Completion { 26 | folder_id: Option, 27 | device_id: Option, 28 | }, 29 | } 30 | 31 | #[derive(Clone, Debug)] 32 | pub struct State { 33 | client: Client, 34 | inner: Arc>, 35 | event_tx: broadcast::Sender, 36 | config_tx: broadcast::Sender<()>, 37 | reload_tx: mpsc::Sender, 38 | } 39 | 40 | impl State { 41 | pub fn new(client: Client) -> Self { 42 | let (event_tx, event_rx) = broadcast::channel(100); 43 | let (config_tx, _) = broadcast::channel(100); 44 | let (reload_tx, reload_rx) = mpsc::channel(10); 45 | let event_tx_clone = event_tx.clone(); 46 | let client_clone = client.clone(); 47 | 48 | let state = Self { 49 | client, 50 | inner: Arc::new(RwLock::new(InnerState::default())), 51 | event_tx, 52 | config_tx, 53 | reload_tx, 54 | }; 55 | 56 | // Start listening to events 57 | let state_handle = state.clone(); 58 | tokio::spawn(async move { 59 | if let Err(e) = client_clone.get_events(event_tx_clone, true).await { 60 | log::error!("failed to get events: {:?}", e); 61 | state_handle.set_error(e.into()); 62 | }; 63 | }); 64 | 65 | // Start reacting to events 66 | let state_handle = state.clone(); 67 | tokio::spawn(async move { 68 | Self::handle_event(event_rx, state_handle).await; 69 | }); 70 | 71 | // Start listening to reloads 72 | let state_handle = state.clone(); 73 | tokio::spawn(async move { Self::listen_to_reload(reload_rx, state_handle).await }); 74 | 75 | // Start reloading everything ones. 76 | // These blocks all start a thread, so are non-blocking. 77 | state.reload(Reload::ID); 78 | state.reload(Reload::Configuration); 79 | state.reload(Reload::PendingDevices); 80 | state.reload(Reload::PendingFolders); 81 | 82 | state 83 | } 84 | 85 | pub fn read(&self, f: F) -> R 86 | where 87 | F: FnOnce(&InnerState) -> R, 88 | { 89 | let guard = self.inner.read().unwrap(); 90 | f(&guard) 91 | } 92 | 93 | /// Read only access to the state. External users should never 94 | /// have to modify the inner state directly, but should use functions 95 | /// in [`State`](Self) 96 | fn write(&self, f: F) -> R 97 | where 98 | F: FnOnce(&mut InnerState) -> R, 99 | { 100 | let mut guard = self.inner.write().unwrap(); 101 | f(&mut guard) 102 | } 103 | 104 | /// Initiate a reload of parts of the state, defined by `Reload`, 105 | /// by initiating a request to the API. 106 | pub fn reload(&self, reload: Reload) { 107 | let reload_tx = self.reload_tx.clone(); 108 | let state = self.clone(); 109 | tokio::spawn(async move { 110 | if let Err(e) = reload_tx.send(reload.clone()).await { 111 | log::error!("failed to initiate {:?} reload {:?}", reload, e); 112 | state.set_error(e.into()); 113 | } 114 | }); 115 | } 116 | 117 | pub fn set_error(&self, _error: AppError) {} 118 | 119 | pub fn clear_error(&self) {} 120 | 121 | /// Emits an [`Event`](api::events::Event) if a new one arrives 122 | pub fn subscribe_to_events(&self) -> broadcast::Receiver { 123 | self.event_tx.subscribe() 124 | } 125 | 126 | /// Emits `()` if the config (everything except events) changes 127 | pub fn subscribe_to_config(&self) -> broadcast::Receiver<()> { 128 | self.config_tx.subscribe() 129 | } 130 | 131 | /// Starts listening to reload commands, and will start reloading parts 132 | /// of the configuration. 133 | // TODO maybe reload in separate threads, so reloads can be handled faster 134 | async fn listen_to_reload(mut reload_rx: mpsc::Receiver, state: State) { 135 | while let Some(reload) = reload_rx.recv().await { 136 | match reload { 137 | Reload::Configuration => { 138 | let config = state.client.get_configuration().await; 139 | match config { 140 | Ok(conf) => { 141 | state.write(|state| state.update_from_configuration(conf.clone())); 142 | state.reload(Reload::Connections); 143 | for f in conf.folders { 144 | state.reload(Reload::Completion { 145 | folder_id: Some(f.id), 146 | device_id: None, 147 | }); 148 | } 149 | } 150 | Err(e) => { 151 | log::error!("failed to reload config: {:?}", e); 152 | state.set_error(e.into()); 153 | } 154 | } 155 | } 156 | Reload::ID => { 157 | let id = state.client.get_id().await; 158 | match id { 159 | Ok(id) => { 160 | state.write(|state| state.id = id); 161 | } 162 | Err(e) => { 163 | log::error!("failed to load Syncthing ID: {:?}", e); 164 | state.set_error(e.into()); 165 | } 166 | } 167 | } 168 | Reload::PendingDevices => { 169 | let devices = state.client.get_pending_devices().await; 170 | match devices { 171 | Ok(devices) => state.write(|state| state.set_pending_devices(devices)), 172 | Err(e) => log::warn!("failed to reload pending devices: {:?}", e), 173 | } 174 | } 175 | Reload::PendingFolders => { 176 | let folders = state.client.get_pending_folders().await; 177 | match folders { 178 | Ok(folders) => state.write(|state| state.set_pending_folders(folders)), 179 | Err(e) => log::warn!("failed to reload pending folders: {:?}", e), 180 | } 181 | } 182 | Reload::Connections => { 183 | let connections = state.client.get_connections().await; 184 | match connections { 185 | Ok(connections) => state.write(|inner_state| { 186 | for (device_id, connection) in connections.connections { 187 | if let Ok(device) = inner_state.get_device_mut(&device_id) { 188 | if connection.connected { 189 | device.connected = DeviceStatus::UpToDate; 190 | state.reload(Reload::Completion { 191 | folder_id: None, 192 | device_id: Some(device_id), 193 | }); 194 | } else { 195 | device.connected = DeviceStatus::Disconnected; 196 | } 197 | } 198 | } 199 | }), 200 | Err(e) => log::warn!("failed to reload connections: {:?}", e), 201 | } 202 | } 203 | Reload::Completion { 204 | folder_id, 205 | device_id, 206 | } => { 207 | let completion = state 208 | .client 209 | .get_completion(folder_id.as_deref(), device_id.as_deref()) 210 | .await; 211 | match completion { 212 | Ok(completion) => { 213 | if let Some(device_id) = device_id { 214 | if let Some(_folder_id) = folder_id { 215 | todo!("update folder completion for device"); 216 | } else { 217 | state.write(|state| { 218 | if let Ok(device) = state.get_device_mut(&device_id) { 219 | if completion.completion == 100.0 { 220 | device.connected = DeviceStatus::UpToDate 221 | } else { 222 | device.connected = 223 | DeviceStatus::Syncing(completion.completion) 224 | } 225 | } 226 | }) 227 | } 228 | } 229 | // Set local completion of folder 230 | else if let Some(folder_id) = folder_id { 231 | state.write(|state| { 232 | if let Ok(folder) = state.get_folder_mut(&folder_id) { 233 | folder.completion = completion.completion; 234 | } 235 | }); 236 | } 237 | } 238 | Err(e) => log::warn!("failed to reload completion: {:?}", e), 239 | } 240 | } 241 | } 242 | // For every case, if we reach this point, the config has changed 243 | if let Err(e) = state.config_tx.send(()) { 244 | log::warn!( 245 | "could not initiate a config update after a reload has been completed: {:?}", 246 | e 247 | ); 248 | } 249 | } 250 | } 251 | 252 | /// Some events motivate a reload of the configuration. That is done here 253 | /// in the background. 254 | async fn handle_event(mut event_rx: broadcast::Receiver, state: State) { 255 | while let Ok(event) = event_rx.recv().await { 256 | log::debug!("state is handling event {:?}", event); 257 | match event.ty { 258 | EventType::ConfigSaved { .. } => { 259 | if let Err(e) = state.reload_tx.send(Reload::Configuration).await { 260 | log::error!( 261 | "failed to initiate configuration reload due to new saved config: {:?}", 262 | e 263 | ); 264 | state.set_error(e.into()); 265 | } 266 | } 267 | EventType::DeviceConnected { id, .. } => { 268 | state.write(|state| { 269 | log::debug!("Device {id} connected"); 270 | if let Ok(device) = state.get_device_mut(&id) { 271 | device.connected = DeviceStatus::UpToDate; 272 | } 273 | }); 274 | // Not that important of an event 275 | let _ = state.config_tx.send(()); 276 | } 277 | EventType::DeviceDisconnected { id, .. } => { 278 | state.write(|state| { 279 | if let Ok(device) = state.get_device_mut(&id) { 280 | device.connected = DeviceStatus::Disconnected; 281 | } 282 | }); 283 | // Not that important of an event 284 | let _ = state.config_tx.send(()); 285 | } 286 | EventType::PendingDevicesChanged { .. } => { 287 | if let Err(e) = state.reload_tx.send(Reload::PendingDevices).await { 288 | log::error!("failed to initiate pending devices reload: {:?}", e); 289 | state.set_error(e.into()); 290 | } 291 | } 292 | EventType::PendingFoldersChanged { .. } => { 293 | if let Err(e) = state.reload_tx.send(Reload::PendingFolders).await { 294 | log::error!("failed to initiate pending devices reload: {:?}", e); 295 | state.set_error(e.into()); 296 | } 297 | } 298 | EventType::RemoteDownloadProgress { ref device, .. } => { 299 | if let Err(e) = state 300 | .reload_tx 301 | .send(Reload::Completion { 302 | device_id: Some(device.to_string()), 303 | folder_id: None, 304 | }) 305 | .await 306 | { 307 | log::error!( 308 | "failed to initiate completion status based on remote download progress: {:?}", 309 | e 310 | ); 311 | } 312 | } 313 | _ => {} 314 | } 315 | } 316 | } 317 | 318 | /// Accept device `device_id` in the background. This function is 319 | /// non-blocking, and will emit a config update once the changes have 320 | /// been applied. 321 | pub fn accept_device(&self, device_id: &str) { 322 | match self.read(|state| state.get_pending_device(device_id).cloned()) { 323 | Ok(device) => { 324 | let state = self.clone(); 325 | tokio::spawn(async move { 326 | if let Err(e) = state.client.add_device(device).await { 327 | log::error!("failed to add device to api: {:?}", e); 328 | state.set_error(e.into()); 329 | } else { 330 | state.reload(Reload::Configuration); 331 | } 332 | }); 333 | } 334 | Err(e) => { 335 | log::error!("failed to accept device: {:?}", e); 336 | self.set_error(e); 337 | } 338 | } 339 | } 340 | 341 | /// Add a new folder 342 | pub fn add_foler(&self, folder: NewFolderConfiguration) { 343 | let state = self.clone(); 344 | tokio::spawn(async move { 345 | if let Err(e) = state.client.add_folder(folder).await { 346 | log::error!("failed to add folder to api: {:?}", e); 347 | state.set_error(e.into()); 348 | } else { 349 | // TODO We don't need to update the config, the event should handle that 350 | state.reload(Reload::Configuration); 351 | } 352 | }); 353 | } 354 | 355 | pub fn share_folder(&self, folder_id: &str, device_id: &str) { 356 | if let Some(folder) = self.write(|state| match state.get_folder_mut(folder_id) { 357 | Ok(folder) => { 358 | folder.config.devices.push(FolderDeviceConfiguration { 359 | device_id: device_id.to_string(), 360 | introduced_by: String::new(), 361 | encryption_password: String::new(), 362 | }); 363 | Some(folder.config.clone()) 364 | } 365 | Err(e) => { 366 | log::error!("fialed to share folder: {:?}", e); 367 | self.set_error(e); 368 | None 369 | } 370 | }) { 371 | let state = self.clone(); 372 | tokio::spawn(async move { 373 | if let Err(e) = state.client.post_folder(folder).await { 374 | log::error!("failed to share folder on api: {:?}", e); 375 | state.set_error(e.into()); 376 | } 377 | }); 378 | } 379 | } 380 | 381 | pub fn edit_folder(&self, folder: FolderConfiguration) { 382 | let state = self.clone(); 383 | tokio::spawn(async move { 384 | if let Err(e) = state.client.post_folder(folder).await { 385 | log::error!("failed to update folder on api: {:?}", e); 386 | state.set_error(e.into()); 387 | } 388 | }); 389 | } 390 | 391 | pub fn dismiss_folder(&self, folder_id: impl Into, device_id: impl Into) { 392 | let state = self.clone(); 393 | let folder_id = folder_id.into(); 394 | let device_id = device_id.into(); 395 | tokio::spawn(async move { 396 | if let Err(e) = state 397 | .client 398 | .dismiss_pending_folder(&folder_id, Some(&device_id)) 399 | .await 400 | { 401 | log::error!("failed to dismiss folder to api: {:?}", e); 402 | state.set_error(e.into()); 403 | } 404 | // We don't need to update the config, the event should handle that 405 | }); 406 | } 407 | 408 | pub fn remove_folder(&self, folder_id: impl Into) { 409 | let state = self.clone(); 410 | let folder_id = folder_id.into(); 411 | 412 | tokio::spawn(async move { 413 | if let Err(e) = state.client.delete_folder(&folder_id).await { 414 | log::error!("failed to delete folder from api: {:?}", e); 415 | state.set_error(e.into()); 416 | } 417 | }); 418 | } 419 | 420 | pub fn dismiss_device(&self, device_id: impl Into) { 421 | let state = self.clone(); 422 | let device_id = device_id.into(); 423 | tokio::spawn(async move { 424 | if let Err(e) = state.client.dismiss_pending_device(&device_id).await { 425 | log::error!("failed to dismiss device to api: {:?}", e); 426 | state.set_error(e.into()); 427 | } 428 | // We don't need to update the config, the event should handle that 429 | }); 430 | } 431 | 432 | pub fn edit_device(&self, device: DeviceConfiguration) { 433 | let state = self.clone(); 434 | tokio::spawn(async move { 435 | if let Err(e) = state.client.post_device(device).await { 436 | log::error!("failed to update device on api: {:?}", e); 437 | state.set_error(e.into()); 438 | } 439 | }); 440 | } 441 | 442 | pub fn remove_device(&self, device_id: impl Into) { 443 | let state = self.clone(); 444 | let device_id = device_id.into(); 445 | 446 | tokio::spawn(async move { 447 | if let Err(e) = state.client.delete_device(&device_id).await { 448 | log::error!("failed to delete device from api: {:?}", e); 449 | state.set_error(e.into()); 450 | } 451 | }); 452 | } 453 | } 454 | 455 | #[derive(Debug, Default)] 456 | pub struct InnerState { 457 | folders: Vec, 458 | devices: Vec, 459 | pending_folders: Vec<(String, NewFolderConfiguration)>, 460 | pending_devices: Vec, 461 | pub events: Vec, 462 | pub error: Option, 463 | /// The device ID of this device 464 | pub id: String, 465 | } 466 | 467 | impl InnerState { 468 | fn update_from_configuration(&mut self, configuration: api::config::Configuration) { 469 | self.folders.clear(); 470 | self.devices.clear(); 471 | for device in configuration.devices { 472 | self.devices.push(device.into()); 473 | } 474 | for folder in configuration.folders { 475 | self.folders.push(folder.into()); 476 | } 477 | } 478 | 479 | fn set_pending_devices(&mut self, pending_devices: api::cluster::PendingDevices) { 480 | self.pending_devices.clear(); 481 | for (device_id, device) in pending_devices.devices.iter() { 482 | self.pending_devices 483 | .push(NewDeviceConfiguration::new(device_id.to_string()).name(device.name.clone())); 484 | } 485 | } 486 | 487 | fn set_pending_folders(&mut self, pending_folders: api::cluster::PendingFolders) { 488 | self.pending_folders.clear(); 489 | for (folder_id, folder) in pending_folders.folders.iter() { 490 | for (introducer_id, offerer) in folder.offered_by.clone() { 491 | self.pending_folders.push(( 492 | introducer_id, 493 | // TODO find a cleaner way to handle the unknown path at this point 494 | NewFolderConfiguration::new(folder_id.to_string(), "?".to_string()) 495 | .label(offerer.label), 496 | )); 497 | } 498 | } 499 | 500 | log::debug!("Pending folders: {:#?}", self.get_pending_folders()); 501 | log::debug!("Folders: {:#?}", self.get_folders()); 502 | } 503 | 504 | /// All configured devices, sorted by name 505 | pub fn get_devices(&self) -> Vec<&Device> { 506 | let mut res: Vec<&Device> = self.devices.iter().collect(); 507 | 508 | res.sort_by(|a, b| { 509 | a.config 510 | .name 511 | .to_lowercase() 512 | .cmp(&b.config.name.to_lowercase()) 513 | }); 514 | res 515 | } 516 | 517 | /// Get a configured device with id `device_id` 518 | pub fn get_device(&self, device_id: &str) -> eyre::Result<&Device, AppError> { 519 | self.devices 520 | .iter() 521 | .find(|d| d.config.device_id == device_id) 522 | .ok_or(AppError::UnknownDevice) 523 | } 524 | 525 | /// Get a configured device with id `device_id` 526 | pub fn get_device_mut(&mut self, device_id: &str) -> eyre::Result<&mut Device, AppError> { 527 | self.devices 528 | .iter_mut() 529 | .find(|d| d.config.device_id == device_id) 530 | .ok_or(AppError::UnknownDevice) 531 | } 532 | 533 | /// All devices, excluding the local device 534 | pub fn get_other_devices(&self) -> Vec<&Device> { 535 | self.get_devices() 536 | .into_iter() 537 | .filter(|device| device.config.device_id != self.id) 538 | .collect() 539 | } 540 | 541 | /// All devices with which `folder_id` is shared. 542 | pub fn get_devices_sharing_folder( 543 | &self, 544 | folder_id: &str, 545 | ) -> eyre::Result, AppError> { 546 | let folder = self 547 | .folders 548 | .iter() 549 | .find(|f| f.config.id == folder_id) 550 | .ok_or(AppError::UnknownFolder)?; 551 | 552 | Ok(self 553 | .get_other_devices() 554 | .iter() 555 | .filter(|device| folder.get_sharer().contains(&&device.config.device_id)) 556 | .copied() 557 | .collect()) 558 | } 559 | 560 | /// All devices we have not yet configured 561 | pub fn get_pending_devices(&self) -> Vec<&NewDeviceConfiguration> { 562 | let mut res: Vec<&NewDeviceConfiguration> = self.pending_devices.iter().collect(); 563 | 564 | // TODO lowercase 565 | res.sort_by(|a, b| a.get_name().cmp(b.get_name())); 566 | res 567 | } 568 | 569 | // Get device which has not yet been configured 570 | pub fn get_pending_device( 571 | &self, 572 | device_id: &str, 573 | ) -> eyre::Result<&NewDeviceConfiguration, AppError> { 574 | self.pending_devices 575 | .iter() 576 | .find(|d| d.get_device_id() == device_id) 577 | .ok_or(AppError::UnknownDevice) 578 | } 579 | 580 | /// All folders, sorted by name and then ID 581 | pub fn get_folders(&self) -> Vec<&Folder> { 582 | let mut res: Vec<&Folder> = self.folders.iter().collect(); 583 | 584 | // TODO id 585 | res.sort_by(|a, b| { 586 | a.config 587 | .label 588 | .to_lowercase() 589 | .cmp(&b.config.label.to_lowercase()) 590 | }); 591 | res 592 | } 593 | 594 | pub fn get_pending_folders(&self) -> Vec<&(String, NewFolderConfiguration)> { 595 | let mut res: Vec<_> = self.pending_folders.iter().collect(); 596 | 597 | // TODO lowercase & id 598 | // BUG this will return different orderings with respect to devices 599 | res.sort_by(|(_, a), (_, b)| a.get_label().cmp(b.get_label())); 600 | res 601 | } 602 | 603 | pub fn get_folder(&self, folder_id: &str) -> eyre::Result<&Folder, AppError> { 604 | self.folders 605 | .iter() 606 | .find(|f| f.config.id == folder_id) 607 | .ok_or(AppError::UnknownFolder) 608 | } 609 | 610 | pub fn get_folder_mut(&mut self, folder_id: &str) -> eyre::Result<&mut Folder, AppError> { 611 | self.folders 612 | .iter_mut() 613 | .find(|f| f.config.id == folder_id) 614 | .ok_or(AppError::UnknownFolder) 615 | } 616 | 617 | // Get all folders which are shared with `device_id`. Does not check 618 | // if `device_id` actually exists. 619 | pub fn get_device_folders(&self, device_id: &str) -> Vec<&Folder> { 620 | self.get_folders() 621 | .into_iter() 622 | .filter(|f| f.get_sharer().iter().any(|d| d == &device_id)) 623 | .collect() 624 | } 625 | } 626 | 627 | #[derive(Clone, Debug, PartialEq)] 628 | pub struct Folder { 629 | pub config: FolderConfiguration, 630 | pub completion: f64, 631 | } 632 | 633 | impl Folder { 634 | /// Get all the devices with which this folder is shared, sorted by device id 635 | pub fn get_sharer(&self) -> Vec<&String> { 636 | let mut to_sort: Vec<_> = self 637 | .config 638 | .devices 639 | .iter() 640 | .map(|folder_device_configuration| &folder_device_configuration.device_id) 641 | .collect(); 642 | to_sort.sort(); 643 | to_sort 644 | } 645 | 646 | /// Get all the devices with which this folder is shared, excluding `device_id`. 647 | /// This is especially useful for excluding the host. 648 | pub fn get_sharer_excluded(&self, device_id: &str) -> Vec<&String> { 649 | self.get_sharer() 650 | .into_iter() 651 | .filter(|d| d != &device_id) 652 | .collect() 653 | } 654 | } 655 | 656 | #[derive(Clone, Debug, PartialEq)] 657 | pub enum DeviceStatus { 658 | UpToDate, 659 | Syncing(f64), 660 | Disconnected, 661 | } 662 | 663 | #[derive(Clone, Debug, PartialEq)] 664 | pub struct Device { 665 | pub config: DeviceConfiguration, 666 | pub connected: DeviceStatus, 667 | } 668 | 669 | impl From for Device { 670 | fn from(value: api::config::DeviceConfiguration) -> Self { 671 | Self { 672 | config: value, 673 | connected: DeviceStatus::Disconnected, 674 | } 675 | } 676 | } 677 | 678 | impl From for Folder { 679 | fn from(folder: api::config::FolderConfiguration) -> Self { 680 | Self { 681 | config: folder, 682 | completion: 100.0, 683 | } 684 | } 685 | } 686 | -------------------------------------------------------------------------------- /src/tui/ui.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Span, Text}, 6 | widgets::{Block, Borders, Paragraph, Widget, Wrap}, 7 | }; 8 | use strum::IntoEnumIterator; 9 | 10 | use super::{ 11 | app::{App, CurrentScreen}, 12 | pages::{DevicesPage, FoldersPage, IDPage, PendingPage}, 13 | }; 14 | 15 | pub fn ui(frame: &mut Frame, app: &App) { 16 | // If we have an error, show only that 17 | if app.state.read(|state| { 18 | if let Some(error) = &state.error { 19 | let popup_block = 20 | create_popup_block(app, "Error".to_string()).style(Style::default().fg(Color::Red)); 21 | 22 | let error_text = Text::styled(error.to_string(), Style::default().fg(Color::default())); 23 | let error_paragraph = Paragraph::new(error_text) 24 | .block(popup_block) 25 | .alignment(ratatui::layout::Alignment::Center) 26 | .wrap(Wrap { trim: false }); // Do not cut off whn over edge 27 | 28 | let area = centered_rect(50, 50, frame.area()); 29 | frame.render_widget(error_paragraph, area); 30 | true 31 | } else { 32 | false 33 | } 34 | }) { 35 | return; 36 | } 37 | 38 | let background = create_background(app); 39 | let inner_area = background.inner(frame.area()); 40 | match app.current_screen { 41 | CurrentScreen::Folders => FoldersPage::new(app).render(inner_area, frame.buffer_mut()), 42 | CurrentScreen::Devices => DevicesPage::new(app).render(inner_area, frame.buffer_mut()), 43 | CurrentScreen::ID => IDPage::new(app.state.read(|state| state.id.clone())) 44 | .render(inner_area, frame.buffer_mut()), 45 | CurrentScreen::Pending => PendingPage::new(app).render(inner_area, frame.buffer_mut()), 46 | }; 47 | 48 | frame.render_widget(background, frame.area()); 49 | 50 | if let Some(popup) = &app.popup { 51 | let state = app.state.clone(); 52 | popup.render(frame, state); 53 | } 54 | } 55 | 56 | fn create_background(app: &App) -> Block { 57 | let block = Block::default() 58 | .title_top(Line::from("| SyncTUI |").centered().bold()) 59 | .borders(Borders::ALL); 60 | 61 | let mut bottom_string = CurrentScreen::iter() 62 | .enumerate() 63 | .map(|(i, screen)| { 64 | Span::styled( 65 | format!("| ({}) {:?} ", i + 1, screen), 66 | if screen == app.current_screen { 67 | Style::default().add_modifier(Modifier::BOLD) 68 | } else { 69 | Style::default() 70 | }, 71 | ) 72 | }) 73 | .collect::>(); 74 | bottom_string.push("|".into()); 75 | 76 | block.title_bottom(bottom_string).title_bottom( 77 | Line::from(format!("| (q) quit | {} |", app.mode.lock().unwrap())).right_aligned(), 78 | ) 79 | } 80 | 81 | fn create_popup_block(_: &App, title: String) -> Block { 82 | Block::default() 83 | .title_top(Line::from(format!("| {} |", title)).centered()) 84 | .borders(Borders::ALL) 85 | } 86 | 87 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 88 | // Adapted from https://ratatui.rs/tutorials/json-editor/ui/ 89 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 90 | // Cut the given rectangle into three vertical pieces 91 | let popup_layout = Layout::default() 92 | .direction(Direction::Vertical) 93 | .constraints([ 94 | Constraint::Percentage((100 - percent_y) / 2), 95 | Constraint::Percentage(percent_y), 96 | Constraint::Percentage((100 - percent_y) / 2), 97 | ]) 98 | .split(r); 99 | 100 | // Then cut the middle vertical piece into three width-wise pieces 101 | Layout::default() 102 | .direction(Direction::Horizontal) 103 | .constraints([ 104 | Constraint::Percentage((100 - percent_x) / 2), 105 | Constraint::Percentage(percent_x), 106 | Constraint::Percentage((100 - percent_x) / 2), 107 | ]) 108 | .split(popup_layout[1])[1] // Return the middle chunk 109 | } 110 | --------------------------------------------------------------------------------