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