Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Introduce flux application
Archived did:key:z6MkswQE...2C1V opened 2 years ago
30 files changed +2619 -126 652720da 74680406
modified Cargo.lock
@@ -3,6 +3,15 @@
version = 3

[[package]]
+
name = "addr2line"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+
dependencies = [
+
 "gimli",
+
]
+

+
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -44,6 +53,18 @@ dependencies = [
]

[[package]]
+
name = "ahash"
+
version = "0.8.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff"
+
dependencies = [
+
 "cfg-if",
+
 "once_cell",
+
 "version_check",
+
 "zerocopy",
+
]
+

+
[[package]]
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -53,6 +74,12 @@ dependencies = [
]

[[package]]
+
name = "allocator-api2"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5"
+

+
[[package]]
name = "amplify"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -139,6 +166,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"

[[package]]
+
name = "backtrace"
+
version = "0.3.69"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
+
dependencies = [
+
 "addr2line",
+
 "cc",
+
 "cfg-if",
+
 "libc",
+
 "miniz_oxide",
+
 "object",
+
 "rustc-demangle",
+
]
+

+
[[package]]
name = "base-x"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -163,6 +205,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"

[[package]]
+
name = "base64"
+
version = "0.21.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+

+
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -233,9 +281,9 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"

[[package]]
name = "bytecount"
-
version = "0.6.3"
+
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c"
+
checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205"

[[package]]
name = "byteorder"
@@ -244,12 +292,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"

[[package]]
+
name = "bytes"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
+

+
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"

[[package]]
+
name = "castaway"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
+
dependencies = [
+
 "rustversion",
+
]
+

+
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -295,7 +358,7 @@ dependencies = [
 "iana-time-zone",
 "js-sys",
 "num-traits",
-
 "time",
+
 "time 0.1.45",
 "wasm-bindgen",
 "winapi",
]
@@ -336,6 +399,19 @@ dependencies = [
]

[[package]]
+
name = "compact_str"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+
dependencies = [
+
 "castaway",
+
 "cfg-if",
+
 "itoa",
+
 "ryu",
+
 "static_assertions",
+
]
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -401,6 +477,22 @@ dependencies = [
]

[[package]]
+
name = "crossterm"
+
version = "0.27.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "crossterm_winapi",
+
 "libc",
+
 "mio",
+
 "parking_lot",
+
 "signal-hook",
+
 "signal-hook-mio",
+
 "winapi",
+
]
+

+
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -516,6 +608,15 @@ dependencies = [
]

[[package]]
+
name = "deranged"
+
version = "0.3.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+
dependencies = [
+
 "powerfmt",
+
]
+

+
[[package]]
name = "derive-new"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -706,6 +807,12 @@ dependencies = [
]

[[package]]
+
name = "futures-core"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+

+
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -748,6 +855,12 @@ dependencies = [
]

[[package]]
+
name = "gimli"
+
version = "0.28.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+

