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