diff --git a/.gitignore b/.gitignore index 0b188bc..e2aab4f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ target/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + + +# Added by cargo + +/target diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4d33caf --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,36 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build - Linux", + "type": "shell", + "command": "cargo build --release && mkdir -p build/linux && cp target/release/tui-fm build/linux/tui-fm", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": "$rustc", + "detail": "Bygger tui-fm för Linux och kopierar binären till build/linux/" + }, + { + "label": "Build - Windows", + "type": "shell", + "command": "cargo build --release --target x86_64-pc-windows-gnu && mkdir -p build/windows && cp target/x86_64-pc-windows-gnu/release/tui-fm.exe build/windows/tui-fm.exe", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": "$rustc", + "detail": "Bygger tui-fm för Windows (kräver mingw-w64 och rustup target add x86_64-pc-windows-gnu)" + } + ] +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f561c7f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,811 @@ +# 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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +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", + "futures-core", + "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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tui-fm" +version = "0.1.0" +dependencies = [ + "crossterm", + "dirs", + "fs_extra", + "ratatui", + "serde", + "serde_json", + "unicode-width 0.2.0", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[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 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..336c562 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tui-fm" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "tui-fm" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +strip = true + +[dependencies] +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +fs_extra = "1" +unicode-width = "0.2" diff --git a/README.md b/README.md index 9ceb7c7..ea26f6a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,163 @@ # TUI-FM -en liten fil hanterare skriven i rust med full mouse stöd med sub menyer och ikon stöd \ No newline at end of file +En terminalbaserad filhanterare skriven i Rust med fullt musstöd, ikonvisning och intuitiv navigation. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TUI-FM v0.1.0 │ +├──────────────────┬──────────────────────────────────────────────────┤ +│ FAVORITER │ /home/user/Documents │ +│ ────────── │ ────────────────────────────────────────────── │ +│ 🏠 Hem │ 📁 projekt/ │ +│ 📁 Dokument ◄ │ 📁 bilder/ │ +│ 📁 Nedladdnin │ 📄 rapport.pdf 120 KB │ +│ 📁 Skrivbord │ 📄 anteckningar.txt 4 KB │ +│ ────────── │ 🖼 foto.png 2.3 MB │ +│ ROOT │ 📦 arkiv.zip 540 KB │ +│ ────────── │ 🦀 main.rs 8 KB │ +│ / │ │ +│ /home │ │ +│ /etc │ │ +│ /usr │ │ +│ │ ┌─────────────────────────────────┐ │ +│ │ │ Högerklicksmeny │ │ +│ [+ Favorit] │ │ ────────────────────────────── │ │ +│ │ │ 📋 Kopiera │ │ +│ │ │ ✂️ Klipp ut │ │ +│ │ │ 📌 Klistra in │ │ +│ │ │ ✏️ Byt namn │ │ +│ │ │ ℹ️ Egenskaper │ │ +│ │ │ ➕ Ny ▶ ┌──────────────────┐ │ │ +│ │ └────────── │ 📄 Ny fil │ │ │ +│ │ │ 📁 Ny mapp │ │ │ +│ │ └──────────────────┘ │ │ +├──────────────────┴──────────────────────────────────────────────────┤ +│ Sökväg: /home/user/Documents/_________ [Enter för att navigera] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Funktioner + +### Huvudvy (mitten) +- Visar filer och mappar med ikoner baserat på filtyp +- **Dubbelklick** på mapp → navigerar in i mappen +- **Enkelklick** → markerar fil/mapp +- **Ctrl+klick** → flermarkering +- Filstorlek visas till höger + +### Sidopanel (vänster) +- **Favoriter** – sparade sökvägar, klicka för att navigera direkt +- **Root-mappar** – snabbnavigering till `/`, `/home`, `/etc`, `/usr` etc. +- Knapp `[+ Favorit]` lägger till nuvarande mapp i favoriter +- Högerklick på favorit för att ta bort den + +### Nedre statusfält +- Visar nuvarande sökväg +- Textruta för manuell sökväginmatning +- **Enter** navigerar till inskriven/inklistrad sökväg +- Stöd för kopiera/klistra in i textfältet + +### Högerklicksmeny (kontextmeny) +Högerklick på fil(er)/mapp(ar) öppnar meny med: +- **Kopiera** – kopierar markerade filer +- **Klipp ut** – klipper ut markerade filer +- **Klistra in** – klistrar in i markerad mapp (kräver att en mapp är markerad) +- **Byt namn** – byter namn på fil/mapp +- **Egenskaper** – visar storlek, rättigheter, ägare + - Om flera är markerade visas gemensam storlek (beräknas rekursivt) och antal filer +- **Ny ▶** (submeny) + - Skapa ny fil + - Skapa ny mapp + +### Musstöd +- Klick, dubbelklick, högerklick +- Scrollning i filvisaren och sidopanelen +- Markering av text i sökvägsrutan för kopiera/klistra in + +### Ikontyper +| Typ | Ikon | +|------------|------| +| Mapp | 📁 | +| Textfil | 📄 | +| Bild | 🖼 | +| Video | 🎬 | +| Ljud | 🎵 | +| Arkiv/zip | 📦 | +| Rust-fil | 🦀 | +| Körbar | ⚙️ | +| Okänd | 📎 | + +## Bygga appen + +### Krav +- [Rust toolchain](https://rustup.rs/) (stable) +- För cross-kompilering till Windows: `mingw-w64` eller [cross](https://github.com/cross-rs/cross) + +### Bygga för Linux +```bash +cargo build --release +# Binären hamnar i: build/linux/tui-fm +``` + +Eller via VS Code task: **Build - Linux** + +### Bygga för Windows +```bash +# Lägg till Windows-target först: +rustup target add x86_64-pc-windows-gnu +# Installera mingw (Ubuntu/Debian): +sudo apt install gcc-mingw-w64-x86-64 + +cargo build --release --target x86_64-pc-windows-gnu +# Binären hamnar i: build/windows/tui-fm.exe +``` + +Eller via VS Code task: **Build - Windows** + +### Output-mappar +``` +build/ +├── linux/ +│ └── tui-fm +└── windows/ + └── tui-fm.exe +``` + +## Tangentbordsgenvägar + +| Tangent | Funktion | +|---------------|-----------------------------------| +| `q` / `Esc` | Avsluta | +| `Enter` | Öppna mapp / navigera till sökväg | +| `Backspace` | Gå upp en nivå | +| `F2` | Byt namn | +| `Delete` | Ta bort markerade | +| `Ctrl+C` | Kopiera | +| `Ctrl+X` | Klipp ut | +| `Ctrl+V` | Klistra in | +| `Ctrl+A` | Markera alla | +| `Tab` | Växla fokus (panel ↔ filvy) | + +## Beroenden (Rust crates) + +| Crate | Version | Används för | +|-------|---------|------------| +| [`ratatui`](https://github.com/ratatui-org/ratatui) | 0.29 | TUI-ramverk för all rendering. Används med: `Table`+`TableState` (filvisaren med kolumner och stateful scroll), `Scrollbar`+`ScrollbarState` (scrollindikator i filvy och sidebar), `List`+`ListItem` (sidopanelen), `Block`+`Borders` (alla ramar), `Paragraph` (textelement och dialoger), `Layout`+`Constraint` (layoutberäkning), `Clear` (overlay för menyer/dialoger), `Line`/`Span`/`Style` (styling och färger) | +| [`crossterm`](https://github.com/crossterm-rs/crossterm) | 0.28 | Cross-platform terminalhantering (Windows + Linux/macOS). Hanterar: raw mode, alternate screen, mushändelser (`MouseEvent`, `MouseButton`, `MouseEventKind::Moved/Down/ScrollUp/ScrollDown`), tangentbordshändelser, terminalstorlek | +| [`serde`](https://serde.rs/) | 1 | Serialiserings-/deserialiseringsramverk med `#[derive(Serialize, Deserialize)]`. Används på `Config`-structen för att spara/läsa favoriter | +| [`serde_json`](https://docs.rs/serde_json) | 1 | JSON-backend till serde. Skriver konfigurationsfilen `~/.config/tui-fm/config.json` med `to_string_pretty` och läser den med `from_str` | +| [`dirs`](https://docs.rs/dirs) | 5 | Plattformsoberoende sökvägar till systemkataloger. Används för `dirs::home_dir()` (startkatalog) och `dirs::config_dir()` (var konfigurationsfilen sparas: `~/.config/` på Linux, `%APPDATA%` på Windows) | +| [`fs_extra`](https://docs.rs/fs_extra) | 1 | Utökade filoperationer som saknas i standardbiblioteket. Används för `dir::copy` och `dir::move_dir` – kopierar/flyttar kataloger rekursivt med alla underkataloger och filer | +| [`unicode-width`](https://docs.rs/unicode-width) | 0.2 | Beräknar visuell bredd av Unicode-tecken (t.ex. CJK-tecken är 2 kolumner breda). Används för korrekt texttrunkering i filnamn och sökvägar | + +### Varför ratatui? + +ratatui är det ledande Rust-biblioteket för terminalgränssnitt och valdes av dessa anledningar: + +- **Stöder Windows + Linux** via crossterm-backenden – ett krav för detta projekt +- **`Table`-widget** ger kolumnjusterad visning utan manuell strängpadding; hanterar dynamiska terminalbredder automatiskt via `Constraint::Min` / `Constraint::Length` +- **`StatefulWidget`-mönstret** (`TableState`, `ScrollbarState`, `ListState`) separerar renderingstillstånd från applikationstillstånd – man lagrar bara `scroll_offset` i `App` och skapar en `TableState` per frame +- **`Scrollbar`-widget** renderar en komplett scrollindikator (spår, tumme, pilar) utan att man behöver beräkna positioner manuellt +- **`Clear`-widget** möjliggör popup-menyer och dialoger ovanpå befintligt innehåll +- **Immediate-mode rendering** – hela UI ritas om varje frame (~50 ms), vilket gör stathantering enkel: det finns ingen knapp-"tillståndsmaskin" att hålla synkroniserad + diff --git a/build/linux/tui-fm b/build/linux/tui-fm new file mode 100755 index 0000000..203ffdb Binary files /dev/null and b/build/linux/tui-fm differ diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..f2c4dba --- /dev/null +++ b/src/app.rs @@ -0,0 +1,413 @@ +use std::path::PathBuf; + +use crate::config::Config; +use crate::file_ops::{self, FileEntry}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Focus { + Sidebar, + FileView, + PathInput, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ClipboardOp { + Copy, + Cut, +} + +#[derive(Debug, Clone)] +pub struct ClipboardEntry { + pub paths: Vec, + pub op: ClipboardOp, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ContextMenuItem { + Copy, + Cut, + Paste, + Rename, + Properties, + NewFile, + NewDir, + Delete, + AddFavorite, + RemoveFavorite(String), + Separator, + NewSubmenu, +} + +#[derive(Debug, Clone)] +pub struct ContextMenu { + pub x: u16, + pub y: u16, + pub items: Vec, + pub selected: usize, + pub show_new_submenu: bool, + pub sub_selected: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DialogMode { + None, + Rename(String), + NewFile(String), + NewDir(String), + Properties(PropertiesInfo), + Error(String), + Confirm(String, ConfirmAction), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmAction { + Delete, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PropertiesInfo { + pub name: String, + pub path: String, + pub size: u64, + pub file_count: u64, + pub permissions: String, + pub owner_user: String, + pub owner_group: String, + pub is_multi: bool, +} + +pub struct App { + pub current_path: PathBuf, + pub entries: Vec, + pub selected_indices: Vec, + pub scroll_offset: usize, + pub sidebar_scroll: usize, + pub focus: Focus, + pub config: Config, + pub clipboard: Option, + pub context_menu: Option, + pub dialog: DialogMode, + pub path_input: String, + pub path_input_cursor: usize, + pub show_hidden: bool, + pub status_message: Option, + pub last_click_entry: Option<(usize, std::time::Instant)>, + pub sidebar_selected: Option, + pub rename_original: Option, +} + +impl App { + pub fn new() -> Self { + let config = Config::load(); + let start_path = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/")); + let entries = file_ops::read_dir(&start_path); + let path_input = start_path.to_string_lossy().to_string(); + + Self { + current_path: start_path, + entries, + selected_indices: Vec::new(), + scroll_offset: 0, + sidebar_scroll: 0, + focus: Focus::FileView, + config, + clipboard: None, + context_menu: None, + dialog: DialogMode::None, + path_input, + path_input_cursor: 0, + show_hidden: false, + status_message: None, + last_click_entry: None, + sidebar_selected: None, + rename_original: None, + } + } + + pub fn navigate_to(&mut self, path: PathBuf) { + if path.is_dir() { + self.current_path = path.clone(); + self.entries = file_ops::read_dir(&path); + if !self.show_hidden { + self.entries.retain(|e| !e.is_hidden); + } + self.selected_indices.clear(); + self.scroll_offset = 0; + self.path_input = path.to_string_lossy().to_string(); + self.path_input_cursor = self.path_input.len(); + self.context_menu = None; + } else { + self.set_status(format!("Not a directory: {}", path.display())); + } + } + + pub fn refresh(&mut self) { + self.entries = file_ops::read_dir(&self.current_path.clone()); + if !self.show_hidden { + self.entries.retain(|e| !e.is_hidden); + } + // Keep valid selections + let count = self.entries.len(); + self.selected_indices.retain(|&i| i < count); + } + + pub fn set_status(&mut self, msg: String) { + self.status_message = Some(msg); + } + + pub fn navigate_up(&mut self) { + if let Some(parent) = self.current_path.parent() { + let p = parent.to_path_buf(); + self.navigate_to(p); + } + } + + pub fn toggle_selection(&mut self, index: usize) { + if self.selected_indices.contains(&index) { + self.selected_indices.retain(|&i| i != index); + } else { + self.selected_indices.push(index); + } + } + + pub fn select_only(&mut self, index: usize) { + self.selected_indices = vec![index]; + } + + pub fn select_all(&mut self) { + self.selected_indices = (0..self.entries.len()).collect(); + } + + pub fn copy_selected(&mut self) { + let paths: Vec = self + .selected_indices + .iter() + .filter_map(|&i| self.entries.get(i)) + .map(|e| e.path.clone()) + .collect(); + if !paths.is_empty() { + self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Copy }); + self.set_status("Copied to clipboard".to_string()); + } + } + + pub fn cut_selected(&mut self) { + let paths: Vec = self + .selected_indices + .iter() + .filter_map(|&i| self.entries.get(i)) + .map(|e| e.path.clone()) + .collect(); + if !paths.is_empty() { + self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Cut }); + self.set_status("Cut to clipboard".to_string()); + } + } + + /// Paste into given destination directory + pub fn paste_into(&mut self, dst: &std::path::Path) { + let Some(clipboard) = self.clipboard.clone() else { + self.set_status("Nothing in clipboard".to_string()); + return; + }; + + let mut errors = Vec::new(); + for src in &clipboard.paths { + let result = match clipboard.op { + ClipboardOp::Copy => file_ops::copy_entry(src, dst), + ClipboardOp::Cut => file_ops::move_entry(src, dst), + }; + if let Err(e) = result { + errors.push(e); + } + } + + if clipboard.op == ClipboardOp::Cut { + self.clipboard = None; + } + + if errors.is_empty() { + self.set_status("Paste successful".to_string()); + } else { + self.set_status(format!("Errors: {}", errors.join(", "))); + } + self.refresh(); + } + + pub fn delete_selected(&mut self) { + let paths: Vec = self + .selected_indices + .iter() + .filter_map(|&i| self.entries.get(i)) + .map(|e| e.path.clone()) + .collect(); + + let mut errors = Vec::new(); + for path in paths { + if let Err(e) = file_ops::delete_entry(&path) { + errors.push(e); + } + } + + self.selected_indices.clear(); + if errors.is_empty() { + self.set_status("Deleted".to_string()); + } else { + self.set_status(format!("Delete errors: {}", errors.join(", "))); + } + self.refresh(); + } + + pub fn rename_entry(&mut self, entry_path: &std::path::Path, new_name: &str) { + let new_name = new_name.trim(); + if new_name.is_empty() { + return; + } + // Prevent path traversal + if new_name.contains('/') || new_name.contains('\\') || new_name == ".." || new_name == "." { + self.set_status("Invalid name".to_string()); + return; + } + let new_path = entry_path + .parent() + .unwrap_or(std::path::Path::new(".")) + .join(new_name); + if let Err(e) = std::fs::rename(entry_path, &new_path) { + self.set_status(format!("Rename error: {}", e)); + } else { + self.set_status(format!("Renamed to {}", new_name)); + } + self.refresh(); + } + + pub fn create_file(&mut self, name: &str) { + let name = name.trim(); + if name.is_empty() { return; } + if name.contains('/') || name.contains('\\') { + self.set_status("Invalid filename".to_string()); + return; + } + let path = self.current_path.join(name); + if let Err(e) = std::fs::File::create(&path) { + self.set_status(format!("Error: {}", e)); + } else { + self.set_status(format!("Created file {}", name)); + } + self.refresh(); + } + + pub fn create_dir(&mut self, name: &str) { + let name = name.trim(); + if name.is_empty() { return; } + if name.contains('/') || name.contains('\\') { + self.set_status("Invalid directory name".to_string()); + return; + } + let path = self.current_path.join(name); + if let Err(e) = std::fs::create_dir(&path) { + self.set_status(format!("Error: {}", e)); + } else { + self.set_status(format!("Created directory {}", name)); + } + self.refresh(); + } + + pub fn compute_properties(&mut self) { + if self.selected_indices.is_empty() { + return; + } + + let is_multi = self.selected_indices.len() > 1; + + if is_multi { + let mut total_size: u64 = 0; + let mut total_files: u64 = 0; + for &i in &self.selected_indices { + if let Some(e) = self.entries.get(i) { + let (s, c) = file_ops::recursive_info(&e.path); + total_size += s; + total_files += c; + } + } + self.dialog = DialogMode::Properties(PropertiesInfo { + name: format!("{} items selected", self.selected_indices.len()), + path: self.current_path.to_string_lossy().to_string(), + size: total_size, + file_count: total_files, + permissions: String::from("N/A"), + owner_user: String::from("N/A"), + owner_group: String::from("N/A"), + is_multi: true, + }); + } else { + let i = self.selected_indices[0]; + if let Some(e) = self.entries.get(i) { + let (size, file_count) = file_ops::recursive_info(&e.path); + let permissions = file_ops::file_permissions(&e.path); + let (owner_user, owner_group) = file_ops::file_owner(&e.path); + self.dialog = DialogMode::Properties(PropertiesInfo { + name: e.name.clone(), + path: e.path.to_string_lossy().to_string(), + size, + file_count, + permissions, + owner_user, + owner_group, + is_multi: false, + }); + } + } + } + + pub fn build_context_menu(&self, x: u16, y: u16, is_sidebar_fav: bool, fav_path: Option) -> ContextMenu { + let has_selection = !self.selected_indices.is_empty(); + let has_clipboard = self.clipboard.is_some(); + let can_paste = has_clipboard && (self.selected_target_dir().is_some() || true); + + let mut items = Vec::new(); + + if is_sidebar_fav { + if let Some(path) = fav_path { + items.push(ContextMenuItem::RemoveFavorite(path)); + } + } else { + if has_selection { + items.push(ContextMenuItem::Copy); + items.push(ContextMenuItem::Cut); + } + if can_paste { + items.push(ContextMenuItem::Paste); + } + if has_selection { + items.push(ContextMenuItem::Separator); + items.push(ContextMenuItem::Rename); + items.push(ContextMenuItem::Properties); + items.push(ContextMenuItem::Separator); + items.push(ContextMenuItem::Delete); + } + items.push(ContextMenuItem::Separator); + items.push(ContextMenuItem::NewSubmenu); + items.push(ContextMenuItem::AddFavorite); + } + + ContextMenu { x, y, items, selected: 0, show_new_submenu: false, sub_selected: None } + } + + /// Returns target directory for paste: selected dir or current path + pub fn selected_target_dir(&self) -> Option { + if self.selected_indices.len() == 1 { + let i = self.selected_indices[0]; + if let Some(e) = self.entries.get(i) { + if e.is_dir() { + return Some(e.path.clone()); + } + } + } + Some(self.current_path.clone()) + } + + pub fn visible_entry_count(&self) -> usize { + self.entries.len() + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..eaa8ca2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct Config { + pub favorites: Vec, +} + +impl Config { + fn config_path() -> Option { + dirs::config_dir().map(|d| d.join("tui-fm").join("config.json")) + } + + pub fn load() -> Self { + if let Some(path) = Self::config_path() { + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(config) = serde_json::from_str::(&data) { + return config; + } + } + } + Config::default() + } + + pub fn save(&self) { + if let Some(path) = Self::config_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(data) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(&path, data); + } + } + } + + pub fn add_favorite(&mut self, path: &str) { + let s = path.to_string(); + if !self.favorites.contains(&s) { + self.favorites.push(s); + self.save(); + } + } + + pub fn remove_favorite(&mut self, path: &str) { + self.favorites.retain(|f| f != path); + self.save(); + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..baa5bfb --- /dev/null +++ b/src/events.rs @@ -0,0 +1,593 @@ +use std::io; +use std::time::{Duration, Instant}; + +use crossterm::event::{ + self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind, +}; +use ratatui::{backend::Backend, Terminal}; + +use crate::app::{App, ConfirmAction, ContextMenuItem, DialogMode, Focus}; +use crate::ui; + +pub fn run_app(terminal: &mut Terminal, app: &mut App) -> io::Result<()> { + loop { + terminal.draw(|f| ui::draw(f, app))?; + + if event::poll(Duration::from_millis(50))? { + let ev = event::read()?; + if handle_event(app, ev) { + break; + } + } + } + Ok(()) +} + +/// Returns true if the app should quit +fn handle_event(app: &mut App, ev: Event) -> bool { + match ev { + Event::Key(key) => handle_key(app, key), + Event::Mouse(mouse) => { + handle_mouse(app, mouse); + false + } + _ => false, + } +} + +fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool { + // Close context menu on any key + if app.context_menu.is_some() { + match key.code { + KeyCode::Esc => { app.context_menu = None; return false; } + KeyCode::Up => { + if let Some(ref mut m) = app.context_menu { + if m.selected > 0 { m.selected -= 1; } + } + return false; + } + KeyCode::Down => { + if let Some(ref mut m) = app.context_menu { + let max = m.items.len().saturating_sub(1); + if m.selected < max { m.selected += 1; } + } + return false; + } + KeyCode::Enter => { + execute_context_menu(app); + return false; + } + _ => { app.context_menu = None; return false; } + } + } + + // Handle dialogs + match &app.dialog.clone() { + DialogMode::Rename(current) => { + return handle_text_dialog_key(app, key, current.clone(), |app, s| { + if let Some(path) = app.rename_original.clone() { + app.rename_entry(&path, &s); + } + app.rename_original = None; + }); + } + DialogMode::NewFile(current) => { + return handle_text_dialog_key(app, key, current.clone(), |app, s| { + app.create_file(&s); + }); + } + DialogMode::NewDir(current) => { + return handle_text_dialog_key(app, key, current.clone(), |app, s| { + app.create_dir(&s); + }); + } + DialogMode::Properties(_) | DialogMode::Error(_) => { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) { + app.dialog = DialogMode::None; + } + return false; + } + DialogMode::Confirm(_, action) => { + let action = action.clone(); + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + app.dialog = DialogMode::None; + match action { + ConfirmAction::Delete => app.delete_selected(), + } + } + _ => { app.dialog = DialogMode::None; } + } + return false; + } + DialogMode::None => {} + } + + // Path input mode + if app.focus == Focus::PathInput { + match key.code { + KeyCode::Esc => { app.focus = Focus::FileView; } + KeyCode::Enter => { + let p = std::path::PathBuf::from(&app.path_input); + app.navigate_to(p); + app.focus = Focus::FileView; + } + KeyCode::Backspace => { + if app.path_input_cursor > 0 { + let cursor = app.path_input_cursor; + app.path_input.remove(cursor - 1); + app.path_input_cursor -= 1; + } + } + KeyCode::Left => { + if app.path_input_cursor > 0 { app.path_input_cursor -= 1; } + } + KeyCode::Right => { + if app.path_input_cursor < app.path_input.len() { app.path_input_cursor += 1; } + } + KeyCode::Home => { app.path_input_cursor = 0; } + KeyCode::End => { app.path_input_cursor = app.path_input.len(); } + KeyCode::Char(c) => { + let cursor = app.path_input_cursor; + app.path_input.insert(cursor, c); + app.path_input_cursor += 1; + } + _ => {} + } + return false; + } + + // Global keys + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('c') => { app.copy_selected(); return false; } + KeyCode::Char('x') => { app.cut_selected(); return false; } + KeyCode::Char('v') => { + let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone()); + app.paste_into(&dst.clone()); + return false; + } + KeyCode::Char('a') => { app.select_all(); return false; } + KeyCode::Char('h') => { + app.show_hidden = !app.show_hidden; + app.refresh(); + return false; + } + _ => {} + } + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Tab => { + app.focus = match app.focus { + Focus::Sidebar => Focus::FileView, + Focus::FileView => Focus::PathInput, + Focus::PathInput => Focus::Sidebar, + }; + } + KeyCode::Backspace => { + app.navigate_up(); + } + KeyCode::F(2) => { + if let Some(&i) = app.selected_indices.first() { + if let Some(e) = app.entries.get(i) { + app.rename_original = Some(e.path.clone()); + app.dialog = DialogMode::Rename(e.name.clone()); + } + } + } + KeyCode::Delete => { + if !app.selected_indices.is_empty() { + let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len()); + app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete); + } + } + KeyCode::Enter => { + if app.focus == Focus::FileView { + if let Some(&i) = app.selected_indices.first() { + if let Some(e) = app.entries.get(i).cloned() { + if e.is_dir() { + app.navigate_to(e.path.clone()); + } + } + } + } + } + KeyCode::Up => { + if app.focus == Focus::FileView { + scroll_selection(app, -1); + } + } + KeyCode::Down => { + if app.focus == Focus::FileView { + scroll_selection(app, 1); + } + } + _ => {} + } + + false +} + +fn scroll_selection(app: &mut App, delta: i32) { + let count = app.entries.len(); + if count == 0 { return; } + + let current = app.selected_indices.first().copied().unwrap_or(0) as i32; + let new_idx = (current + delta).max(0).min(count as i32 - 1) as usize; + app.selected_indices = vec![new_idx]; + + // Ensure visible + if new_idx < app.scroll_offset { + app.scroll_offset = new_idx; + } +} + +fn handle_text_dialog_key( + app: &mut App, + key: crossterm::event::KeyEvent, + current: String, + on_confirm: F, +) -> bool +where + F: FnOnce(&mut App, String), +{ + match key.code { + KeyCode::Esc => { + app.dialog = DialogMode::None; + app.rename_original = None; + } + KeyCode::Enter => { + let s = current.clone(); + app.dialog = DialogMode::None; + on_confirm(app, s); + } + KeyCode::Backspace => { + let mut s = current; + s.pop(); + update_dialog_text(app, s); + } + KeyCode::Char(c) => { + let mut s = current; + s.push(c); + update_dialog_text(app, s); + } + _ => {} + } + false +} + +fn update_dialog_text(app: &mut App, new_text: String) { + match &app.dialog { + DialogMode::Rename(_) => app.dialog = DialogMode::Rename(new_text), + DialogMode::NewFile(_) => app.dialog = DialogMode::NewFile(new_text), + DialogMode::NewDir(_) => app.dialog = DialogMode::NewDir(new_text), + _ => {} + } +} + +fn execute_context_menu(app: &mut App) { + let menu = app.context_menu.clone(); + if let Some(m) = menu { + let item = m.items.get(m.selected).cloned(); + app.context_menu = None; + if let Some(item) = item { + match item { + ContextMenuItem::Copy => app.copy_selected(), + ContextMenuItem::Cut => app.cut_selected(), + ContextMenuItem::Paste => { + let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone()); + app.paste_into(&dst.clone()); + } + ContextMenuItem::Rename => { + if let Some(&i) = app.selected_indices.first() { + if let Some(e) = app.entries.get(i) { + app.rename_original = Some(e.path.clone()); + app.dialog = DialogMode::Rename(e.name.clone()); + } + } + } + ContextMenuItem::Properties => app.compute_properties(), + ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()), + ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(String::new()), + ContextMenuItem::Delete => { + if !app.selected_indices.is_empty() { + let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len()); + app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete); + } + } + ContextMenuItem::AddFavorite => { + let path = app.current_path.to_string_lossy().to_string(); + app.config.add_favorite(&path); + app.set_status("Added to favorites".to_string()); + } + ContextMenuItem::RemoveFavorite(path) => { + app.config.remove_favorite(&path); + app.set_status("Removed from favorites".to_string()); + } + ContextMenuItem::NewSubmenu => { + // Toggle submenu - handled in UI + } + ContextMenuItem::Separator => {} + } + } + } +} + +fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollDown => handle_scroll(app, mouse.column, mouse.row, 3), + MouseEventKind::ScrollUp => handle_scroll(app, mouse.column, mouse.row, -3), + MouseEventKind::Down(MouseButton::Left) => handle_left_click(app, mouse.column, mouse.row), + MouseEventKind::Down(MouseButton::Right) => handle_right_click(app, mouse.column, mouse.row), + MouseEventKind::Moved => handle_mouse_move(app, mouse.column, mouse.row), + _ => {} + } +} + +fn handle_scroll(app: &mut App, col: u16, _row: u16, delta: i32) { + if col < 20 { + // Sidebar scroll + let roots_len = crate::file_ops::get_root_dirs().len(); + let favs_len = app.config.favorites.len(); + let total = favs_len + roots_len + 5; // headings+padding + let max = total.saturating_sub(10); + let new_off = (app.sidebar_scroll as i32 + delta).max(0).min(max as i32) as usize; + app.sidebar_scroll = new_off; + } else { + // File view scroll + let count = app.entries.len(); + let new_off = (app.scroll_offset as i32 + delta).max(0).min(count as i32) as usize; + app.scroll_offset = new_off; + } +} + +/// Hit test: given terminal coords, determine which area and entry index was clicked +pub fn hit_test_file_view(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option { + let area = layout.file_view; + // File list starts after the header row (row+2 from top of area) + if col < area.x || col >= area.x + area.width { return None; } + if row < area.y + 2 || row >= area.y + area.height { return None; } + let list_row = (row - area.y - 2) as usize; + let idx = list_row + app.scroll_offset; + if idx < app.entries.len() { Some(idx) } else { None } +} + +pub fn hit_test_sidebar(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option { + let area = layout.sidebar; + if col < area.x || col >= area.x + area.width { return None; } + // row must be inside the inner content area (skip top border at area.y) + if row <= area.y || row >= area.y + area.height { return None; } + + // Map terminal row → list item index, accounting for top border and scroll + let list_idx = app.sidebar_scroll + (row - area.y - 1) as usize; + + let favs_len = app.config.favorites.len(); + // When empty, "(inga)" placeholder occupies 1 slot in the rendered list + let display_favs = favs_len.max(1); + let roots = crate::file_ops::get_root_dirs(); + + // idx 0 : [↑ Gå upp] + if list_idx == 0 { return Some(SidebarItem::GoUp); } + // idx 1 : FAVORITER heading + if list_idx == 1 { return None; } + // idx 2 .. 1+display_favs : favorites (or "(inga)" placeholder) + if list_idx >= 2 && list_idx <= 1 + display_favs { + let fav_idx = list_idx - 2; + if fav_idx < favs_len { + return Some(SidebarItem::Favorite(fav_idx, app.config.favorites[fav_idx].clone())); + } + return None; // "(inga)" placeholder – not clickable + } + // idx 2+display_favs : separator + if list_idx == 2 + display_favs { return None; } + // idx 3+display_favs : SÖKVÄGAR heading + if list_idx == 3 + display_favs { return None; } + // idx 4+display_favs .. 3+display_favs+roots_len : root dirs + let roots_start = 4 + display_favs; + if list_idx >= roots_start && list_idx < roots_start + roots.len() { + let idx = list_idx - roots_start; + return Some(SidebarItem::Root(roots[idx].clone())); + } + // idx roots_start+roots_len : separator + // idx roots_start+roots_len+1 : [+ Favorit] + if list_idx == roots_start + roots.len() + 1 { + return Some(SidebarItem::AddFavorite); + } + + None +} + +pub enum SidebarItem { + Favorite(usize, String), + Root(std::path::PathBuf), + AddFavorite, + GoUp, +} + +fn handle_left_click(app: &mut App, col: u16, row: u16) { + const MENU_W: u16 = 24; + const SUB_W: u16 = 18; + const SUB_H: u16 = 4; + + if let Some(m) = app.context_menu.clone() { + let mx = m.x; + let my = m.y; + let items_len = m.items.len() as u16; + let menu_height = items_len + 2; // top + bottom border + + let in_main = col >= mx && col < mx + MENU_W + && row >= my && row < my + menu_height; + + // Check if click lands in the submenu (only visible when NewSubmenu is highlighted) + let ns_idx = m.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu)); + let in_sub = if let Some(ns_pos) = ns_idx { + let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40)); + let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W)); + let sub_y = my + 1 + ns_pos as u16; + col >= sub_x && col < sub_x + SUB_W + && row > sub_y && row < sub_y + SUB_H // > to skip top border row + } else { + false + }; + + if in_sub { + let ns_pos = ns_idx.unwrap(); + let sub_y = my + 1 + ns_pos as u16; + // skip the top border row of the submenu box + if row <= sub_y { app.context_menu = None; return; } + let sub_item = row.saturating_sub(sub_y + 1) as usize; + app.context_menu = None; + match sub_item { + 0 => app.dialog = DialogMode::NewFile(String::new()), + 1 => app.dialog = DialogMode::NewDir(String::new()), + _ => {} + } + return; + } + + if in_main { + // Items live between the two border rows + if row > my && row <= my + items_len { + let item_row = (row - my - 1) as usize; + if let Some(ref mut menu) = app.context_menu { + menu.selected = item_row.min(menu.items.len().saturating_sub(1)); + } + execute_context_menu(app); + } else { + // Clicked on a border row – dismiss + app.context_menu = None; + } + return; + } + + // Clicked outside everything – dismiss + app.context_menu = None; + } + + let layout = ui::compute_layout(app); + + // Check path input area + let pi = layout.path_input; + if row == pi.y && col >= pi.x && col < pi.x + pi.width { + app.focus = Focus::PathInput; + return; + } + + // Check sidebar + if col < layout.sidebar.x + layout.sidebar.width { + app.focus = Focus::Sidebar; + if let Some(item) = hit_test_sidebar(app, col, row, &layout) { + match item { + SidebarItem::Favorite(_, path) => { + let p = std::path::PathBuf::from(&path); + app.navigate_to(p); + } + SidebarItem::Root(p) => { + app.navigate_to(p); + } + SidebarItem::AddFavorite => { + let path = app.current_path.to_string_lossy().to_string(); + app.config.add_favorite(&path); + app.set_status("Added to favorites".to_string()); + } + SidebarItem::GoUp => { + app.navigate_up(); + } + } + } + return; + } + + // Check file view + app.focus = Focus::FileView; + if let Some(idx) = hit_test_file_view(app, col, row, &layout) { + let now = Instant::now(); + let is_double = if let Some((last_idx, last_time)) = app.last_click_entry { + last_idx == idx && now.duration_since(last_time) < Duration::from_millis(400) + } else { + false + }; + app.last_click_entry = Some((idx, now)); + + if is_double { + app.last_click_entry = None; + if let Some(e) = app.entries.get(idx).cloned() { + if e.is_dir() { + app.navigate_to(e.path.clone()); + } + } + } else { + app.select_only(idx); + } + } +} + +fn handle_right_click(app: &mut App, col: u16, row: u16) { + let layout = ui::compute_layout(app); + + // Right-click on sidebar favorite + if col < layout.sidebar.x + layout.sidebar.width { + if let Some(item) = hit_test_sidebar(app, col, row, &layout) { + if let SidebarItem::Favorite(_, path) = item { + let menu = app.build_context_menu(col, row, true, Some(path)); + app.context_menu = Some(menu); + return; + } + } + return; + } + + // Right-click on file + if let Some(idx) = hit_test_file_view(app, col, row, &layout) { + if !app.selected_indices.contains(&idx) { + app.select_only(idx); + } + } + + let menu = app.build_context_menu(col, row, false, None); + app.context_menu = Some(menu); +} + +fn handle_mouse_move(app: &mut App, col: u16, row: u16) { + const MENU_W: u16 = 24; + const SUB_W: u16 = 18; + + let Some(ref mut menu) = app.context_menu else { return; }; + + let mx = menu.x; + let my = menu.y; + let items_len = menu.items.len() as u16; + + // Hovering over a main-menu item (inside the border) + if col > mx && col < mx + MENU_W - 1 + && row > my && row <= my + items_len + { + let item_idx = (row - my - 1) as usize; + if item_idx < menu.items.len() { + menu.selected = item_idx; + menu.sub_selected = None; + } + return; + } + + // When NewSubmenu is highlighted, check if the mouse moves into the flyout + if let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu)) { + if menu.selected == ns_pos { + let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40)); + let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W)); + let sub_y = my + 1 + ns_pos as u16; + + if col >= sub_x && col < sub_x + SUB_W + && row >= sub_y && row < sub_y + 4 + { + // Keep NewSubmenu highlighted and track which flyout row is hovered + if row > sub_y && row < sub_y + 3 { + menu.sub_selected = Some((row - sub_y - 1) as usize); + } + return; + } + } + } +} diff --git a/src/file_ops.rs b/src/file_ops.rs new file mode 100644 index 0000000..3a0aa60 --- /dev/null +++ b/src/file_ops.rs @@ -0,0 +1,267 @@ +use std::path::{Path, PathBuf}; +use std::fs; + +#[derive(Clone, Debug, PartialEq)] +pub enum FileType { + Directory, + File, + Symlink, +} + +#[derive(Clone, Debug)] +pub struct FileEntry { + pub name: String, + pub path: PathBuf, + pub file_type: FileType, + pub size: u64, + pub is_hidden: bool, +} + +impl FileEntry { + pub fn icon(&self) -> &'static str { + match &self.file_type { + FileType::Directory => "📁", + FileType::Symlink => "🔗", + FileType::File => { + let ext = self + .name + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "rs" => "🦀", + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "🖼", + "mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬", + "mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵", + "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦", + "pdf" => "📕", + "txt" | "md" | "log" => "📄", + "json" | "yaml" | "yml" | "toml" | "xml" => "⚙", + "sh" | "bash" | "zsh" | "fish" => "🐚", + "py" => "🐍", + "js" | "ts" => "📜", + "c" | "cpp" | "h" | "hpp" => "🔧", + "exe" | "bin" | "out" | "run" => "⚙", + _ => "📎", + } + } + } + } + + pub fn is_dir(&self) -> bool { + self.file_type == FileType::Directory + } + + pub fn formatted_size(&self) -> String { + if self.file_type == FileType::Directory { + return String::from(""); + } + format_size(self.size) + } +} + +pub fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{} KB", bytes / KB) + } else { + format!("{} B", bytes) + } +} + +pub fn read_dir(path: &Path) -> Vec { + let mut entries = Vec::new(); + let Ok(dir) = fs::read_dir(path) else { + return entries; + }; + + for entry in dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let is_hidden = name.starts_with('.'); + + let (file_type, size) = if let Ok(meta) = entry.metadata() { + let ft = if meta.is_symlink() { + FileType::Symlink + } else if meta.is_dir() { + FileType::Directory + } else { + FileType::File + }; + (ft, meta.len()) + } else { + (FileType::File, 0) + }; + + entries.push(FileEntry { name, path, file_type, size, is_hidden }); + } + + // Sort: dirs first, then files, both alphabetically + entries.sort_by(|a, b| { + match (&a.file_type, &b.file_type) { + (FileType::Directory, FileType::Directory) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + (FileType::Directory, _) => std::cmp::Ordering::Less, + (_, FileType::Directory) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + entries +} + +/// Recursively compute total size and file count of a path +pub fn recursive_info(path: &Path) -> (u64, u64) { + let mut total_size: u64 = 0; + let mut file_count: u64 = 0; + + if path.is_file() { + let size = path.metadata().map(|m| m.len()).unwrap_or(0); + return (size, 1); + } + + if let Ok(dir) = fs::read_dir(path) { + for entry in dir.flatten() { + let p = entry.path(); + if p.is_dir() { + let (s, c) = recursive_info(&p); + total_size += s; + file_count += c; + } else { + total_size += p.metadata().map(|m| m.len()).unwrap_or(0); + file_count += 1; + } + } + } + + (total_size, file_count) +} + +#[cfg(unix)] +pub fn file_permissions(path: &Path) -> String { + use std::os::unix::fs::PermissionsExt; + let mode = path.metadata().map(|m| m.permissions().mode()).unwrap_or(0); + format!("{:o}", mode & 0o777) +} + +#[cfg(windows)] +pub fn file_permissions(_path: &Path) -> String { + String::from("N/A") +} + +pub fn file_owner(path: &Path) -> (String, String) { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let Ok(meta) = path.metadata() { + let uid = meta.uid(); + let gid = meta.gid(); + let user_name = lookup_name("/etc/passwd", uid) + .map(|n| format!("{} ({})", n, uid)) + .unwrap_or_else(|| uid.to_string()); + let group_name = lookup_name("/etc/group", gid) + .map(|n| format!("{} ({})", n, gid)) + .unwrap_or_else(|| gid.to_string()); + return (user_name, group_name); + } + } + #[cfg(windows)] + { + let _ = path; + } + (String::from("N/A"), String::from("N/A")) +} + +/// Parse /etc/passwd or /etc/group and return the name for a given numeric id. +/// Both files share the same format: `name:x:id:...` +#[cfg(unix)] +fn lookup_name(db_file: &str, id: u32) -> Option { + let content = std::fs::read_to_string(db_file).ok()?; + for line in content.lines() { + let mut parts = line.splitn(4, ':'); + let name = parts.next()?; + parts.next(); // password/placeholder + let entry_id: u32 = parts.next()?.parse().ok()?; + if entry_id == id { + return Some(name.to_string()); + } + } + None +} + +/// Copy a file or directory recursively +pub fn copy_entry(src: &Path, dst_dir: &Path) -> Result<(), String> { + let name = src.file_name().ok_or("No filename")?; + let dst = dst_dir.join(name); + + if src.is_dir() { + fs_extra::dir::copy( + src, + dst_dir, + &fs_extra::dir::CopyOptions::new(), + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + } else { + fs::copy(src, &dst).map(|_| ()).map_err(|e| e.to_string()) + } +} + +/// Move (cut/paste) a file or directory +pub fn move_entry(src: &Path, dst_dir: &Path) -> Result<(), String> { + let name = src.file_name().ok_or("No filename")?; + let dst = dst_dir.join(name); + + if src.is_dir() { + fs_extra::dir::move_dir( + src, + dst_dir, + &fs_extra::dir::CopyOptions::new(), + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + } else { + fs::rename(src, &dst).map_err(|e| e.to_string()) + } +} + +pub fn delete_entry(path: &Path) -> Result<(), String> { + if path.is_dir() { + fs::remove_dir_all(path).map_err(|e| e.to_string()) + } else { + fs::remove_file(path).map_err(|e| e.to_string()) + } +} + +pub fn get_root_dirs() -> Vec { + #[cfg(unix)] + { + let roots = ["/", "/home", "/etc", "/usr", "/var", "/opt", "/tmp"]; + roots + .iter() + .filter_map(|r| { + let p = PathBuf::from(r); + if p.exists() { Some(p) } else { None } + }) + .collect() + } + #[cfg(windows)] + { + let mut drives = Vec::new(); + for letter in b'A'..=b'Z' { + let d = format!("{}:\\", letter as char); + let p = PathBuf::from(&d); + if p.exists() { + drives.push(p); + } + } + drives + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..07bda4d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,41 @@ +mod app; +mod config; +mod file_ops; +mod ui; +mod events; + +use std::io; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use app::App; +use events::run_app; + +fn main() -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let res = run_app(&mut terminal, &mut app); + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + eprintln!("Error: {:?}", err); + } + + Ok(()) +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..7a89344 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,614 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, Borders, Cell, Clear, List, ListItem, Paragraph, + Row, Scrollbar, ScrollbarOrientation, ScrollbarState, + Table, TableState, Wrap, + }, + Frame, +}; + +use crate::app::{App, ContextMenuItem, DialogMode, Focus}; +use crate::file_ops::{self, format_size}; + +#[derive(Clone)] +pub struct LayoutAreas { + pub sidebar: Rect, + pub file_view: Rect, + pub status_bar: Rect, + pub path_input: Rect, +} + +/// Compute the layout areas for hit testing (without a frame) +pub fn compute_layout(_app: &App) -> LayoutAreas { + // We use a dummy size; in real rendering ratatui provides the actual size. + // For hit-testing, we just need approximate positions. + // We'll use the terminal size from crossterm. + let (width, height) = crossterm::terminal::size().unwrap_or((120, 40)); + let full = Rect::new(0, 0, width, height); + + split_layout(full) +} + +fn split_layout(area: Rect) -> LayoutAreas { + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(22), + Constraint::Min(10), + ]) + .split(main_chunks[0]); + + let bottom_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(10), + ]) + .split(main_chunks[1]); + + LayoutAreas { + sidebar: content_chunks[0], + file_view: content_chunks[1], + status_bar: main_chunks[1], + path_input: bottom_chunks[0], + } +} + +pub fn draw(f: &mut Frame, app: &mut App) { + let area = f.area(); + let layout = split_layout(area); + + draw_sidebar(f, app, layout.sidebar); + draw_file_view(f, app, layout.file_view); + draw_bottom_bar(f, app, layout.status_bar); + + if let Some(ref menu) = app.context_menu.clone() { + draw_context_menu(f, app, menu.clone()); + } + + match &app.dialog.clone() { + DialogMode::Rename(text) => draw_input_dialog(f, "Byt namn", text, area), + DialogMode::NewFile(text) => draw_input_dialog(f, "Ny fil - Ange namn:", text, area), + DialogMode::NewDir(text) => draw_input_dialog(f, "Ny mapp - Ange namn:", text, area), + DialogMode::Properties(info) => draw_properties(f, info.clone(), area), + DialogMode::Error(msg) => draw_error_dialog(f, msg, area), + DialogMode::Confirm(msg, _) => draw_confirm_dialog(f, msg, area), + DialogMode::None => {} + } +} + +fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Sidebar; + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .title(" Navigering ") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + let mut items: Vec = Vec::new(); + + // Up-level button + items.push(ListItem::new(Line::from(vec![ + Span::styled(" ↑ Gå upp", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)), + ]))); + + // Favorites section + items.push(ListItem::new(Line::from(vec![ + Span::styled("FAVORITER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]))); + + if app.config.favorites.is_empty() { + items.push(ListItem::new(Line::from(vec![ + Span::styled(" (inga)", Style::default().fg(Color::DarkGray)), + ]))); + } else { + for fav in &app.config.favorites { + let name = std::path::Path::new(fav) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| fav.clone()); + let is_current = fav == &app.current_path.to_string_lossy().to_string(); + let style = if is_current { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(format!(" ★ {}", name), style), + ]))); + } + } + + items.push(ListItem::new(Line::from(vec![ + Span::styled("─────────────────", Style::default().fg(Color::DarkGray)), + ]))); + + // Root directories + items.push(ListItem::new(Line::from(vec![ + Span::styled("SÖKVÄGAR", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]))); + + let roots = file_ops::get_root_dirs(); + for root in &roots { + let label = root.to_string_lossy(); + let is_current = root == &app.current_path; + let style = if is_current { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(format!(" 📁 {}", label), style), + ]))); + } + + items.push(ListItem::new(Line::from(vec![ + Span::styled("─────────────────", Style::default().fg(Color::DarkGray)), + ]))); + + // Add favorite button + items.push(ListItem::new(Line::from(vec![ + Span::styled(" [+ Favorit]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + ]))); + + // Apply scroll offset + let visible: Vec = items + .into_iter() + .skip(app.sidebar_scroll) + .take(inner.height as usize) + .collect(); + let total_sidebar = visible.len() + app.sidebar_scroll; + + let list = List::new(visible); + f.render_widget(list, inner); + + // Scrollbar for sidebar + if total_sidebar > inner.height as usize { + let mut sb_state = ScrollbarState::new(total_sidebar).position(app.sidebar_scroll); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .thumb_symbol("█"); + f.render_stateful_widget( + scrollbar, + area.inner(Margin { vertical: 1, horizontal: 0 }), + &mut sb_state, + ); + } +} + +fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::FileView; + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + // Build rows from ALL entries (TableState offset handles which rows are visible) + let rows: Vec = app + .entries + .iter() + .enumerate() + .map(|(idx, entry)| { + let is_selected = app.selected_indices.contains(&idx); + let name_cell = format!("{} {}", entry.icon(), entry.name); + let size_cell = entry.formatted_size(); + + let style = if is_selected { + Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD) + } else if entry.is_dir() { + Style::default().fg(Color::LightBlue) + } else { + Style::default().fg(Color::White) + }; + + Row::new(vec![ + Cell::from(name_cell), + Cell::from(size_cell).style(Style::default().fg(if is_selected { Color::White } else { Color::DarkGray })), + ]) + .style(style) + }) + .collect(); + + let header = Row::new(vec![ + Cell::from("Namn"), + Cell::from("Storlek"), + ]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | Modifier::UNDERLINED)) + .height(1); + + let path_title = format!(" {} ", app.current_path.to_string_lossy()); + let block = Block::default() + .title(path_title) + .borders(Borders::ALL) + .border_style(border_style); + + // Column widths: name takes remaining space, size is fixed + let widths = [Constraint::Min(10), Constraint::Length(10)]; + + let table = Table::new(rows, widths) + .header(header) + .block(block) + .column_spacing(1); + + let mut table_state = TableState::default(); + *table_state.offset_mut() = app.scroll_offset; + + f.render_stateful_widget(table, area, &mut table_state); + + // Scrollbar — rendered over the right border of the block + let total = app.entries.len(); + let visible = area.height.saturating_sub(3) as usize; // -2 borders -1 header + if total > visible { + let mut sb_state = ScrollbarState::new(total).position(app.scroll_offset); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .track_symbol(Some("│")) + .thumb_symbol("█"); + f.render_stateful_widget( + scrollbar, + area.inner(Margin { vertical: 1, horizontal: 0 }), + &mut sb_state, + ); + } + + // Selection count badge + if !app.selected_indices.is_empty() { + let sel_text = format!(" {} markerade ", app.selected_indices.len()); + let inner = Block::default().borders(Borders::ALL).inner(area); + let badge_area = Rect::new( + inner.x + inner.width.saturating_sub(sel_text.len() as u16 + 2), + inner.y, + sel_text.len() as u16 + 2, + 1, + ); + f.render_widget( + Paragraph::new(sel_text).style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)), + badge_area, + ); + } +} + +fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::PathInput; + + let outer_block = Block::default() + .borders(Borders::ALL) + .border_style(if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }); + + let inner = outer_block.inner(area); + f.render_widget(outer_block, area); + + // Left: status message or hint + let hint = app + .status_message + .as_deref() + .unwrap_or("Tab=fokus Backspace=upp Ctrl+C/X/V=kopiera/klipp/klistra q=avsluta"); + + let hint_width = (inner.width / 3).min(hint.len() as u16 + 2); + let hint_area = Rect::new(inner.x, inner.y, hint_width, inner.height); + f.render_widget( + Paragraph::new(hint) + .style(Style::default().fg(Color::DarkGray)) + .wrap(Wrap { trim: true }), + hint_area, + ); + + // Right: path input + let input_x = inner.x + hint_width + 1; + let input_width = inner.width.saturating_sub(hint_width + 1); + if input_width < 4 { return; } + + let input_area = Rect::new(input_x, inner.y, input_width, inner.height); + + let label = "Sökväg: "; + let label_len = label.len() as u16; + let cursor_pos = app.path_input_cursor; + let avail = input_width.saturating_sub(label_len) as usize; + + // Scroll the input text to show cursor + let (display_text, cursor_in_display) = + scroll_input_text(&app.path_input, cursor_pos, avail); + + let mut spans = vec![ + Span::styled(label, Style::default().fg(Color::Yellow)), + ]; + + if focused { + // Render with cursor highlight + let before = &display_text[..cursor_in_display.min(display_text.len())]; + let cursor_char = display_text + .chars() + .nth(cursor_in_display) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after = if cursor_in_display + 1 <= display_text.len() { + &display_text[cursor_in_display + cursor_char.len()..] + } else { + "" + }; + + spans.push(Span::styled(before, Style::default().fg(Color::White))); + spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(Color::White))); + spans.push(Span::styled(after, Style::default().fg(Color::White))); + } else { + spans.push(Span::styled(&display_text, Style::default().fg(Color::White))); + } + + let para = Paragraph::new(Line::from(spans)); + f.render_widget(para, input_area); +} + +fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize) { + let len = text.len(); + if len <= avail { + return (text.to_string(), cursor); + } + // Scroll to show cursor + let start = if cursor > avail { cursor - avail } else { 0 }; + let end = (start + avail).min(len); + let slice = &text[start..end]; + let cursor_in_display = cursor.saturating_sub(start); + (slice.to_string(), cursor_in_display) +} + +fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) { + let items = &menu.items; + if items.is_empty() { return; } + + // Calculate menu dimensions + let menu_width: u16 = 24; + let menu_height = items.len() as u16 + 2; + + let term_area = f.area(); + let x = menu.x.min(term_area.width.saturating_sub(menu_width)); + let y = menu.y.min(term_area.height.saturating_sub(menu_height)); + + let menu_area = Rect::new(x, y, menu_width, menu_height); + + f.render_widget(Clear, menu_area); + + let block = Block::default() + .title(" Meny ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(menu_area); + f.render_widget(block, menu_area); + + let mut show_new_sub = false; + let mut new_sub_row: u16 = 0; + + for (i, item) in items.iter().enumerate() { + let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1); + let is_sel = i == menu.selected; + + let (text, style) = match item { + ContextMenuItem::Copy => (" 📋 Kopiera ", Style::default()), + ContextMenuItem::Cut => (" ✂ Klipp ut ", Style::default()), + ContextMenuItem::Paste => (" 📌 Klistra in ", Style::default()), + ContextMenuItem::Rename => (" ✏ Byt namn ", Style::default()), + ContextMenuItem::Properties => (" ℹ Egenskaper ", Style::default()), + ContextMenuItem::Delete => (" 🗑 Ta bort ", Style::default().fg(Color::Red)), + ContextMenuItem::NewSubmenu => { + if is_sel { show_new_sub = true; new_sub_row = inner.y + i as u16; } + (" ➕ Ny ▶ ", Style::default().fg(Color::Green)) + } + ContextMenuItem::AddFavorite => (" ★ Lägg till fav ", Style::default().fg(Color::Yellow)), + ContextMenuItem::RemoveFavorite(_) => (" ✖ Ta bort favorit ", Style::default().fg(Color::Red)), + ContextMenuItem::Separator => (" ──────────────── ", Style::default().fg(Color::DarkGray)), + ContextMenuItem::NewFile => (" 📄 Ny fil ", Style::default().fg(Color::Green)), + ContextMenuItem::NewDir => (" 📁 Ny mapp ", Style::default().fg(Color::Green)), + }; + + let bg = if is_sel && !matches!(item, ContextMenuItem::Separator) { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + style + }; + + f.render_widget( + Paragraph::new(text).style(bg), + row, + ); + } + + // Draw submenu for "Ny" if selected + if show_new_sub { + let sub_x = x + menu_width; + let sub_area = Rect::new(sub_x.min(term_area.width.saturating_sub(18)), new_sub_row, 18, 4); + f.render_widget(Clear, sub_area); + + let sub_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + let sub_inner = sub_block.inner(sub_area); + f.render_widget(sub_block, sub_area); + + let sub_item0_style = if menu.sub_selected == Some(0) { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default().fg(Color::White) + }; + let sub_item1_style = if menu.sub_selected == Some(1) { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default().fg(Color::White) + }; + f.render_widget( + Paragraph::new(" 📄 Ny fil ").style(sub_item0_style), + Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1), + ); + f.render_widget( + Paragraph::new(" 📁 Ny mapp ").style(sub_item1_style), + Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1), + ); + } +} + +fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) { + let dlg_width = 50u16.min(area.width.saturating_sub(4)); + let dlg_height = 5u16; + let x = (area.width.saturating_sub(dlg_width)) / 2; + let y = (area.height.saturating_sub(dlg_height)) / 2; + let dlg = Rect::new(x, y, dlg_width, dlg_height); + + f.render_widget(Clear, dlg); + let block = Block::default() + .title(format!(" {} ", title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(dlg); + f.render_widget(block, dlg); + + // Input field with cursor + let display = format!("{}_", current); + f.render_widget( + Paragraph::new(display) + .style(Style::default().fg(Color::White)), + Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(2), 1), + ); + f.render_widget( + Paragraph::new("Enter=bekräfta Esc=avbryt") + .style(Style::default().fg(Color::DarkGray)), + Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1), + ); +} + +fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect) { + let dlg_width = 56u16.min(area.width.saturating_sub(4)); + let dlg_height = 13u16; + let x = (area.width.saturating_sub(dlg_width)) / 2; + let y = (area.height.saturating_sub(dlg_height)) / 2; + let dlg = Rect::new(x, y, dlg_width, dlg_height); + + f.render_widget(Clear, dlg); + let block = Block::default() + .title(" Egenskaper ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(dlg); + f.render_widget(block, dlg); + + let label_style = Style::default().fg(Color::Yellow); + let value_style = Style::default().fg(Color::White); + let hint_style = Style::default().fg(Color::DarkGray); + let label_w = 14usize; + let val_w = (inner.width as usize).saturating_sub(label_w + 2); + + let rows: &[(&str, String)] = &[ + ("Namn", info.name.clone()), + ("Sökväg", truncate_str(&info.path, val_w)), + ("Storlek", format_size(info.size).to_string()), + ("Filer", if info.is_multi || info.file_count > 0 { info.file_count.to_string() } else { String::from("-") }), + ("Rättigheter", info.permissions.clone()), + ("Ägare", info.owner_user.clone()), + ("Grupp", info.owner_group.clone()), + ]; + + for (i, (label, value)) in rows.iter().enumerate() { + if i as u16 + 1 >= inner.height { break; } + let row_y = inner.y + i as u16; + // Label + f.render_widget( + Paragraph::new(format!("{: String { + if s.len() <= max { + s.to_string() + } else if max <= 3 { + s[..max].to_string() + } else { + format!("{}...", &s[..max.saturating_sub(3)]) + } +}