+
[[package]]
name = "git-ref-format"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -805,15 +918,13 @@ dependencies = [

[[package]]
name = "hashbrown"
-
version = "0.12.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
-

-
[[package]]
-
name = "hashbrown"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
+
dependencies = [
+
 "ahash",
+
 "allocator-api2",
+
]

[[package]]
name = "heck"
@@ -822,6 +933,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"

[[package]]
+
name = "hermit-abi"
+
version = "0.3.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d0c62115964e08cb8039170eb33c1d0e2388a256930279edca206fff675f82c3"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -865,22 +982,12 @@ dependencies = [

[[package]]
name = "indexmap"
-
version = "1.9.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
-
dependencies = [
-
 "autocfg",
-
 "hashbrown 0.12.3",
-
]
-

-
[[package]]
-
name = "indexmap"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
dependencies = [
 "equivalent",
-
 "hashbrown 0.14.3",
+
 "hashbrown",
]

[[package]]
@@ -935,6 +1042,15 @@ dependencies = [
]

[[package]]
+
name = "itertools"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+
dependencies = [
+
 "either",
+
]
+

+
[[package]]
name = "itoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -969,17 +1085,40 @@ dependencies = [

[[package]]
name = "lazy-regex"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ff63c423c68ea6814b7da9e88ce585f793c87ddd9e78f646970891769c8235d4"
+
dependencies = [
+
 "lazy-regex-proc_macros 2.4.1",
+
 "once_cell",
+
 "regex",
+
]
+

+
[[package]]
+
name = "lazy-regex"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57451d19ad5e289ff6c3d69c2a2424652995c42b79dafa11e9c4d5508c913c01"
dependencies = [
-
 "lazy-regex-proc_macros",
+
 "lazy-regex-proc_macros 3.0.1",
 "once_cell",
 "regex",
]

[[package]]
name = "lazy-regex-proc_macros"
+
version = "2.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8edfc11b8f56ce85e207e62ea21557cfa09bb24a8f6b04ae181b086ff8611c22"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "regex",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "lazy-regex-proc_macros"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0a1d9139f0ee2e862e08a9c5d0ba0470f2aa21cd1e1aa1b1562f83116c725f"
@@ -1001,15 +1140,15 @@ dependencies = [

[[package]]
name = "lexopt"
-
version = "0.2.1"
+
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8"
+
checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"

[[package]]
name = "libc"
-
version = "0.2.147"
+
version = "0.2.153"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"

[[package]]
name = "libgit2-sys"
@@ -1030,6 +1169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"

[[package]]
+
name = "libredox"
+
version = "0.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "libc",
+
 "redox_syscall 0.4.1",
+
]
+

+
[[package]]
name = "libz-sys"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1073,6 +1223,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"

[[package]]
+
name = "lru"
+
version = "0.12.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db2c024b41519440580066ba82aab04092b333e09066a5eb86c7c4890df31f22"
+
dependencies = [
+
 "hashbrown",
+
]
+

+
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1113,9 +1272,9 @@ dependencies = [

[[package]]
name = "mio"
-
version = "0.8.8"
+
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
dependencies = [
 "libc",
 "log",
@@ -1173,9 +1332,9 @@ checksum = "9ff7ac1e5ea23db6d61ad103e91864675049644bf47c35912336352fa4e9c109"

[[package]]
name = "nonempty"
-
version = "0.8.1"
+
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "aeaf4ad7403de93e699c191202f017118df734d3850b01e13a3a8b2e6953d3c9"
+
checksum = "995defdca0a589acfdd1bd2e8e3b896b4d4f7675a31fd14c32611440c7f608e6"
dependencies = [
 "serde",
]
@@ -1198,6 +1357,12 @@ dependencies = [
]

[[package]]
+
name = "num-conv"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+

+
[[package]]
name = "num-integer"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1229,6 +1394,25 @@ dependencies = [
]

[[package]]
+
name = "num_cpus"
+
version = "1.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+
dependencies = [
+
 "hermit-abi",
+
 "libc",
+
]
+

+
[[package]]
+
name = "num_threads"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "numtoa"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1264,6 +1448,15 @@ dependencies = [
]

[[package]]
+
name = "object"
+
version = "0.32.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+
dependencies = [
+
 "memchr",
+
]
+

+
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1277,12 +1470,12 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"

[[package]]
name = "os_pipe"
-
version = "1.1.4"
+
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0ae859aa07428ca9a929b936690f8b12dc5f11dd8c6992a18ca93919f28bc177"
+
checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9"
dependencies = [
 "libc",
-
 "windows-sys 0.48.0",
+
 "windows-sys 0.52.0",
]

[[package]]
@@ -1378,12 +1571,12 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"

[[package]]
name = "petgraph"
-
version = "0.6.3"
+
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4"
+
checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9"
dependencies = [
 "fixedbitset",
-
 "indexmap 1.9.3",
+
 "indexmap",
]

[[package]]
@@ -1401,10 +1594,16 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
-
 "siphasher",
+
 "siphasher 0.3.10",
]

[[package]]
+
name = "pin-project-lite"
+
version = "0.2.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+

+
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1455,6 +1654,12 @@ dependencies = [
]

[[package]]
+
name = "powerfmt"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+

+
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1514,17 +1719,19 @@ dependencies = [
[[package]]
name = "radicle"
version = "0.2.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "amplify",
+
 "base64 0.21.7",
 "crossbeam-channel",
 "cyphernet",
 "fastrand",
 "git2",
+
 "libc",
 "localtime",
 "log",
 "multibase",
-
 "nonempty 0.8.1",
+
 "nonempty 0.9.0",
 "once_cell",
 "radicle-cob",
 "radicle-crypto",
@@ -1532,7 +1739,7 @@ dependencies = [
 "radicle-ssh",
 "serde",
 "serde_json",
-
 "siphasher",
+
 "siphasher 1.0.0",
 "sqlite",
 "tempfile",
 "thiserror",
@@ -1542,12 +1749,12 @@ dependencies = [
[[package]]
name = "radicle-cob"
version = "0.2.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "fastrand",
 "git2",
 "log",
-
 "nonempty 0.8.1",
+
 "nonempty 0.9.0",
 "once_cell",
 "radicle-crypto",
 "radicle-dag",
@@ -1560,7 +1767,7 @@ dependencies = [
[[package]]
name = "radicle-crypto"
version = "0.2.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "amplify",
 "cyphernet",
@@ -1578,7 +1785,7 @@ dependencies = [
[[package]]
name = "radicle-dag"
version = "0.2.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "fastrand",
]
@@ -1600,7 +1807,7 @@ dependencies = [
[[package]]
name = "radicle-ssh"
version = "0.2.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "byteorder",
 "log",
@@ -1621,7 +1828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9403736ddf2be5e7de42928f94a5f68ef0785916171d009809d19b4202b58d83"
dependencies = [
 "anyhow",
-
 "base64",
+
 "base64 0.13.1",
 "flate2",
 "git2",
 "log",
@@ -1635,7 +1842,7 @@ dependencies = [
[[package]]
name = "radicle-term"
version = "0.1.0"
-
source = "git+https://github.com/radicle-dev/heartwood#59f506dbb5591d3fe68e638038495730c455d72a"
+
source = "git+https://github.com/radicle-dev/heartwood#fe55de181d4320a0cd7a6ebd2820764280ae9adc"
dependencies = [
 "anstyle-query",
 "anyhow",
@@ -1643,7 +1850,7 @@ dependencies = [
 "inquire",
 "libc",
 "once_cell",
-
 "termion 2.0.1",
+
 "termion 3.0.0",
 "unicode-display-width",
 "unicode-segmentation",
 "zeroize",
@@ -1660,12 +1867,16 @@ dependencies = [
 "radicle",
 "radicle-surf",
 "radicle-term",
+
 "ratatui 0.26.1",
 "serde",
 "serde_json",
 "simple-logging",
+
 "termion 3.0.0",
 "textwrap 0.16.0",
 "thiserror",
 "timeago",
+
 "tokio",
+
 "tokio-stream",
 "tui-realm-stdlib",
 "tui-realm-textarea",
 "tuirealm",
@@ -1709,15 +1920,37 @@ dependencies = [
 "bitflags 2.4.1",
 "cassowary",
 "indoc",
-
 "itertools",
+
 "itertools 0.11.0",
 "paste",
-
 "strum",
+
 "strum 0.25.0",
 "termion 2.0.1",
 "unicode-segmentation",
 "unicode-width",
]

[[package]]
+
name = "ratatui"
+
version = "0.26.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "cassowary",
+
 "compact_str",
+
 "crossterm 0.27.0",
+
 "indoc",
+
 "itertools 0.12.1",
+
 "lru",
+
 "paste",
+
 "stability",
+
 "strum 0.26.1",
+
 "termion 3.0.0",
+
 "time 0.3.34",
+
 "unicode-segmentation",
+
 "unicode-width",
+
]
+

+
[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1742,15 +1975,21 @@ dependencies = [
]

[[package]]
-
name = "redox_termios"
-
version = "0.1.2"
+
name = "redox_syscall"
+
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f"
+
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
dependencies = [
-
 "redox_syscall 0.2.16",
+
 "bitflags 1.3.2",
]

[[package]]
+
name = "redox_termios"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"
+

+
[[package]]
name = "regex"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1811,6 +2050,12 @@ dependencies = [
]

[[package]]
+
name = "rustc-demangle"
+
version = "0.1.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+

+
[[package]]
name = "rustix"
version = "0.38.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1881,7 +2126,7 @@ version = "1.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b"
dependencies = [
-
 "indexmap 2.1.0",
+
 "indexmap",
 "itoa",
 "ryu",
 "serde",
@@ -1972,6 +2217,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de"

[[package]]
+
name = "siphasher"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "54ac45299ccbd390721be55b412d41931911f654fa99e2cb8bfb57184b2061fe"
+

+
[[package]]
name = "smallvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1984,6 +2235,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"

[[package]]
+
name = "socket2"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9"
+
dependencies = [
+
 "libc",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
name = "socks5-client"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2089,6 +2350,22 @@ dependencies = [
]

[[package]]
+
name = "stability"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce"
+
dependencies = [
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "static_assertions"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+

+
[[package]]
name = "str-buf"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2100,7 +2377,16 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
-
 "strum_macros",
+
 "strum_macros 0.25.3",
+
]
+

+
[[package]]
+
name = "strum"
+
version = "0.26.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f"
+
dependencies = [
+
 "strum_macros 0.26.1",
]

[[package]]
@@ -2117,6 +2403,19 @@ dependencies = [
]

[[package]]
+
name = "strum_macros"
+
version = "0.26.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "rustversion",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2193,6 +2492,18 @@ dependencies = [
]

[[package]]
+
name = "termion"
+
version = "3.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "417813675a504dfbbf21bfde32c03e5bf9f2413999962b479023c02848c1c7a5"
+
dependencies = [
+
 "libc",
+
 "libredox",
+
 "numtoa",
+
 "redox_termios",
+
]
+

+
[[package]]
name = "textwrap"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2257,6 +2568,27 @@ dependencies = [
]

[[package]]
+
name = "time"
+
version = "0.3.34"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
+
dependencies = [
+
 "deranged",
+
 "libc",
+
 "num-conv",
+
 "num_threads",
+
 "powerfmt",
+
 "serde",
+
 "time-core",
+
]
+

+
[[package]]
+
name = "time-core"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+

+
[[package]]
name = "timeago"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2282,6 +2614,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
+
name = "tokio"
+
version = "1.36.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
+
dependencies = [
+
 "backtrace",
+
 "bytes",
+
 "libc",
+
 "mio",
+
 "num_cpus",
+
 "parking_lot",
+
 "pin-project-lite",
+
 "signal-hook-registry",
+
 "socket2",
+
 "tokio-macros",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
+
name = "tokio-macros"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
+
name = "tokio-stream"
+
version = "0.1.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
+
dependencies = [
+
 "futures-core",
+
 "pin-project-lite",
+
 "tokio",
+
]
+

+
[[package]]
name = "tree_magic_mini"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2303,7 +2676,7 @@ checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
dependencies = [
 "bitflags 1.3.2",
 "cassowary",
-
 "crossterm",
+
 "crossterm 0.25.0",
 "termion 1.5.6",
 "unicode-segmentation",
 "unicode-width",
@@ -2323,10 +2696,10 @@ dependencies = [
[[package]]
name = "tui-realm-textarea"
version = "1.1.2"
-
source = "git+https://github.com/erak/tui-realm-textarea.git#e9dc23b6bfbaca0501cdb23c2641c0deae110027"
+
source = "git+https://github.com/erak/tui-realm-textarea.git#28938d4f1f025e2d4848de61736e7853440e043f"
dependencies = [
 "cli-clipboard",
-
 "lazy-regex",
+
 "lazy-regex 2.5.0",
 "tui-textarea",
 "tuirealm",
]
@@ -2336,7 +2709,7 @@ name = "tui-textarea"
version = "0.2.0"
source = "git+https://github.com/erak/tui-textarea.git?branch=textwrap#2381ef729a8d2199f99cfa611116a6b6cd485011"
dependencies = [
-
 "crossterm",
+
 "crossterm 0.25.0",
 "tui",
]

@@ -2347,8 +2720,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "412447298ad477c25ff50c4a894ff5077b6ee3e25b913d42db30021d81b1af53"
dependencies = [
 "bitflags 2.4.1",
-
 "lazy-regex",
-
 "ratatui",
+
 "lazy-regex 3.0.1",
+
 "ratatui 0.23.0",
 "termion 2.0.1",
 "thiserror",
 "tui",
@@ -2810,9 +3183,29 @@ dependencies = [

[[package]]
name = "xml-rs"
-
version = "0.8.16"
+
version = "0.8.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a"
+

+
[[package]]
+
name = "zerocopy"
+
version = "0.7.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
+
dependencies = [
+
 "zerocopy-derive",
+
]
+

+
[[package]]
+
name = "zerocopy-derive"
+
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "47430998a7b5d499ccee752b41567bc3afc57e1327dc855b1a2aa44ce29b5fa1"
+
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.48",
+
]

[[package]]
name = "zeroize"
modified Cargo.toml
@@ -13,17 +13,23 @@ path = "bin/main.rs"
[dependencies]
anyhow = { version = "1" }
inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] }
-
lexopt = { version = "0.2" }
+
lexopt = { version = "0.3.0" }
log = { version = "0.4.19" }
radicle = { git = "https://github.com/radicle-dev/heartwood" }
radicle-term = { git = "https://github.com/radicle-dev/heartwood", package = "radicle-term" }
radicle-surf = { version = "0.18.0" }
+
ratatui = { version = "0.26.0", features = ["all-widgets", "termion"] }
simple-logging = { version = "2.0.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
timeago = { version = "0.4.1" }
+
termion = { version = "3" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
-
tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion" ] }
+
tokio = { version = "1.32.0", features = ["full"] }
+
tokio-stream = { version = "0.1.14" }
+
# tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion" ] }
+
# tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion", "ratatui" ] }
+
tuirealm = { version = "1.9.1", default-features = false, features = [ "with-termion" ] }
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
tui-realm-textarea = { git = "https://github.com/erak/tui-realm-textarea.git", default-features = false, features = [ "with-termion", "clipboard" ] }
modified bin/commands.rs
@@ -1,5 +1,7 @@
#[path = "commands/help.rs"]
pub mod tui_help;
+
#[path = "commands/inbox.rs"]
+
pub mod tui_inbox;
#[path = "commands/issue.rs"]
pub mod tui_issue;
#[path = "commands/patch.rs"]
added bin/commands/inbox.rs
@@ -0,0 +1,99 @@
+
#[path = "inbox/select.rs"]
+
mod select;
+

+
use std::ffi::OsString;
+

+
use anyhow::anyhow;
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::{self};
+
use tui::{context, log};
+

+
use crate::terminal;
+
use crate::terminal::args::{Args, Error, Help};
+

+
pub const HELP: Help = Help {
+
    name: "inbox",
+
    description: "Terminal interfaces for notifications",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad-tui inbox select
+

+
Other options
+

+
    --help               Print help
+
"#,
+
};
+

+
pub struct Options {
+
    op: Operation,
+
}
+

+
pub enum Operation {
+
    Select { opts: SelectOptions },
+
}
+

+
#[derive(PartialEq, Eq)]
+
pub enum OperationName {
+
    Select,
+
}
+

+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub struct SelectOptions {
+
    filter: inbox::Filter,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut op: Option<OperationName> = None;
+
        let select_opts = SelectOptions::default();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("help") | Short('h') => {
+
                    return Err(Error::Help.into());
+
                }
+

+
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
+
                    "select" => op = Some(OperationName::Select),
+
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                },
+
                _ => return Err(anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
+
            OperationName::Select => Operation::Select { opts: select_opts },
+
        };
+
        Ok((Options { op }, vec![]))
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    let (_, id) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    match options.op {
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let context = context::Context::new(profile, id)?.with_issues();
+

+
            log::enable(context.profile(), "inbox", "select")?;
+

+
            let output = select::flux::App::new(context, opts.filter.clone())
+
                .run()
+
                .await;
+

+
            // eprint!("{:?}", output);
+
        }
+
    }
+

+
    Ok(())
+
}
added bin/commands/inbox/select.rs
@@ -0,0 +1,4 @@
+
#[path = "select/flux.rs"]
+
pub mod flux;
+
#[path = "select/realm.rs"]
+
pub mod realm;
added bin/commands/inbox/select/flux.rs
@@ -0,0 +1,99 @@
+
#[path = "flux/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::identity::RepoId;
+
use radicle::node::notifications::Notification;
+
use radicle::Profile;
+
use radicle_tui as tui;
+
use tui::cob::inbox::Filter;
+
use tui::context::Context;
+
use tui::flux::store::{State, Store};
+
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use crate::tui_inbox::select::flux::ui::ListPage;
+

+
pub struct App {
+
    context: Context,
+
    _filter: Filter,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct InboxState {
+
    profile: Profile,
+
    rid: RepoId,
+
    notifications: Vec<Notification>,
+
    selected: usize,
+
}
+

+
impl From<&Context> for InboxState {
+
    fn from(context: &Context) -> Self {
+
        Self {
+
            profile: context.profile().clone(),
+
            rid: *context.rid(),
+
            notifications: context.notifications().to_vec(),
+
            selected: 0,
+
        }
+
    }
+
}
+

+
pub enum Action {
+
    Exit,
+
    Prev,
+
    Next,
+
}
+

+
impl State<Action> for InboxState {
+
    type Exit = Exit<String>;
+

+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<String>> {
+
        match action {
+
            Action::Prev => {
+
                self.selected = self.selected.checked_sub(1).unwrap_or_default();
+
                None
+
            }
+
            Action::Next => {
+
                self.selected = self.selected.checked_add(1).unwrap_or_default();
+
                None
+
            }
+
            Action::Exit => Some(Exit { value: None }),
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context, filter: Filter) -> Self {
+
        Self {
+
            context,
+
            _filter: filter,
+
        }
+
    }
+

+
    pub async fn run(&self) -> Result<()> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, InboxState>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = InboxState::from(&self.context);
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<InboxState, ListPage>(state_rx, interrupt_rx.resubscribe()),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::UserInt => {}
+
                Interrupted::OsSigInt => println!("exited because of an os sig int"),
+
            }
+
        } else {
+
            println!("exited because of an unexpected error");
+
        }
+

+
        Ok(())
+
    }
+
}
added bin/commands/inbox/select/flux/ui.rs
@@ -0,0 +1,302 @@
+
use std::os::linux::raw::stat;
+

+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::Rect;
+
use ratatui::widgets::Cell;
+

+
use radicle::{
+
    cob::{ObjectId, Timestamp},
+
    node::notifications::{Notification, NotificationId},
+
    storage::git::Repository,
+
};
+

+
use radicle::storage::ReadStorage;
+

+
use radicle_tui as tui;
+

+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::{
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, ToRow, Widget,
+
};
+
use tui::ui::cob::format;
+

+
use crate::tui_inbox::select::flux::{Action, InboxState};
+

+
pub struct ListPageProps {
+
    // notifications: Vec<NotificationItem>,
+
}
+

+
impl From<&InboxState> for ListPageProps {
+
    fn from(state: &InboxState) -> Self {
+
        ListPageProps {
+
            // notifications: state
+
            //     .notifications
+
            //     .iter()
+
            //     .map(|notif| NotificationItem::from_notification(notif))
+
            //     .collect::<Vec<_>>(),
+
        }
+
    }
+
}
+

+
pub struct ListPage {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    // Mapped Props from State
+
    props: ListPageProps,
+
    /// notification widget
+
    notifications: Notifications,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<InboxState, Action> for ListPage {
+
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            notifications: Notifications::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &InboxState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            props: ListPageProps::from(state),
+
            notifications: self.notifications.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Char('q') => {
+
                let _ = self.action_tx.send(Action::Exit);
+
            }
+
            _ => {
+
                <Notifications as Widget<InboxState, Action>>::handle_key_event(
+
                    &mut self.notifications,
+
                    key,
+
                );
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&mut self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        let area = frame.size();
+
        let layout = tui::flux::ui::layout::default_page(area, 1u16, 1u16);
+

+
        self.notifications.render::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts: vec![
+
                    Shortcut::new("q", "quit"),
+
                    Shortcut::new("s", "show"),
+
                    Shortcut::new("?", "help"),
+
                ],
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct NotificationsProps {
+
    notifications: Vec<NotificationItem>,
+
    // selected: Option<NotificationItem>,
+
    selected: usize,
+
}
+

+
impl From<&InboxState> for NotificationsProps {
+
    fn from(state: &InboxState) -> Self {
+
        let repo = state.profile.storage.repository(state.rid).unwrap();
+
        Self {
+
            notifications: state
+
                .notifications
+
                .iter()
+
                .map(|notif| NotificationItem::from((&repo, notif)))
+
                .collect::<Vec<_>>(),
+
            selected: state.selected,
+
        }
+
    }
+
}
+

+
struct Notifications {
+
    /// Sending actions to the state store
+
    action_tx: UnboundedSender<Action>,
+
    /// State Mapped RoomList Props
+
    props: NotificationsProps,
+
    /// Notification table
+
    table: Table<Action, NotificationItem, 3>,
+
}
+

+
impl Widget<InboxState, Action> for Notifications {
+
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: NotificationsProps::from(state),
+
            table: Table::new(state, action_tx.clone()),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &InboxState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: NotificationsProps::from(state),
+
            table: self.table.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "notification-list"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up => {
+
                let _ = self.action_tx.send(Action::Prev);
+
            }
+
            Key::Down => {
+
                let _ = self.action_tx.send(Action::Next);
+
            }
+
            _ => {}
+
        }
+
        // <Table<Action, NotificationItem, 3> as Widget<InboxState, Action>>::handle_key_event(
+
        //     &mut self.table,
+
        //     key,
+
        // );
+
        // if key.kind != KeyEventKind::Press {
+
        //     return;
+
        // }
+

+
        // match key {
+
        //     Key::Up => {
+
        //         self.selection = self.selection.checked_sub(1).unwrap_or_default()
+
        //     }
+
        //     Key::Down => {
+
        //         self.selection = self.selection.checked_add(1).unwrap_or_default()
+
        //     }
+
        //     _ => {}
+
        // }
+

+
        // match key {
+
        //     Key::Up => {
+
        //         self.previous();
+
        //     }
+
        //     Key::Down => {
+
        //         self.next();
+
        //     }
+
        //     Key::Alt('\n') | Key::Char('\n') | Key::Ctrl('\n') if self.list_state.selected().is_some() => {
+
        //         let selected_idx = self.list_state.selected().unwrap();
+

+
        //         let rooms = self.rooms();
+
        //         let room_state = rooms.get(selected_idx).unwrap();
+

+
        //         // TODO: handle the error scenario somehow
+
        //         let _ = self.action_tx.send(Action::SelectRoom {
+
        //             room: room_state.name.clone(),
+
        //         });
+
        //     }
+
        //     _ => (),
+
        // }
+
    }
+
}
+

+
impl Render<()> for Notifications {
+
    fn render<B: Backend>(&mut self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        // let items = self.props.notifications.iter().map(|mock| ).collect::<Vec<_>>();
+

+
        self.table.render::<B>(
+
            frame,
+
            area,
+
            TableProps {
+
                widths: vec![],
+
                items: self.props.notifications.to_vec(),
+
                selection: self.props.selected,
+
            },
+
        );
+
    }
+
}
+

+
#[derive(Clone)]
+
pub enum NotificationKindItem {
+
    Branch {
+
        name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// Mark this notification as seen.
+
    pub seen: bool,
+
    /// Wrapped notification kind.
+
    // pub kind: NotificationKindItem,
+
    /// Time the update has happened.
+
    timestamp: Timestamp,
+
}
+

+
impl From<(&Repository, &Notification)> for NotificationItem {
+
    fn from(value: (&Repository, &Notification)) -> Self {
+
        let (repo, notification) = value;
+
        // let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?;
+

+
        NotificationItem {
+
            id: notification.id,
+
            seen: notification.status.is_read(),
+
            // kind,
+
            timestamp: notification.timestamp.into(),
+
        }
+
    }
+
}
+

+
impl ToRow<3> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 3] {
+
        let id = span::default(format!(" {}", &self.id));
+
        let seen = if self.seen {
+
            span::blank()
+
        } else {
+
            span::positive(" ● ".into())
+
        };
+
        let timestamp = span::timestamp(format::timestamp(&self.timestamp));
+

+
        // let timestamp = if highlight {
+
        //     label::reversed(&format::timestamp(&self.timestamp))
+
        // } else {
+
        //     label::timestamp(&format::timestamp(&self.timestamp))
+
        // };
+

+
        [id.into(), seen.into(), timestamp.into()]
+
    }
+
}
added bin/commands/inbox/select/realm.rs
@@ -0,0 +1,225 @@
+
#[path = "realm/event.rs"]
+
mod event;
+
#[path = "realm/page.rs"]
+
mod page;
+
#[path = "realm/ui.rs"]
+
mod ui;
+

+
use std::fmt::Display;
+
use std::hash::Hash;
+

+
use anyhow::Result;
+

+
use serde::{Serialize, Serializer};
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::Filter;
+
use tui::context::Context;
+
use tui::ui::subscription;
+
use tui::ui::theme::Theme;
+
use tui::{Exit, PageStack, SelectionExit, Tui};
+

+
use page::ListView;
+

+
/// Wrapper around radicle's `PatchId` that serializes
+
/// to a human-readable string.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct PatchId(radicle::cob::patch::PatchId);
+

+
impl From<radicle::cob::patch::PatchId> for PatchId {
+
    fn from(value: radicle::cob::patch::PatchId) -> Self {
+
        PatchId(value)
+
    }
+
}
+

+
impl Display for PatchId {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

+
impl Serialize for PatchId {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        serializer.serialize_str(&format!("{}", *self.0))
+
    }
+
}
+

+
/// The application's subject. It tells the application
+
/// which widgets to render and which output to produce.
+
///
+
/// Depends on CLI arguments given by the user.
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Mode {
+
    #[default]
+
    Operation,
+
    #[allow(dead_code)]
+
    Id,
+
}
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum InboxOperation {
+
    Show,
+
    Clear,
+
}
+

+
impl Display for InboxOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            InboxOperation::Show => {
+
                write!(f, "show")
+
            }
+
            InboxOperation::Clear => {
+
                write!(f, "clear")
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    NotificationBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<SelectionExit>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
    filter: Filter,
+
    output: Option<SelectionExit>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
#[allow(dead_code)]
+
impl App {
+
    pub fn new(context: Context, filter: Filter) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            quit: false,
+
            filter,
+
            output: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(self.filter.clone()));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(output) => {
+
                self.quit = true;
+
                self.output = output;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

+
impl Tui<Cid, Message, SelectionExit> for App {
+
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.view_list(app, &self.theme.clone())?;
+

+
        // Add global key listener and subscribe to key events
+
        let global = tui::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(tuirealm::event::Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        if let Ok(page) = self.pages.peek_mut() {
+
            page.view(app, frame);
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<SelectionExit>> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.output.clone(),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/inbox/select/realm/event.rs
@@ -0,0 +1,118 @@
+
use radicle::node::notifications::NotificationId;
+

+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::ui::state::ItemState;
+
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::ui::widget::list::PropertyList;
+
use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};
+

+
use super::ui::OperationSelect;
+
use super::{InboxOperation, Message};
+

+
/// Since the framework does not know the type of messages that are being
+
/// passed around in the app, the following handlers need to be implemented for
+
/// each component used.
+
///
+
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit(None)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<NotificationId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let exit = SelectionExit::default()
+
                    .with_operation(InboxOperation::Show.to_string())
+
                    .with_id(Id::Notification(id));
+
                Message::Quit(Some(exit))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('c'),
+
                ..
+
            }) => submit().map(|id| {
+
                let exit = SelectionExit::default()
+
                    .with_operation(InboxOperation::Clear.to_string())
+
                    .with_id(Id::Notification(id));
+
                Message::Quit(Some(exit))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
added bin/commands/inbox/select/realm/page.rs
@@ -0,0 +1,146 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tui::ui::state::ItemState;
+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::Filter;
+
use tui::context::Context;
+
use tui::ui::layout;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::context::{Progress, Shortcuts};
+
use tui::ui::widget::Widget;
+
use tui::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    filter: Filter,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(filter: Filter) -> Self {
+
        Self {
+
            active_component: ListCid::NotificationBrowser,
+
            filter,
+
            shortcuts: HashMap::default(),
+
        }
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::NotificationBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+

+
        let context = ui::browse_context(context, theme, self.filter.clone(), progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let browser = ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
+
        self.shortcuts = browser.as_ref().shortcuts();
+

+
        app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::List(ListCid::NotificationBrowser))?;
+
        app.umount(&Cid::List(ListCid::Context))?;
+
        app.umount(&Cid::List(ListCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::default_page(area, context_h, shortcuts_h);
+

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

+
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+
}
added bin/commands/inbox/select/realm/ui.rs
@@ -0,0 +1,190 @@
+
use std::collections::HashMap;
+

+
use radicle::node::notifications::Notification;
+

+
use tui::ui::cob::NotificationItem;
+
use tui::ui::widget::list::{ColumnWidth, Table};
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::Filter;
+
use tui::context::Context;
+
use tui::ui::theme::{style, Theme};
+
use tui::ui::widget::context::{ContextBar, Progress, Shortcuts};
+
use tui::ui::widget::label::{self};
+
use tui::ui::widget::{Widget, WidgetComponent};
+

+
use super::ListCid;
+

+
pub struct NotificationBrowser {
+
    items: Vec<NotificationItem>,
+
    table: Widget<Table<NotificationItem, 7>>,
+
}
+

+
impl NotificationBrowser {
+
    pub fn new(theme: &Theme, context: &Context, selected: Option<Notification>) -> Self {
+
        let header = [
+
            label::header(""),
+
            label::header(" ● "),
+
            label::header("Type"),
+
            label::header("Summary"),
+
            label::header("ID"),
+
            label::header("Status"),
+
            label::header("Updated"),
+
        ];
+
        let widths = [
+
            ColumnWidth::Fixed(5),
+
            ColumnWidth::Fixed(3),
+
            ColumnWidth::Fixed(6),
+
            ColumnWidth::Grow,
+
            ColumnWidth::Fixed(15),
+
            ColumnWidth::Fixed(10),
+
            ColumnWidth::Fixed(15),
+
        ];
+
        
+
        let mut items = vec![];
+
        for notification in context.notifications() {
+
            if let Ok(item) =
+
                NotificationItem::try_from((context.repository(), notification.clone()))
+
            {
+
                items.push(item);
+
            }
+
        }
+

+
        let selected = match selected {
+
            Some(notif) => {
+
                Some(NotificationItem::try_from((context.repository(), notif.clone())).unwrap())
+
            }
+
            _ => items.first().cloned(),
+
        };
+

+
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
+

+
        Self { items, table }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for NotificationBrowser {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.table.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.table.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.table.perform(cmd)
+
    }
+
}
+

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<NotificationBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        self.browser.items()
+
    }
+

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::NotificationBrowser,
+
            tui::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::ui::shortcut(&self.theme, "c", "clear"),
+
                    tui::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

+
impl WidgetComponent for OperationSelect {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.browser.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.browser.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.browser.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.browser.perform(cmd)
+
    }
+
}
+

+
pub fn operation_select(
+
    theme: &Theme,
+
    context: &Context,
+
    _filter: Filter,
+
    selected: Option<Notification>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(NotificationBrowser::new(theme, context, selected));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
+
}
+

+
pub fn browse_context(
+
    _context: &Context,
+
    _theme: &Theme,
+
    _filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default("").style(style::magenta_dim());
+

+
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

+
    let spacer = label::default("");
+
    let _divider = label::default(" | ");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[filter]),
+
        label::group(&[spacer.clone()]),
+
        label::group(&[
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
modified bin/commands/issue/select/event.rs
@@ -1,17 +1,15 @@
-
use radicle::issue::IssueId;
-
use tui::ui::state::ItemState;
-
use tui::SelectionExit;
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent};

use radicle_tui as tui;

+
use tui::ui::state::ItemState;
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
use tui::ui::widget::context::{ContextBar, Shortcuts};
use tui::ui::widget::list::PropertyList;
-

use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};

use super::ui::{IdSelect, OperationSelect};
use super::{IssueOperation, Message};
@@ -68,7 +66,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
            Event::Keyboard(KeyEvent {
                code: Key::Enter, ..
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(IssueId::from(id));
+
                let output = SelectionExit::default().with_id(Id::Object(id));
                Message::Quit(Some(output))
            }),
            _ => None,
@@ -113,7 +111,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Show.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -122,7 +120,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Delete.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -131,7 +129,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Edit.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -140,7 +138,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Comment.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            _ => None,
modified bin/commands/patch/select/event.rs
@@ -1,17 +1,15 @@
-
use radicle::patch::PatchId;
-
use tui::ui::state::ItemState;
-
use tui::SelectionExit;
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent};

use radicle_tui as tui;

+
use tui::ui::state::ItemState;
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
use tui::ui::widget::context::{ContextBar, Shortcuts};
use tui::ui::widget::list::PropertyList;
-

use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};

use super::ui::{IdSelect, OperationSelect};
use super::{Message, PatchOperation};
@@ -68,7 +66,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
            Event::Keyboard(KeyEvent {
                code: Key::Enter, ..
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(PatchId::from(id));
+
                let output = SelectionExit::default().with_id(Id::Object(id));
                Message::Quit(Some(output))
            }),
            _ => None,
@@ -113,7 +111,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Show.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -122,7 +120,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Checkout.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -131,7 +129,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Delete.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -140,7 +138,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Edit.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -149,7 +147,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Comment.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            _ => None,
modified bin/main.rs
@@ -111,6 +111,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
+
        "inbox" => {
+
            terminal::run_command_args::<tui_inbox::Options, _>(
+
                tui_inbox::HELP,
+
                tui_inbox::run,
+
                args.to_vec(),
+
            );
+
        }
        other => Err(Some(anyhow!(
            "`{other}` is not a command. See `rad-tui --help` for a list of commands.",
        ))),
added out.txt
@@ -0,0 +1,4 @@
+
   Compiling radicle-tui v0.1.0 (/home/erikli/projects/radicle/dev/radicle-tui)
+
    Finished dev [unoptimized + debuginfo] target(s) in 4.92s
+
     Running `target/debug/rad-tui inbox select`
+
{"operation":"clear","ids":["7"],"args":[]}

\ No newline at end of file
modified src/cob.rs
@@ -6,6 +6,7 @@ use radicle::cob::Label;
use radicle::prelude::Did;

pub mod format;
+
pub mod inbox;
pub mod issue;
pub mod patch;

added src/cob/inbox.rs
@@ -0,0 +1,23 @@
+
use anyhow::Result;
+

+
use radicle::node::notifications::Notification;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct Filter {}
+

+
pub fn all(repository: &Repository, profile: &Profile) -> Result<Vec<Notification>> {
+
    let all = profile
+
        .notifications_mut()?
+
        .by_repo(&repository.id, "timestamp")?
+
        .collect::<Vec<_>>();
+

+
    let mut notifications = vec![];
+
    for n in all {
+
        let n = n?;
+
        notifications.push(n);
+
    }
+

+
    Ok(notifications)
+
}
modified src/context.rs
@@ -1,22 +1,24 @@
use std::fmt::Display;

-
use radicle_term as term;
-

use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Patch, PatchId};
use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle::crypto::Signer;
-
use radicle::identity::{Id, Project};
+
use radicle::identity::Project;
+
use radicle::identity::RepoId;
+
use radicle::node::notifications::*;
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
-

use radicle::Profile;

+
use radicle_term as term;
use term::{passphrase, spinner, Passphrase};

use inquire::validator;

+
use crate::cob::inbox;
+

/// Git revision parameter. Supports extended SHA-1 syntax.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rev(String);
@@ -37,29 +39,33 @@ impl Display for Rev {
/// needed to render it.
pub struct Context {
    profile: Profile,
-
    id: Id,
+
    rid: RepoId,
    project: Project,
    repository: Repository,
    issues: Option<Vec<(IssueId, Issue)>>,
    patches: Option<Vec<(PatchId, Patch)>>,
+
    notifications: Vec<Notification>,
    signer: Option<Box<dyn Signer>>,
}

impl Context {
-
    pub fn new(profile: Profile, id: Id) -> Result<Self, anyhow::Error> {
-
        let repository = profile.storage.repository(id).unwrap();
+
    pub fn new(profile: Profile, rid: RepoId) -> Result<Self, anyhow::Error> {
+
        let repository = profile.storage.repository(rid).unwrap();
        let project = repository.identity_doc()?.project()?;
+
        let notifications = inbox::all(&repository, &profile)?;
+

        let issues = None;
        let patches = None;
        let signer = None;

        Ok(Self {
            profile,
-
            id,
+
            rid,
            project,
            repository,
            issues,
            patches,
+
            notifications,
            signer,
        })
    }
@@ -85,8 +91,8 @@ impl Context {
        &self.profile
    }

-
    pub fn id(&self) -> &Id {
-
        &self.id
+
    pub fn rid(&self) -> &RepoId {
+
        &self.rid
    }

    pub fn project(&self) -> &Project {
@@ -105,23 +111,15 @@ impl Context {
        &self.patches
    }

+
    pub fn notifications(&self) -> &Vec<Notification> {
+
        &self.notifications
+
    }
+

    #[allow(clippy::borrowed_box)]
    pub fn signer(&self) -> &Option<Box<dyn Signer>> {
        &self.signer
    }

-
    // pub fn reload(&mut self) {
-
    //     use crate::cob::issue;
-
    //     use crate::cob::patch;
-

-
    //     if self.issues.is_some() {
-
    //         self.issues = Some(issue::all(&self.repository).unwrap_or_default());
-
    //     }
-
    //     if self.patches.is_some() {
-
    //         self.patches = Some(patch::all(&self.repository).unwrap_or_default());
-
    //     }
-
    // }
-

    pub fn reload_patches(&mut self) {
        use crate::cob::patch;
        self.patches = Some(patch::all(&self.repository).unwrap_or_default());
added src/flux.rs
@@ -0,0 +1,3 @@
+
pub mod store;
+
pub mod termination;
+
pub mod ui;
added src/flux/store.rs
@@ -0,0 +1,87 @@
+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+
use std::time::Duration;
+

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+

+
use super::termination::{Interrupted, Terminator};
+

+
const STORE_TICK_RATE: Duration = Duration::from_millis(1000);
+

+
pub trait State<A> {
+
    type Exit;
+

+
    fn tick(&self);
+

+
    fn handle_action(&mut self, action: A) -> Option<Self::Exit>;
+
}
+

+
pub struct Store<A, S>
+
where
+
    S: State<A> + Clone + Send + Sync,
+
{
+
    state_tx: UnboundedSender<S>,
+
    _phantom: PhantomData<A>,
+
}
+

+
impl<A, S> Store<A, S>
+
where
+
    S: State<A> + Clone + Send + Sync,
+
{
+
    pub fn new() -> (Self, UnboundedReceiver<S>) {
+
        let (state_tx, state_rx) = mpsc::unbounded_channel::<S>();
+

+
        (
+
            Store {
+
                state_tx,
+
                _phantom: PhantomData,
+
            },
+
            state_rx,
+
        )
+
    }
+
}
+

+
impl<A, S> Store<A, S>
+
where
+
    S: State<A> + Clone + Send + Sync + 'static + Debug,
+
{
+
    pub async fn main_loop(
+
        self,
+
        mut state: S,
+
        mut terminator: Terminator,
+
        mut action_rx: UnboundedReceiver<A>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted>,
+
    ) -> anyhow::Result<Interrupted> {
+
        // the initial state once
+
        self.state_tx.send(state.clone())?;
+

+
        let mut ticker = tokio::time::interval(STORE_TICK_RATE);
+

+
        let result = loop {
+
            tokio::select! {
+
                // Handle the actions coming from the UI
+
                // and process them to do async operations
+
                Some(action) = action_rx.recv() => {
+
                    if let Some(exit) = state.handle_action(action) {
+
                        let _ = terminator.terminate(Interrupted::UserInt);
+

+
                        break Interrupted::UserInt;
+
                    }
+
                },
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => {
+
                    state.tick();
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    break interrupted;
+
                }
+
            }
+

+
            self.state_tx.send(state.clone())?;
+
        };
+

+
        Ok(result)
+
    }
+
}
added src/flux/termination.rs
@@ -0,0 +1,49 @@
+
#[cfg(unix)]
+
use tokio::signal::unix::signal;
+
use tokio::sync::broadcast;
+

+
#[derive(Debug, Clone)]
+
pub enum Interrupted {
+
    OsSigInt,
+
    UserInt,
+
}
+

+
#[derive(Debug, Clone)]
+
pub struct Terminator {
+
    interrupt_tx: broadcast::Sender<Interrupted>,
+
}
+

+
impl Terminator {
+
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted>) -> Self {
+
        Self { interrupt_tx }
+
    }
+

+
    pub fn terminate(&mut self, interrupted: Interrupted) -> anyhow::Result<()> {
+
        self.interrupt_tx.send(interrupted)?;
+

+
        Ok(())
+
    }
+
}
+

+
#[cfg(unix)]
+
async fn terminate_by_unix_signal(mut terminator: Terminator) {
+
    let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
+
        .expect("failed to create interrupt signal stream");
+

+
    interrupt_signal.recv().await;
+

+
    terminator
+
        .terminate(Interrupted::OsSigInt)
+
        .expect("failed to send interrupt signal");
+
}
+

+
// create a broadcast channel for retrieving the application kill signal
+
pub fn create_termination() -> (Terminator, broadcast::Receiver<Interrupted>) {
+
    let (tx, rx) = broadcast::channel(1);
+
    let terminator = Terminator::new(tx);
+

+
    #[cfg(unix)]
+
    tokio::spawn(terminate_by_unix_signal(terminator.clone()));
+

+
    (terminator, rx)
+
}
added src/flux/ui.rs
@@ -0,0 +1,120 @@
+
pub mod layout;
+
pub mod span;
+
pub mod theme;
+
pub mod widget;
+

+
use std::io::{self};
+
use std::thread;
+
use std::time::Duration;
+

+
use termion::event::Event;
+
use termion::input::TermRead;
+
use termion::raw::{IntoRawMode, RawTerminal};
+

+
use ratatui::prelude::*;
+

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{self, UnboundedReceiver};
+

+
use super::store::State;
+
use super::termination::Interrupted;
+
use super::ui::widget::{Render, Widget};
+

+
type Backend = TermionBackend<RawTerminal<io::Stdout>>;
+

+
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
+

+
pub struct Frontend<A> {
+
    action_tx: mpsc::UnboundedSender<A>,
+
}
+

+
impl<A> Frontend<A> {
+
    pub fn new() -> (Self, UnboundedReceiver<A>) {
+
        let (action_tx, action_rx) = mpsc::unbounded_channel();
+

+
        (Self { action_tx }, action_rx)
+
    }
+

+
    pub async fn main_loop<S: State<A>, W: Widget<S, A> + Render<()>>(
+
        self,
+
        mut state_rx: UnboundedReceiver<S>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted>,
+
    ) -> anyhow::Result<Interrupted> {
+
        let mut terminal = setup_terminal()?;
+
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
+
        let mut events_rx = events();
+

+
        let mut root = {
+
            let state = state_rx.recv().await.unwrap();
+

+
            W::new(&state, self.action_tx.clone())
+
        };
+

+
        // let mut last_frame: Option<CompletedFrame> = None;
+
        let result: anyhow::Result<Interrupted> = loop {
+
            tokio::select! {
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => (),
+
                Some(event) = events_rx.recv() => match event {
+
                    Event::Key(key) => {
+
                        root.handle_key_event(key)
+
                    }
+
                    _ => (),
+
                },
+
                // Handle state updates
+
                Some(state) = state_rx.recv() => {
+
                    root = root.move_with_state(&state);
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    break Ok(interrupted);
+
                }
+
            }
+

+
            // last_frame = Some(terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?);
+
            terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?;
+
        };
+

+
        // if let Some(frame) = last_frame {
+
        //     terminal.d
+
        // }
+

+
        // terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?;
+

+
        restore_terminal(&mut terminal)?;
+

+
        result
+
    }
+
}
+

+
fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
+
    let stdout = io::stdout().into_raw_mode()?;
+
    let options = TerminalOptions {
+
        viewport: Viewport::Inline(15),
+
    };
+

+
    Ok(Terminal::with_options(
+
        TermionBackend::new(stdout),
+
        options,
+
    )?)
+
}
+

+
fn restore_terminal(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
+
    // Ok(terminal.show_cursor()?)
+
    Ok(())
+
}
+

+
fn events() -> mpsc::UnboundedReceiver<Event> {
+
    let (tx, rx) = mpsc::unbounded_channel();
+
    let keys_tx = tx.clone();
+
    thread::spawn(move || {
+
        let stdin = io::stdin();
+
        for key in stdin.keys().flatten() {
+
            if let Err(err) = keys_tx.send(Event::Key(key)) {
+
                // eprintln!("{err}");
+
                return;
+
            }
+
        }
+
    });
+
    rx
+
}
added src/flux/ui/layout.rs
@@ -0,0 +1,33 @@
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+

+
pub struct DefaultPage {
+
    pub component: Rect,
+
    pub context: Rect,
+
    pub shortcuts: Rect,
+
}
+

+
pub fn default_page(area: Rect, context_h: u16, shortcuts_h: u16) -> DefaultPage {
+
    let margin_h = 1u16;
+
    let component_h = area
+
        .height
+
        .saturating_sub(context_h.saturating_add(shortcuts_h));
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints(
+
            [
+
                Constraint::Length(component_h),
+
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    DefaultPage {
+
        component: layout[0],
+
        context: layout[1],
+
        shortcuts: layout[2],
+
    }
+
}
added src/flux/ui/span.rs
@@ -0,0 +1,43 @@
+
use ratatui::{style::Style, text::{Span, Text}};
+

+
use crate::flux::ui::theme::style;
+

+
// #[derive(Default)]
+
// pub struct Label {
+
//     content: String,
+
//     style: Style,
+
// }
+

+
// impl Label {
+
//     pub fn content(mut self, content: &str) -> Self {
+
//         self.content = content.to_string();
+
//         self
+
//     }
+

+
//     pub fn style(mut self, style: Style) -> Self {
+
//         self.style = style;
+
//         self
+
//     }
+
// }
+

+
// impl From<Label> for Text<'_> {
+
//     fn from(label: Label) -> Self {
+
//         Text::styled(label.content(), label.style())
+
//     }
+
// }
+

+
pub fn default(content: String) -> Text<'static> {
+
    Text::styled(content, Style::default())
+
}
+

+
pub fn blank() -> Text<'static> {
+
    Text::styled("", Style::default())
+
}
+

+
pub fn positive(content: String) -> Text<'static> {
+
    default(content).style(style::green())
+
}
+

+
pub fn timestamp(content: String) -> Text<'static> {
+
    default(content).style(style::gray_dim())
+
}
added src/flux/ui/theme.rs
@@ -0,0 +1,101 @@
+
pub mod style {
+
    use ratatui::style::{Color, Modifier, Style};
+

+
    pub fn reset() -> Style {
+
        Style::default().fg(Color::Reset)
+
    }
+

+
    pub fn reset_dim() -> Style {
+
        Style::default()
+
            .fg(Color::Reset)
+
            .add_modifier(Modifier::DIM)
+
    }
+

+
    pub fn red() -> Style {
+
        Style::default().fg(Color::Red)
+
    }
+

+
    pub fn green() -> Style {
+
        Style::default().fg(Color::Green)
+
    }
+

+
    pub fn yellow() -> Style {
+
        Style::default().fg(Color::Yellow)
+
    }
+

+
    pub fn yellow_dim() -> Style {
+
        yellow().add_modifier(Modifier::DIM)
+
    }
+

+
    pub fn yellow_dim_reversed() -> Style {
+
        yellow_dim().add_modifier(Modifier::REVERSED)
+
    }
+

+
    pub fn blue() -> Style {
+
        Style::default().fg(Color::Blue)
+
    }
+

+
    pub fn magenta() -> Style {
+
        Style::default().fg(Color::Magenta)
+
    }
+

+
    pub fn magenta_dim() -> Style {
+
        Style::default()
+
            .fg(Color::Magenta)
+
            .add_modifier(Modifier::DIM)
+
    }
+

+
    pub fn cyan() -> Style {
+
        Style::default().fg(Color::Cyan)
+
    }
+

+
    pub fn lightblue() -> Style {
+
        Style::default().fg(Color::LightBlue)
+
    }
+

+
    pub fn gray() -> Style {
+
        Style::default().fg(Color::Gray)
+
    }
+

+
    pub fn gray_dim() -> Style {
+
        Style::default().fg(Color::Gray).add_modifier(Modifier::DIM)
+
    }
+

+
    pub fn darkgray() -> Style {
+
        Style::default().fg(Color::DarkGray)
+
    }
+

+
    pub fn reversed() -> Style {
+
        Style::default().add_modifier(Modifier::REVERSED)
+
    }
+

+
    pub fn default_reversed() -> Style {
+
        Style::default()
+
            .fg(Color::DarkGray)
+
            .add_modifier(Modifier::REVERSED)
+
    }
+

+
    pub fn magenta_reversed() -> Style {
+
        Style::default()
+
            .fg(Color::Magenta)
+
            .add_modifier(Modifier::REVERSED)
+
    }
+

+
    pub fn yellow_reversed() -> Style {
+
        Style::default().fg(Color::DarkGray).bg(Color::Yellow)
+
    }
+

+
    pub fn border(focus: bool) -> Style {
+
        if focus {
+
            gray_dim()
+
        } else {
+
            darkgray()
+
        }
+
    }
+

+
    pub fn highlight() -> Style {
+
        Style::default()
+
            .fg(Color::Cyan)
+
            .add_modifier(Modifier::REVERSED)
+
    }
+
}
added src/flux/ui/widget.rs
@@ -0,0 +1,233 @@
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};
+

+
use super::theme::style;
+

+
pub trait Widget<S, A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized;
+

+
    fn move_with_state(self, state: &S) -> Self
+
    where
+
        Self: Sized;
+

+
    fn name(&self) -> &str;
+

+
    fn handle_key_event(&mut self, key: Key);
+
}
+

+
pub trait Render<P> {
+
    fn render<B: ratatui::backend::Backend>(&mut self, frame: &mut Frame, area: Rect, props: P);
+
}
+

+
///
+
///
+
///
+
pub struct Shortcut {
+
    pub short: String,
+
    pub long: String,
+
}
+

+
impl Shortcut {
+
    pub fn new(short: &str, long: &str) -> Self {
+
        Self {
+
            short: short.to_string(),
+
            long: long.to_string(),
+
        }
+
    }
+
}
+

+
pub struct ShortcutsProps {
+
    pub shortcuts: Vec<Shortcut>,
+
    pub divider: char,
+
}
+

+
pub struct Shortcuts<A> {
+
    pub action_tx: UnboundedSender<A>,
+
}
+

+
impl<S, A> Widget<S, A> for Shortcuts<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "shortcuts"
+
    }
+

+
    fn handle_key_event(&mut self, _key: termion::event::Key) {}
+
}
+

+
impl<A> Render<ShortcutsProps> for Shortcuts<A> {
+
    fn render<B: Backend>(
+
        &mut self,
+
        frame: &mut ratatui::Frame,
+
        area: Rect,
+
        props: ShortcutsProps,
+
    ) {
+
        use ratatui::widgets::Table;
+

+
        let mut shortcuts = props.shortcuts.iter().peekable();
+
        let mut row = vec![];
+

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.short.clone()).style(style::gray());
+
            let long = Text::from(shortcut.long.clone()).style(style::gray_dim());
+
            let spacer = Text::from(String::new());
+
            let divider =
+
                Text::from(String::from(format!(" {} ", props.divider))).style(style::gray_dim());
+

+
            row.push((1, short));
+
            row.push((1, spacer));
+
            row.push((shortcut.long.len(), long));
+

+
            if shortcuts.peek().is_some() {
+
                row.push((3, divider));
+
            }
+
        }
+

+
        let row_copy = row.clone();
+
        let row: Vec<Text<'_>> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(_, text)| text.clone())
+
            .collect();
+
        let widths: Vec<Constraint> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(width, _)| Constraint::Length(*width as u16))
+
            .collect();
+

+
        let table = Table::new([Row::new(row)], widths).column_spacing(0);
+
        frame.render_widget(table, area);
+
    }
+
}
+

+
///
+
///
+
///
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

+
#[derive(Debug)]
+
pub struct TableProps<R: ToRow<W>, const W: usize> {
+
    pub widths: Vec<Constraint>,
+
    pub items: Vec<R>,
+
    pub selection: usize,
+
}
+

+
pub struct Table<A, R: ToRow<W>, const W: usize> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
    /// State Mapped RoomList Props
+
    _props: Option<TableProps<R, W>>,
+
    /// List with optional selection and current offset
+
    pub table_state: ratatui::widgets::TableState,
+
}
+

+
impl<S, A, R: ToRow<W>, const W: usize> Widget<S, A> for Table<A, R, W> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut table_state = TableState::default();
+
        table_state.select(Some(0));
+

+
        Self {
+
            action_tx: action_tx.clone(),
+
            table_state,
+
            _props: None,
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "shortcuts"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        // let selected = self.table_state.selected();
+
        // match key {
+
        //     Key::Up => {
+
        //         let selected = selected.unwrap_or_default();
+
        //         if selected > 0 {
+
        //             self.table_state.select(selected.checked_sub(1));
+
        //             // self.selection = selected.checked_sub(1).unwrap_or_default();
+
        //         }
+
        //     }
+
        //     Key::Down => {
+
        //         let selected = selected.unwrap_or_default();
+
        //         self.table_state.select(selected.checked_add(1));
+
        //         // self.selection = selected.checked_add(1).unwrap_or_default();
+
        //     }
+
        //     _ => {}
+
        // }
+
    }
+
}
+

+
impl<A, R, const W: usize> Render<TableProps<R, W>> for Table<A, R, W>
+
where
+
    R: ToRow<W> + std::fmt::Debug,
+
{
+
    fn render<B: Backend>(
+
        &mut self,
+
        frame: &mut ratatui::Frame,
+
        area: Rect,
+
        props: TableProps<R, W>,
+
    ) {
+
        use ratatui::widgets::Table;
+

+
        let rows = props
+
            .items
+
            .iter()
+
            .map(|item| Row::new(item.to_row()))
+
            .collect::<Vec<_>>();
+

+
        let widths = [
+
            Constraint::Length(5),
+
            Constraint::Length(5),
+
            Constraint::Length(10),
+
        ];
+

+
        let table = Table::new(rows, widths)
+
            .column_spacing(1)
+
            .header(Row::new(vec!["Col1", "Col2", "Col3"]).style(Style::new().bold()))
+
            .block(Block::default().border_type(BorderType::Rounded).borders(Borders::ALL))
+
            .highlight_style(Style::new().reversed());
+

+
        // let mut table_state = self.table_state.clone();
+
        let mut state = TableState::default();
+
        state.select(Some(props.selection));
+

+
        log::debug!("{:?}", props);
+

+
        frame.render_stateful_widget(table, area, &mut state);
+
        // frame.render_widget(table, area);
+
    }
+
}
modified src/lib.rs
@@ -1,24 +1,36 @@
+
use std::fmt::Display;
use std::hash::Hash;
+
use std::io;
use std::time::Duration;

use anyhow::Result;
+

use serde::ser::{Serialize, SerializeStruct, Serializer};

-
use radicle::cob::ObjectId;
+
use termion::raw::{IntoRawMode, RawTerminal};

-
use tuirealm::terminal::TerminalBridge;
+
use tuirealm::tui::backend::TermionBackend;
use tuirealm::tui::layout::Rect;
-
use tuirealm::Frame;
+
use tuirealm::tui::Frame as TuiFrame;
+
use tuirealm::tui::Terminal;
use tuirealm::{Application, EventListenerCfg, NoUserEvent};

+
use radicle::cob::ObjectId;
+
use radicle::node::notifications::NotificationId;
+

pub mod cob;
pub mod context;
pub mod log;
pub mod ui;

+
pub mod flux;
+

use context::Context;
use ui::theme::Theme;

+
type Backend = TermionBackend<RawTerminal<io::Stdout>>;
+
type Frame<'a> = TuiFrame<'a, Backend>;
+

/// Trait that must be implemented by client applications in order to be run
/// as tui-application using tui-realm. Implementors act as models to the
/// tui-realm application that can be polled for new messages, updated
@@ -49,11 +61,31 @@ pub struct Exit<T> {
    pub value: Option<T>,
}

+
/// Returned ids can be of type `ObjectId` or `NotificationId`.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum Id {
+
    Object(ObjectId),
+
    Notification(NotificationId),
+
}
+

+
impl Display for Id {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Id::Object(id) => {
+
                write!(f, "{id}")
+
            }
+
            Id::Notification(id) => {
+
                write!(f, "{id}")
+
            }
+
        }
+
    }
+
}
+

/// The output that is returned by all selection interfaces.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct SelectionExit {
    operation: Option<String>,
-
    ids: Vec<ObjectId>,
+
    ids: Vec<Id>,
    args: Vec<String>,
}

@@ -63,7 +95,7 @@ impl SelectionExit {
        self
    }

-
    pub fn with_id(mut self, id: ObjectId) -> Self {
+
    pub fn with_id(mut self, id: Id) -> Self {
        self.ids.push(id);
        self
    }
@@ -98,7 +130,7 @@ impl Serialize for SelectionExit {
/// by tui-realm.
pub struct Window {
    /// Helper around `Terminal` to quickly setup and perform on terminal.
-
    pub terminal: TerminalBridge,
+
    pub terminal: Terminal<Backend>,
}

impl Default for Window {
@@ -112,7 +144,10 @@ impl Window {
    /// Creates a tui-window using the default cross-platform Terminal
    /// helper and panics if its creation fails.
    pub fn new() -> Self {
-
        let terminal = TerminalBridge::new().expect("Cannot create terminal bridge");
+
        let stdout = io::stdout()
+
            .into_raw_mode()
+
            .expect("Cannot switch stdout to raw mode");
+
        let terminal = Terminal::new(TermionBackend::new(stdout)).expect("Cannot create terminal");

        Self { terminal }
    }
@@ -145,14 +180,12 @@ impl Window {

        while tui.exit().is_none() {
            if update || resize {
-
                self.terminal
-
                    .raw_mut()
-
                    .draw(|frame| tui.view(&mut app, frame))?;
+
                self.terminal.draw(|frame| tui.view(&mut app, frame))?;
            }
            update = tui.update(&mut app)?;

-
            resize = size != self.terminal.raw().size()?;
-
            size = self.terminal.raw().size()?;
+
            resize = size != self.terminal.size()?;
+
            size = self.terminal.size()?;
        }

        Ok(tui.exit().unwrap().value)
modified src/ui.rs
@@ -108,7 +108,7 @@ pub fn tabs(_theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {

pub fn app_info(context: &Context) -> Widget<AppInfo> {
    let project = label::default(context.project().name()).style(style::cyan());
-
    let rid = label::default(&format!(" ({})", context.id())).style(style::yellow());
+
    let rid = label::default(&format!(" ({})", context.rid())).style(style::yellow());

    let project_w = project
        .query(Attribute::Width)
modified src/ui/cob.rs
@@ -1,21 +1,24 @@
pub mod format;

+
use anyhow::anyhow;
+

use radicle_surf;

use tuirealm::props::{Color, Style};
use tuirealm::tui::text::Spans;
use tuirealm::tui::widgets::Cell;

+
use radicle::cob::issue::{self, Issue, IssueId};
+
use radicle::cob::patch::{self, Patch, PatchId};
+
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::issue::Issues;
+
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
use radicle::node::{Alias, AliasStore};
-

+
use radicle::patch::Patches;
use radicle::prelude::Did;
use radicle::storage::git::Repository;
-
use radicle::storage::{Oid, ReadRepository};
-
use radicle::Profile;
-

-
use radicle::cob::issue::{self, Issue, IssueId};
-
use radicle::cob::patch::{self, Patch, PatchId};
-
use radicle::cob::{Label, Timestamp};
+
use radicle::storage::{Oid, ReadRepository, RefUpdate};
+
use radicle::{cob, Profile};

use crate::ui::theme::Theme;
use crate::ui::widget::list::{ListItem, TableItem};
@@ -380,6 +383,177 @@ impl PartialEq for IssueItem {
    }
}

+
//////////////////////////////////////////////////////
+
#[derive(Clone)]
+
pub enum NotificationKindItem {
+
    Branch {
+
        name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
}
+

+
impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result<Self, Self::Error> {
+
        let (repo, kind, update) = value;
+
        let issues = Issues::open(repo)?;
+
        let patches = Patches::open(repo)?;
+

+
        match kind {
+
            NotificationKind::Branch { name } => {
+
                let (head, message) = if let Some(head) = update.new() {
+
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
+
                    (Some(head), message)
+
                } else {
+
                    (None, String::new())
+
                };
+
                let status = match update {
+
                    RefUpdate::Updated { .. } => "updated",
+
                    RefUpdate::Created { .. } => "created",
+
                    RefUpdate::Deleted { .. } => "deleted",
+
                    RefUpdate::Skipped { .. } => "skipped",
+
                };
+

+
                Ok(NotificationKindItem::Branch {
+
                    name: name.to_string(),
+
                    summary: message,
+
                    status: status.to_string(),
+
                    id: head.map(ObjectId::from),
+
                })
+
            }
+
            NotificationKind::Cob { type_name, id } => {
+
                let (category, summary) = if type_name == *cob::issue::TYPENAME {
+
                    let issue = issues.get(&id)?.ok_or(anyhow!("missing"))?;
+
                    (String::from("issue"), issue.title().to_owned())
+
                } else if type_name == *cob::patch::TYPENAME {
+
                    let patch = patches.get(&id)?.ok_or(anyhow!("missing"))?;
+
                    (String::from("patch"), patch.title().to_owned())
+
                } else {
+
                    (type_name.to_string(), "".to_owned())
+
                };
+
                let status = match update {
+
                    RefUpdate::Updated { .. } => "updated",
+
                    RefUpdate::Created { .. } => "opened",
+
                    RefUpdate::Deleted { .. } => "deleted",
+
                    RefUpdate::Skipped { .. } => "skipped",
+
                };
+

+
                Ok(NotificationKindItem::Cob {
+
                    type_name: category.to_string(),
+
                    summary: summary.to_string(),
+
                    status: status.to_string(),
+
                    id: Some(id),
+
                })
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// Mark this notification as seen.
+
    pub seen: bool,
+
    /// Wrapped notification kind.
+
    pub kind: NotificationKindItem,
+
    /// Time the update has happened.
+
    timestamp: Timestamp,
+
}
+

+
impl NotificationItem {
+
    pub fn id(&self) -> &NotificationId {
+
        &self.id
+
    }
+

+
    pub fn seen(&self) -> bool {
+
        self.seen
+
    }
+

+
    pub fn kind(&self) -> &NotificationKindItem {
+
        &self.kind
+
    }
+

+
    pub fn timestamp(&self) -> &Timestamp {
+
        &self.timestamp
+
    }
+
}
+

+
impl TableItem<7> for NotificationItem {
+
    fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
+
        let seen = if self.seen {
+
            label::blank()
+
        } else {
+
            label::positive(" ● ")
+
        };
+

+
        let (type_name, summary, status, id) = match &self.kind() {
+
            NotificationKindItem::Branch {
+
                name,
+
                summary,
+
                status,
+
                id: _,
+
            } => ("branch".to_string(), summary, status, name.to_string()),
+
            NotificationKindItem::Cob {
+
                type_name,
+
                summary,
+
                status,
+
                id,
+
            } => {
+
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
+
                (type_name.to_string(), summary, status, id.to_string())
+
            }
+
        };
+

+
        let timestamp = if highlight {
+
            label::reversed(&format::timestamp(&self.timestamp))
+
        } else {
+
            label::timestamp(&format::timestamp(&self.timestamp))
+
        };
+

+
        [
+
            label::default(&format!(" {}", &self.id)).into(),
+
            seen.into(),
+
            label::alias(&type_name).into(),
+
            label::default(summary).into(),
+
            label::id(&id).into(),
+
            label::default(status).into(),
+
            timestamp.into(),
+
        ]
+
    }
+
}
+

+
impl TryFrom<(&Repository, Notification)> for NotificationItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, Notification)) -> Result<Self, Self::Error> {
+
        let (repo, notification) = value;
+
        let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?;
+

+
        Ok(NotificationItem {
+
            id: notification.id,
+
            seen: notification.status.is_read(),
+
            kind,
+
            timestamp: notification.timestamp.into(),
+
        })
+
    }
+
}
+

+
impl PartialEq for NotificationItem {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.id == other.id
+
    }
+
}
+

pub fn format_patch_state(state: &patch::State) -> (String, Color) {
    match state {
        patch::State::Open { conflicts: _ } => (" ● ".into(), Color::Green),
modified src/ui/widget/label.rs
@@ -8,6 +8,10 @@ use crate::ui::layout;
use crate::ui::theme::style;
use crate::ui::widget::{Widget, WidgetComponent};

+
pub fn blank() -> Widget<Label> {
+
    default("")
+
}
+

pub fn default(content: &str) -> Widget<Label> {
    // TODO: Remove when size constraints are implemented
    let width = content.chars().count() as u16;