Add file operations and UI components for file management application

This commit is contained in:
2026-03-28 02:55:16 +01:00
parent d52b163417
commit e6fdfa1aa3
12 changed files with 3010 additions and 1 deletions

5
.gitignore vendored
View File

@@ -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

36
.vscode/tasks.json vendored Normal file
View File

@@ -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)"
}
]
}

811
Cargo.lock generated Normal file
View File

@@ -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"

21
Cargo.toml Normal file
View File

@@ -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"

162
README.md
View File

@@ -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
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

BIN
build/linux/tui-fm Executable file

Binary file not shown.

413
src/app.rs Normal file
View File

@@ -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<PathBuf>,
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<ContextMenuItem>,
pub selected: usize,
pub show_new_submenu: bool,
pub sub_selected: Option<usize>,
}
#[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<FileEntry>,
pub selected_indices: Vec<usize>,
pub scroll_offset: usize,
pub sidebar_scroll: usize,
pub focus: Focus,
pub config: Config,
pub clipboard: Option<ClipboardEntry>,
pub context_menu: Option<ContextMenu>,
pub dialog: DialogMode,
pub path_input: String,
pub path_input_cursor: usize,
pub show_hidden: bool,
pub status_message: Option<String>,
pub last_click_entry: Option<(usize, std::time::Instant)>,
pub sidebar_selected: Option<usize>,
pub rename_original: Option<PathBuf>,
}
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<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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<String>) -> 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<PathBuf> {
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()
}
}

48
src/config.rs Normal file
View File

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct Config {
pub favorites: Vec<String>,
}
impl Config {
fn config_path() -> Option<PathBuf> {
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::<Config>(&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();
}
}

593
src/events.rs Normal file
View File

@@ -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<B: Backend>(terminal: &mut Terminal<B>, 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<F>(
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<usize> {
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<SidebarItem> {
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;
}
}
}
}

267
src/file_ops.rs Normal file
View File

@@ -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("<dir>");
}
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<FileEntry> {
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<String> {
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<PathBuf> {
#[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
}
}

41
src/main.rs Normal file
View File

@@ -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(())
}

614
src/ui.rs Normal file
View File

@@ -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<ListItem> = 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<ListItem> = 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<Row> = 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!("{:<width$}", label, width = label_w)).style(label_style),
Rect::new(inner.x + 1, row_y, label_w as u16, 1),
);
// Value
f.render_widget(
Paragraph::new(value.as_str()).style(value_style),
Rect::new(inner.x + 1 + label_w as u16, row_y, val_w as u16, 1),
);
}
// Hint at the bottom
let hint_y = inner.y + inner.height.saturating_sub(1);
f.render_widget(
Paragraph::new("Stäng: Enter / Esc").style(hint_style),
Rect::new(inner.x + 1, hint_y, inner.width.saturating_sub(2), 1),
);
}
fn draw_error_dialog(f: &mut Frame, msg: &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(" Fel ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
f.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::Red)),
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
);
f.render_widget(
Paragraph::new("Stäng: Enter / Esc").style(Style::default().fg(Color::DarkGray)),
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
);
}
fn draw_confirm_dialog(f: &mut Frame, msg: &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(" Bekräfta ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
f.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::White)),
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
);
f.render_widget(
Paragraph::new("y=ja Annan tangent=avbryt").style(Style::default().fg(Color::DarkGray)),
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
);
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else if max <= 3 {
s[..max].to_string()
} else {
format!("{}...", &s[..max.saturating_sub(3)])
}
}