Initial commit: kmod-tui v1 — kernel module inspector TUI
Features: - Browse all loaded kernel modules from /proc/modules - Two-pane layout: scrollable list + detail pane - Full-screen detail view with modinfo, parameters, holders - Vim-style navigation (j/k, Enter, /, s, r, ?, q) - Edge-scrolling (cursor moves freely, viewport follows) - Filter by module name AND dependents (used_by) - Sort by name, size, refcnt, or dependents count - Lazy modinfo fetching (shells out only on selection) - 13 unit tests (parser, filter, scroll logic) Tech: Rust + ratatui + crossterm
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
*.rs.bk
|
||||||
|
*.o
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
Generated
+599
@@ -0,0 +1,599 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[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.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instability"
|
||||||
|
version = "0.3.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kmod-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"ratatui",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum",
|
||||||
|
"strum_macros",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "kmod-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A TUI for browsing and inspecting loaded Linux kernel modules"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "kmod-tui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.28"
|
||||||
|
crossterm = "0.28"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
|
lto = "thin"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# kmod-tui
|
||||||
|
|
||||||
|
A terminal UI for Linux sysadmins to browse and inspect loaded kernel modules.
|
||||||
|
Read-only — built for looking, not for changing module state.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
`lsmod` shows a flat table. `modinfo <mod>` shows one module. This tool gives
|
||||||
|
you a navigable, filterable, sorted view of all loaded modules with rich
|
||||||
|
per-module detail (license, author, description, parameters, holders,
|
||||||
|
dependency info) a keystroke away.
|
||||||
|
|
||||||
|
## Data sources (read-only)
|
||||||
|
- `/proc/modules` — the loaded module list
|
||||||
|
- `/sys/module/<name>/` — refcnt, coresize, initsize, initstate, srcversion,
|
||||||
|
taint, `parameters/`, `holders/`
|
||||||
|
- `modinfo <name>` — filename, license, description, author, depends,
|
||||||
|
vermagic, retpoline, intree, signature info (fetched lazily, only when a
|
||||||
|
module is selected for detail, so the list view stays fast)
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
```sh
|
||||||
|
cargo run --release
|
||||||
|
# or
|
||||||
|
cargo build --release
|
||||||
|
./target/release/kmod-tui
|
||||||
|
```
|
||||||
|
Requires the Rust toolchain (rustup stable). No other deps; ratatui/crossterm
|
||||||
|
are vendored by cargo.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
List view:
|
||||||
|
```
|
||||||
|
j / ↓ move down
|
||||||
|
k / ↑ move up
|
||||||
|
g / Home top
|
||||||
|
G / End bottom
|
||||||
|
PgDn / PgUp scroll by 10
|
||||||
|
Enter open full-detail screen for the selected module
|
||||||
|
/ start filtering by module name (type, Esc to clear)
|
||||||
|
s cycle sort: name → size → refcnt → dependents
|
||||||
|
r refresh the module list from /proc/modules
|
||||||
|
? toggle help overlay
|
||||||
|
q quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Full-detail view:
|
||||||
|
```
|
||||||
|
j / ↓ , k / ↑ move selection (stays in full-detail, reloads detail)
|
||||||
|
Esc / q back to list
|
||||||
|
? help
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter mode (after `/`):
|
||||||
|
```
|
||||||
|
type append to filter
|
||||||
|
Backspace delete last char
|
||||||
|
Enter / Esc exit filter (Esc also clears)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- Top bar: module count + kernel version + current sort/filter.
|
||||||
|
- Middle: scrollable module table (#, name, size, refcnt, used-by, state).
|
||||||
|
- Bottom: two-column detail pane for the highlighted module.
|
||||||
|
- Footer: key hints (context-sensitive).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs CLI bootstrap, event loop, key dispatch
|
||||||
|
app.rs App state: module list, cursor, filter, sort, view mode, lazy fetches
|
||||||
|
event.rs crossterm input polling
|
||||||
|
model.rs Module/ModInfo types, /proc + /sys collection, modinfo parsing, tests
|
||||||
|
ui/
|
||||||
|
mod.rs frame composition, status bar, footer, help overlay
|
||||||
|
list.rs module table
|
||||||
|
detail.rs detail pane + full-detail screen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
```sh
|
||||||
|
cargo test --release
|
||||||
|
```
|
||||||
|
Unit tests cover the /proc/modules `used-by` parser, the `modinfo` output
|
||||||
|
parser, and the human-readable size formatter.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
- Writing module parameters or loading/unloading modules — read-only by design.
|
||||||
|
- Showing all installable (not-yet-loaded) modules — planned for v1.1.
|
||||||
|
- Remote hosts — local only.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
- v1.1: dependency tree view, all-available-modules toggle, CSV/TSV export,
|
||||||
|
config file (`~/.config/kmod-tui/config.toml`).
|
||||||
|
- v2: optional inotify auto-refresh on `/proc/modules`.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# kmod-tui — Kernel Module Inspector (Rust TUI)
|
||||||
|
|
||||||
|
A terminal UI for Linux sysadmins to browse and inspect loaded kernel modules.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Show all loaded kernel modules in a navigable, filterable list, with a detail
|
||||||
|
view for each module's metadata, dependencies, parameters, and holders.
|
||||||
|
|
||||||
|
## Data Sources (verified on this host, kernel 6.8.0-137-generic, 130 modules)
|
||||||
|
- `/proc/modules` — one line per loaded module:
|
||||||
|
`name size refcnt used-by state addr`
|
||||||
|
(e.g. `wireguard 114688 0 - Live 0xffffffffc125d000`)
|
||||||
|
- `/sys/module/<name>/` — per-module sysfs dir with:
|
||||||
|
`refcnt`, `initstate`, `coresize`, `initsize`, `version`, `srcversion`,
|
||||||
|
`taint`, `holders/` (modules depending on this one), `parameters/` (tunable params)
|
||||||
|
- `modinfo <name>` — richest metadata: filename, license, description, author,
|
||||||
|
depends, retpoline, intree, vermagic, signature info
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Rust (stable)
|
||||||
|
- `ratatui` — TUI rendering (modern maintained fork of tui-rs)
|
||||||
|
- `crossterm` — terminal backend (cross-platform, works everywhere)
|
||||||
|
- `clap` — CLI args (optional: --sort, --filter, --no-modinfo for speed)
|
||||||
|
- No shell-out to `modinfo`; parse `/proc/modules` + walk `/sys/module` directly.
|
||||||
|
(Avoids a process spawn per module; modinfo data like license/description/author
|
||||||
|
comes from the .ko file's modinfo section — we parse the relevant sysfs files
|
||||||
|
and, where sysfs lacks it, read the module file's modinfo section directly.
|
||||||
|
Decision: for v1, shell out to `modinfo` lazily only when a module is selected
|
||||||
|
for detail — keeps the list fast and detail rich. Revisit later.)
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
```
|
||||||
|
┌─ kmod-tui — 130 modules loaded (kernel 6.8.0-137-generic) ──────────────┐
|
||||||
|
│ Filter: [_____________] Sort: name▼ Loaded only: on │
|
||||||
|
├────┬──────────────────┬────────┬──────┬────────────────────┬────────────┤
|
||||||
|
│ # │ Module │ Size │ Refc │ Used by │ State │
|
||||||
|
├────┼──────────────────┼────────┼──────┼────────────────────┼────────────┤
|
||||||
|
│ 1 │ cpuid │ 12288 │ 0 │ - │ Live │
|
||||||
|
│ 2 │ wireguard │ 114688 │ 0 │ - │ Live │
|
||||||
|
│ 3 │ curve25519_x86_64│ 36864 │ 1 │ wireguard │ Live │
|
||||||
|
│ .. │ .. │ .. │ .. │ .. │ .. │
|
||||||
|
├────┴──────────────────┴────────┴──────┴────────────────────┴────────────┤
|
||||||
|
│ Detail: wireguard │
|
||||||
|
│ Filename: /lib/modules/.../wireguard.ko.zst │
|
||||||
|
│ License: GPL Description: ... Author: ... │
|
||||||
|
│ Depends: (none) Holders: (none) Parameters: (none) │
|
||||||
|
│ Coresize: 114688 Initsize: 0 Refcnt: 0 Srcversion: ... │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
↑/↓ move Enter expand / filter s sort q quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Two-pane layout: scrollable module list (top ~70%), detail pane (bottom ~30%).
|
||||||
|
Selecting a row populates the detail pane. Enter opens a full-screen detail
|
||||||
|
view for the selected module with all sysfs fields, parameters, holders,
|
||||||
|
and a dependency tree.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
### v1 (MVP — the "nice looking, human-navigable" baseline)
|
||||||
|
1. Module list from /proc/modules with columns: #, name, size (human-readable),
|
||||||
|
refcnt, used-by, state.
|
||||||
|
2. Detail pane for highlighted module: filename, license, description, author,
|
||||||
|
depends, holders, parameters (name + current value), coresize, initsize,
|
||||||
|
refcnt, srcversion, taint, vermagic.
|
||||||
|
3. Vim-style navigation: j/k or ↑/↓ to move, Enter for full detail, Esc/q back.
|
||||||
|
4. Live filter (`/` then type, fuzzy or substring match on name).
|
||||||
|
5. Sort cycle (`s`): name, size, refcnt, dependents-count.
|
||||||
|
6. Footer help bar.
|
||||||
|
7. Color: state (Live=green, Loading/Unloading=yellow), refcnt>0 (bold),
|
||||||
|
module-with-dependents highlighted.
|
||||||
|
8. Header shows module count, kernel version (from `uname -r`).
|
||||||
|
|
||||||
|
### v1.1 (polish)
|
||||||
|
- Dependency tree view (expand a module → see what it depends on and what
|
||||||
|
depends on it, recursively).
|
||||||
|
- Parameter inspection with current values read from /sys/module/<n>/parameters.
|
||||||
|
- Export current view to file (CSV/TSV).
|
||||||
|
- Config file (~/.config/kmod-tui/config.toml) for default sort, columns.
|
||||||
|
|
||||||
|
### v2 (nice-to-have, later)
|
||||||
|
- Show ALL installable modules (from /lib/modules/$(uname -r)/), not just
|
||||||
|
loaded — mark loaded vs available-not-loaded.
|
||||||
|
- Toggle between "loaded only" and "all available".
|
||||||
|
- modprobe/insmod/rmmod actions (guarded, with confirmation — but see note:
|
||||||
|
this is read-only for v1; mutating is a separate, deliberate decision).
|
||||||
|
- Module loading state watch (inotify on /proc/modules, auto-refresh).
|
||||||
|
|
||||||
|
## Architecture (v1)
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs — CLI args, app bootstrap, event loop
|
||||||
|
app.rs — App state: module list, cursor, filter, sort mode, focus
|
||||||
|
model.rs — Module struct, data collection from /proc + /sys
|
||||||
|
ui/
|
||||||
|
mod.rs — frame composition, layout
|
||||||
|
list.rs — module list table rendering
|
||||||
|
detail.rs — detail pane + full detail view
|
||||||
|
help.rs — help bar / overlay
|
||||||
|
event.rs — crossterm input polling
|
||||||
|
config.rs — (v1.1) config file load
|
||||||
|
```
|
||||||
|
|
||||||
|
Data flow:
|
||||||
|
collect_modules() → Vec<Module> (once on startup; refresh on 'r')
|
||||||
|
App holds Vec<Module> + filter/sort/cursor state
|
||||||
|
ratatui draws from App state each frame
|
||||||
|
crossterm polls input; key handlers mutate App state; redraw on change
|
||||||
|
(or redraw on a fixed tick — simpler: event-driven redraw)
|
||||||
|
|
||||||
|
## Open Decisions (for Isaac)
|
||||||
|
1. Shelling out to `modinfo` for detail metadata (filename/license/desc/author)
|
||||||
|
vs parsing .ko files directly? Recommend: shell out lazily on selection —
|
||||||
|
simple and correct. We only parse /proc + /sys for the list.
|
||||||
|
2. Read-only for v1 (no rmmod/modprobe)? Recommend: YES, read-only v1.
|
||||||
|
3. Project location: /root/kmod-tui ? Recommend: yes.
|
||||||
|
4. Install Rust via rustup (recommended) or apt rustc package?
|
||||||
|
Recommend: rustup (stable toolchain, latest ratatui).
|
||||||
|
5. Naming: `kmod-tui` ok?
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
cargo run --release
|
||||||
|
(or `cargo build --release` then ./target/release/kmod-tui)
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
- Editing module parameters (write to /sys) — risky, out of scope.
|
||||||
|
- Loading/unloading modules — risky, out of scope.
|
||||||
|
- Remote hosts — local only (could add SSH later).
|
||||||
|
- Persistent state — none needed.
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
//! App state: the in-memory model the UI renders from.
|
||||||
|
|
||||||
|
use crate::model::{self, ModInfo, Module};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortMode {
|
||||||
|
Name,
|
||||||
|
Size,
|
||||||
|
Refcnt,
|
||||||
|
Dependents,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortMode {
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
SortMode::Name => SortMode::Size,
|
||||||
|
SortMode::Size => SortMode::Refcnt,
|
||||||
|
SortMode::Refcnt => SortMode::Dependents,
|
||||||
|
SortMode::Dependents => SortMode::Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SortMode::Name => "name",
|
||||||
|
SortMode::Size => "size",
|
||||||
|
SortMode::Refcnt => "refcnt",
|
||||||
|
SortMode::Dependents => "dependents",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which screen is active.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum View {
|
||||||
|
List,
|
||||||
|
FullDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
/// All loaded modules (unfiltered, unsorted master copy).
|
||||||
|
pub modules: Vec<Module>,
|
||||||
|
/// Indices into `modules` after filtering + sorting.
|
||||||
|
pub view: Vec<usize>,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub filter: String,
|
||||||
|
pub filter_active: bool,
|
||||||
|
pub sort: SortMode,
|
||||||
|
pub view_mode: View,
|
||||||
|
pub kernel: String,
|
||||||
|
/// Pending lazy modinfo fetches.
|
||||||
|
pub pending: Vec<mpsc::Receiver<(String, Option<ModInfo>)>>,
|
||||||
|
/// Whether the help overlay is shown.
|
||||||
|
pub help: bool,
|
||||||
|
pub should_quit: bool,
|
||||||
|
/// Vertical scroll offset (index of the first visible row in the list).
|
||||||
|
pub scroll: usize,
|
||||||
|
/// Number of data rows visible in the list pane (set by the UI each draw).
|
||||||
|
pub viewport_rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let modules = model::collect_modules();
|
||||||
|
let kernel = model::kernel_release();
|
||||||
|
let mut app = App {
|
||||||
|
modules,
|
||||||
|
view: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
filter: String::new(),
|
||||||
|
filter_active: false,
|
||||||
|
sort: SortMode::Name,
|
||||||
|
view_mode: View::List,
|
||||||
|
kernel,
|
||||||
|
pending: Vec::new(),
|
||||||
|
help: false,
|
||||||
|
should_quit: false,
|
||||||
|
scroll: 0,
|
||||||
|
viewport_rows: 0,
|
||||||
|
};
|
||||||
|
app.rebuild_view();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the filtered + sorted view from the master module list.
|
||||||
|
pub fn rebuild_view(&mut self) {
|
||||||
|
let f = self.filter.to_lowercase();
|
||||||
|
let mut idxs: Vec<usize> = self
|
||||||
|
.modules
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, m)| m.matches_filter(&f))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
idxs.sort_by(|&a, &b| {
|
||||||
|
let ma = &self.modules[a];
|
||||||
|
let mb = &self.modules[b];
|
||||||
|
match self.sort {
|
||||||
|
SortMode::Name => ma.name.cmp(&mb.name),
|
||||||
|
SortMode::Size => mb.size.cmp(&ma.size),
|
||||||
|
SortMode::Refcnt => mb.refcnt.cmp(&ma.refcnt),
|
||||||
|
SortMode::Dependents => mb.used_by.len().cmp(&ma.used_by.len()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.view = idxs;
|
||||||
|
if self.cursor >= self.view.len() {
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index into `modules` of the module under the cursor, if any.
|
||||||
|
pub fn current_module(&self) -> Option<&Module> {
|
||||||
|
self.view
|
||||||
|
.get(self.cursor)
|
||||||
|
.and_then(|&i| self.modules.get(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserved for v1.1: mutating a selected module in place (e.g. on refresh).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn current_module_mut(&mut self) -> Option<&mut Module> {
|
||||||
|
self.view
|
||||||
|
.get(self.cursor)
|
||||||
|
.and_then(|&i| self.modules.get_mut(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_cursor(&mut self, delta: i32) {
|
||||||
|
if self.view.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = self.view.len() as i32;
|
||||||
|
let mut c = self.cursor as i32 + delta;
|
||||||
|
if c < 0 {
|
||||||
|
c = 0;
|
||||||
|
}
|
||||||
|
if c >= n {
|
||||||
|
c = n - 1;
|
||||||
|
}
|
||||||
|
self.cursor = c as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_sort(&mut self) {
|
||||||
|
self.sort = self.sort.next();
|
||||||
|
self.rebuild_view();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserved for v1.1: set an explicit sort mode by key.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn set_sort(&mut self, s: SortMode) {
|
||||||
|
self.sort = s;
|
||||||
|
self.rebuild_view();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_filter(&mut self) {
|
||||||
|
self.filter_active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_filter_char(&mut self, c: char) {
|
||||||
|
self.filter.push(c);
|
||||||
|
self.rebuild_view();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pop_filter_char(&mut self) {
|
||||||
|
self.filter.pop();
|
||||||
|
self.rebuild_view();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_filter(&mut self) {
|
||||||
|
self.filter.clear();
|
||||||
|
self.filter_active = false;
|
||||||
|
self.rebuild_view();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
self.modules = model::collect_modules();
|
||||||
|
self.rebuild_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request modinfo for the module under the cursor (if not already loaded).
|
||||||
|
pub fn ensure_modinfo(&mut self) {
|
||||||
|
if let Some(m) = self.current_module() {
|
||||||
|
if m.modinfo.is_none() {
|
||||||
|
let name = m.name.clone();
|
||||||
|
let rx = model::spawn_modinfo_fetch(name);
|
||||||
|
self.pending.push(rx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any completed lazy modinfo fetches and merge them into the
|
||||||
|
/// matching module. Returns true if anything changed (needs redraw).
|
||||||
|
pub fn poll_pending(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < self.pending.len() {
|
||||||
|
match self.pending[i].try_recv() {
|
||||||
|
Ok((name, mi)) => {
|
||||||
|
if let Some(m) = self.modules.iter_mut().find(|m| m.name == name) {
|
||||||
|
m.modinfo = mi;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
self.pending.remove(i);
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
self.pending.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the scroll offset so the cursor stays within the viewport.
|
||||||
|
/// Edge-scrolling: only scrolls when the cursor would leave the visible
|
||||||
|
/// area. Called by the UI after layout is known (viewport_rows is set).
|
||||||
|
pub fn adjust_scroll(&mut self) {
|
||||||
|
self.scroll = compute_scroll(self.cursor, self.scroll, self.viewport_rows, self.view.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure scroll-offset computation. Edge-scrolling: the cursor moves freely
|
||||||
|
/// within the viewport; the viewport only scrolls when the cursor would
|
||||||
|
/// leave it.
|
||||||
|
///
|
||||||
|
/// - `cursor` — index of the selected row
|
||||||
|
/// - `current_scroll`— the current scroll offset (top visible row index)
|
||||||
|
/// - `rows_visible` — how many data rows fit in the list pane
|
||||||
|
/// - `total` — total number of rows in the view
|
||||||
|
pub fn compute_scroll(cursor: usize, current_scroll: usize, rows_visible: usize, total: usize) -> usize {
|
||||||
|
if rows_visible == 0 || total <= rows_visible {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let max_scroll = total - rows_visible;
|
||||||
|
let mut scroll = current_scroll;
|
||||||
|
if cursor < scroll {
|
||||||
|
// Cursor is above the viewport — scroll up to reveal it.
|
||||||
|
scroll = cursor;
|
||||||
|
} else if cursor >= scroll + rows_visible {
|
||||||
|
// Cursor is below the viewport — scroll down so it's the last row.
|
||||||
|
scroll = cursor - rows_visible + 1;
|
||||||
|
}
|
||||||
|
// cursor is within [scroll, scroll+rows_visible) — no change needed.
|
||||||
|
scroll.min(max_scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_no_scroll_when_all_fit() {
|
||||||
|
// total (8) < rows_visible (10) — everything fits, no scrolling.
|
||||||
|
assert_eq!(compute_scroll(5, 0, 10, 8), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_stays_at_top_when_cursor_in_viewport() {
|
||||||
|
// cursor 3 in [0,10) — no scroll change.
|
||||||
|
assert_eq!(compute_scroll(3, 0, 10, 100), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_down_when_cursor_below_viewport() {
|
||||||
|
// cursor 10, viewport [0,10) — scroll to 1, cursor becomes last row.
|
||||||
|
assert_eq!(compute_scroll(10, 0, 10, 100), 1);
|
||||||
|
// cursor 15 — scroll to 6.
|
||||||
|
assert_eq!(compute_scroll(15, 0, 10, 100), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_up_when_cursor_above_viewport() {
|
||||||
|
// cursor 5 < scroll 20 — scroll up to 5.
|
||||||
|
assert_eq!(compute_scroll(5, 20, 10, 100), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_no_change_when_cursor_in_middle_of_viewport() {
|
||||||
|
// cursor 25 in [20,30) — no scroll change.
|
||||||
|
assert_eq!(compute_scroll(25, 20, 10, 100), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_clamps_at_bottom() {
|
||||||
|
// Can't scroll past total - rows_visible = 90.
|
||||||
|
assert_eq!(compute_scroll(99, 90, 10, 100), 90);
|
||||||
|
// Even if cursor is at the very last row.
|
||||||
|
assert_eq!(compute_scroll(99, 0, 10, 100), 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_handles_zero_rows() {
|
||||||
|
assert_eq!(compute_scroll(5, 0, 0, 100), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Terminal input events via crossterm.
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyEvent};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Poll for a terminal event with a small timeout. Returns None if no event
|
||||||
|
/// arrived within the poll window (lets the app stay responsive and do
|
||||||
|
/// background work like receiving lazy modinfo fetches).
|
||||||
|
pub fn poll(timeout_ms: u64) -> Option<KeyEvent> {
|
||||||
|
if event::poll(Duration::from_millis(timeout_ms)).unwrap_or(false) {
|
||||||
|
if let Ok(Event::Key(key)) = event::read() {
|
||||||
|
return Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
//! kmod-tui — a TUI for browsing and inspecting loaded Linux kernel modules.
|
||||||
|
//!
|
||||||
|
//! Read-only. Data from /proc/modules, /sys/module/<name>/, and `modinfo`.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod event;
|
||||||
|
mod model;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io::{self, stdout};
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
use crate::app::View;
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
// Setup terminal.
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// Run the app.
|
||||||
|
let result = run(&mut terminal);
|
||||||
|
|
||||||
|
// Restore terminal regardless of outcome.
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||||
|
let mut app = app::App::new();
|
||||||
|
// Eagerly fetch modinfo for the first module so the detail pane isn't empty.
|
||||||
|
app.ensure_modinfo();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Draw current state.
|
||||||
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
|
||||||
|
// Poll for input (short timeout so we can drain background fetches).
|
||||||
|
if let Some(key) = event::poll(100) {
|
||||||
|
handle_key(&mut app, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain any completed modinfo fetches; redraw if something arrived.
|
||||||
|
if app.poll_pending() {
|
||||||
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut app::App, key: KeyEvent) {
|
||||||
|
// Global: quit works anywhere (unless typing in filter).
|
||||||
|
if !app.filter_active && matches!(key.code, KeyCode::Char('q')) {
|
||||||
|
app.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !app.filter_active && matches!(key.code, KeyCode::Char('?')) {
|
||||||
|
app.help = !app.help;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.help {
|
||||||
|
// Any key (except ?) closes help.
|
||||||
|
if matches!(key.code, KeyCode::Char('?')) {
|
||||||
|
app.help = false;
|
||||||
|
} else {
|
||||||
|
app.help = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.filter_active {
|
||||||
|
handle_filter_key(app, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.view_mode {
|
||||||
|
View::List => handle_list_key(app, key),
|
||||||
|
View::FullDetail => handle_detail_key(app, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_filter_key(app: &mut app::App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.clear_filter(),
|
||||||
|
KeyCode::Enter => app.filter_active = false,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.pop_filter_char();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
if c == 'c' {
|
||||||
|
app.clear_filter();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.push_filter_char(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list_key(app: &mut app::App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1),
|
||||||
|
KeyCode::Char('G') => {
|
||||||
|
if !app.view.is_empty() {
|
||||||
|
app.cursor = app.view.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('g') => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.is_empty() {
|
||||||
|
app.cursor = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
let step = 10;
|
||||||
|
app.move_cursor(step);
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => app.move_cursor(-10),
|
||||||
|
KeyCode::Home => app.cursor = 0,
|
||||||
|
KeyCode::End => {
|
||||||
|
if !app.view.is_empty() {
|
||||||
|
app.cursor = app.view.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.view_mode = View::FullDetail;
|
||||||
|
app.ensure_modinfo();
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => app.enter_filter(),
|
||||||
|
KeyCode::Char('s') => app.toggle_sort(),
|
||||||
|
KeyCode::Char('r') => app.refresh(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_detail_key(app: &mut app::App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => app.view_mode = View::List,
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
app.view_mode = View::List;
|
||||||
|
app.move_cursor(-1);
|
||||||
|
app.ensure_modinfo();
|
||||||
|
app.view_mode = View::FullDetail;
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
app.view_mode = View::List;
|
||||||
|
app.move_cursor(1);
|
||||||
|
app.ensure_modinfo();
|
||||||
|
app.view_mode = View::FullDetail;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
//! Data model: collect and represent loaded kernel modules.
|
||||||
|
//!
|
||||||
|
//! Data sources (read-only):
|
||||||
|
//! - /proc/modules : one line per loaded module
|
||||||
|
//! - /sys/module/<name>/ : refcnt, coresize, initsize, initstate, srcversion,
|
||||||
|
//! taint, version, parameters/, holders/
|
||||||
|
//! - `modinfo <name>` : filename, license, description, author, depends,
|
||||||
|
//! vermagic, retpoline, intree, signature info
|
||||||
|
//! (shelled out lazily, only when detail is requested,
|
||||||
|
//! to keep the list view fast.)
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
/// One loaded kernel module.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Module {
|
||||||
|
pub name: String,
|
||||||
|
/// Size in bytes from /proc/modules (this is the core size for most modules).
|
||||||
|
pub size: usize,
|
||||||
|
pub refcnt: i64,
|
||||||
|
/// Modules that depend on this one (parsed from the "used-by" column).
|
||||||
|
pub used_by: Vec<String>,
|
||||||
|
pub state: String,
|
||||||
|
/// Base address (hex string as in /proc/modules).
|
||||||
|
pub addr: String,
|
||||||
|
|
||||||
|
// From /sys/module/<name>/
|
||||||
|
pub coresize: Option<usize>,
|
||||||
|
pub initsize: Option<usize>,
|
||||||
|
pub initstate: Option<String>,
|
||||||
|
pub srcversion: Option<String>,
|
||||||
|
pub taint: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// Parameters and their current values (read from /sys/module/<name>/parameters).
|
||||||
|
pub parameters: Vec<(String, String)>,
|
||||||
|
/// Modules holding a reference (from /sys/module/<name>/holders).
|
||||||
|
pub holders: Vec<String>,
|
||||||
|
|
||||||
|
// From `modinfo` (lazy; None until explicitly fetched).
|
||||||
|
pub modinfo: Option<ModInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module {
|
||||||
|
/// True if the module matches a substring filter: against its own name OR
|
||||||
|
/// any of the modules that depend on it (`used_by`). Case-insensitive.
|
||||||
|
pub fn matches_filter(&self, needle: &str) -> bool {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let needle = needle.to_lowercase();
|
||||||
|
if self.name.to_lowercase().contains(&needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.used_by
|
||||||
|
.iter()
|
||||||
|
.any(|dep| dep.to_lowercase().contains(&needle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The richest metadata, obtained from `modinfo`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ModInfo {
|
||||||
|
pub filename: String,
|
||||||
|
pub license: String,
|
||||||
|
pub description: String,
|
||||||
|
pub author: String,
|
||||||
|
pub depends: Vec<String>,
|
||||||
|
pub vermagic: String,
|
||||||
|
pub retpoline: String,
|
||||||
|
pub intree: String,
|
||||||
|
pub signer: String,
|
||||||
|
pub sig_hashalgo: String,
|
||||||
|
/// Other fields we didn't classify, kept as (key, value) pairs.
|
||||||
|
pub other: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a sysfs file to a trimmed String. Returns None on any error.
|
||||||
|
fn read_sysfs(path: &Path) -> Option<String> {
|
||||||
|
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the "used-by" column of /proc/modules: tokens separated by commas,
|
||||||
|
/// or "-" if nothing uses this module.
|
||||||
|
fn parse_used_by(s: &str) -> Vec<String> {
|
||||||
|
if s == "-" || s.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
s.split_terminator(',')
|
||||||
|
.map(|x| x.trim().trim_end_matches('[').trim_end_matches(']').to_string())
|
||||||
|
.filter(|x| !x.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read parameters from /sys/module/<name>/parameters.
|
||||||
|
fn read_parameters(name: &str) -> Vec<(String, String)> {
|
||||||
|
let dir = Path::new("/sys/module").join(name).join("parameters");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
|
for ent in entries.flatten() {
|
||||||
|
let pname = ent.file_name().to_string_lossy().to_string();
|
||||||
|
let val = fs::read_to_string(ent.path())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
out.push((pname, val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic ordering.
|
||||||
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read holders from /sys/module/<name>/holders.
|
||||||
|
fn read_holders(name: &str) -> Vec<String> {
|
||||||
|
let dir = Path::new("/sys/module").join(name).join("holders");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
|
for ent in entries.flatten() {
|
||||||
|
out.push(ent.file_name().to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all currently-loaded modules from /proc/modules and /sys/module.
|
||||||
|
pub fn collect_modules() -> Vec<Module> {
|
||||||
|
let proc = fs::read_to_string("/proc/modules").unwrap_or_default();
|
||||||
|
let mut modules: Vec<Module> = Vec::new();
|
||||||
|
|
||||||
|
for line in proc.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// parts: name size refcnt used_by state addr [extra...]
|
||||||
|
let name = parts[0].to_string();
|
||||||
|
let size: usize = parts[1].parse().unwrap_or(0);
|
||||||
|
let refcnt: i64 = parts[2].parse().unwrap_or(0);
|
||||||
|
let used_by = parse_used_by(parts[3]);
|
||||||
|
let state = parts.get(4).map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
let addr = parts.get(5).map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
// Enrich from /sys/module/<name>.
|
||||||
|
let sys = Path::new("/sys/module").join(&name);
|
||||||
|
let coresize = read_sysfs(&sys.join("coresize")).and_then(|s| s.parse().ok());
|
||||||
|
let initsize = read_sysfs(&sys.join("initsize")).and_then(|s| s.parse().ok());
|
||||||
|
let initstate = read_sysfs(&sys.join("initstate"));
|
||||||
|
let srcversion = read_sysfs(&sys.join("srcversion"));
|
||||||
|
let taint = read_sysfs(&sys.join("taint"));
|
||||||
|
let version = read_sysfs(&sys.join("version"));
|
||||||
|
let parameters = read_parameters(&name);
|
||||||
|
let holders = read_holders(&name);
|
||||||
|
|
||||||
|
modules.push(Module {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
refcnt,
|
||||||
|
used_by,
|
||||||
|
state,
|
||||||
|
addr,
|
||||||
|
coresize,
|
||||||
|
initsize,
|
||||||
|
initstate,
|
||||||
|
srcversion,
|
||||||
|
taint,
|
||||||
|
version,
|
||||||
|
parameters,
|
||||||
|
holders,
|
||||||
|
modinfo: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
modules
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch rich metadata via `modinfo <name>` and parse it into a ModInfo struct.
|
||||||
|
pub fn fetch_modinfo(name: &str) -> Option<ModInfo> {
|
||||||
|
let output = Command::new("modinfo").arg(name).output().ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Some(parse_modinfo(&stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `modinfo` output. Each line: `key: value`. Multi-line values (like
|
||||||
|
/// `signature`) are folded by detecting a leading tab/whitespace continuation.
|
||||||
|
fn parse_modinfo(text: &str) -> ModInfo {
|
||||||
|
let mut mi = ModInfo::default();
|
||||||
|
let known: &[&str] = &[
|
||||||
|
"filename",
|
||||||
|
"license",
|
||||||
|
"description",
|
||||||
|
"author",
|
||||||
|
"depends",
|
||||||
|
"vermagic",
|
||||||
|
"retpoline",
|
||||||
|
"intree",
|
||||||
|
"signer",
|
||||||
|
"sig_hashalgo",
|
||||||
|
"sig_id",
|
||||||
|
"sig_key",
|
||||||
|
"signature",
|
||||||
|
"srcversion",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
];
|
||||||
|
let mut last_key = String::new();
|
||||||
|
for raw in text.lines() {
|
||||||
|
// Continuation line (starts with whitespace) appends to last value.
|
||||||
|
if raw.starts_with('\t') || (raw.starts_with(' ') && !raw.trim().is_empty()) {
|
||||||
|
if !last_key.is_empty() {
|
||||||
|
let cont = raw.trim();
|
||||||
|
// Append to whichever field last_key refers to.
|
||||||
|
append_to_field(&mut mi, &last_key, cont);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((k, v)) = raw.split_once(':') {
|
||||||
|
let key = k.trim().to_string();
|
||||||
|
let val = v.trim().to_string();
|
||||||
|
last_key = key.clone();
|
||||||
|
set_field(&mut mi, &key, &val, known);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mi
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field(mi: &mut ModInfo, key: &str, val: &str, known: &[&str]) {
|
||||||
|
match key {
|
||||||
|
"filename" => mi.filename = val.to_string(),
|
||||||
|
"license" => mi.license = val.to_string(),
|
||||||
|
"description" => mi.description = val.to_string(),
|
||||||
|
"author" => mi.author = val.to_string(),
|
||||||
|
"depends" => {
|
||||||
|
mi.depends = if val == "-" || val.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
val.split(',').map(|s| s.trim().to_string()).collect()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"vermagic" => mi.vermagic = val.to_string(),
|
||||||
|
"retpoline" => mi.retpoline = val.to_string(),
|
||||||
|
"intree" => mi.intree = val.to_string(),
|
||||||
|
"signer" => mi.signer = val.to_string(),
|
||||||
|
"sig_hashalgo" => mi.sig_hashalgo = val.to_string(),
|
||||||
|
_ => {
|
||||||
|
let _ = known;
|
||||||
|
mi.other.push((key.to_string(), val.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_to_field(mi: &mut ModInfo, key: &str, val: &str) {
|
||||||
|
match key {
|
||||||
|
"description" => {
|
||||||
|
if !mi.description.is_empty() {
|
||||||
|
mi.description.push(' ');
|
||||||
|
}
|
||||||
|
mi.description.push_str(val);
|
||||||
|
}
|
||||||
|
"signature" | "sig_key" => {
|
||||||
|
// Append to the most recent 'other' entry if it matches.
|
||||||
|
if let Some(last) = mi.other.last_mut() {
|
||||||
|
if last.0 == key {
|
||||||
|
last.1.push_str(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a thread to fetch modinfo for a module, returning a channel that
|
||||||
|
/// yields (name, Option<ModInfo>) when ready. Lets the UI stay responsive.
|
||||||
|
pub fn spawn_modinfo_fetch(name: String) -> mpsc::Receiver<(String, Option<ModInfo>)> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mi = fetch_modinfo(&name);
|
||||||
|
let _ = tx.send((name, mi));
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable byte size, /proc/modules style: 12288 -> "12K", 114688 -> "112K".
|
||||||
|
pub fn human_size(bytes: usize) -> String {
|
||||||
|
let k = 1024usize;
|
||||||
|
let m = k * 1024;
|
||||||
|
if bytes >= m {
|
||||||
|
format!("{:.1}M", bytes as f64 / m as f64)
|
||||||
|
} else if bytes >= k {
|
||||||
|
format!("{}K", bytes / k)
|
||||||
|
} else {
|
||||||
|
format!("{}B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kernel release string, e.g. "6.8.0-137-generic".
|
||||||
|
pub fn kernel_release() -> String {
|
||||||
|
fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of modules currently loaded (cheap: line count of /proc/modules).
|
||||||
|
/// Reserved for a future status-bar "loaded vs filtered" split.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn loaded_count() -> usize {
|
||||||
|
fs::read_to_string("/proc/modules")
|
||||||
|
.map(|s| s.lines().filter(|l| !l.is_empty()).count())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_used_by_dash() {
|
||||||
|
assert!(parse_used_by("-").is_empty());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parse_used_by_list() {
|
||||||
|
let v = parse_used_by("wireguard,curve25519_x86_64");
|
||||||
|
assert_eq!(v, vec!["wireguard", "curve25519_x86_64"]);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn filter_matches_name() {
|
||||||
|
let m = Module {
|
||||||
|
name: "wireguard".into(),
|
||||||
|
size: 0,
|
||||||
|
refcnt: 0,
|
||||||
|
used_by: vec![],
|
||||||
|
state: "Live".into(),
|
||||||
|
addr: String::new(),
|
||||||
|
coresize: None,
|
||||||
|
initsize: None,
|
||||||
|
initstate: None,
|
||||||
|
srcversion: None,
|
||||||
|
taint: None,
|
||||||
|
version: None,
|
||||||
|
parameters: vec![],
|
||||||
|
holders: vec![],
|
||||||
|
modinfo: None,
|
||||||
|
};
|
||||||
|
assert!(m.matches_filter("wire"));
|
||||||
|
assert!(m.matches_filter("GUARD")); // case-insensitive
|
||||||
|
assert!(!m.matches_filter("ipsec"));
|
||||||
|
assert!(m.matches_filter("")); // empty matches all
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn filter_matches_dependents() {
|
||||||
|
let m = Module {
|
||||||
|
name: "curve25519_x86_64".into(),
|
||||||
|
size: 0,
|
||||||
|
refcnt: 1,
|
||||||
|
used_by: vec!["wireguard".into()],
|
||||||
|
state: "Live".into(),
|
||||||
|
addr: String::new(),
|
||||||
|
coresize: None,
|
||||||
|
initsize: None,
|
||||||
|
initstate: None,
|
||||||
|
srcversion: None,
|
||||||
|
taint: None,
|
||||||
|
version: None,
|
||||||
|
parameters: vec![],
|
||||||
|
holders: vec![],
|
||||||
|
modinfo: None,
|
||||||
|
};
|
||||||
|
// Name doesn't contain "wireguard" but a dependent does.
|
||||||
|
assert!(m.matches_filter("wireguard"));
|
||||||
|
assert!(m.matches_filter("curve")); // also still matches own name
|
||||||
|
assert!(!m.matches_filter("chacha"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parse_modinfo_basic() {
|
||||||
|
let s = "filename: /lib/modules/6.8.0-137-generic/kernel/wireguard/wireguard.ko.zst\n\
|
||||||
|
license: GPL\n\
|
||||||
|
description: WireGuard secure tunnel\n\
|
||||||
|
author: Jason A. Donenfeld <Jason@zx2c4.com>\n\
|
||||||
|
depends: \n\
|
||||||
|
retpoline: Y\n\
|
||||||
|
intree: Y\n\
|
||||||
|
name: wireguard\n\
|
||||||
|
vermagic: 6.8.0-137-generic SMP preempt mod_unload modversions \n";
|
||||||
|
let mi = parse_modinfo(s);
|
||||||
|
assert_eq!(mi.license, "GPL");
|
||||||
|
assert_eq!(mi.description, "WireGuard secure tunnel");
|
||||||
|
assert_eq!(mi.author, "Jason A. Donenfeld <Jason@zx2c4.com>");
|
||||||
|
assert!(mi.depends.is_empty());
|
||||||
|
assert_eq!(mi.retpoline, "Y");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn human_size_units() {
|
||||||
|
assert_eq!(human_size(12288), "12K");
|
||||||
|
assert_eq!(human_size(114688), "112K");
|
||||||
|
assert_eq!(human_size(1048576), "1.0M");
|
||||||
|
assert_eq!(human_size(512), "512B");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! The detail pane (bottom of list view) and the full-screen detail view.
|
||||||
|
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::model::{human_size, Module};
|
||||||
|
|
||||||
|
/// Bottom detail pane: a compact two-column summary of the selected module.
|
||||||
|
pub fn draw_pane(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let title = match app.current_module() {
|
||||||
|
Some(m) => format!(" Detail: {} ", m.name),
|
||||||
|
None => " Detail ".to_string(),
|
||||||
|
};
|
||||||
|
let block = Block::default().borders(Borders::ALL).title(title);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
if inner.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(m) = app.current_module() else {
|
||||||
|
let p = Paragraph::new("No module selected.")
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
f.render_widget(p, inner);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(inner);
|
||||||
|
let left = summary_lines(m);
|
||||||
|
let right = meta_lines(m);
|
||||||
|
f.render_widget(Paragraph::new(left).wrap(Wrap { trim: true }), cols[0]);
|
||||||
|
f.render_widget(Paragraph::new(right).wrap(Wrap { trim: true }), cols[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kv<'a>(k: &str, v: &str) -> Line<'a> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<11}", k),
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(v.to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary_lines<'a>(m: &Module) -> Vec<Line<'a>> {
|
||||||
|
let mut lines = vec![
|
||||||
|
kv("Name", &m.name),
|
||||||
|
kv("Size", &human_size(m.size)),
|
||||||
|
kv("Refcnt", &m.refcnt.to_string()),
|
||||||
|
kv("State", &m.state),
|
||||||
|
kv("Address", &m.addr),
|
||||||
|
];
|
||||||
|
if let Some(cs) = m.coresize {
|
||||||
|
lines.push(kv("Coresize", &human_size(cs)));
|
||||||
|
}
|
||||||
|
if let Some(is) = m.initsize {
|
||||||
|
lines.push(kv("Initsize", &human_size(is)));
|
||||||
|
}
|
||||||
|
if let Some(v) = &m.initstate {
|
||||||
|
lines.push(kv("Initstate", v));
|
||||||
|
}
|
||||||
|
if let Some(v) = &m.taint {
|
||||||
|
if !v.is_empty() {
|
||||||
|
lines.push(kv("Taint", v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = &m.version {
|
||||||
|
lines.push(kv("Version", v));
|
||||||
|
}
|
||||||
|
if let Some(v) = &m.srcversion {
|
||||||
|
lines.push(kv("Srcversion", v));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_lines<'a>(m: &Module) -> Vec<Line<'a>> {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
match &m.modinfo {
|
||||||
|
Some(mi) => {
|
||||||
|
lines.push(kv("Filename", &mi.filename));
|
||||||
|
lines.push(kv("License", &mi.license));
|
||||||
|
lines.push(kv("Author", &mi.author));
|
||||||
|
lines.push(kv("Vermagic", &mi.vermagic));
|
||||||
|
lines.push(kv("Retpoline", &mi.retpoline));
|
||||||
|
lines.push(kv("Intree", &mi.intree));
|
||||||
|
lines.push(kv("Signer", &mi.signer));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Loading modinfo…",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dep = if m.used_by.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
m.used_by.join(", ")
|
||||||
|
};
|
||||||
|
lines.push(kv("Used by", &dep));
|
||||||
|
let holders = if m.holders.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
m.holders.join(", ")
|
||||||
|
};
|
||||||
|
lines.push(kv("Holders", &holders));
|
||||||
|
let params = if m.parameters.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
m.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
lines.push(kv("Params", ¶ms));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-screen detail view for the selected module.
|
||||||
|
pub fn draw_full(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let title = match app.current_module() {
|
||||||
|
Some(m) => format!(" {} — full detail (Esc to return) ", m.name),
|
||||||
|
None => " Full detail ".to_string(),
|
||||||
|
};
|
||||||
|
let block = Block::default().borders(Borders::ALL).title(title);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
let Some(m) = app.current_module() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Three sections: summary, modinfo/depends, parameters+holders.
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(9),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Min(5),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
f.render_widget(Paragraph::new(summary_lines(m)), chunks[0]);
|
||||||
|
|
||||||
|
let mut meta = meta_lines_full(m);
|
||||||
|
f.render_widget(Paragraph::new(meta.drain(..).collect::<Vec<_>>()), chunks[1]);
|
||||||
|
|
||||||
|
// Parameters and holders as lists.
|
||||||
|
let sub = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(chunks[2]);
|
||||||
|
let pitems: Vec<ListItem> = if m.parameters.is_empty() {
|
||||||
|
vec![ListItem::new("(none)")]
|
||||||
|
} else {
|
||||||
|
m.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| ListItem::new(format!("{} = {}", k, v)))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
List::new(pitems).block(Block::default().borders(Borders::ALL).title("Parameters")),
|
||||||
|
sub[0],
|
||||||
|
);
|
||||||
|
let hitems: Vec<ListItem> = if m.holders.is_empty() {
|
||||||
|
vec![ListItem::new("(none)")]
|
||||||
|
} else {
|
||||||
|
m.holders.iter().map(|h| ListItem::new(h.clone())).collect()
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
List::new(hitems).block(Block::default().borders(Borders::ALL).title("Holders")),
|
||||||
|
sub[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_lines_full<'a>(m: &Module) -> Vec<Line<'a>> {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
if let Some(mi) = &m.modinfo {
|
||||||
|
lines.push(kv("Filename", &mi.filename));
|
||||||
|
lines.push(kv("License", &mi.license));
|
||||||
|
lines.push(kv("Description", &mi.description));
|
||||||
|
lines.push(kv("Author", &mi.author));
|
||||||
|
lines.push(kv("Vermagic", &mi.vermagic));
|
||||||
|
let dep = if mi.depends.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
mi.depends.join(", ")
|
||||||
|
};
|
||||||
|
lines.push(kv("Depends", &dep));
|
||||||
|
lines.push(kv("Retpoline", &mi.retpoline));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Loading modinfo… (press Enter again if it stalls)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _alignment_unused() -> Alignment {
|
||||||
|
Alignment::Left
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! The module list pane (top of the list view).
|
||||||
|
//!
|
||||||
|
//! Scrolling is edge-based: the cursor moves freely within the viewport and
|
||||||
|
//! the list only scrolls when the cursor would leave the visible area. The
|
||||||
|
//! scroll offset lives in `App` (set by `ui::mod` via `adjust_scroll`) so it's
|
||||||
|
//! stateful and stable across draws.
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::widgets::{Block, Borders, Row, Table};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::model::human_size;
|
||||||
|
|
||||||
|
/// Column widths.
|
||||||
|
const WIDTHS: [Constraint; 6] = [
|
||||||
|
Constraint::Length(4), // #
|
||||||
|
Constraint::Min(22), // name
|
||||||
|
Constraint::Length(8), // size
|
||||||
|
Constraint::Length(5), // refcnt
|
||||||
|
Constraint::Min(18), // used by
|
||||||
|
Constraint::Length(7), // state
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How many data rows fit in the list pane for the given area (excludes the
|
||||||
|
/// header row and the border). Used by `ui::mod` to size the viewport before
|
||||||
|
/// the list is drawn.
|
||||||
|
pub fn visible_rows(area: Rect) -> usize {
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
inner.height.saturating_sub(1) as usize // account for the header row
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Loaded modules: {} ", app.view.len()));
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
if inner.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = Row::new(vec!["#", "Module", "Size", "Refc", "Used by", "State"])
|
||||||
|
.style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan));
|
||||||
|
|
||||||
|
let rows_visible = visible_rows(area);
|
||||||
|
let start = app.scroll;
|
||||||
|
|
||||||
|
let rows: Vec<Row> = app
|
||||||
|
.view
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(start)
|
||||||
|
.take(rows_visible)
|
||||||
|
.map(|(vn, &idx)| {
|
||||||
|
let m = &app.modules[idx];
|
||||||
|
let used = if m.used_by.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
m.used_by.join(",")
|
||||||
|
};
|
||||||
|
let selected = vn == app.cursor;
|
||||||
|
let row_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
Row::new(vec![
|
||||||
|
format!("{}", vn + 1),
|
||||||
|
m.name.clone(),
|
||||||
|
human_size(m.size),
|
||||||
|
format!("{}", m.refcnt),
|
||||||
|
used,
|
||||||
|
m.state.clone(),
|
||||||
|
])
|
||||||
|
.style(row_style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let table = Table::new(rows, WIDTHS).header(header);
|
||||||
|
f.render_widget(table, inner);
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
pub mod list;
|
||||||
|
pub mod detail;
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::app::{App, View};
|
||||||
|
|
||||||
|
/// Compose the whole frame from the app state.
|
||||||
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
|
let area = f.area();
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(3), // status/filter bar
|
||||||
|
Constraint::Min(5), // main area
|
||||||
|
Constraint::Length(2), // footer
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
draw_status(f, chunks[0], app);
|
||||||
|
|
||||||
|
match app.view_mode {
|
||||||
|
View::List => {
|
||||||
|
let main = Layout::vertical([Constraint::Fill(2), Constraint::Length(10)])
|
||||||
|
.split(chunks[1]);
|
||||||
|
// Set how many rows the list pane can show, then edge-scroll so
|
||||||
|
// the cursor stays visible without centering it.
|
||||||
|
app.viewport_rows = list::visible_rows(main[0]);
|
||||||
|
app.adjust_scroll();
|
||||||
|
list::draw(f, main[0], app);
|
||||||
|
detail::draw_pane(f, main[1], app);
|
||||||
|
}
|
||||||
|
View::FullDetail => {
|
||||||
|
detail::draw_full(f, chunks[1], app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_footer(f, chunks[2], app);
|
||||||
|
|
||||||
|
if app.help {
|
||||||
|
draw_help(f, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let title = format!(
|
||||||
|
" kmod-tui — {} modules loaded (kernel {}) ",
|
||||||
|
app.view.len(),
|
||||||
|
app.kernel
|
||||||
|
);
|
||||||
|
let block = Block::default().borders(Borders::ALL).title(title);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let line = if app.filter_active {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"Filter: ",
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(app.filter.clone()),
|
||||||
|
Span::styled("_", Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw(format!("Sort: {}", app.sort.label())),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"Sort: ",
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(app.sort.label()),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
format!("Filter: {}", if app.filter.is_empty() { "(none)" } else { &app.filter }),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
f.render_widget(Paragraph::new(line), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let block = Block::default().borders(Borders::TOP);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 0,
|
||||||
|
});
|
||||||
|
let keys = match app.view_mode {
|
||||||
|
View::List => {
|
||||||
|
if app.filter_active {
|
||||||
|
"Esc clear Backspace del type to filter Enter accept q quit"
|
||||||
|
} else {
|
||||||
|
"↑/j down ↓/k up Enter detail / filter s sort r refresh ? help q quit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::FullDetail => "Esc back ? help q quit",
|
||||||
|
};
|
||||||
|
let line = Line::from(Span::styled(
|
||||||
|
keys,
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
f.render_widget(Paragraph::new(line), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame, area: Rect) {
|
||||||
|
let help_text = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"kmod-tui — help",
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" ↑/j ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("move selection down"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" ↓/k ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("move selection up"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("open full detail for selected module"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" / ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("start filtering by module name"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("clear filter / close full detail"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" s ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("cycle sort: name → size → refcnt → dependents"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" r ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("refresh module list from /proc/modules"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" ? ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("toggle this help overlay"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" q ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw("quit"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
let width = 52;
|
||||||
|
let height = help_text.len() as u16 + 4;
|
||||||
|
let x = area.width.saturating_sub(width) / 2;
|
||||||
|
let y = area.height.saturating_sub(height) / 2;
|
||||||
|
let popup = Rect::new(x, y, width, height);
|
||||||
|
f.render_widget(Clear, popup);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Help (press ? to close) ");
|
||||||
|
f.render_widget(block, popup);
|
||||||
|
let inner = popup.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
f.render_widget(Paragraph::new(help_text), inner);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user