@smbcloud / cli / commits / f09144f

Add interactive TUI views for projects, deployments, and account

- Add new TUI modules in `ui/` for project and deployment tables, detail cards, account profile, and branded confirmation dialogs, using `ratatui` and `crossterm` for rich terminal UI. - Replace tabled-based output in `me`, `project`, and `deployment` commands with interactive TUI views. - Add `crossterm` as a workspace dependency and inherit in CLI crate. - Centralize TUI theme colors in `ui/theme.rs`. - All TUI views use the smbCloud brand palette and support keyboard navigation. - Update module wiring in `ui/mod.rs` and command handlers.

Seto Elkahfi committed Apr 9, 2026 at 07:11 UTC f09144f987b12325f87cd3faa14d030688176876
15 files changed +1846 -169
Cargo.lock
+257 -9
@@ -24,6 +24,12 @@ dependencies = [
24 "memchr",
25 ]
26
27 +[[package]]
28 +name = "allocator-api2"
29 +version = "0.2.21"
30 +source = "registry+https://github.com/rust-lang/crates.io-index"
31 +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
32 +
33 [[package]]
34 name = "android_system_properties"
35 version = "0.1.5"
@@ -192,6 +198,21 @@ version = "1.11.1"
198 source = "registry+https://github.com/rust-lang/crates.io-index"
199 checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
200
201 +[[package]]
202 +name = "cassowary"
203 +version = "0.3.0"
204 +source = "registry+https://github.com/rust-lang/crates.io-index"
205 +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
206 +
207 +[[package]]
208 +name = "castaway"
209 +version = "0.2.4"
210 +source = "registry+https://github.com/rust-lang/crates.io-index"
211 +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
212 +dependencies = [
213 + "rustversion",
214 +]
215 +
216 [[package]]
217 name = "cc"
218 version = "1.2.59"
@@ -318,6 +339,20 @@ version = "1.0.5"
339 source = "registry+https://github.com/rust-lang/crates.io-index"
340 checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
341
342 +[[package]]
343 +name = "compact_str"
344 +version = "0.8.1"
345 +source = "registry+https://github.com/rust-lang/crates.io-index"
346 +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
347 +dependencies = [
348 + "castaway",
349 + "cfg-if",
350 + "itoa",
351 + "rustversion",
352 + "ryu",
353 + "static_assertions",
354 +]
355 +
356 [[package]]
357 name = "console"
358 version = "0.15.11"
@@ -327,7 +362,7 @@ dependencies = [
362 "encode_unicode",
363 "libc",
364 "once_cell",
330 - "unicode-width 0.2.2",
365 + "unicode-width 0.2.0",
366 "windows-sys 0.59.0",
367 ]
368
@@ -339,7 +374,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
374 dependencies = [
375 "encode_unicode",
376 "libc",
342 - "unicode-width 0.2.2",
377 + "unicode-width 0.2.0",
378 "windows-sys 0.61.2",
379 ]
380
@@ -392,6 +427,31 @@ dependencies = [
427 "libc",
428 ]
429
430 +[[package]]
431 +name = "crossterm"
432 +version = "0.28.1"
433 +source = "registry+https://github.com/rust-lang/crates.io-index"
434 +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
435 +dependencies = [
436 + "bitflags 2.11.0",
437 + "crossterm_winapi",
438 + "mio",
439 + "parking_lot",
440 + "rustix 0.38.44",
441 + "signal-hook",
442 + "signal-hook-mio",
443 + "winapi",
444 +]
445 +
446 +[[package]]
447 +name = "crossterm_winapi"
448 +version = "0.9.1"
449 +source = "registry+https://github.com/rust-lang/crates.io-index"
450 +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
451 +dependencies = [
452 + "winapi",
453 +]
454 +
455 [[package]]
456 name = "crypto-common"
457 version = "0.1.7"
@@ -420,6 +480,40 @@ dependencies = [
480 "cmov",
481 ]
482
483 +[[package]]
484 +name = "darling"
485 +version = "0.23.0"
486 +source = "registry+https://github.com/rust-lang/crates.io-index"
487 +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
488 +dependencies = [
489 + "darling_core",
490 + "darling_macro",
491 +]
492 +
493 +[[package]]
494 +name = "darling_core"
495 +version = "0.23.0"
496 +source = "registry+https://github.com/rust-lang/crates.io-index"
497 +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
498 +dependencies = [
499 + "ident_case",
500 + "proc-macro2",
501 + "quote",
502 + "strsim 0.11.1",
503 + "syn 2.0.117",
504 +]
505 +
506 +[[package]]
507 +name = "darling_macro"
508 +version = "0.23.0"
509 +source = "registry+https://github.com/rust-lang/crates.io-index"
510 +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
511 +dependencies = [
512 + "darling_core",
513 + "quote",
514 + "syn 2.0.117",
515 +]
516 +
517 [[package]]
518 name = "deranged"
519 version = "0.5.8"
@@ -496,6 +590,12 @@ dependencies = [
590 "syn 2.0.117",
591 ]
592
593 +[[package]]
594 +name = "either"
595 +version = "1.15.0"
596 +source = "registry+https://github.com/rust-lang/crates.io-index"
597 +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
598 +
599 [[package]]
600 name = "encode_unicode"
601 version = "1.0.0"
@@ -716,6 +816,8 @@ version = "0.15.5"
816 source = "registry+https://github.com/rust-lang/crates.io-index"
817 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
818 dependencies = [
819 + "allocator-api2",
820 + "equivalent",
821 "foldhash",
822 ]
823
@@ -993,6 +1095,12 @@ version = "2.3.0"
1095 source = "registry+https://github.com/rust-lang/crates.io-index"
1096 checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
1097
1098 +[[package]]
1099 +name = "ident_case"
1100 +version = "1.0.1"
1101 +source = "registry+https://github.com/rust-lang/crates.io-index"
1102 +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1103 +
1104 [[package]]
1105 name = "idna"
1106 version = "1.1.0"
@@ -1035,7 +1143,7 @@ dependencies = [
1143 "console 0.15.11",
1144 "number_prefix",
1145 "portable-atomic",
1038 - "unicode-width 0.2.2",
1146 + "unicode-width 0.2.0",
1147 "web-time",
1148 ]
1149
@@ -1048,6 +1156,19 @@ dependencies = [
1156 "rustversion",
1157 ]
1158
1159 +[[package]]
1160 +name = "instability"
1161 +version = "0.3.12"
1162 +source = "registry+https://github.com/rust-lang/crates.io-index"
1163 +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
1164 +dependencies = [
1165 + "darling",
1166 + "indoc",
1167 + "proc-macro2",
1168 + "quote",
1169 + "syn 2.0.117",
1170 +]
1171 +
1172 [[package]]
1173 name = "ipnet"
1174 version = "2.12.0"
@@ -1089,6 +1210,15 @@ version = "1.70.2"
1210 source = "registry+https://github.com/rust-lang/crates.io-index"
1211 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1212
1213 +[[package]]
1214 +name = "itertools"
1215 +version = "0.13.0"
1216 +source = "registry+https://github.com/rust-lang/crates.io-index"
1217 +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
1218 +dependencies = [
1219 + "either",
1220 +]
1221 +
1222 [[package]]
1223 name = "itoa"
1224 version = "1.0.18"
@@ -1184,6 +1314,12 @@ dependencies = [
1314 "vcpkg",
1315 ]
1316
1317 +[[package]]
1318 +name = "linux-raw-sys"
1319 +version = "0.4.15"
1320 +source = "registry+https://github.com/rust-lang/crates.io-index"
1321 +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1322 +
1323 [[package]]
1324 name = "linux-raw-sys"
1325 version = "0.12.1"
@@ -1226,6 +1362,15 @@ dependencies = [
1362 "tracing-subscriber",
1363 ]
1364
1365 +[[package]]
1366 +name = "lru"
1367 +version = "0.12.5"
1368 +source = "registry+https://github.com/rust-lang/crates.io-index"
1369 +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1370 +dependencies = [
1371 + "hashbrown 0.15.5",
1372 +]
1373 +
1374 [[package]]
1375 name = "lru-slab"
1376 version = "0.1.2"
@@ -1290,6 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1435 checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
1436 dependencies = [
1437 "libc",
1438 + "log",
1439 "wasi 0.11.1+wasi-snapshot-preview1",
1440 "windows-sys 0.61.2",
1441 ]
@@ -1439,7 +1585,7 @@ checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
1585 dependencies = [
1586 "bytecount",
1587 "fnv",
1442 - "unicode-width 0.2.2",
1588 + "unicode-width 0.2.0",
1589 ]
1590
1591 [[package]]
@@ -1465,6 +1611,12 @@ dependencies = [
1611 "windows-link",
1612 ]
1613
1614 +[[package]]
1615 +name = "paste"
1616 +version = "1.0.15"
1617 +source = "registry+https://github.com/rust-lang/crates.io-index"
1618 +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
1619 +
1620 [[package]]
1621 name = "pathdiff"
1622 version = "0.2.3"
@@ -1827,6 +1979,27 @@ version = "0.10.0"
1979 source = "registry+https://github.com/rust-lang/crates.io-index"
1980 checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
1981
1982 +[[package]]
1983 +name = "ratatui"
1984 +version = "0.29.0"
1985 +source = "registry+https://github.com/rust-lang/crates.io-index"
1986 +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
1987 +dependencies = [
1988 + "bitflags 2.11.0",
1989 + "cassowary",
1990 + "compact_str",
1991 + "crossterm",
1992 + "indoc",
1993 + "instability",
1994 + "itertools",
1995 + "lru",
1996 + "paste",
1997 + "strum 0.26.3",
1998 + "unicode-segmentation",
1999 + "unicode-truncate",
2000 + "unicode-width 0.2.0",
2001 +]
2002 +
2003 [[package]]
2004 name = "redox_syscall"
2005 version = "0.5.18"
@@ -1940,6 +2113,19 @@ version = "2.1.2"
2113 source = "registry+https://github.com/rust-lang/crates.io-index"
2114 checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
2115
2116 +[[package]]
2117 +name = "rustix"
2118 +version = "0.38.44"
2119 +source = "registry+https://github.com/rust-lang/crates.io-index"
2120 +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
2121 +dependencies = [
2122 + "bitflags 2.11.0",
2123 + "errno",
2124 + "libc",
2125 + "linux-raw-sys 0.4.15",
2126 + "windows-sys 0.59.0",
2127 +]
2128 +
2129 [[package]]
2130 name = "rustix"
2131 version = "1.1.4"
@@ -1949,7 +2135,7 @@ dependencies = [
2135 "bitflags 2.11.0",
2136 "errno",
2137 "libc",
1952 - "linux-raw-sys",
2138 + "linux-raw-sys 0.12.1",
2139 "windows-sys 0.61.2",
2140 ]
2141
@@ -2204,6 +2390,27 @@ version = "1.3.0"
2390 source = "registry+https://github.com/rust-lang/crates.io-index"
2391 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
2392
2393 +[[package]]
2394 +name = "signal-hook"
2395 +version = "0.3.18"
2396 +source = "registry+https://github.com/rust-lang/crates.io-index"
2397 +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
2398 +dependencies = [
2399 + "libc",
2400 + "signal-hook-registry",
2401 +]
2402 +
2403 +[[package]]
2404 +name = "signal-hook-mio"
2405 +version = "0.2.5"
2406 +source = "registry+https://github.com/rust-lang/crates.io-index"
2407 +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
2408 +dependencies = [
2409 + "libc",
2410 + "mio",
2411 + "signal-hook",
2412 +]
2413 +
2414 [[package]]
2415 name = "signal-hook-registry"
2416 version = "1.4.8"
@@ -2284,6 +2491,7 @@ dependencies = [
2491 "clap 4.6.0",
2492 "clap_mangen",
2493 "console 0.16.3",
2494 + "crossterm",
2495 "dialoguer",
2496 "dirs",
2497 "git2",
@@ -2292,6 +2500,7 @@ dependencies = [
2500 "log",
2501 "open",
2502 "openssl",
2503 + "ratatui",
2504 "regex",
2505 "reqwest",
2506 "serde",
@@ -2421,6 +2630,12 @@ dependencies = [
2630 "loom",
2631 ]
2632
2633 +[[package]]
2634 +name = "static_assertions"
2635 +version = "1.1.0"
2636 +source = "registry+https://github.com/rust-lang/crates.io-index"
2637 +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2638 +
2639 [[package]]
2640 name = "stringprep"
2641 version = "0.1.5"
@@ -2477,6 +2692,15 @@ dependencies = [
2692 "strum_macros 0.24.3",
2693 ]
2694
2695 +[[package]]
2696 +name = "strum"
2697 +version = "0.26.3"
2698 +source = "registry+https://github.com/rust-lang/crates.io-index"
2699 +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
2700 +dependencies = [
2701 + "strum_macros 0.26.4",
2702 +]
2703 +
2704 [[package]]
2705 name = "strum"
2706 version = "0.27.2"
@@ -2496,6 +2720,19 @@ dependencies = [
2720 "syn 1.0.109",
2721 ]
2722
2723 +[[package]]
2724 +name = "strum_macros"
2725 +version = "0.26.4"
2726 +source = "registry+https://github.com/rust-lang/crates.io-index"
2727 +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
2728 +dependencies = [
2729 + "heck 0.5.0",
2730 + "proc-macro2",
2731 + "quote",
2732 + "rustversion",
2733 + "syn 2.0.117",
2734 +]
2735 +
2736 [[package]]
2737 name = "strum_macros"
2738 version = "0.27.2"
@@ -2595,7 +2832,7 @@ dependencies = [
2832 "fastrand",
2833 "getrandom 0.4.2",
2834 "once_cell",
2598 - "rustix",
2835 + "rustix 1.1.4",
2836 "windows-sys 0.61.2",
2837 ]
2838
@@ -2605,7 +2842,7 @@ version = "0.3.0"
2842 source = "registry+https://github.com/rust-lang/crates.io-index"
2843 checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc"
2844 dependencies = [
2608 - "unicode-width 0.2.2",
2845 + "unicode-width 0.2.0",
2846 ]
2847
2848 [[package]]
@@ -3043,6 +3280,17 @@ version = "1.13.2"
3280 source = "registry+https://github.com/rust-lang/crates.io-index"
3281 checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
3282
3283 +[[package]]
3284 +name = "unicode-truncate"
3285 +version = "1.1.0"
3286 +source = "registry+https://github.com/rust-lang/crates.io-index"
3287 +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
3288 +dependencies = [
3289 + "itertools",
3290 + "unicode-segmentation",
3291 + "unicode-width 0.1.14",
3292 +]
3293 +
3294 [[package]]
3295 name = "unicode-width"
3296 version = "0.1.14"
@@ -3051,9 +3299,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
3299
3300 [[package]]
3301 name = "unicode-width"
3054 -version = "0.2.2"
3302 +version = "0.2.0"
3303 source = "registry+https://github.com/rust-lang/crates.io-index"
3056 -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3304 +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
3305
3306 [[package]]
3307 name = "unicode-xid"
Cargo.toml
+2
@@ -9,6 +9,7 @@ chrono = "0.4"
9 clap = "4.1.6"
10 clap_mangen = "0.2.10"
11 console = "0.16"
12 +crossterm = "0.28"
13 dialoguer = "0.11.0"
14 dirs = "6.0"
15
@@ -20,6 +21,7 @@ log = "0.4.14"
21 maybe-async = "0.2"
22 open = "5.3"
23 openssl = { version = "0.10", features = ["vendored"] }
24 +ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
25 regex = "1.11"
26 reqwest = { version = "0.12.18", default-features = false }
27 serde = "1"
crates/cli/Cargo.toml
+2
@@ -27,6 +27,7 @@ chrono = { workspace = true }
27 clap = { workspace = true, features = ["derive", "env"] }
28 clap_mangen = { workspace = true }
29 console = { workspace = true }
30 +crossterm = { workspace = true }
31 dialoguer = { workspace = true }
32 dirs = { workspace = true }
33
@@ -37,6 +38,7 @@ home = { workspace = true }
38 indicatif = { workspace = true }
39 log = { workspace = true }
40 open = { workspace = true }
41 +ratatui = { workspace = true }
42 regex = { workspace = true }
43 reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
44 serde = { workspace = true, features = ["derive"] }
crates/cli/src/account/me/mod.rs
+5 -29
@@ -3,37 +3,13 @@ use crate::client;
3 use crate::token::get_smb_token::get_smb_token;
4 use crate::{
5 cli::CommandResult,
6 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6 + ui::{fail_message, fail_symbol, me_view::show_user_tui, succeed_message, succeed_symbol},
7 };
8 -use anyhow::Result;
8 +use anyhow::{anyhow, Result};
9 use smbcloud_auth::me::me;
10 -use smbcloud_model::account::User;
10 +
11 use smbcloud_network::environment::Environment;
12 use spinners::Spinner;
13 -use tabled::{Table, Tabled};
14 -
15 -#[derive(Tabled)]
16 -struct UserRow {
17 - #[tabled(rename = "ID")]
18 - id: i32,
19 - #[tabled(rename = "Email")]
20 - email: String,
21 - #[tabled(rename = "Created At")]
22 - created_at: String,
23 - #[tabled(rename = "Updated At")]
24 - updated_at: String,
25 -}
26 -
27 -fn show_user(user: &User) {
28 - let row = UserRow {
29 - id: user.id,
30 - email: user.email.clone(),
31 - created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
32 - updated_at: user.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
33 - };
34 - let table = Table::new(vec![row]);
35 - println!("{table}");
36 -}
13
14 pub async fn process_me(env: Environment) -> Result<CommandResult> {
15 if !is_logged_in(env) {
@@ -54,14 +30,14 @@ pub async fn process_me(env: Environment) -> Result<CommandResult> {
30 match me(env, client(), &token).await {
31 Ok(user) => {
32 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
57 - show_user(&user);
33 + show_user_tui(&user).map_err(|e| anyhow!(e))?;
34 Ok(CommandResult {
35 spinner: Spinner::new(
36 spinners::Spinners::SimpleDotsScrolling,
37 succeed_message("Loading"),
38 ),
39 symbol: succeed_symbol(),
64 - msg: succeed_message("User info loaded."),
40 + msg: succeed_message("Done."),
41 })
42 }
43 Err(e) => {
crates/cli/src/project/crud_delete.rs
+6 -7
@@ -3,10 +3,12 @@ use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 account::lib::is_logged_in,
5 cli::CommandResult,
6 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6 + ui::{
7 + confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
8 + succeed_symbol,
9 + },
10 };
11 use anyhow::{anyhow, Result};
9 -use dialoguer::{theme::ColorfulTheme, Input};
12 use smbcloud_network::environment::Environment;
13 use smbcloud_networking_project::crud_project_delete::delete_project;
14 use spinners::Spinner;
@@ -16,17 +18,14 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
18 return Err(anyhow!(fail_message("Please log in with `smb init`.")));
19 }
20
19 - let confirmation = Input::<String>::with_theme(&ColorfulTheme::default())
20 - .with_prompt("Are you sure you want to delete this project? (y/n)")
21 - .interact()
22 - .unwrap();
21 + let confirmed = confirm_delete_tui(&format!("Delete project #{id}")).map_err(|e| anyhow!(e))?;
22
23 let spinner = Spinner::new(
24 spinners::Spinners::SimpleDotsScrolling,
25 succeed_message("Deleting project"),
26 );
27
29 - if confirmation != "y" {
28 + if !confirmed {
29 return Ok(CommandResult {
30 spinner,
31 symbol: succeed_symbol(),
crates/cli/src/project/crud_read.rs
+12 -60
@@ -4,7 +4,10 @@ use crate::client;
4 use crate::token::get_smb_token::get_smb_token;
5 use crate::{
6 cli::CommandResult,
7 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
7 + ui::{
8 + fail_message, fail_symbol, project_detail_view::show_project_detail_tui,
9 + project_table::show_projects_tui, succeed_message, succeed_symbol,
10 + },
11 };
12 use anyhow::{anyhow, Result};
13 use log::debug;
@@ -12,37 +15,6 @@ use smbcloud_model::project::{Config, Project};
15 use smbcloud_network::environment::Environment;
16 use smbcloud_networking_project::crud_project_read::{get_project, get_projects};
17 use spinners::Spinner;
15 -use tabled::{Table, Tabled};
16 -
17 -#[derive(Tabled)]
18 -struct ProjectRow {
19 - #[tabled(rename = "ID")]
20 - id: i32,
21 - #[tabled(rename = "Name")]
22 - name: String,
23 - #[tabled(rename = "Runner")]
24 - runner: String,
25 - #[tabled(rename = "Repository")]
26 - repository: String,
27 - #[tabled(rename = "Description")]
28 - description: String,
29 -}
30 -
31 -#[derive(Tabled)]
32 -struct ProjectDetailRow {
33 - #[tabled(rename = "ID")]
34 - id: i32,
35 - #[tabled(rename = "Name")]
36 - name: String,
37 - #[tabled(rename = "Repository")]
38 - repository: String,
39 - #[tabled(rename = "Description")]
40 - description: String,
41 - #[tabled(rename = "Created at")]
42 - created_at: String,
43 - #[tabled(rename = "Updated at")]
44 - updated_at: String,
45 -}
18
19 pub async fn process_project_list(env: Environment) -> Result<CommandResult> {
20 let mut spinner = Spinner::new(
@@ -56,9 +28,9 @@ pub async fn process_project_list(env: Environment) -> Result<CommandResult> {
28 let msg = if projects.is_empty() {
29 succeed_message("No projects found.")
30 } else {
59 - succeed_message("Showing all projects.")
31 + succeed_message("Done.")
32 };
61 - show_projects(projects);
33 + show_projects(projects)?;
34 Ok(CommandResult {
35 spinner: Spinner::new(
36 spinners::Spinners::SimpleDotsScrolling,
@@ -89,7 +61,7 @@ pub async fn process_project_show(env: Environment, id: String) -> Result<Comman
61 Ok(project) => {
62 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
63 let message = succeed_message(&format!("Showing project {}.", &project.name));
92 - show_project_detail(&project);
64 + show_project_detail(&project)?;
65 Ok(CommandResult {
66 spinner: Spinner::new(
67 spinners::Spinners::SimpleDotsScrolling,
@@ -106,35 +78,15 @@ pub async fn process_project_show(env: Environment, id: String) -> Result<Comman
78 }
79 }
80
109 -pub(crate) fn show_projects(projects: Vec<Project>) {
81 +pub(crate) fn show_projects(projects: Vec<Project>) -> Result<()> {
82 if projects.is_empty() {
111 - return;
83 + return Ok(());
84 }
113 - let rows: Vec<ProjectRow> = projects
114 - .into_iter()
115 - .map(|p| ProjectRow {
116 - id: p.id,
117 - name: p.name,
118 - runner: p.runner.to_string(),
119 - repository: p.repository.unwrap_or("-".to_string()),
120 - description: p.description.unwrap_or("-".to_owned()),
121 - })
122 - .collect();
123 - let table = Table::new(rows);
124 - println!("{table}");
85 + show_projects_tui(projects).map_err(|e| anyhow!(e))
86 }
87
127 -pub(crate) fn show_project_detail(project: &Project) {
128 - let row = ProjectDetailRow {
129 - id: project.id,
130 - name: project.name.clone(),
131 - repository: project.repository.clone().unwrap_or("-".to_owned()),
132 - description: project.description.clone().unwrap_or("-".to_owned()),
133 - created_at: project.created_at.date_naive().to_string(),
134 - updated_at: project.updated_at.date_naive().to_string(),
135 - };
136 - let table = Table::new(vec![row]);
137 - println!("{table}");
88 +pub(crate) fn show_project_detail(project: &Project) -> Result<()> {
89 + show_project_detail_tui(project).map_err(|e| anyhow!(e))
90 }
91
92 pub(crate) async fn process_project_use(env: Environment, id: String) -> Result<CommandResult> {
crates/cli/src/project/deployment.rs
+7 -64
@@ -3,14 +3,15 @@ use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 cli::CommandResult,
5 deploy::config::{check_project, get_config},
6 - ui::{succeed_message, succeed_symbol},
6 + ui::{
7 + deployment_detail_view::show_deployment_detail_tui, deployment_table::show_deployments_tui,
8 + succeed_message, succeed_symbol,
9 + },
10 };
8 -use anyhow::Result;
9 -use smbcloud_model::project::Deployment;
11 +use anyhow::{anyhow, Result};
12 use smbcloud_network::environment::Environment;
13 use smbcloud_networking_project::crud_project_deployment_read::{get_deployment, get_deployments};
14 use spinners::Spinner;
13 -use tabled::{Table, Tabled};
15
16 pub(crate) async fn process_deployment(
17 env: Environment,
@@ -37,13 +38,13 @@ pub(crate) async fn process_deployment(
38 )
39 .await?;
40 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded"));
40 - show_deployment_detail(&deployment);
41 + show_deployment_detail_tui(&deployment).map_err(|e| anyhow!(e))?;
42 } else {
43 // List all deployments for the project
44 let access_token = get_smb_token(env)?;
45 let deployments = get_deployments(env, client(), access_token, config.project.id).await?;
46 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Load all deployments"));
46 - show_project_deployments(&deployments);
47 + show_deployments_tui(deployments).map_err(|e| anyhow!(e))?;
48 };
49
50 Ok(CommandResult {
@@ -52,61 +53,3 @@ pub(crate) async fn process_deployment(
53 msg: succeed_message("Loaded"),
54 })
55 }
55 -
56 -// Helper struct for table display
57 -#[derive(Tabled)]
58 -struct DeploymentRow {
59 - id: i32,
60 - commit_hash: String,
61 - status: String, // Keep as String, but populate with Display trait output + Debug output
62 -}
63 -
64 -pub fn show_project_deployments(deployments: &[Deployment]) {
65 - let rows: Vec<DeploymentRow> = deployments
66 - .iter()
67 - .map(|d| DeploymentRow {
68 - id: d.id,
69 - commit_hash: d.commit_hash.clone(),
70 - status: format!("{} {:?}", d.status, d.status), // Use Display for emoji and Debug for text
71 - })
72 - .collect();
73 -
74 - let table = Table::new(rows);
75 - println!("{table}");
76 -}
77 -
78 -pub fn show_deployment_detail(deployment: &Deployment) {
79 - #[derive(Tabled)]
80 - struct Detail {
81 - #[tabled(rename = "ID")]
82 - id: i32,
83 - #[tabled(rename = "Project ID")]
84 - project_id: i32,
85 - #[tabled(rename = "Commit Hash")]
86 - commit_hash: String,
87 - #[tabled(rename = "Status")]
88 - status: String,
89 - #[tabled(rename = "Created At")]
90 - created_at: String,
91 - #[tabled(rename = "Updated At")]
92 - updated_at: String,
93 - }
94 -
95 - let row = Detail {
96 - id: deployment.id,
97 - project_id: deployment.project_id,
98 - commit_hash: deployment.commit_hash.clone(),
99 - status: format!("{} {:?}", deployment.status, deployment.status), // Use Display for emoji and Debug for text
100 - created_at: deployment
101 - .created_at
102 - .format("%Y-%m-%d %H:%M:%S")
103 - .to_string(),
104 - updated_at: deployment
105 - .updated_at
106 - .format("%Y-%m-%d %H:%M:%S")
107 - .to_string(),
108 - };
109 -
110 - let table = Table::new(vec![row]);
111 - println!("{table}");
112 -}
crates/cli/src/ui/confirm_dialog.rs new
+236
@@ -0,0 +1,236 @@
1 +//! Branded deletion-confirmation modal.
2 +//!
3 +//! Displays a centred danger-zone dialog and returns `true` if the user
4 +//! confirms, `false` if they cancel or press `Esc`.
5 +//!
6 +//! Keys:
7 +//! `y` → confirm immediately
8 +//! `n` / `q` / `Esc` → cancel immediately
9 +//! `←` / `→` / `Tab` → toggle between Confirm and Cancel buttons
10 +//! `Enter` → activate the currently selected button
11 +use super::theme::{BG, BG_SURFACE, BRAND, BRAND_DARK, BRAND_LIGHT, TEXT_MUTED, TEXT_PRIMARY};
12 +use crossterm::{
13 + event::{self, Event, KeyCode, KeyEventKind},
14 + execute,
15 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16 +};
17 +use ratatui::{
18 + backend::CrosstermBackend,
19 + layout::{Alignment, Constraint, Direction, Layout},
20 + style::{Color, Modifier, Style},
21 + text::{Line, Span},
22 + widgets::{Block, Borders, Paragraph},
23 + Terminal,
24 +};
25 +use std::io;
26 +
27 +// ── State ─────────────────────────────────────────────────────────────────────
28 +
29 +/// Which button currently has focus.
30 +/// Defaults to `Cancel` (index 1) to prevent accidental destructive actions.
31 +struct State {
32 + selected: usize, // 0 = Confirm, 1 = Cancel
33 +}
34 +
35 +impl State {
36 + fn new() -> Self {
37 + Self { selected: 1 }
38 + }
39 +
40 + fn toggle(&mut self) {
41 + self.selected = 1 - self.selected;
42 + }
43 +}
44 +
45 +// ── Public entry-point ────────────────────────────────────────────────────────
46 +
47 +/// Display a full-screen danger confirmation dialog.
48 +///
49 +/// Returns `Ok(true)` if the user confirmed, `Ok(false)` if they cancelled.
50 +pub fn confirm_delete_tui(message: &str) -> io::Result<bool> {
51 + enable_raw_mode()?;
52 + let mut stdout = io::stdout();
53 + execute!(stdout, EnterAlternateScreen)?;
54 +
55 + let backend = CrosstermBackend::new(stdout);
56 + let mut terminal = Terminal::new(backend)?;
57 +
58 + let mut state = State::new();
59 + let result = run_loop(&mut terminal, &mut state, message);
60 +
61 + disable_raw_mode()?;
62 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
63 + terminal.show_cursor()?;
64 +
65 + result
66 +}
67 +
68 +// ── Event loop ────────────────────────────────────────────────────────────────
69 +
70 +fn run_loop<B: ratatui::backend::Backend>(
71 + terminal: &mut Terminal<B>,
72 + state: &mut State,
73 + message: &str,
74 +) -> io::Result<bool> {
75 + loop {
76 + terminal.draw(|frame| render(frame, state, message))?;
77 +
78 + if let Event::Key(key) = event::read()? {
79 + if key.kind != KeyEventKind::Press {
80 + continue;
81 + }
82 + match key.code {
83 + // Quick shortcuts.
84 + KeyCode::Char('y') => return Ok(true),
85 + KeyCode::Char('n') | KeyCode::Char('q') | KeyCode::Esc => return Ok(false),
86 + // Button navigation.
87 + KeyCode::Left | KeyCode::Right | KeyCode::Tab => state.toggle(),
88 + // Activate selected button.
89 + KeyCode::Enter => return Ok(state.selected == 0),
90 + _ => {}
91 + }
92 + }
93 + }
94 +}
95 +
96 +// ── Rendering ─────────────────────────────────────────────────────────────────
97 +
98 +fn render(frame: &mut ratatui::Frame, state: &State, message: &str) {
99 + let area = frame.area();
100 +
101 + // Full-screen near-black background.
102 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
103 +
104 + // Split content area + footer.
105 + let outer = Layout::default()
106 + .direction(Direction::Vertical)
107 + .constraints([Constraint::Min(0), Constraint::Length(2)])
108 + .split(area);
109 +
110 + render_modal(frame, state, message, outer[0]);
111 + render_footer(frame, outer[1]);
112 +}
113 +
114 +fn render_modal(
115 + frame: &mut ratatui::Frame,
116 + state: &State,
117 + message: &str,
118 + area: ratatui::layout::Rect,
119 +) {
120 + // Horizontally center — at most 56 columns wide.
121 + let h_zones = Layout::default()
122 + .direction(Direction::Horizontal)
123 + .constraints([
124 + Constraint::Fill(1),
125 + Constraint::Max(56),
126 + Constraint::Fill(1),
127 + ])
128 + .split(area);
129 +
130 + // Vertically center — 11 lines tall (9 content + 2 border).
131 + let v_zones = Layout::default()
132 + .direction(Direction::Vertical)
133 + .constraints([
134 + Constraint::Fill(1),
135 + Constraint::Length(11),
136 + Constraint::Fill(1),
137 + ])
138 + .split(h_zones[1]);
139 +
140 + let modal_area = v_zones[1];
141 +
142 + // Button styles — the focused button gets a filled background.
143 + let confirm_style = if state.selected == 0 {
144 + Style::default()
145 + .fg(TEXT_PRIMARY)
146 + .bg(BRAND)
147 + .add_modifier(Modifier::BOLD)
148 + } else {
149 + Style::default().fg(TEXT_MUTED).bg(Color::Rgb(39, 39, 42))
150 + };
151 +
152 + let cancel_style = if state.selected == 1 {
153 + Style::default()
154 + .fg(TEXT_PRIMARY)
155 + .bg(Color::Rgb(63, 63, 70))
156 + .add_modifier(Modifier::BOLD)
157 + } else {
158 + Style::default().fg(TEXT_MUTED).bg(Color::Rgb(39, 39, 42))
159 + };
160 +
161 + let content: Vec<Line<'static>> = vec![
162 + Line::from(""),
163 + Line::from(vec![
164 + Span::raw(" "),
165 + Span::styled(
166 + "⚠ This action cannot be undone.",
167 + Style::default()
168 + .fg(BRAND_LIGHT)
169 + .add_modifier(Modifier::BOLD),
170 + ),
171 + ]),
172 + Line::from(""),
173 + Line::from(vec![
174 + Span::raw(" "),
175 + Span::styled(message.to_string(), Style::default().fg(TEXT_PRIMARY)),
176 + ]),
177 + Line::from(""),
178 + Line::from(""),
179 + Line::from(""),
180 + // Buttons row.
181 + Line::from(vec![
182 + Span::raw(" "),
183 + Span::styled(" Confirm ", confirm_style),
184 + Span::raw(" "),
185 + Span::styled(" Cancel ", cancel_style),
186 + ]),
187 + Line::from(""),
188 + ];
189 +
190 + let block = Block::default()
191 + .title(Span::styled(
192 + " ⚠ Danger Zone ",
193 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
194 + ))
195 + .borders(Borders::ALL)
196 + .border_style(Style::default().fg(BRAND))
197 + .style(Style::default().bg(BG_SURFACE));
198 +
199 + frame.render_widget(Paragraph::new(content).block(block), modal_area);
200 +}
201 +
202 +// ── Footer ────────────────────────────────────────────────────────────────────
203 +
204 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
205 + let hints: &[(&str, &str)] = &[
206 + ("y", "Confirm"),
207 + ("n / Esc", "Cancel"),
208 + ("← / →", "Switch"),
209 + ("Enter", "Select"),
210 + ];
211 +
212 + let mut spans = Vec::with_capacity(hints.len() * 4);
213 + for (i, (key, label)) in hints.iter().enumerate() {
214 + if i > 0 {
215 + spans.push(Span::styled(" ", Style::default()));
216 + }
217 + spans.push(Span::styled(
218 + format!(" {key} "),
219 + Style::default()
220 + .fg(BRAND)
221 + .bg(BRAND_DARK)
222 + .add_modifier(Modifier::BOLD),
223 + ));
224 + spans.push(Span::styled(
225 + format!(" {label}"),
226 + Style::default().fg(TEXT_MUTED),
227 + ));
228 + }
229 +
230 + frame.render_widget(
231 + Paragraph::new(Line::from(spans))
232 + .alignment(Alignment::Center)
233 + .style(Style::default().bg(BG_SURFACE)),
234 + area,
235 + );
236 +}
crates/cli/src/ui/deployment_detail_view.rs new
+253
@@ -0,0 +1,253 @@
1 +//! Deployment detail TUI card.
2 +//!
3 +//! Shows all fields for a single deployment in a centred card.
4 +//! Blocks until `q` / `Esc` is pressed, then restores the terminal.
5 +use super::theme::{
6 + BG, BG_SURFACE, BORDER_ACTIVE, BORDER_IDLE, BRAND, BRAND_DARK, BRAND_MID, TEXT_MUTED,
7 + TEXT_PRIMARY,
8 +};
9 +use crossterm::{
10 + event::{self, Event, KeyCode, KeyEventKind},
11 + execute,
12 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13 +};
14 +use ratatui::{
15 + backend::CrosstermBackend,
16 + layout::{Alignment, Constraint, Direction, Layout},
17 + style::{Color, Modifier, Style},
18 + text::{Line, Span},
19 + widgets::{Block, Borders, Paragraph},
20 + Terminal,
21 +};
22 +use smbcloud_model::project::{Deployment, DeploymentStatus};
23 +use std::io;
24 +
25 +// ── Public entry-point ────────────────────────────────────────────────────────
26 +
27 +/// Render a deployment detail card.
28 +///
29 +/// Enters the alternate screen, blocks until `q` / `Esc`, then restores
30 +/// the terminal.
31 +pub fn show_deployment_detail_tui(deployment: &Deployment) -> io::Result<()> {
32 + enable_raw_mode()?;
33 + let mut stdout = io::stdout();
34 + execute!(stdout, EnterAlternateScreen)?;
35 +
36 + let backend = CrosstermBackend::new(stdout);
37 + let mut terminal = Terminal::new(backend)?;
38 +
39 + let result = run_loop(&mut terminal, deployment);
40 +
41 + disable_raw_mode()?;
42 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
43 + terminal.show_cursor()?;
44 +
45 + result
46 +}
47 +
48 +// ── Event loop ────────────────────────────────────────────────────────────────
49 +
50 +fn run_loop<B: ratatui::backend::Backend>(
51 + terminal: &mut Terminal<B>,
52 + deployment: &Deployment,
53 +) -> io::Result<()> {
54 + loop {
55 + terminal.draw(|frame| render(frame, deployment))?;
56 +
57 + if let Event::Key(key) = event::read()? {
58 + if key.kind != KeyEventKind::Press {
59 + continue;
60 + }
61 + if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
62 + return Ok(());
63 + }
64 + }
65 + }
66 +}
67 +
68 +// ── Rendering ─────────────────────────────────────────────────────────────────
69 +
70 +fn render(frame: &mut ratatui::Frame, deployment: &Deployment) {
71 + let area = frame.area();
72 +
73 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
74 +
75 + let zones = Layout::default()
76 + .direction(Direction::Vertical)
77 + .constraints([
78 + Constraint::Length(3), // title bar
79 + Constraint::Min(0), // card
80 + Constraint::Length(2), // footer
81 + ])
82 + .split(area);
83 +
84 + render_title(frame, zones[0]);
85 + render_card(frame, deployment, zones[1]);
86 + render_footer(frame, zones[2]);
87 +}
88 +
89 +// ── Title bar ─────────────────────────────────────────────────────────────────
90 +
91 +fn render_title(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
92 + let block = Block::default()
93 + .style(Style::default().bg(BG_SURFACE))
94 + .borders(Borders::BOTTOM)
95 + .border_style(Style::default().fg(BORDER_IDLE));
96 +
97 + let line = Line::from(vec![
98 + Span::styled(
99 + "smb",
100 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
101 + ),
102 + Span::styled(
103 + "Cloud",
104 + Style::default()
105 + .fg(TEXT_PRIMARY)
106 + .add_modifier(Modifier::BOLD),
107 + ),
108 + Span::styled(" · ", Style::default().fg(BORDER_ACTIVE)),
109 + Span::styled("Deployment", Style::default().fg(TEXT_MUTED)),
110 + ]);
111 +
112 + frame.render_widget(
113 + Paragraph::new(line)
114 + .block(block)
115 + .alignment(Alignment::Center),
116 + area,
117 + );
118 +}
119 +
120 +// ── Deployment detail card ────────────────────────────────────────────────────
121 +
122 +fn render_card(frame: &mut ratatui::Frame, deployment: &Deployment, area: ratatui::layout::Rect) {
123 + // Horizontally center — at most 72 columns wide.
124 + let h_zones = Layout::default()
125 + .direction(Direction::Horizontal)
126 + .constraints([
127 + Constraint::Fill(1),
128 + Constraint::Max(72),
129 + Constraint::Fill(1),
130 + ])
131 + .split(area);
132 +
133 + let card_col = h_zones[1];
134 +
135 + // 13 content lines + 2 border rows = 15 total.
136 + let v_zones = Layout::default()
137 + .direction(Direction::Vertical)
138 + .constraints([
139 + Constraint::Fill(1),
140 + Constraint::Length(15),
141 + Constraint::Fill(1),
142 + ])
143 + .split(card_col);
144 +
145 + let card_area = v_zones[1];
146 +
147 + let sep_len = (card_area.width.saturating_sub(5)) as usize;
148 + let separator = "─".repeat(sep_len);
149 +
150 + let created_at = deployment
151 + .created_at
152 + .format("%Y-%m-%d %H:%M:%S")
153 + .to_string();
154 + let updated_at = deployment
155 + .updated_at
156 + .format("%Y-%m-%d %H:%M:%S")
157 + .to_string();
158 +
159 + let mut content: Vec<Line<'static>> = vec![
160 + Line::from(""),
161 + // Primary identity: "◈ Deploy #42"
162 + Line::from(vec![
163 + Span::raw(" "),
164 + Span::styled(
165 + "◈ ",
166 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
167 + ),
168 + Span::styled(
169 + format!("Deploy #{}", deployment.id),
170 + Style::default()
171 + .fg(TEXT_PRIMARY)
172 + .add_modifier(Modifier::BOLD),
173 + ),
174 + ]),
175 + Line::from(""),
176 + // Separator in brand-700.
177 + Line::from(vec![
178 + Span::raw(" "),
179 + Span::styled(separator, Style::default().fg(BRAND_MID)),
180 + ]),
181 + Line::from(""),
182 + field_line("ID", &deployment.id.to_string()),
183 + field_line("Project ID", &deployment.project_id.to_string()),
184 + field_line("Commit", &deployment.commit_hash),
185 + // Status row — coloured by outcome.
186 + status_line(&deployment.status),
187 + Line::from(""),
188 + field_line("Created at", &created_at),
189 + field_line("Updated at", &updated_at),
190 + Line::from(""),
191 + ];
192 +
193 + // Ensure content is exactly 13 lines (pad if short).
194 + while content.len() < 13 {
195 + content.push(Line::from(""));
196 + }
197 +
198 + let block = Block::default()
199 + .title(Span::styled(
200 + " Deployment ",
201 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
202 + ))
203 + .borders(Borders::ALL)
204 + .border_style(Style::default().fg(BORDER_ACTIVE))
205 + .style(Style::default().bg(BG_SURFACE));
206 +
207 + frame.render_widget(Paragraph::new(content).block(block), card_area);
208 +}
209 +
210 +// ── Helpers ───────────────────────────────────────────────────────────────────
211 +
212 +fn field_line(label: &str, value: &str) -> Line<'static> {
213 + Line::from(vec![
214 + Span::raw(" "),
215 + Span::styled(format!("{:<16}", label), Style::default().fg(TEXT_MUTED)),
216 + Span::styled(value.to_string(), Style::default().fg(TEXT_PRIMARY)),
217 + ])
218 +}
219 +
220 +fn status_line(status: &DeploymentStatus) -> Line<'static> {
221 + let (label, color): (&'static str, Color) = match status {
222 + DeploymentStatus::Started => ("🚀 Starting", Color::Rgb(251, 191, 36)), // amber-400
223 + DeploymentStatus::Done => ("✅ Done", Color::Rgb(34, 197, 94)), // green-500
224 + DeploymentStatus::Failed => ("❌ Failed", BRAND), // brand-600
225 + };
226 + Line::from(vec![
227 + Span::raw(" "),
228 + Span::styled(format!("{:<16}", "Status"), Style::default().fg(TEXT_MUTED)),
229 + Span::styled(
230 + label,
231 + Style::default().fg(color).add_modifier(Modifier::BOLD),
232 + ),
233 + ])
234 +}
235 +
236 +// ── Footer ────────────────────────────────────────────────────────────────────
237 +
238 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
239 + let footer = Paragraph::new(Line::from(vec![
240 + Span::styled(
241 + " q / Esc ",
242 + Style::default()
243 + .fg(BRAND)
244 + .bg(BRAND_DARK)
245 + .add_modifier(Modifier::BOLD),
246 + ),
247 + Span::styled(" Quit", Style::default().fg(TEXT_MUTED)),
248 + ]))
249 + .alignment(Alignment::Center)
250 + .style(Style::default().bg(BG_SURFACE));
251 +
252 + frame.render_widget(footer, area);
253 +}
crates/cli/src/ui/deployment_table.rs new
+292
@@ -0,0 +1,292 @@
1 +//! Deployments list TUI table.
2 +//!
3 +//! Interactive scrollable table for a list of deployments.
4 +//! Navigate with ↑ / k and ↓ / j. Press `q` or `Esc` to quit.
5 +use super::theme::{
6 + BG, BG_HEADER_ROW, BG_SURFACE, BORDER_ACTIVE, BORDER_IDLE, BRAND, BRAND_DARK, BRAND_LIGHT,
7 + BRAND_MID, TEXT_MUTED, TEXT_PRIMARY,
8 +};
9 +use crossterm::{
10 + event::{self, Event, KeyCode, KeyEventKind},
11 + execute,
12 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13 +};
14 +use ratatui::{
15 + backend::CrosstermBackend,
16 + layout::{Alignment, Constraint, Direction, Layout, Margin},
17 + style::{Color, Modifier, Style},
18 + text::{Line, Span},
19 + widgets::{
20 + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
21 + Table, TableState,
22 + },
23 + Terminal,
24 +};
25 +use smbcloud_model::project::{Deployment, DeploymentStatus};
26 +use std::io;
27 +
28 +// ── Internal app state ────────────────────────────────────────────────────────
29 +
30 +struct App {
31 + state: TableState,
32 + scroll: ScrollbarState,
33 + deployments: Vec<Deployment>,
34 +}
35 +
36 +impl App {
37 + fn new(deployments: Vec<Deployment>) -> Self {
38 + let count = deployments.len();
39 + let mut state = TableState::default();
40 + if count > 0 {
41 + state.select(Some(0));
42 + }
43 + Self {
44 + state,
45 + scroll: ScrollbarState::new(count.saturating_sub(1)),
46 + deployments,
47 + }
48 + }
49 +
50 + fn next(&mut self) {
51 + if self.deployments.is_empty() {
52 + return;
53 + }
54 + let next = match self.state.selected() {
55 + Some(i) => (i + 1).min(self.deployments.len() - 1),
56 + None => 0,
57 + };
58 + self.state.select(Some(next));
59 + self.scroll = self.scroll.position(next);
60 + }
61 +
62 + fn previous(&mut self) {
63 + if self.deployments.is_empty() {
64 + return;
65 + }
66 + let prev = match self.state.selected() {
67 + Some(i) => i.saturating_sub(1),
68 + None => 0,
69 + };
70 + self.state.select(Some(prev));
71 + self.scroll = self.scroll.position(prev);
72 + }
73 +}
74 +
75 +// ── Public entry-point ────────────────────────────────────────────────────────
76 +
77 +/// Render an interactive, scrollable deployments table.
78 +///
79 +/// Enters the alternate screen, blocks until `q` / `Esc`, then restores
80 +/// the terminal.
81 +pub fn show_deployments_tui(deployments: Vec<Deployment>) -> io::Result<()> {
82 + enable_raw_mode()?;
83 + let mut stdout = io::stdout();
84 + execute!(stdout, EnterAlternateScreen)?;
85 +
86 + let backend = CrosstermBackend::new(stdout);
87 + let mut terminal = Terminal::new(backend)?;
88 +
89 + let mut app = App::new(deployments);
90 + let result = run_loop(&mut terminal, &mut app);
91 +
92 + disable_raw_mode()?;
93 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
94 + terminal.show_cursor()?;
95 +
96 + result
97 +}
98 +
99 +// ── Event loop ────────────────────────────────────────────────────────────────
100 +
101 +fn run_loop<B: ratatui::backend::Backend>(
102 + terminal: &mut Terminal<B>,
103 + app: &mut App,
104 +) -> io::Result<()> {
105 + loop {
106 + terminal.draw(|frame| render(frame, app))?;
107 +
108 + if let Event::Key(key) = event::read()? {
109 + if key.kind != KeyEventKind::Press {
110 + continue;
111 + }
112 + match key.code {
113 + KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
114 + KeyCode::Down | KeyCode::Char('j') => app.next(),
115 + KeyCode::Up | KeyCode::Char('k') => app.previous(),
116 + _ => {}
117 + }
118 + }
119 + }
120 +}
121 +
122 +// ── Rendering ─────────────────────────────────────────────────────────────────
123 +
124 +fn render(frame: &mut ratatui::Frame, app: &mut App) {
125 + let area = frame.area();
126 +
127 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
128 +
129 + let zones = Layout::default()
130 + .direction(Direction::Vertical)
131 + .constraints([
132 + Constraint::Length(3), // title bar
133 + Constraint::Min(0), // table body
134 + Constraint::Length(2), // footer
135 + ])
136 + .split(area);
137 +
138 + render_title(frame, zones[0]);
139 + render_table(frame, app, zones[1]);
140 + render_footer(frame, zones[2]);
141 +}
142 +
143 +// ── Title bar ─────────────────────────────────────────────────────────────────
144 +
145 +fn render_title(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
146 + let block = Block::default()
147 + .style(Style::default().bg(BG_SURFACE))
148 + .borders(Borders::BOTTOM)
149 + .border_style(Style::default().fg(BORDER_IDLE));
150 +
151 + let line = Line::from(vec![
152 + Span::styled(
153 + "smb",
154 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
155 + ),
156 + Span::styled(
157 + "Cloud",
158 + Style::default()
159 + .fg(TEXT_PRIMARY)
160 + .add_modifier(Modifier::BOLD),
161 + ),
162 + Span::styled(" · ", Style::default().fg(BORDER_ACTIVE)),
163 + Span::styled("Deployments", Style::default().fg(TEXT_MUTED)),
164 + ]);
165 +
166 + frame.render_widget(
167 + Paragraph::new(line)
168 + .block(block)
169 + .alignment(Alignment::Center),
170 + area,
171 + );
172 +}
173 +
174 +// ── Deployments table ─────────────────────────────────────────────────────────
175 +
176 +fn status_cell(status: &DeploymentStatus) -> Cell<'static> {
177 + let (text, color): (&'static str, Color) = match status {
178 + DeploymentStatus::Started => ("🚀 Starting", Color::Rgb(251, 191, 36)), // amber-400
179 + DeploymentStatus::Done => ("✅ Done", Color::Rgb(34, 197, 94)), // green-500
180 + DeploymentStatus::Failed => ("❌ Failed", BRAND), // brand-600
181 + };
182 + Cell::from(text).style(Style::default().fg(color).add_modifier(Modifier::BOLD))
183 +}
184 +
185 +fn render_table(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) {
186 + let header = Row::new(
187 + ["#", "Commit", "Status", "Created", "Updated"]
188 + .iter()
189 + .map(|label| {
190 + Cell::from(*label).style(
191 + Style::default()
192 + .fg(BRAND)
193 + .bg(BG_HEADER_ROW)
194 + .add_modifier(Modifier::BOLD),
195 + )
196 + }),
197 + )
198 + .height(1)
199 + .bottom_margin(0);
200 +
201 + let rows = app.deployments.iter().enumerate().map(|(idx, deployment)| {
202 + let base_bg = if idx % 2 == 0 {
203 + BG
204 + } else {
205 + Color::Rgb(13, 13, 18)
206 + };
207 +
208 + // Truncate commit hash to 12 chars for a short-hash display.
209 + let short_commit: String = deployment.commit_hash.chars().take(12).collect();
210 + let created = deployment.created_at.format("%Y-%m-%d %H:%M").to_string();
211 + let updated = deployment.updated_at.format("%Y-%m-%d %H:%M").to_string();
212 +
213 + Row::new([
214 + Cell::from(deployment.id.to_string()),
215 + Cell::from(short_commit),
216 + status_cell(&deployment.status),
217 + Cell::from(created),
218 + Cell::from(updated),
219 + ])
220 + .style(Style::default().fg(TEXT_PRIMARY).bg(base_bg))
221 + .height(1)
222 + });
223 +
224 + let widths = [
225 + Constraint::Length(6), // #
226 + Constraint::Length(14), // Commit (12 chars + 2 padding)
227 + Constraint::Percentage(20), // Status
228 + Constraint::Percentage(25), // Created
229 + Constraint::Fill(1), // Updated
230 + ];
231 +
232 + let table = Table::new(rows, widths)
233 + .header(header)
234 + .block(
235 + Block::default()
236 + .borders(Borders::ALL)
237 + .border_style(Style::default().fg(BORDER_ACTIVE))
238 + .style(Style::default().bg(BG)),
239 + )
240 + .row_highlight_style(
241 + Style::default()
242 + .fg(BRAND_LIGHT)
243 + .bg(BRAND_DARK)
244 + .add_modifier(Modifier::BOLD),
245 + )
246 + .highlight_symbol("▶ ");
247 +
248 + frame.render_stateful_widget(table, area, &mut app.state);
249 +
250 + frame.render_stateful_widget(
251 + Scrollbar::new(ScrollbarOrientation::VerticalRight)
252 + .begin_symbol(Some("↑"))
253 + .end_symbol(Some("↓"))
254 + .thumb_style(Style::default().fg(BRAND_MID))
255 + .track_style(Style::default().fg(BORDER_IDLE)),
256 + area.inner(Margin {
257 + vertical: 1,
258 + horizontal: 0,
259 + }),
260 + &mut app.scroll,
261 + );
262 +}
263 +
264 +// ── Footer ────────────────────────────────────────────────────────────────────
265 +
266 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
267 + let hints: &[(&str, &str)] = &[("↑ / k", "Up"), ("↓ / j", "Down"), ("q / Esc", "Quit")];
268 +
269 + let mut spans = Vec::with_capacity(hints.len() * 4);
270 + for (i, (key, label)) in hints.iter().enumerate() {
271 + if i > 0 {
272 + spans.push(Span::styled(" ", Style::default()));
273 + }
274 + spans.push(Span::styled(
275 + format!(" {key} "),
276 + Style::default()
277 + .fg(BRAND)
278 + .bg(BRAND_DARK)
279 + .add_modifier(Modifier::BOLD),
280 + ));
281 + spans.push(Span::styled(
282 + format!(" {label}"),
283 + Style::default().fg(TEXT_MUTED),
284 + ));
285 + }
286 +
287 + let footer = Paragraph::new(Line::from(spans))
288 + .alignment(Alignment::Center)
289 + .style(Style::default().bg(BG_SURFACE));
290 +
291 + frame.render_widget(footer, area);
292 +}
crates/cli/src/ui/me_view.rs new
+224
@@ -0,0 +1,224 @@
1 +//! Profile / "me" TUI view.
2 +//!
3 +//! Displays the authenticated user's account details in a centred card
4 +//! with the smbCloud brand palette. Blocks until the user presses `q` or
5 +//! `Esc`, then restores the terminal.
6 +use super::theme::{
7 + BG, BG_SURFACE, BORDER_ACTIVE, BORDER_IDLE, BRAND, BRAND_DARK, BRAND_MID, TEXT_MUTED,
8 + TEXT_PRIMARY,
9 +};
10 +use crossterm::{
11 + event::{self, Event, KeyCode, KeyEventKind},
12 + execute,
13 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14 +};
15 +use ratatui::{
16 + backend::CrosstermBackend,
17 + layout::{Alignment, Constraint, Direction, Layout},
18 + style::{Modifier, Style},
19 + text::{Line, Span},
20 + widgets::{Block, Borders, Paragraph},
21 + Terminal,
22 +};
23 +use smbcloud_model::account::User;
24 +use std::io;
25 +
26 +// ── Public entry-point ────────────────────────────────────────────────────────
27 +
28 +/// Render the interactive account profile card.
29 +///
30 +/// Switches to the alternate screen and blocks until `q` / `Esc` is pressed,
31 +/// then restores the terminal exactly as it was.
32 +pub fn show_user_tui(user: &User) -> io::Result<()> {
33 + enable_raw_mode()?;
34 + let mut stdout = io::stdout();
35 + execute!(stdout, EnterAlternateScreen)?;
36 +
37 + let backend = CrosstermBackend::new(stdout);
38 + let mut terminal = Terminal::new(backend)?;
39 +
40 + let result = run_loop(&mut terminal, user);
41 +
42 + disable_raw_mode()?;
43 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
44 + terminal.show_cursor()?;
45 +
46 + result
47 +}
48 +
49 +// ── Event loop ────────────────────────────────────────────────────────────────
50 +
51 +fn run_loop<B: ratatui::backend::Backend>(
52 + terminal: &mut Terminal<B>,
53 + user: &User,
54 +) -> io::Result<()> {
55 + loop {
56 + terminal.draw(|frame| render(frame, user))?;
57 +
58 + if let Event::Key(key) = event::read()? {
59 + if key.kind != KeyEventKind::Press {
60 + continue;
61 + }
62 + if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
63 + return Ok(());
64 + }
65 + }
66 + }
67 +}
68 +
69 +// ── Rendering ─────────────────────────────────────────────────────────────────
70 +
71 +fn render(frame: &mut ratatui::Frame, user: &User) {
72 + let area = frame.area();
73 +
74 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
75 +
76 + let zones = Layout::default()
77 + .direction(Direction::Vertical)
78 + .constraints([
79 + Constraint::Length(3), // title bar
80 + Constraint::Min(0), // profile card
81 + Constraint::Length(2), // footer
82 + ])
83 + .split(area);
84 +
85 + render_title(frame, zones[0]);
86 + render_profile(frame, user, zones[1]);
87 + render_footer(frame, zones[2]);
88 +}
89 +
90 +// ── Title bar ─────────────────────────────────────────────────────────────────
91 +
92 +fn render_title(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
93 + let block = Block::default()
94 + .style(Style::default().bg(BG_SURFACE))
95 + .borders(Borders::BOTTOM)
96 + .border_style(Style::default().fg(BORDER_IDLE));
97 +
98 + let line = Line::from(vec![
99 + Span::styled(
100 + "smb",
101 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
102 + ),
103 + Span::styled(
104 + "Cloud",
105 + Style::default()
106 + .fg(TEXT_PRIMARY)
107 + .add_modifier(Modifier::BOLD),
108 + ),
109 + Span::styled(" · ", Style::default().fg(BORDER_ACTIVE)),
110 + Span::styled("Profile", Style::default().fg(TEXT_MUTED)),
111 + ]);
112 +
113 + frame.render_widget(
114 + Paragraph::new(line)
115 + .block(block)
116 + .alignment(Alignment::Center),
117 + area,
118 + );
119 +}
120 +
121 +// ── Profile card ──────────────────────────────────────────────────────────────
122 +
123 +fn render_profile(frame: &mut ratatui::Frame, user: &User, area: ratatui::layout::Rect) {
124 + // Center the card horizontally — at most 66 columns wide.
125 + let h_zones = Layout::default()
126 + .direction(Direction::Horizontal)
127 + .constraints([
128 + Constraint::Fill(1),
129 + Constraint::Max(66),
130 + Constraint::Fill(1),
131 + ])
132 + .split(area);
133 +
134 + let card_col = h_zones[1];
135 +
136 + // Center the card vertically — 13 lines tall (11 content + 2 border).
137 + let v_zones = Layout::default()
138 + .direction(Direction::Vertical)
139 + .constraints([
140 + Constraint::Fill(1),
141 + Constraint::Length(13),
142 + Constraint::Fill(1),
143 + ])
144 + .split(card_col);
145 +
146 + let card_area = v_zones[1];
147 +
148 + // Separator line — scales with the actual card width (minus 2 borders, minus 3 padding).
149 + let sep_len = (card_area.width.saturating_sub(5)) as usize;
150 + let separator = "─".repeat(sep_len);
151 +
152 + let member_since = user.created_at.format("%Y-%m-%d").to_string();
153 + let last_updated = user.updated_at.format("%Y-%m-%d").to_string();
154 +
155 + let content: Vec<Line<'static>> = vec![
156 + Line::from(""),
157 + // ◉ email — the primary identity line.
158 + Line::from(vec![
159 + Span::raw(" "),
160 + Span::styled(
161 + "◉ ",
162 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
163 + ),
164 + Span::styled(
165 + user.email.clone(),
166 + Style::default()
167 + .fg(TEXT_PRIMARY)
168 + .add_modifier(Modifier::BOLD),
169 + ),
170 + ]),
171 + Line::from(""),
172 + // Thin separator in brand-700.
173 + Line::from(vec![
174 + Span::raw(" "),
175 + Span::styled(separator, Style::default().fg(BRAND_MID)),
176 + ]),
177 + Line::from(""),
178 + // Field rows.
179 + field_line("ID", &user.id.to_string()),
180 + Line::from(""),
181 + field_line("Member since", &member_since),
182 + field_line("Last updated", &last_updated),
183 + Line::from(""),
184 + ];
185 +
186 + let block = Block::default()
187 + .title(Span::styled(
188 + " Account ",
189 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
190 + ))
191 + .borders(Borders::ALL)
192 + .border_style(Style::default().fg(BORDER_ACTIVE))
193 + .style(Style::default().bg(BG_SURFACE));
194 +
195 + frame.render_widget(Paragraph::new(content).block(block), card_area);
196 +}
197 +
198 +/// Render a single label / value field row with consistent alignment.
199 +fn field_line(label: &str, value: &str) -> Line<'static> {
200 + Line::from(vec![
201 + Span::raw(" "),
202 + Span::styled(format!("{:<16}", label), Style::default().fg(TEXT_MUTED)),
203 + Span::styled(value.to_string(), Style::default().fg(TEXT_PRIMARY)),
204 + ])
205 +}
206 +
207 +// ── Footer ────────────────────────────────────────────────────────────────────
208 +
209 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
210 + let footer = Paragraph::new(Line::from(vec![
211 + Span::styled(
212 + " q / Esc ",
213 + Style::default()
214 + .fg(BRAND)
215 + .bg(BRAND_DARK)
216 + .add_modifier(Modifier::BOLD),
217 + ),
218 + Span::styled(" Quit", Style::default().fg(TEXT_MUTED)),
219 + ]))
220 + .alignment(Alignment::Center)
221 + .style(Style::default().bg(BG_SURFACE));
222 +
223 + frame.render_widget(footer, area);
224 +}
crates/cli/src/ui/mod.rs
+8
@@ -1,3 +1,11 @@
1 +pub mod confirm_dialog;
2 +pub mod deployment_detail_view;
3 +pub mod deployment_table;
4 +pub mod me_view;
5 +pub mod project_detail_view;
6 +pub mod project_table;
7 +pub mod theme;
8 +
9 use console::style;
10
11 pub fn succeed_symbol() -> String {
crates/cli/src/ui/project_detail_view.rs new
+235
@@ -0,0 +1,235 @@
1 +//! Project detail TUI card.
2 +//!
3 +//! Shows all key fields for a single project in a centred card.
4 +//! Blocks until `q` / `Esc` is pressed, then restores the terminal.
5 +use super::theme::{
6 + BG, BG_SURFACE, BORDER_ACTIVE, BORDER_IDLE, BRAND, BRAND_DARK, BRAND_MID, TEXT_MUTED,
7 + TEXT_PRIMARY,
8 +};
9 +use crossterm::{
10 + event::{self, Event, KeyCode, KeyEventKind},
11 + execute,
12 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13 +};
14 +use ratatui::{
15 + backend::CrosstermBackend,
16 + layout::{Alignment, Constraint, Direction, Layout},
17 + style::{Modifier, Style},
18 + text::{Line, Span},
19 + widgets::{Block, Borders, Paragraph},
20 + Terminal,
21 +};
22 +use smbcloud_model::project::Project;
23 +use std::io;
24 +
25 +// ── Public entry-point ────────────────────────────────────────────────────────
26 +
27 +/// Render a project detail card.
28 +///
29 +/// Enters the alternate screen, blocks until `q` / `Esc`, then restores
30 +/// the terminal.
31 +pub fn show_project_detail_tui(project: &Project) -> io::Result<()> {
32 + enable_raw_mode()?;
33 + let mut stdout = io::stdout();
34 + execute!(stdout, EnterAlternateScreen)?;
35 +
36 + let backend = CrosstermBackend::new(stdout);
37 + let mut terminal = Terminal::new(backend)?;
38 +
39 + let result = run_loop(&mut terminal, project);
40 +
41 + disable_raw_mode()?;
42 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
43 + terminal.show_cursor()?;
44 +
45 + result
46 +}
47 +
48 +// ── Event loop ────────────────────────────────────────────────────────────────
49 +
50 +fn run_loop<B: ratatui::backend::Backend>(
51 + terminal: &mut Terminal<B>,
52 + project: &Project,
53 +) -> io::Result<()> {
54 + loop {
55 + terminal.draw(|frame| render(frame, project))?;
56 +
57 + if let Event::Key(key) = event::read()? {
58 + if key.kind != KeyEventKind::Press {
59 + continue;
60 + }
61 + if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
62 + return Ok(());
63 + }
64 + }
65 + }
66 +}
67 +
68 +// ── Rendering ─────────────────────────────────────────────────────────────────
69 +
70 +fn render(frame: &mut ratatui::Frame, project: &Project) {
71 + let area = frame.area();
72 +
73 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
74 +
75 + let zones = Layout::default()
76 + .direction(Direction::Vertical)
77 + .constraints([
78 + Constraint::Length(3), // title bar
79 + Constraint::Min(0), // card
80 + Constraint::Length(2), // footer
81 + ])
82 + .split(area);
83 +
84 + render_title(frame, zones[0]);
85 + render_card(frame, project, zones[1]);
86 + render_footer(frame, zones[2]);
87 +}
88 +
89 +// ── Title bar ─────────────────────────────────────────────────────────────────
90 +
91 +fn render_title(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
92 + let block = Block::default()
93 + .style(Style::default().bg(BG_SURFACE))
94 + .borders(Borders::BOTTOM)
95 + .border_style(Style::default().fg(BORDER_IDLE));
96 +
97 + let line = Line::from(vec![
98 + Span::styled(
99 + "smb",
100 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
101 + ),
102 + Span::styled(
103 + "Cloud",
104 + Style::default()
105 + .fg(TEXT_PRIMARY)
106 + .add_modifier(Modifier::BOLD),
107 + ),
108 + Span::styled(" · ", Style::default().fg(BORDER_ACTIVE)),
109 + Span::styled("Project", Style::default().fg(TEXT_MUTED)),
110 + ]);
111 +
112 + frame.render_widget(
113 + Paragraph::new(line)
114 + .block(block)
115 + .alignment(Alignment::Center),
116 + area,
117 + );
118 +}
119 +
120 +// ── Project detail card ───────────────────────────────────────────────────────
121 +
122 +fn render_card(frame: &mut ratatui::Frame, project: &Project, area: ratatui::layout::Rect) {
123 + // Horizontally center — at most 72 columns wide.
124 + let h_zones = Layout::default()
125 + .direction(Direction::Horizontal)
126 + .constraints([
127 + Constraint::Fill(1),
128 + Constraint::Max(72),
129 + Constraint::Fill(1),
130 + ])
131 + .split(area);
132 +
133 + let card_col = h_zones[1];
134 +
135 + // 15 content lines + 2 border rows = 17 total.
136 + let v_zones = Layout::default()
137 + .direction(Direction::Vertical)
138 + .constraints([
139 + Constraint::Fill(1),
140 + Constraint::Length(17),
141 + Constraint::Fill(1),
142 + ])
143 + .split(card_col);
144 +
145 + let card_area = v_zones[1];
146 +
147 + let sep_len = (card_area.width.saturating_sub(5)) as usize;
148 + let separator = "─".repeat(sep_len);
149 +
150 + let created_at = project.created_at.format("%Y-%m-%d").to_string();
151 + let updated_at = project.updated_at.format("%Y-%m-%d").to_string();
152 +
153 + let content: Vec<Line<'static>> = vec![
154 + Line::from(""),
155 + // ◈ project-name — primary identity.
156 + Line::from(vec![
157 + Span::raw(" "),
158 + Span::styled(
159 + "◈ ",
160 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
161 + ),
162 + Span::styled(
163 + project.name.clone(),
164 + Style::default()
165 + .fg(TEXT_PRIMARY)
166 + .add_modifier(Modifier::BOLD),
167 + ),
168 + ]),
169 + Line::from(""),
170 + // Separator in brand-700.
171 + Line::from(vec![
172 + Span::raw(" "),
173 + Span::styled(separator, Style::default().fg(BRAND_MID)),
174 + ]),
175 + Line::from(""),
176 + // Fields.
177 + field_line("ID", &project.id.to_string()),
178 + field_line("Runner", &project.runner.to_string()),
179 + field_line("Deploy method", &project.deployment_method.to_string()),
180 + Line::from(""),
181 + field_line(
182 + "Repository",
183 + &project.repository.clone().unwrap_or_else(|| "—".into()),
184 + ),
185 + field_line(
186 + "Description",
187 + &project.description.clone().unwrap_or_else(|| "—".into()),
188 + ),
189 + Line::from(""),
190 + field_line("Created at", &created_at),
191 + field_line("Updated at", &updated_at),
192 + Line::from(""),
193 + ];
194 +
195 + let block = Block::default()
196 + .title(Span::styled(
197 + " Project ",
198 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
199 + ))
200 + .borders(Borders::ALL)
201 + .border_style(Style::default().fg(BORDER_ACTIVE))
202 + .style(Style::default().bg(BG_SURFACE));
203 +
204 + frame.render_widget(Paragraph::new(content).block(block), card_area);
205 +}
206 +
207 +// ── Helpers ───────────────────────────────────────────────────────────────────
208 +
209 +/// Label / value row with 16-char label padding.
210 +fn field_line(label: &str, value: &str) -> Line<'static> {
211 + Line::from(vec![
212 + Span::raw(" "),
213 + Span::styled(format!("{:<16}", label), Style::default().fg(TEXT_MUTED)),
214 + Span::styled(value.to_string(), Style::default().fg(TEXT_PRIMARY)),
215 + ])
216 +}
217 +
218 +// ── Footer ────────────────────────────────────────────────────────────────────
219 +
220 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
221 + let footer = Paragraph::new(Line::from(vec![
222 + Span::styled(
223 + " q / Esc ",
224 + Style::default()
225 + .fg(BRAND)
226 + .bg(BRAND_DARK)
227 + .add_modifier(Modifier::BOLD),
228 + ),
229 + Span::styled(" Quit", Style::default().fg(TEXT_MUTED)),
230 + ]))
231 + .alignment(Alignment::Center)
232 + .style(Style::default().bg(BG_SURFACE));
233 +
234 + frame.render_widget(footer, area);
235 +}
crates/cli/src/ui/project_table.rs new
+283
@@ -0,0 +1,283 @@
1 +use super::theme::{
2 + BG, BG_HEADER_ROW, BG_SURFACE, BORDER_ACTIVE, BORDER_IDLE, BRAND, BRAND_DARK, BRAND_LIGHT,
3 + BRAND_MID, TEXT_MUTED, TEXT_PRIMARY,
4 +};
5 +use crossterm::{
6 + event::{self, Event, KeyCode, KeyEventKind},
7 + execute,
8 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9 +};
10 +use ratatui::{
11 + backend::CrosstermBackend,
12 + layout::{Alignment, Constraint, Direction, Layout, Margin},
13 + style::{Color, Modifier, Style},
14 + text::{Line, Span},
15 + widgets::{
16 + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
17 + Table, TableState,
18 + },
19 + Terminal,
20 +};
21 +use smbcloud_model::project::Project;
22 +use std::io;
23 +
24 +// ── Internal app state ────────────────────────────────────────────────────────────────────────────
25 +
26 +struct App {
27 + state: TableState,
28 + scroll: ScrollbarState,
29 + projects: Vec<Project>,
30 +}
31 +
32 +impl App {
33 + fn new(projects: Vec<Project>) -> Self {
34 + let count = projects.len();
35 + let mut state = TableState::default();
36 + if count > 0 {
37 + state.select(Some(0));
38 + }
39 + Self {
40 + state,
41 + scroll: ScrollbarState::new(count.saturating_sub(1)),
42 + projects,
43 + }
44 + }
45 +
46 + fn next(&mut self) {
47 + if self.projects.is_empty() {
48 + return;
49 + }
50 + let next = match self.state.selected() {
51 + Some(i) => (i + 1).min(self.projects.len() - 1),
52 + None => 0,
53 + };
54 + self.state.select(Some(next));
55 + self.scroll = self.scroll.position(next);
56 + }
57 +
58 + fn previous(&mut self) {
59 + if self.projects.is_empty() {
60 + return;
61 + }
62 + let prev = match self.state.selected() {
63 + Some(i) => i.saturating_sub(1),
64 + None => 0,
65 + };
66 + self.state.select(Some(prev));
67 + self.scroll = self.scroll.position(prev);
68 + }
69 +}
70 +
71 +// ── Public entry-point ──────────────────────────────────────────────────────────────────────────────
72 +
73 +/// Render an interactive, scrollable project list table.
74 +///
75 +/// Switches to the alternate screen and blocks until the user presses
76 +/// `q` or `Esc`, then restores the terminal exactly as it was.
77 +/// Any IO error is propagated to the caller.
78 +pub fn show_projects_tui(projects: Vec<Project>) -> io::Result<()> {
79 + enable_raw_mode()?;
80 + let mut stdout = io::stdout();
81 + execute!(stdout, EnterAlternateScreen)?;
82 +
83 + let backend = CrosstermBackend::new(stdout);
84 + let mut terminal = Terminal::new(backend)?;
85 +
86 + let mut app = App::new(projects);
87 + let result = run_loop(&mut terminal, &mut app);
88 +
89 + // Always restore terminal state, even on error.
90 + disable_raw_mode()?;
91 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
92 + terminal.show_cursor()?;
93 +
94 + result
95 +}
96 +
97 +// ── Event loop ────────────────────────────────────────────────────────────────────────────────────
98 +
99 +fn run_loop<B: ratatui::backend::Backend>(
100 + terminal: &mut Terminal<B>,
101 + app: &mut App,
102 +) -> io::Result<()> {
103 + loop {
104 + terminal.draw(|frame| render(frame, app))?;
105 +
106 + if let Event::Key(key) = event::read()? {
107 + if key.kind != KeyEventKind::Press {
108 + continue;
109 + }
110 + match key.code {
111 + KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
112 + KeyCode::Down | KeyCode::Char('j') => app.next(),
113 + KeyCode::Up | KeyCode::Char('k') => app.previous(),
114 + _ => {}
115 + }
116 + }
117 + }
118 +}
119 +
120 +// ── Rendering ─────────────────────────────────────────────────────────────────────────────────────────
121 +
122 +fn render(frame: &mut ratatui::Frame, app: &mut App) {
123 + let area = frame.area();
124 +
125 + // Root canvas background.
126 + frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
127 +
128 + let zones = Layout::default()
129 + .direction(Direction::Vertical)
130 + .constraints([
131 + Constraint::Length(3), // title bar
132 + Constraint::Min(0), // table body
133 + Constraint::Length(2), // key-hint footer
134 + ])
135 + .split(area);
136 +
137 + render_title(frame, zones[0]);
138 + render_table(frame, app, zones[1]);
139 + render_footer(frame, zones[2]);
140 +}
141 +
142 +// ── Title bar ─────────────────────────────────────────────────────────────────────────────────────────
143 +
144 +fn render_title(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
145 + let block = Block::default()
146 + .style(Style::default().bg(BG_SURFACE))
147 + .borders(Borders::BOTTOM)
148 + .border_style(Style::default().fg(BORDER_IDLE));
149 +
150 + let line = Line::from(vec![
151 + Span::styled(
152 + "smb",
153 + Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
154 + ),
155 + Span::styled(
156 + "Cloud",
157 + Style::default()
158 + .fg(TEXT_PRIMARY)
159 + .add_modifier(Modifier::BOLD),
160 + ),
161 + Span::styled(" · ", Style::default().fg(BORDER_ACTIVE)),
162 + Span::styled("Projects", Style::default().fg(TEXT_MUTED)),
163 + ]);
164 +
165 + let title = Paragraph::new(line)
166 + .block(block)
167 + .alignment(Alignment::Center);
168 +
169 + frame.render_widget(title, area);
170 +}
171 +
172 +// ── Projects table ────────────────────────────────────────────────────────────────────────────────────
173 +
174 +fn render_table(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layout::Rect) {
175 + // Column header row.
176 + let header = Row::new(
177 + ["#", "Name", "Runner", "Repository", "Description"]
178 + .iter()
179 + .map(|label| {
180 + Cell::from(*label).style(
181 + Style::default()
182 + .fg(BRAND)
183 + .bg(BG_HEADER_ROW)
184 + .add_modifier(Modifier::BOLD),
185 + )
186 + }),
187 + )
188 + .height(1)
189 + .bottom_margin(0);
190 +
191 + // Data rows.
192 + let rows = app.projects.iter().enumerate().map(|(idx, project)| {
193 + // Subtle zebra-stripe: slightly lighter surface on even rows.
194 + let base_bg = if idx % 2 == 0 {
195 + BG
196 + } else {
197 + Color::Rgb(13, 13, 18)
198 + };
199 +
200 + Row::new([
201 + Cell::from(project.id.to_string()),
202 + Cell::from(project.name.clone()),
203 + Cell::from(project.runner.to_string()),
204 + Cell::from(project.repository.clone().unwrap_or_else(|| "—".into())),
205 + Cell::from(project.description.clone().unwrap_or_else(|| "—".into())),
206 + ])
207 + .style(Style::default().fg(TEXT_PRIMARY).bg(base_bg))
208 + .height(1)
209 + });
210 +
211 + // Column widths.
212 + let widths = [
213 + Constraint::Length(6), // #
214 + Constraint::Percentage(22), // Name
215 + Constraint::Percentage(14), // Runner
216 + Constraint::Percentage(28), // Repository
217 + Constraint::Fill(1), // Description (greedy)
218 + ];
219 +
220 + let table = Table::new(rows, widths)
221 + .header(header)
222 + .block(
223 + Block::default()
224 + .borders(Borders::ALL)
225 + .border_style(Style::default().fg(BORDER_ACTIVE))
226 + .style(Style::default().bg(BG)),
227 + )
228 + // Selected row: brand-800 bg + brand-300 fg, bold.
229 + .row_highlight_style(
230 + Style::default()
231 + .fg(BRAND_LIGHT)
232 + .bg(BRAND_DARK)
233 + .add_modifier(Modifier::BOLD),
234 + )
235 + // Right-pointing triangle glyph marks the active row.
236 + .highlight_symbol("▶ ");
237 +
238 + frame.render_stateful_widget(table, area, &mut app.state);
239 +
240 + // Scrollbar on the right edge of the table body.
241 + frame.render_stateful_widget(
242 + Scrollbar::new(ScrollbarOrientation::VerticalRight)
243 + .begin_symbol(Some("↑"))
244 + .end_symbol(Some("↓"))
245 + .thumb_style(Style::default().fg(BRAND_MID))
246 + .track_style(Style::default().fg(BORDER_IDLE)),
247 + area.inner(Margin {
248 + vertical: 1,
249 + horizontal: 0,
250 + }),
251 + &mut app.scroll,
252 + );
253 +}
254 +
255 +// ── Key-hint footer ───────────────────────────────────────────────────────────────────────────────────────
256 +
257 +fn render_footer(frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
258 + let hints: &[(&str, &str)] = &[("↑ / k", "Up"), ("↓ / j", "Down"), ("q / Esc", "Quit")];
259 +
260 + let mut spans = Vec::with_capacity(hints.len() * 4);
261 + for (i, (key, label)) in hints.iter().enumerate() {
262 + if i > 0 {
263 + spans.push(Span::styled(" ", Style::default()));
264 + }
265 + spans.push(Span::styled(
266 + format!(" {key} "),
267 + Style::default()
268 + .fg(BRAND)
269 + .bg(BRAND_DARK)
270 + .add_modifier(Modifier::BOLD),
271 + ));
272 + spans.push(Span::styled(
273 + format!(" {label}"),
274 + Style::default().fg(TEXT_MUTED),
275 + ));
276 + }
277 +
278 + let footer = Paragraph::new(Line::from(spans))
279 + .alignment(Alignment::Center)
280 + .style(Style::default().bg(BG_SURFACE));
281 +
282 + frame.render_widget(footer, area);
283 +}
crates/cli/src/ui/theme.rs new
+24
@@ -0,0 +1,24 @@
1 +//! Shared smbCloud brand colour palette for all TUI views.
2 +//!
3 +//! Sourced 1-to-1 from `smbcloud-web/tailwind.config.ts` `colors.brand` scale.
4 +//!
5 +//! brand-600 #E11D48 → primary accent (titles, column headers, key hints)
6 +//! brand-700 #BE123C → active borders / scrollbar thumb
7 +//! brand-800 #9F1239 → selected-row / key-hint pill background
8 +//! brand-300 #FDA4AF → selected-row foreground
9 +use ratatui::style::Color;
10 +
11 +pub const BRAND: Color = Color::Rgb(225, 29, 72); // #E11D48 brand-600
12 +pub const BRAND_MID: Color = Color::Rgb(190, 18, 60); // #BE123C brand-700
13 +pub const BRAND_DARK: Color = Color::Rgb(159, 18, 57); // #9F1239 brand-800
14 +pub const BRAND_LIGHT: Color = Color::Rgb(253, 164, 175); // #FDA4AF brand-300
15 +
16 +pub const BG: Color = Color::Rgb(9, 9, 11); // near-black canvas
17 +pub const BG_SURFACE: Color = Color::Rgb(18, 18, 24); // elevated chrome (bars)
18 +pub const BG_HEADER_ROW: Color = Color::Rgb(26, 26, 36); // column-header band
19 +
20 +pub const TEXT_PRIMARY: Color = Color::Rgb(241, 241, 243); // off-white body
21 +pub const TEXT_MUTED: Color = Color::Rgb(113, 113, 122); // zinc-500 secondary
22 +
23 +pub const BORDER_IDLE: Color = Color::Rgb(39, 39, 42); // zinc-800 resting
24 +pub const BORDER_ACTIVE: Color = Color::Rgb(63, 63, 70); // zinc-700 focused