From 87ad2f7dd949cfc7f8a8f279b1f3c527c2f3f532 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 1 Feb 2024 15:14:14 -0700 Subject: [PATCH] Convert to library --- Cargo.lock | 157 +++--- src/app.rs | 1190 +++++++++++++++++++++++++++++++++++++++++++ src/key_bind.rs | 2 +- src/lib.rs | 85 ++++ src/main.rs | 1265 +--------------------------------------------- src/menu.rs | 7 +- src/operation.rs | 2 +- src/tab.rs | 4 +- 8 files changed, 1377 insertions(+), 1335 deletions(-) create mode 100644 src/app.rs create mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a383f76..a9abca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" +checksum = "2faccea4cc4ab4a667ce676a30e8ec13922a692c99bb8f5b11f1502c72e04220" [[package]] name = "anstyle-parse" @@ -413,7 +413,7 @@ dependencies = [ "futures-lite 2.2.0", "parking", "polling 3.3.2", - "rustix 0.38.30", + "rustix 0.38.31", "slab", "tracing", "windows-sys 0.52.0", @@ -452,7 +452,7 @@ dependencies = [ "cfg-if 1.0.0", "event-listener 3.1.0", "futures-lite 1.13.0", - "rustix 0.38.30", + "rustix 0.38.31", "windows-sys 0.48.0", ] @@ -479,7 +479,7 @@ dependencies = [ "cfg-if 1.0.0", "futures-core", "futures-io", - "rustix 0.38.30", + "rustix 0.38.31", "signal-hook-registry", "slab", "windows-sys 0.48.0", @@ -525,7 +525,7 @@ name = "atomicwrites" version = "0.4.2" source = "git+https://github.com/jackpot51/rust-atomicwrites#043ab4859d53ffd3d55334685303d8df39c9f768" dependencies = [ - "rustix 0.38.30", + "rustix 0.38.31", "tempfile", "windows-sys 0.48.0", ] @@ -751,7 +751,7 @@ dependencies = [ "bitflags 2.4.2", "log", "polling 3.3.2", - "rustix 0.38.30", + "rustix 0.38.31", "slab", "thiserror", ] @@ -763,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" dependencies = [ "calloop 0.12.4", - "rustix 0.38.30", + "rustix 0.38.31", "wayland-backend", "wayland-client 0.31.2", ] @@ -1062,7 +1062,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "quote", "syn 1.0.109", @@ -1091,7 +1091,7 @@ version = "0.1.0" dependencies = [ "chrono", "dirs", - "env_logger", + "env_logger 0.11.1", "fastrand 2.0.1", "fork", "i18n-embed", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.10.0" -source = "git+https://github.com/pop-os/cosmic-text.git#e0ae465f918cd1cffca3a8239547dcf8166d3f77" +source = "git+https://github.com/pop-os/cosmic-text.git#1b025ae56e0122cff5798b9f54fc56d47a182d2b" dependencies = [ "bitflags 2.4.2", "fontdb", @@ -1136,7 +1136,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "almost", "cosmic-config", @@ -1298,12 +1298,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.3" +version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +checksum = "fc5d6b04b3fd0ba9926f945895de7d806260a2d7431ba82e7edaecb043c4c6b8" dependencies = [ - "darling_core 0.20.3", - "darling_macro 0.20.3", + "darling_core 0.20.5", + "darling_macro 0.20.5", ] [[package]] @@ -1322,9 +1322,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.3" +version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +checksum = "04e48a959bcd5c761246f5d090ebc2fbf7b9cd527a492b07a67510c108f1e7e3" dependencies = [ "fnv", "ident_case", @@ -1347,11 +1347,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.3" +version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +checksum = "1d1545d67a2149e1d93b7e5c7752dce5a7426eb5d1357ddcfd89336b94444f77" dependencies = [ - "darling_core 0.20.3", + "darling_core 0.20.5", "quote", "syn 2.0.48", ] @@ -1402,7 +1402,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d" dependencies = [ - "darling 0.20.3", + "darling 0.20.5", "proc-macro2", "quote", "syn 2.0.48", @@ -1582,6 +1582,15 @@ dependencies = [ "regex", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", +] + [[package]] name = "env_logger" version = "0.11.1" @@ -1842,6 +1851,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "font-types" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd7f3ea17572640b606b35df42cfb6ecdf003704b062580e59918692190b73d" + [[package]] name = "fontconfig-parser" version = "0.5.6" @@ -1909,9 +1924,9 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "fork" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf2ca97a59201425e7ee4d197c9c4fea282fe87a97d666a580bda889b95b8e88" +checksum = "60e74d3423998a57e9d906e49252fb79eb4a04d5cdfe188fb1b7ff9fc076a8ed" dependencies = [ "libc", ] @@ -2425,7 +2440,7 @@ dependencies = [ "serde", "serde_derive", "thiserror", - "toml 0.8.8", + "toml 0.8.9", "unic-langid", ] @@ -2511,7 +2526,7 @@ dependencies = [ [[package]] name = "iced" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_accessibility", "iced_core", @@ -2526,7 +2541,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "accesskit", "accesskit_winit", @@ -2535,7 +2550,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "bitflags 1.3.2", "instant", @@ -2551,7 +2566,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "futures", "iced_core", @@ -2564,7 +2579,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "bitflags 1.3.2", "bytemuck", @@ -2587,7 +2602,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2600,7 +2615,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_core", "iced_futures", @@ -2610,7 +2625,7 @@ dependencies = [ [[package]] name = "iced_style" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_core", "once_cell", @@ -2620,7 +2635,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "bytemuck", "cosmic-text", @@ -2638,7 +2653,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "bitflags 1.3.2", "bytemuck", @@ -2658,7 +2673,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_renderer", "iced_runtime", @@ -2672,7 +2687,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "iced_graphics", "iced_runtime", @@ -2747,9 +2762,9 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "indexmap" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433de089bd45971eecf4668ee0ee8f4cec17db4f8bd8f7bc3197a6ce37aa7d9b" +checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" dependencies = [ "equivalent", "hashbrown", @@ -2959,14 +2974,14 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.152" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#1291a48d4d62f1da5ca178292a3ce0082204d8d4" +source = "git+https://github.com/pop-os/libcosmic.git#ca1469a6b26eb7fbdc8e28d6135e86593e6a77fd" dependencies = [ "apply", "ashpd", @@ -4090,7 +4105,7 @@ dependencies = [ "cfg-if 1.0.0", "concurrent-queue", "pin-project-lite", - "rustix 0.38.30", + "rustix 0.38.31", "tracing", "windows-sys 0.52.0", ] @@ -4161,9 +4176,9 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135ede8821cf6376eb7a64148901e1690b788c11ae94dc297ae917dbc91dc0e" +checksum = "0f0f7f43585c34e4fdd7497d746bc32e14458cf11c69341cc0587b1d825dde42" [[package]] name = "pure-rust-locales" @@ -4291,6 +4306,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" +[[package]] +name = "read-fonts" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7555e052e772f964a1c99f1434f6a2c3a47a5f8e4292236921f121a7753cb2b5" +dependencies = [ + "font-types", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -4504,9 +4528,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.30" +version = "0.38.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" dependencies = [ "bitflags 2.4.2", "errno", @@ -4768,7 +4792,7 @@ dependencies = [ "libc", "log", "memmap2 0.9.4", - "rustix 0.38.30", + "rustix 0.38.31", "thiserror", "wayland-backend", "wayland-client 0.31.2", @@ -4830,7 +4854,7 @@ dependencies = [ "objc", "raw-window-handle 0.5.2", "redox_syscall 0.4.1", - "rustix 0.38.30", + "rustix 0.38.31", "tiny-xlib", "wasm-bindgen", "wayland-backend", @@ -4911,10 +4935,11 @@ dependencies = [ [[package]] name = "swash" -version = "0.1.8" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7c73c813353c347272919aa1af2885068b05e625e5532b43049e4f641ae77f" +checksum = "d06ff4664af8923625604261c645f5c4cc610cc83c84bec74b50d76237089de7" dependencies = [ + "read-fonts", "yazi", "zeno", ] @@ -4959,7 +4984,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 0.8.8", + "toml 0.8.9", "version-compare", ] @@ -5004,7 +5029,7 @@ dependencies = [ "cfg-if 1.0.0", "fastrand 2.0.1", "redox_syscall 0.4.1", - "rustix 0.38.30", + "rustix 0.38.31", "windows-sys 0.52.0", ] @@ -5023,7 +5048,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6159ab4116165c99fc88cce31f99fa2c9dbe08d3691cb38da02fc3b45f357d2b" dependencies = [ - "env_logger", + "env_logger 0.10.2", "test-log-macros", ] @@ -5205,14 +5230,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +checksum = "c6a4b9e8023eb94392d3dca65d717c53abc5dad49c07cb65bb8fcd87115fa325" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.21.0", + "toml_edit 0.21.1", ] [[package]] @@ -5237,9 +5262,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ "indexmap", "serde", @@ -5644,7 +5669,7 @@ checksum = "9d50fa61ce90d76474c87f5fc002828d81b32677340112b4ef08079a9d459a40" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.30", + "rustix 0.38.31", "scoped-tls", "smallvec", "wayland-sys 0.31.1", @@ -5689,7 +5714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82fb96ee935c2cea6668ccb470fb7771f6215d1691746c2d896b447a00ad3f1f" dependencies = [ "bitflags 2.4.2", - "rustix 0.38.30", + "rustix 0.38.31", "wayland-backend", "wayland-scanner 0.31.1", ] @@ -5757,7 +5782,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71ce5fa868dd13d11a0d04c5e2e65726d0897be8de247c0c5a65886e283231ba" dependencies = [ - "rustix 0.38.30", + "rustix 0.38.31", "wayland-client 0.31.2", "xcursor", ] @@ -6383,9 +6408,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.5.35" +version = "0.5.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1931d78a9c73861da0134f453bb1f790ce49b2e30eba8410b4b79bac72b46a2d" +checksum = "818ce546a11a9986bc24f93d0cdf38a8a1a400f1473ea8c82e59f6e0ffab9249" dependencies = [ "memchr", ] @@ -6425,7 +6450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" dependencies = [ "gethostname 0.4.3", - "rustix 0.38.30", + "rustix 0.38.31", "x11rb-protocol 0.13.0", ] diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0a11f1e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1190 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::{ + app::{message, Command, Core}, + cosmic_config::{self, CosmicConfigEntry}, + cosmic_theme, executor, + iced::{ + event, + futures::{self, SinkExt}, + keyboard::{Event as KeyEvent, KeyCode, Modifiers}, + subscription::{self, Subscription}, + window, Event, Length, Point, + }, + style, + widget::{self, segmented_button}, + Application, ApplicationExt, Element, +}; +use notify::Watcher; +use std::{ + any::TypeId, + collections::{BTreeMap, HashMap, HashSet}, + env, fs, + path::PathBuf, + process, time, +}; + +use crate::{ + config::{AppTheme, Config, CONFIG_VERSION}, + fl, home_dir, + key_bind::{key_binds, KeyBind}, + menu, mouse_area, + operation::Operation, + tab::{self, ItemMetadata, Location, Tab}, +}; + +#[derive(Clone, Debug)] +pub struct Flags { + pub config_handler: Option, + pub config: Config, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Action { + Copy, + Cut, + MoveToTrash, + NewFile, + NewFolder, + Paste, + Properties, + RestoreFromTrash, + SelectAll, + Settings, + TabClose, + TabNew, + TabNext, + TabPrev, + TabViewGrid, + TabViewList, + WindowClose, + WindowNew, +} + +impl Action { + pub fn message(self, entity_opt: Option) -> Message { + match self { + Action::Copy => Message::Copy(entity_opt), + Action::Cut => Message::Cut(entity_opt), + Action::MoveToTrash => Message::MoveToTrash(entity_opt), + Action::NewFile => Message::NewFile(entity_opt), + Action::NewFolder => Message::NewFolder(entity_opt), + Action::Paste => Message::Paste(entity_opt), + Action::Properties => Message::ToggleContextPage(ContextPage::Properties), + Action::RestoreFromTrash => Message::RestoreFromTrash(entity_opt), + Action::SelectAll => Message::SelectAll(entity_opt), + Action::Settings => Message::ToggleContextPage(ContextPage::Settings), + Action::TabClose => Message::TabClose(entity_opt), + Action::TabNew => Message::TabNew, + Action::TabNext => Message::TabNext, + Action::TabPrev => Message::TabPrev, + Action::TabViewGrid => { + Message::TabMessage(entity_opt, tab::Message::View(tab::View::Grid)) + } + Action::TabViewList => { + Message::TabMessage(entity_opt, tab::Message::View(tab::View::List)) + } + Action::WindowClose => Message::WindowClose, + Action::WindowNew => Message::WindowNew, + } + } +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + Todo, + AppTheme(AppTheme), + Config(Config), + Copy(Option), + Cut(Option), + Key(Modifiers, KeyCode), + Modifiers(Modifiers), + MoveToTrash(Option), + NewFile(Option), + NewFolder(Option), + NotifyEvent(notify::Event), + NotifyWatcher(WatcherWrapper), + Paste(Option), + PendingComplete(u64), + PendingError(u64, String), + PendingProgress(u64, f32), + RestoreFromTrash(Option), + SelectAll(Option), + SystemThemeModeChange(cosmic_theme::ThemeMode), + TabActivate(segmented_button::Entity), + TabNext, + TabPrev, + TabClose(Option), + TabContextAction(segmented_button::Entity, Action), + TabContextMenu(segmented_button::Entity, Option), + TabMessage(Option, tab::Message), + TabNew, + TabRescan(segmented_button::Entity, Vec), + ToggleContextPage(ContextPage), + WindowClose, + WindowNew, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContextPage { + Operations, + Properties, + Settings, +} + +impl ContextPage { + fn title(&self) -> String { + match self { + Self::Operations => fl!("operations"), + Self::Properties => fl!("properties"), + Self::Settings => fl!("settings"), + } + } +} + +#[derive(Debug)] +pub struct WatcherWrapper { + watcher_opt: Option, +} + +impl Clone for WatcherWrapper { + fn clone(&self) -> Self { + Self { watcher_opt: None } + } +} + +impl PartialEq for WatcherWrapper { + fn eq(&self, _other: &Self) -> bool { + false + } +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + nav_model: segmented_button::SingleSelectModel, + tab_model: segmented_button::Model, + config_handler: Option, + config: Config, + app_themes: Vec, + context_page: ContextPage, + key_binds: HashMap, + modifiers: Modifiers, + pending_operation_id: u64, + pending_operations: BTreeMap, + complete_operations: BTreeMap, + failed_operations: BTreeMap, + watcher_opt: Option<(notify::RecommendedWatcher, HashSet)>, +} + +impl App { + fn open_tab(&mut self, location: Location) -> Command { + let tab = Tab::new(location.clone()); + let entity = self + .tab_model + .insert() + .text(tab.title()) + .data(tab) + .closable() + .activate() + .id(); + Command::batch([ + self.update_title(), + self.update_watcher(), + self.rescan_tab(entity, location), + ]) + } + + fn operation(&mut self, operation: Operation) { + let id = self.pending_operation_id; + self.pending_operation_id += 1; + self.pending_operations.insert(id, (operation, 0.0)); + //TODO: have some button to show current status + self.core.window.show_context = true; + self.context_page = ContextPage::Operations; + } + + fn rescan_tab( + &mut self, + entity: segmented_button::Entity, + location: Location, + ) -> Command { + Command::perform( + async move { + match tokio::task::spawn_blocking(move || location.scan()).await { + Ok(items) => message::app(Message::TabRescan(entity, items)), + Err(err) => { + log::warn!("failed to rescan: {}", err); + message::none() + } + } + }, + |x| x, + ) + } + + fn update_config(&mut self) -> Command { + cosmic::app::command::set_theme(self.config.app_theme.theme()) + } + + fn save_config(&mut self) -> Command { + match self.config_handler { + Some(ref config_handler) => match self.config.write_entry(&config_handler) { + Ok(()) => {} + Err(err) => { + log::error!("failed to save config: {}", err); + } + }, + None => {} + } + self.update_config() + } + + fn update_title(&mut self) -> Command { + let (header_title, window_title) = match self.tab_model.text(self.tab_model.active()) { + Some(tab_title) => ( + tab_title.to_string(), + format!("{tab_title} — COSMIC File Manager"), + ), + None => (String::new(), "COSMIC File Manager".to_string()), + }; + self.set_header_title(header_title); + self.set_window_title(window_title) + } + + fn update_watcher(&mut self) -> Command { + if let Some((mut watcher, old_paths)) = self.watcher_opt.take() { + let mut new_paths = HashSet::new(); + for entity in self.tab_model.iter() { + if let Some(tab) = self.tab_model.data::(entity) { + if let Location::Path(path) = &tab.location { + new_paths.insert(path.clone()); + } + } + } + + // Unwatch paths no longer used + for path in old_paths.iter() { + if !new_paths.contains(path) { + match watcher.unwatch(path) { + Ok(()) => { + log::debug!("unwatching {:?}", path); + } + Err(err) => { + log::debug!("failed to unwatch {:?}: {}", path, err); + } + } + } + } + + // Watch new paths + for path in new_paths.iter() { + if !old_paths.contains(path) { + //TODO: should this be recursive? + match watcher.watch(path, notify::RecursiveMode::NonRecursive) { + Ok(()) => { + log::debug!("watching {:?}", path); + } + Err(err) => { + log::debug!("failed to watch {:?}: {}", path, err); + } + } + } + } + + self.watcher_opt = Some((watcher, new_paths)); + } + + //TODO: should any of this run in a command? + Command::none() + } + + fn operations(&self) -> Element { + let mut children = Vec::new(); + + //TODO: get height from theme? + let progress_bar_height = Length::Fixed(4.0); + + if !self.pending_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("pending")); + for (id, (op, progress)) in self.pending_operations.iter().rev() { + section = section.add(widget::column::with_children(vec![ + widget::text(format!("{:?}", op)).into(), + widget::progress_bar(0.0..=100.0, *progress) + .height(progress_bar_height) + .into(), + ])); + } + children.push(section.into()); + } + + if !self.failed_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("failed")); + for (id, (op, error)) in self.failed_operations.iter().rev() { + section = section.add(widget::column::with_children(vec![ + widget::text(format!("{:?}", op)).into(), + widget::text(error).into(), + ])); + } + children.push(section.into()); + } + + if !self.complete_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("complete")); + for (id, op) in self.complete_operations.iter().rev() { + section = section.add(widget::text(format!("{:?}", op))); + } + children.push(section.into()); + } + + widget::settings::view_column(children).into() + } + + fn properties(&self) -> Element { + let mut children = Vec::new(); + let entity = self.tab_model.active(); + if let Some(tab) = self.tab_model.data::(entity) { + if let Some(ref items) = tab.items_opt { + for item in items.iter() { + if item.selected { + children.push(item.property_view(&self.core)); + } + } + } + } + widget::settings::view_column(children).into() + } + + fn settings(&self) -> Element { + let app_theme_selected = match self.config.app_theme { + AppTheme::Dark => 1, + AppTheme::Light => 2, + AppTheme::System => 0, + }; + widget::settings::view_column(vec![widget::settings::view_section(fl!("appearance")) + .add( + widget::settings::item::builder(fl!("theme")).control(widget::dropdown( + &self.app_themes, + Some(app_theme_selected), + move |index| { + Message::AppTheme(match index { + 1 => AppTheme::Dark, + 2 => AppTheme::Light, + _ => AppTheme::System, + }) + }, + )), + ) + .into()]) + .into() + } +} + +/// Implement [`Application`] to integrate with COSMIC. +impl Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received + type Flags = Flags; + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "com.system76.CosmicFiles"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + fn init(core: Core, flags: Self::Flags) -> (Self, Command) { + let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")]; + + let mut nav_model = segmented_button::ModelBuilder::default(); + if let Some(dir) = dirs::home_dir() { + nav_model = nav_model.insert(move |b| { + b.text(fl!("home")) + .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) + .data(Location::Path(dir.clone())) + }); + } + //TODO: Sort by name? + for dir_opt in &[ + dirs::document_dir(), + dirs::download_dir(), + dirs::audio_dir(), + dirs::picture_dir(), + dirs::video_dir(), + ] { + if let Some(dir) = dir_opt { + if let Some(file_name) = dir.file_name().and_then(|x| x.to_str()) { + nav_model = nav_model.insert(move |b| { + b.text(file_name.to_string()) + .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) + .data(Location::Path(dir.clone())) + }); + } + } + } + nav_model = nav_model.insert(|b| { + b.text(fl!("trash")) + .icon(widget::icon::icon(tab::trash_icon_symbolic(16))) + .data(Location::Trash) + }); + + let mut app = App { + core, + nav_model: nav_model.build(), + tab_model: segmented_button::ModelBuilder::default().build(), + config_handler: flags.config_handler, + config: flags.config, + app_themes, + context_page: ContextPage::Settings, + key_binds: key_binds(), + modifiers: Modifiers::empty(), + pending_operation_id: 0, + pending_operations: BTreeMap::new(), + complete_operations: BTreeMap::new(), + failed_operations: BTreeMap::new(), + watcher_opt: None, + }; + + let mut commands = Vec::new(); + + for arg in env::args().skip(1) { + let location = match fs::canonicalize(&arg) { + Ok(absolute) => Location::Path(absolute), + Err(err) => { + log::warn!("failed to canonicalize {:?}: {}", arg, err); + continue; + } + }; + commands.push(app.open_tab(location)); + } + + if app.tab_model.iter().next().is_none() { + commands.push(app.open_tab(Location::Path(home_dir()))); + } + + (app, Command::batch(commands)) + } + + // The default nav_bar widget needs to have its width reduced for cosmic-files + fn nav_bar(&self) -> Option>> { + if !self.core().nav_bar_active() { + return None; + } + + let nav_model = self.nav_model()?; + + let mut nav = widget::nav_bar(nav_model, |entity| { + message::cosmic(cosmic::app::cosmic::Message::NavBar(entity)) + }); + + if !self.core().is_condensed() { + nav = nav.max_width(200); + } + + Some(Element::from(nav)) + } + + fn nav_model(&self) -> Option<&segmented_button::SingleSelectModel> { + Some(&self.nav_model) + } + + fn on_nav_select(&mut self, entity: segmented_button::Entity) -> Command { + let location_opt = self.nav_model.data::(entity).clone(); + + if let Some(location) = location_opt { + let message = Message::TabMessage(None, tab::Message::Location(location.clone())); + return self.update(message); + } + + Command::none() + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + // Helper for updating config values efficiently + macro_rules! config_set { + ($name: ident, $value: expr) => { + match &self.config_handler { + Some(config_handler) => { + match paste::paste! { self.config.[](config_handler, $value) } { + Ok(_) => {} + Err(err) => { + log::warn!( + "failed to save config {:?}: {}", + stringify!($name), + err + ); + } + } + } + None => { + self.config.$name = $value; + log::warn!( + "failed to save config {:?}: no config handler", + stringify!($name) + ); + } + } + }; + } + + match message { + Message::Todo => { + log::warn!("TODO"); + } + Message::AppTheme(app_theme) => { + config_set!(app_theme, app_theme); + return self.update_config(); + } + Message::Config(config) => { + if config != self.config { + log::info!("update config"); + //TODO: update syntax theme by clearing tabs, only if needed + self.config = config; + return self.update_config(); + } + } + Message::Copy(entity_opt) => { + log::warn!("TODO: COPY"); + } + Message::Cut(entity_opt) => { + log::warn!("TODO: CUT"); + } + Message::Key(modifiers, key_code) => { + let entity = self.tab_model.active(); + for (key_bind, action) in self.key_binds.iter() { + if key_bind.matches(modifiers, key_code) { + return self.update(action.message(Some(entity))); + } + } + } + Message::Modifiers(modifiers) => { + self.modifiers = modifiers; + } + Message::MoveToTrash(entity_opt) => { + let mut paths = Vec::new(); + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.selected { + paths.push(item.path.clone()); + } + } + } + } + if !paths.is_empty() { + self.operation(Operation::Delete { paths }); + } + } + Message::NewFile(entity_opt) => { + log::warn!("TODO: NEW FILE"); + } + Message::NewFolder(entity_opt) => { + log::warn!("TODO: NEW FOLDER"); + } + Message::NotifyEvent(event) => { + log::debug!("{:?}", event); + + let mut needs_reload = Vec::new(); + for entity in self.tab_model.iter() { + if let Some(tab) = self.tab_model.data::(entity) { + //TODO: support reloading trash, somehow + if let Location::Path(path) = &tab.location { + let mut contains_change = false; + for event_path in event.paths.iter() { + if event_path.starts_with(&path) { + contains_change = true; + break; + } + } + if contains_change { + needs_reload.push((entity, tab.location.clone())); + } + } + } + } + + let mut commands = Vec::with_capacity(needs_reload.len()); + for (entity, location) in needs_reload { + commands.push(self.rescan_tab(entity, location)); + } + return Command::batch(commands); + } + Message::NotifyWatcher(mut watcher_wrapper) => match watcher_wrapper.watcher_opt.take() + { + Some(mut watcher) => { + self.watcher_opt = Some((watcher, HashSet::new())); + return self.update_watcher(); + } + None => { + log::warn!("message did not contain notify watcher"); + } + }, + Message::Paste(entity_opt) => { + log::warn!("TODO: PASTE"); + } + Message::PendingComplete(id) => { + if let Some((op, _)) = self.pending_operations.remove(&id) { + self.complete_operations.insert(id, op); + } + } + Message::PendingError(id, err) => { + if let Some((op, _)) = self.pending_operations.remove(&id) { + self.failed_operations.insert(id, (op, err)); + } + } + Message::PendingProgress(id, new_progress) => { + if let Some((_, progress)) = self.pending_operations.get_mut(&id) { + *progress = new_progress; + } + } + Message::RestoreFromTrash(entity_opt) => { + let mut paths = Vec::new(); + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.selected { + match &item.metadata { + ItemMetadata::Trash { entry, .. } => { + paths.push(entry.clone()); + } + _ => { + //TODO: error on trying to restore non-trash file? + } + } + } + } + } + } + if !paths.is_empty() { + self.operation(Operation::Restore { paths }); + } + } + Message::SelectAll(entity_opt) => { + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.hidden { + //TODO: option to show hidden files + continue; + } + item.selected = true; + item.click_time = None; + } + } + } + } + Message::SystemThemeModeChange(_theme_mode) => { + return self.update_config(); + } + Message::TabActivate(entity) => { + self.tab_model.activate(entity); + return self.update_title(); + } + Message::TabNext => { + let len = self.tab_model.iter().count(); + let pos = self + .tab_model + .position(self.tab_model.active()) + // Wraparound to 0 if i + 1 > num of tabs + .map(|i| (i as usize + 1) % len) + .expect("should always be at least one tab open"); + + let entity = self.tab_model.iter().nth(pos); + if let Some(entity) = entity { + return self.update(Message::TabActivate(entity)); + } + } + Message::TabPrev => { + let pos = self + .tab_model + .position(self.tab_model.active()) + .and_then(|i| (i as usize).checked_sub(1)) + // Subtraction underflow => last tab; i.e. it wraps around + .unwrap_or_else(|| { + self.tab_model + .iter() + .count() + .checked_sub(1) + .unwrap_or_default() + }); + + let entity = self.tab_model.iter().nth(pos); + if let Some(entity) = entity { + return self.update(Message::TabActivate(entity)); + } + } + Message::TabClose(entity_opt) => { + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + + // Activate closest item + if let Some(position) = self.tab_model.position(entity) { + if position > 0 { + self.tab_model.activate_position(position - 1); + } else { + self.tab_model.activate_position(position + 1); + } + } + + // Remove item + self.tab_model.remove(entity); + + // If that was the last tab, close window + if self.tab_model.iter().next().is_none() { + return window::close(window::Id::MAIN); + } + + return Command::batch([self.update_title(), self.update_watcher()]); + } + Message::TabContextAction(entity, action) => { + match self.tab_model.data_mut::(entity) { + Some(tab) => { + // Close context menu + { + tab.context_menu = None; + } + // Run action's message + return self.update(action.message(Some(entity))); + } + _ => {} + } + } + Message::TabContextMenu(entity, position_opt) => { + match self.tab_model.data_mut::(entity) { + Some(tab) => { + // Update context menu position + tab.context_menu = position_opt; + } + _ => {} + } + // Disable side context page + self.core.window.show_context = false; + } + Message::TabMessage(entity_opt, tab_message) => { + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + + let mut update_opt = None; + match self.tab_model.data_mut::(entity) { + Some(tab) => { + if tab.update(tab_message, self.modifiers) { + update_opt = Some((tab.title(), tab.location.clone())); + } + } + _ => (), + } + if let Some((tab_title, tab_path)) = update_opt { + self.tab_model.text_set(entity, tab_title); + return Command::batch([ + self.update_title(), + self.update_watcher(), + self.rescan_tab(entity, tab_path), + ]); + } + } + Message::TabNew => { + let active = self.tab_model.active(); + let location = match self.tab_model.data::(active) { + Some(tab) => tab.location.clone(), + None => Location::Path(home_dir()), + }; + return self.open_tab(location); + } + Message::TabRescan(entity, items) => match self.tab_model.data_mut::(entity) { + Some(tab) => { + tab.items_opt = Some(items); + } + _ => (), + }, + //TODO: TABRELOAD + Message::ToggleContextPage(context_page) => { + //TODO: ensure context menus are closed + if self.context_page == context_page { + self.core.window.show_context = !self.core.window.show_context; + } else { + self.context_page = context_page; + self.core.window.show_context = true; + } + self.set_context_title(context_page.title()); + } + Message::WindowClose => { + return window::close(window::Id::MAIN); + } + Message::WindowNew => match env::current_exe() { + Ok(exe) => match process::Command::new(&exe).spawn() { + Ok(_child) => {} + Err(err) => { + log::error!("failed to execute {:?}: {}", exe, err); + } + }, + Err(err) => { + log::error!("failed to get current executable path: {}", err); + } + }, + } + + Command::none() + } + + fn context_drawer(&self) -> Option> { + if !self.core.window.show_context { + return None; + } + + Some(match self.context_page { + ContextPage::Operations => self.operations(), + ContextPage::Properties => self.properties(), + ContextPage::Settings => self.settings(), + }) + } + + fn header_start(&self) -> Vec> { + vec![menu::menu_bar(&self.key_binds).into()] + } + + fn header_end(&self) -> Vec> { + vec![] + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing; + + let mut tab_column = widget::column::with_capacity(1); + + if self.tab_model.iter().count() > 1 { + tab_column = tab_column.push( + widget::container( + widget::view_switcher::horizontal(&self.tab_model) + .button_height(32) + .button_spacing(space_xxs) + .on_activate(Message::TabActivate) + .on_close(|entity| Message::TabClose(Some(entity))), + ) + .style(style::Container::Background) + .width(Length::Fill), + ); + } + + let entity = self.tab_model.active(); + match self.tab_model.data::(entity) { + Some(tab) => { + let mut mouse_area = mouse_area::MouseArea::new( + tab.view(self.core()) + .map(move |message| Message::TabMessage(Some(entity), message)), + ) + .on_press(move |_point_opt| { + Message::TabMessage(Some(entity), tab::Message::Click(None)) + }); + if tab.context_menu.is_some() { + mouse_area = mouse_area + .on_right_press(move |_point_opt| Message::TabContextMenu(entity, None)); + } else { + mouse_area = mouse_area.on_right_press(move |point_opt| { + Message::TabContextMenu(entity, point_opt) + }); + } + let mut popover = widget::popover(mouse_area, menu::context_menu(entity, &tab)); + match tab.context_menu { + Some(point) => { + let rounded = Point::new(point.x.round(), point.y.round()); + popover = popover.position(rounded); + } + None => { + popover = popover.show_popup(false); + } + } + tab_column = tab_column.push(popover); + } + None => { + //TODO + } + } + + let content: Element<_> = tab_column.into(); + + // Uncomment to debug layout: + //content.explain(cosmic::iced::Color::WHITE) + content + } + + fn subscription(&self) -> Subscription { + struct ConfigSubscription; + struct ThemeSubscription; + struct WatcherSubscription; + + let mut subscriptions = vec![ + event::listen_with(|event, _status| match event { + Event::Keyboard(KeyEvent::KeyPressed { + key_code, + modifiers, + }) => Some(Message::Key(modifiers, key_code)), + Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => { + Some(Message::Modifiers(modifiers)) + } + _ => None, + }), + cosmic_config::config_subscription( + TypeId::of::(), + Self::APP_ID.into(), + CONFIG_VERSION, + ) + .map(|update| { + if !update.errors.is_empty() { + log::info!( + "errors loading config {:?}: {:?}", + update.keys, + update.errors + ); + } + Message::Config(update.config) + }), + cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>( + TypeId::of::(), + cosmic_theme::THEME_MODE_ID.into(), + cosmic_theme::ThemeMode::version(), + ) + .map(|update| { + if !update.errors.is_empty() { + log::info!( + "errors loading theme mode {:?}: {:?}", + update.keys, + update.errors + ); + } + Message::SystemThemeModeChange(update.config) + }), + subscription::channel( + TypeId::of::(), + 100, + |mut output| async move { + let watcher_res = { + let mut output = output.clone(); + //TODO: debounce + notify::recommended_watcher( + move |event_res: Result| match event_res { + Ok(event) => { + match &event.kind { + notify::EventKind::Access(_) + | notify::EventKind::Modify( + notify::event::ModifyKind::Metadata(_), + ) => { + // Data not mutated + return; + } + _ => {} + } + + match futures::executor::block_on(async { + output.send(Message::NotifyEvent(event)).await + }) { + Ok(()) => {} + Err(err) => { + log::warn!("failed to send notify event: {:?}", err); + } + } + } + Err(err) => { + log::warn!("failed to watch files: {:?}", err); + } + }, + ) + }; + + match watcher_res { + Ok(watcher) => { + match output + .send(Message::NotifyWatcher(WatcherWrapper { + watcher_opt: Some(watcher), + })) + .await + { + Ok(()) => {} + Err(err) => { + log::warn!("failed to send notify watcher: {:?}", err); + } + } + } + Err(err) => { + log::warn!("failed to create file watcher: {:?}", err); + } + } + + //TODO: how to properly kill this task? + loop { + tokio::time::sleep(time::Duration::new(1, 0)).await; + } + }, + ), + ]; + + for (id, (pending_operation, _)) in self.pending_operations.iter() { + //TODO: use recipe? + let id = *id; + let pending_operation = pending_operation.clone(); + subscriptions.push(subscription::channel( + id, + 16, + move |mut msg_tx| async move { + match pending_operation.perform(id, &mut msg_tx).await { + Ok(()) => { + msg_tx.send(Message::PendingComplete(id)).await; + } + Err(err) => { + msg_tx + .send(Message::PendingError(id, err.to_string())) + .await; + } + } + + loop { + tokio::time::sleep(time::Duration::new(1, 0)).await; + } + }, + )); + } + + Subscription::batch(subscriptions) + } +} + +// Utilities to build a temporary file hierarchy for tests. +// +// Ideally, tests would use the cap-std crate which limits path traversal. +#[cfg(test)] +pub(crate) mod test_utils { + use std::{ + cmp::Ordering, + fs::File, + io::{self, Write}, + iter, + path::Path, + }; + + use log::{debug, trace}; + use tempfile::{tempdir, TempDir}; + + use crate::tab::Item; + + use super::*; + + // Default number of files, directories, and nested directories for test file system + pub const NUM_FILES: usize = 2; + pub const NUM_DIRS: usize = 2; + pub const NUM_NESTED: usize = 1; + pub const NAME_LEN: usize = 5; + + /// Add `n` temporary files in `dir` + /// + /// Each file is assigned a numeric name from [0, n). + pub fn file_flat_hier>(dir: D, n: usize) -> io::Result> { + let dir = dir.as_ref(); + (0..n) + .map(|i| -> io::Result { + let name = i.to_string(); + let path = dir.join(&name); + + let mut file = File::create(path)?; + file.write_all(name.as_bytes())?; + + Ok(file) + }) + .collect() + } + + // Random alphanumeric String of length `len` + fn rand_string(len: usize) -> String { + (0..len).map(|_| fastrand::alphanumeric()).collect() + } + + /// Create a small, temporary file hierarchy. + pub fn simple_fs( + files: usize, + dirs: usize, + nested: usize, + name_len: usize, + ) -> io::Result { + // Files created inside of a TempDir are deleted with the directory + // TempDir won't leak resources as long as the destructor runs + let root = tempdir()?; + debug!("Root temp directory: {}", root.as_ref().display()); + + // All paths for directories and nested directories + let paths = (0..dirs).flat_map(|_| { + let root = root.as_ref(); + let current = rand_string(name_len); + + iter::once(root.join(¤t)).chain( + (0..nested).map(move |_| root.join(format!("{current}/{}", rand_string(name_len)))), + ) + }); + + // Create directories from `paths` and add a few files + for path in paths { + fs::create_dir_all(&path)?; + file_flat_hier(&path, files)?; + + for entry in path.read_dir()? { + let entry = entry?; + if entry.file_type()?.is_file() { + trace!("Created file: {}", entry.path().display()); + } + } + } + + Ok(root) + } + + /// Empty file hierarchy + pub fn empty_fs() -> io::Result { + tempdir() + } + + /// Sort files. + /// + /// Directories are placed before files. + /// Files are lexically sorted. + /// This is more or less copied right from the [Tab] code + pub fn sort_files(a: &Path, b: &Path) -> Ordering { + match (a.is_dir(), b.is_dir()) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => lexical_sort::natural_lexical_cmp( + a.file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"), + b.file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"), + ), + } + } + + /// Equality for [Path] and [Item]. + pub fn eq_path_item(path: &Path, item: &Item) -> bool { + let name = path + .file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"); + let metadata = path.is_dir(); + + name == item.name && metadata == item.metadata.is_dir() && path == item.path + } +} diff --git a/src/key_bind.rs b/src/key_bind.rs index 25a536d..a8c3cce 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -2,7 +2,7 @@ use cosmic::iced::keyboard::{KeyCode, Modifiers}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; -use crate::Action; +use crate::app::Action; #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] pub enum Modifier { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d005473 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,85 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::{ + app::{Application, Settings}, + cosmic_config::{self, CosmicConfigEntry}, +}; +use std::{path::PathBuf, process}; + +use app::{App, Flags}; +mod app; +use config::{Config, CONFIG_VERSION}; +mod config; +mod key_bind; +mod localize; +mod menu; +mod mime_icon; +mod mouse_area; +mod operation; +mod tab; + +pub fn home_dir() -> PathBuf { + match dirs::home_dir() { + Some(home) => home, + None => { + log::warn!("failed to locate home directory"); + PathBuf::from("/") + } + } +} + +/// Runs application with these settings +#[rustfmt::skip] +pub fn main() -> Result<(), Box> { + #[cfg(all(unix, not(target_os = "redox")))] + match fork::daemon(true, true) { + Ok(fork::Fork::Child) => (), + Ok(fork::Fork::Parent(_child_pid)) => process::exit(0), + Err(err) => { + eprintln!("failed to daemonize: {:?}", err); + process::exit(1); + } + } + + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + localize::localize(); + + let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { + Ok(config_handler) => { + let config = match Config::get_entry(&config_handler) { + Ok(ok) => ok, + Err((errs, config)) => { + log::info!("errors loading config: {:?}", errs); + config + } + }; + (Some(config_handler), config) + } + Err(err) => { + log::error!("failed to create config handler: {}", err); + (None, Config::default()) + } + }; + + let mut settings = Settings::default(); + settings = settings.theme(config.app_theme.theme()); + + #[cfg(target_os = "redox")] + { + // Redox does not support resize if doing CSDs + settings = settings.client_decorations(false); + } + + //TODO: allow size limits on iced_winit + //settings = settings.size_limits(Limits::NONE.min_width(400.0).min_height(200.0)); + + let flags = Flags { + config_handler, + config, + }; + cosmic::app::run::(settings, flags)?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 2c11acc..9dca6cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1266 +1,3 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: GPL-3.0-only - -use cosmic::{ - app::{message, Command, Core, Settings}, - cosmic_config::{self, CosmicConfigEntry}, - cosmic_theme, executor, - iced::{ - event, - futures::{self, SinkExt}, - keyboard::{Event as KeyEvent, KeyCode, Modifiers}, - subscription::{self, Subscription}, - window, Event, Length, Point, - }, - style, - widget::{self, segmented_button}, - Application, ApplicationExt, Element, -}; -use notify::Watcher; -use std::{ - any::TypeId, - collections::{BTreeMap, HashMap, HashSet}, - env, fs, io, - path::PathBuf, - process, time, -}; - -use config::{AppTheme, Config, CONFIG_VERSION}; -mod config; - -use key_bind::{key_binds, KeyBind}; -mod key_bind; - -mod localize; - -mod menu; - -mod mouse_area; - -mod mime_icon; - -use operation::Operation; -mod operation; - -use tab::{ItemMetadata, Location, Tab}; -mod tab; - -/// Runs application with these settings -#[rustfmt::skip] fn main() -> Result<(), Box> { - #[cfg(all(unix, not(target_os = "redox")))] - match fork::daemon(true, true) { - Ok(fork::Fork::Child) => (), - Ok(fork::Fork::Parent(_child_pid)) => process::exit(0), - Err(err) => { - eprintln!("failed to daemonize: {:?}", err); - process::exit(1); - } - } - - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); - - localize::localize(); - - let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { - Ok(config_handler) => { - let config = match Config::get_entry(&config_handler) { - Ok(ok) => ok, - Err((errs, config)) => { - log::info!("errors loading config: {:?}", errs); - config - } - }; - (Some(config_handler), config) - } - Err(err) => { - log::error!("failed to create config handler: {}", err); - (None, Config::default()) - } - }; - - let mut settings = Settings::default(); - settings = settings.theme(config.app_theme.theme()); - - #[cfg(target_os = "redox")] - { - // Redox does not support resize if doing CSDs - settings = settings.client_decorations(false); - } - - //TODO: allow size limits on iced_winit - //settings = settings.size_limits(Limits::NONE.min_width(400.0).min_height(200.0)); - - let flags = Flags { - config_handler, - config, - }; - cosmic::app::run::(settings, flags)?; - - Ok(()) -} - -fn home_dir() -> PathBuf { - match dirs::home_dir() { - Some(home) => home, - None => { - log::warn!("failed to locate home directory"); - PathBuf::from("/") - } - } -} - -#[derive(Clone, Debug)] -pub struct Flags { - config_handler: Option, - config: Config, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Action { - Copy, - Cut, - MoveToTrash, - NewFile, - NewFolder, - Paste, - Properties, - RestoreFromTrash, - SelectAll, - Settings, - TabClose, - TabNew, - TabNext, - TabPrev, - TabViewGrid, - TabViewList, - WindowClose, - WindowNew, -} - -impl Action { - pub fn message(self, entity_opt: Option) -> Message { - match self { - Action::Copy => Message::Copy(entity_opt), - Action::Cut => Message::Cut(entity_opt), - Action::MoveToTrash => Message::MoveToTrash(entity_opt), - Action::NewFile => Message::NewFile(entity_opt), - Action::NewFolder => Message::NewFolder(entity_opt), - Action::Paste => Message::Paste(entity_opt), - Action::Properties => Message::ToggleContextPage(ContextPage::Properties), - Action::RestoreFromTrash => Message::RestoreFromTrash(entity_opt), - Action::SelectAll => Message::SelectAll(entity_opt), - Action::Settings => Message::ToggleContextPage(ContextPage::Settings), - Action::TabClose => Message::TabClose(entity_opt), - Action::TabNew => Message::TabNew, - Action::TabNext => Message::TabNext, - Action::TabPrev => Message::TabPrev, - Action::TabViewGrid => { - Message::TabMessage(entity_opt, tab::Message::View(tab::View::Grid)) - } - Action::TabViewList => { - Message::TabMessage(entity_opt, tab::Message::View(tab::View::List)) - } - Action::WindowClose => Message::WindowClose, - Action::WindowNew => Message::WindowNew, - } - } -} - -/// Messages that are used specifically by our [`App`]. -#[derive(Clone, Debug)] -pub enum Message { - Todo, - AppTheme(AppTheme), - Config(Config), - Copy(Option), - Cut(Option), - Key(Modifiers, KeyCode), - Modifiers(Modifiers), - MoveToTrash(Option), - NewFile(Option), - NewFolder(Option), - NotifyEvent(notify::Event), - NotifyWatcher(WatcherWrapper), - Paste(Option), - PendingComplete(u64), - PendingError(u64, String), - PendingProgress(u64, f32), - RestoreFromTrash(Option), - SelectAll(Option), - SystemThemeModeChange(cosmic_theme::ThemeMode), - TabActivate(segmented_button::Entity), - TabNext, - TabPrev, - TabClose(Option), - TabContextAction(segmented_button::Entity, Action), - TabContextMenu(segmented_button::Entity, Option), - TabMessage(Option, tab::Message), - TabNew, - TabRescan(segmented_button::Entity, Vec), - ToggleContextPage(ContextPage), - WindowClose, - WindowNew, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ContextPage { - Operations, - Properties, - Settings, -} - -impl ContextPage { - fn title(&self) -> String { - match self { - Self::Operations => fl!("operations"), - Self::Properties => fl!("properties"), - Self::Settings => fl!("settings"), - } - } -} - -#[derive(Debug)] -pub struct WatcherWrapper { - watcher_opt: Option, -} - -impl Clone for WatcherWrapper { - fn clone(&self) -> Self { - Self { watcher_opt: None } - } -} - -impl PartialEq for WatcherWrapper { - fn eq(&self, _other: &Self) -> bool { - false - } -} - -/// The [`App`] stores application-specific state. -pub struct App { - core: Core, - nav_model: segmented_button::SingleSelectModel, - tab_model: segmented_button::Model, - config_handler: Option, - config: Config, - app_themes: Vec, - context_page: ContextPage, - key_binds: HashMap, - modifiers: Modifiers, - pending_operation_id: u64, - pending_operations: BTreeMap, - complete_operations: BTreeMap, - failed_operations: BTreeMap, - watcher_opt: Option<(notify::RecommendedWatcher, HashSet)>, -} - -impl App { - fn open_tab(&mut self, location: Location) -> Command { - let tab = Tab::new(location.clone()); - let entity = self - .tab_model - .insert() - .text(tab.title()) - .data(tab) - .closable() - .activate() - .id(); - Command::batch([ - self.update_title(), - self.update_watcher(), - self.rescan_tab(entity, location), - ]) - } - - fn operation(&mut self, operation: Operation) { - let id = self.pending_operation_id; - self.pending_operation_id += 1; - self.pending_operations.insert(id, (operation, 0.0)); - //TODO: have some button to show current status - self.core.window.show_context = true; - self.context_page = ContextPage::Operations; - } - - fn rescan_tab( - &mut self, - entity: segmented_button::Entity, - location: Location, - ) -> Command { - Command::perform( - async move { - match tokio::task::spawn_blocking(move || location.scan()).await { - Ok(items) => message::app(Message::TabRescan(entity, items)), - Err(err) => { - log::warn!("failed to rescan: {}", err); - message::none() - } - } - }, - |x| x, - ) - } - - fn update_config(&mut self) -> Command { - cosmic::app::command::set_theme(self.config.app_theme.theme()) - } - - fn save_config(&mut self) -> Command { - match self.config_handler { - Some(ref config_handler) => match self.config.write_entry(&config_handler) { - Ok(()) => {} - Err(err) => { - log::error!("failed to save config: {}", err); - } - }, - None => {} - } - self.update_config() - } - - fn update_title(&mut self) -> Command { - let (header_title, window_title) = match self.tab_model.text(self.tab_model.active()) { - Some(tab_title) => ( - tab_title.to_string(), - format!("{tab_title} — COSMIC File Manager"), - ), - None => (String::new(), "COSMIC File Manager".to_string()), - }; - self.set_header_title(header_title); - self.set_window_title(window_title) - } - - fn update_watcher(&mut self) -> Command { - if let Some((mut watcher, old_paths)) = self.watcher_opt.take() { - let mut new_paths = HashSet::new(); - for entity in self.tab_model.iter() { - if let Some(tab) = self.tab_model.data::(entity) { - if let Location::Path(path) = &tab.location { - new_paths.insert(path.clone()); - } - } - } - - // Unwatch paths no longer used - for path in old_paths.iter() { - if !new_paths.contains(path) { - match watcher.unwatch(path) { - Ok(()) => { - log::debug!("unwatching {:?}", path); - } - Err(err) => { - log::debug!("failed to unwatch {:?}: {}", path, err); - } - } - } - } - - // Watch new paths - for path in new_paths.iter() { - if !old_paths.contains(path) { - //TODO: should this be recursive? - match watcher.watch(path, notify::RecursiveMode::NonRecursive) { - Ok(()) => { - log::debug!("watching {:?}", path); - } - Err(err) => { - log::debug!("failed to watch {:?}: {}", path, err); - } - } - } - } - - self.watcher_opt = Some((watcher, new_paths)); - } - - //TODO: should any of this run in a command? - Command::none() - } - - fn operations(&self) -> Element { - let mut children = Vec::new(); - - //TODO: get height from theme? - let progress_bar_height = Length::Fixed(4.0); - - if !self.pending_operations.is_empty() { - let mut section = widget::settings::view_section(fl!("pending")); - for (id, (op, progress)) in self.pending_operations.iter().rev() { - section = section.add(widget::column::with_children(vec![ - widget::text(format!("{:?}", op)).into(), - widget::progress_bar(0.0..=100.0, *progress) - .height(progress_bar_height) - .into(), - ])); - } - children.push(section.into()); - } - - if !self.failed_operations.is_empty() { - let mut section = widget::settings::view_section(fl!("failed")); - for (id, (op, error)) in self.failed_operations.iter().rev() { - section = section.add(widget::column::with_children(vec![ - widget::text(format!("{:?}", op)).into(), - widget::text(error).into(), - ])); - } - children.push(section.into()); - } - - if !self.complete_operations.is_empty() { - let mut section = widget::settings::view_section(fl!("complete")); - for (id, op) in self.complete_operations.iter().rev() { - section = section.add(widget::text(format!("{:?}", op))); - } - children.push(section.into()); - } - - widget::settings::view_column(children).into() - } - - fn properties(&self) -> Element { - let mut children = Vec::new(); - let entity = self.tab_model.active(); - if let Some(tab) = self.tab_model.data::(entity) { - if let Some(ref items) = tab.items_opt { - for item in items.iter() { - if item.selected { - children.push(item.property_view(&self.core)); - } - } - } - } - widget::settings::view_column(children).into() - } - - fn settings(&self) -> Element { - let app_theme_selected = match self.config.app_theme { - AppTheme::Dark => 1, - AppTheme::Light => 2, - AppTheme::System => 0, - }; - widget::settings::view_column(vec![widget::settings::view_section(fl!("appearance")) - .add( - widget::settings::item::builder(fl!("theme")).control(widget::dropdown( - &self.app_themes, - Some(app_theme_selected), - move |index| { - Message::AppTheme(match index { - 1 => AppTheme::Dark, - 2 => AppTheme::Light, - _ => AppTheme::System, - }) - }, - )), - ) - .into()]) - .into() - } -} - -/// Implement [`Application`] to integrate with COSMIC. -impl Application for App { - /// Default async executor to use with the app. - type Executor = executor::Default; - - /// Argument received - type Flags = Flags; - - /// Message type specific to our [`App`]. - type Message = Message; - - /// The unique application ID to supply to the window manager. - const APP_ID: &'static str = "com.system76.CosmicFiles"; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - /// Creates the application, and optionally emits command on initialize. - fn init(core: Core, flags: Self::Flags) -> (Self, Command) { - let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")]; - - let mut nav_model = segmented_button::ModelBuilder::default(); - if let Some(dir) = dirs::home_dir() { - nav_model = nav_model.insert(move |b| { - b.text(fl!("home")) - .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) - .data(Location::Path(dir.clone())) - }); - } - //TODO: Sort by name? - for dir_opt in &[ - dirs::document_dir(), - dirs::download_dir(), - dirs::audio_dir(), - dirs::picture_dir(), - dirs::video_dir(), - ] { - if let Some(dir) = dir_opt { - if let Some(file_name) = dir.file_name().and_then(|x| x.to_str()) { - nav_model = nav_model.insert(move |b| { - b.text(file_name.to_string()) - .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) - .data(Location::Path(dir.clone())) - }); - } - } - } - nav_model = nav_model.insert(|b| { - b.text(fl!("trash")) - .icon(widget::icon::icon(tab::trash_icon_symbolic(16))) - .data(Location::Trash) - }); - - let mut app = App { - core, - nav_model: nav_model.build(), - tab_model: segmented_button::ModelBuilder::default().build(), - config_handler: flags.config_handler, - config: flags.config, - app_themes, - context_page: ContextPage::Settings, - key_binds: key_binds(), - modifiers: Modifiers::empty(), - pending_operation_id: 0, - pending_operations: BTreeMap::new(), - complete_operations: BTreeMap::new(), - failed_operations: BTreeMap::new(), - watcher_opt: None, - }; - - let mut commands = Vec::new(); - - for arg in env::args().skip(1) { - let location = match fs::canonicalize(&arg) { - Ok(absolute) => Location::Path(absolute), - Err(err) => { - log::warn!("failed to canonicalize {:?}: {}", arg, err); - continue; - } - }; - commands.push(app.open_tab(location)); - } - - if app.tab_model.iter().next().is_none() { - commands.push(app.open_tab(Location::Path(home_dir()))); - } - - (app, Command::batch(commands)) - } - - // The default nav_bar widget needs to have its width reduced for cosmic-files - fn nav_bar(&self) -> Option>> { - if !self.core().nav_bar_active() { - return None; - } - - let nav_model = self.nav_model()?; - - let mut nav = crate::widget::nav_bar(nav_model, |entity| { - message::cosmic(cosmic::app::cosmic::Message::NavBar(entity)) - }); - - if !self.core().is_condensed() { - nav = nav.max_width(200); - } - - Some(Element::from(nav)) - } - - fn nav_model(&self) -> Option<&segmented_button::SingleSelectModel> { - Some(&self.nav_model) - } - - fn on_nav_select(&mut self, entity: segmented_button::Entity) -> Command { - let location_opt = self.nav_model.data::(entity).clone(); - - if let Some(location) = location_opt { - let message = Message::TabMessage(None, tab::Message::Location(location.clone())); - return self.update(message); - } - - Command::none() - } - - /// Handle application events here. - fn update(&mut self, message: Self::Message) -> Command { - // Helper for updating config values efficiently - macro_rules! config_set { - ($name: ident, $value: expr) => { - match &self.config_handler { - Some(config_handler) => { - match paste::paste! { self.config.[](config_handler, $value) } { - Ok(_) => {} - Err(err) => { - log::warn!( - "failed to save config {:?}: {}", - stringify!($name), - err - ); - } - } - } - None => { - self.config.$name = $value; - log::warn!( - "failed to save config {:?}: no config handler", - stringify!($name) - ); - } - } - }; - } - - match message { - Message::Todo => { - log::warn!("TODO"); - } - Message::AppTheme(app_theme) => { - config_set!(app_theme, app_theme); - return self.update_config(); - } - Message::Config(config) => { - if config != self.config { - log::info!("update config"); - //TODO: update syntax theme by clearing tabs, only if needed - self.config = config; - return self.update_config(); - } - } - Message::Copy(entity_opt) => { - log::warn!("TODO: COPY"); - } - Message::Cut(entity_opt) => { - log::warn!("TODO: CUT"); - } - Message::Key(modifiers, key_code) => { - let entity = self.tab_model.active(); - for (key_bind, action) in self.key_binds.iter() { - if key_bind.matches(modifiers, key_code) { - return self.update(action.message(Some(entity))); - } - } - } - Message::Modifiers(modifiers) => { - self.modifiers = modifiers; - } - Message::MoveToTrash(entity_opt) => { - let mut paths = Vec::new(); - let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); - if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Some(ref mut items) = tab.items_opt { - for item in items.iter_mut() { - if item.selected { - paths.push(item.path.clone()); - } - } - } - } - if !paths.is_empty() { - self.operation(Operation::Delete { paths }); - } - } - Message::NewFile(entity_opt) => { - log::warn!("TODO: NEW FILE"); - } - Message::NewFolder(entity_opt) => { - log::warn!("TODO: NEW FOLDER"); - } - Message::NotifyEvent(event) => { - log::debug!("{:?}", event); - - let mut needs_reload = Vec::new(); - for entity in self.tab_model.iter() { - if let Some(tab) = self.tab_model.data::(entity) { - //TODO: support reloading trash, somehow - if let Location::Path(path) = &tab.location { - let mut contains_change = false; - for event_path in event.paths.iter() { - if event_path.starts_with(&path) { - contains_change = true; - break; - } - } - if contains_change { - needs_reload.push((entity, tab.location.clone())); - } - } - } - } - - let mut commands = Vec::with_capacity(needs_reload.len()); - for (entity, location) in needs_reload { - commands.push(self.rescan_tab(entity, location)); - } - return Command::batch(commands); - } - Message::NotifyWatcher(mut watcher_wrapper) => match watcher_wrapper.watcher_opt.take() - { - Some(mut watcher) => { - self.watcher_opt = Some((watcher, HashSet::new())); - return self.update_watcher(); - } - None => { - log::warn!("message did not contain notify watcher"); - } - }, - Message::Paste(entity_opt) => { - log::warn!("TODO: PASTE"); - } - Message::PendingComplete(id) => { - if let Some((op, _)) = self.pending_operations.remove(&id) { - self.complete_operations.insert(id, op); - } - } - Message::PendingError(id, err) => { - if let Some((op, _)) = self.pending_operations.remove(&id) { - self.failed_operations.insert(id, (op, err)); - } - } - Message::PendingProgress(id, new_progress) => { - if let Some((_, progress)) = self.pending_operations.get_mut(&id) { - *progress = new_progress; - } - } - Message::RestoreFromTrash(entity_opt) => { - let mut paths = Vec::new(); - let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); - if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Some(ref mut items) = tab.items_opt { - for item in items.iter_mut() { - if item.selected { - match &item.metadata { - ItemMetadata::Trash { entry, .. } => { - paths.push(entry.clone()); - } - _ => { - //TODO: error on trying to restore non-trash file? - } - } - } - } - } - } - if !paths.is_empty() { - self.operation(Operation::Restore { paths }); - } - } - Message::SelectAll(entity_opt) => { - let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); - if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Some(ref mut items) = tab.items_opt { - for item in items.iter_mut() { - if item.hidden { - //TODO: option to show hidden files - continue; - } - item.selected = true; - item.click_time = None; - } - } - } - } - Message::SystemThemeModeChange(_theme_mode) => { - return self.update_config(); - } - Message::TabActivate(entity) => { - self.tab_model.activate(entity); - return self.update_title(); - } - Message::TabNext => { - let len = self.tab_model.iter().count(); - let pos = self - .tab_model - .position(self.tab_model.active()) - // Wraparound to 0 if i + 1 > num of tabs - .map(|i| (i as usize + 1) % len) - .expect("should always be at least one tab open"); - - let entity = self.tab_model.iter().nth(pos); - if let Some(entity) = entity { - return self.update(Message::TabActivate(entity)); - } - } - Message::TabPrev => { - let pos = self - .tab_model - .position(self.tab_model.active()) - .and_then(|i| (i as usize).checked_sub(1)) - // Subtraction underflow => last tab; i.e. it wraps around - .unwrap_or_else(|| { - self.tab_model - .iter() - .count() - .checked_sub(1) - .unwrap_or_default() - }); - - let entity = self.tab_model.iter().nth(pos); - if let Some(entity) = entity { - return self.update(Message::TabActivate(entity)); - } - } - Message::TabClose(entity_opt) => { - let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); - - // Activate closest item - if let Some(position) = self.tab_model.position(entity) { - if position > 0 { - self.tab_model.activate_position(position - 1); - } else { - self.tab_model.activate_position(position + 1); - } - } - - // Remove item - self.tab_model.remove(entity); - - // If that was the last tab, close window - if self.tab_model.iter().next().is_none() { - return window::close(window::Id::MAIN); - } - - return Command::batch([self.update_title(), self.update_watcher()]); - } - Message::TabContextAction(entity, action) => { - match self.tab_model.data_mut::(entity) { - Some(tab) => { - // Close context menu - { - tab.context_menu = None; - } - // Run action's message - return self.update(action.message(Some(entity))); - } - _ => {} - } - } - Message::TabContextMenu(entity, position_opt) => { - match self.tab_model.data_mut::(entity) { - Some(tab) => { - // Update context menu position - tab.context_menu = position_opt; - } - _ => {} - } - // Disable side context page - self.core.window.show_context = false; - } - Message::TabMessage(entity_opt, tab_message) => { - let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); - - let mut update_opt = None; - match self.tab_model.data_mut::(entity) { - Some(tab) => { - if tab.update(tab_message, self.modifiers) { - update_opt = Some((tab.title(), tab.location.clone())); - } - } - _ => (), - } - if let Some((tab_title, tab_path)) = update_opt { - self.tab_model.text_set(entity, tab_title); - return Command::batch([ - self.update_title(), - self.update_watcher(), - self.rescan_tab(entity, tab_path), - ]); - } - } - Message::TabNew => { - let active = self.tab_model.active(); - let location = match self.tab_model.data::(active) { - Some(tab) => tab.location.clone(), - None => Location::Path(home_dir()), - }; - return self.open_tab(location); - } - Message::TabRescan(entity, items) => match self.tab_model.data_mut::(entity) { - Some(tab) => { - tab.items_opt = Some(items); - } - _ => (), - }, - //TODO: TABRELOAD - Message::ToggleContextPage(context_page) => { - //TODO: ensure context menus are closed - if self.context_page == context_page { - self.core.window.show_context = !self.core.window.show_context; - } else { - self.context_page = context_page; - self.core.window.show_context = true; - } - self.set_context_title(context_page.title()); - } - Message::WindowClose => { - return window::close(window::Id::MAIN); - } - Message::WindowNew => match env::current_exe() { - Ok(exe) => match process::Command::new(&exe).spawn() { - Ok(_child) => {} - Err(err) => { - log::error!("failed to execute {:?}: {}", exe, err); - } - }, - Err(err) => { - log::error!("failed to get current executable path: {}", err); - } - }, - } - - Command::none() - } - - fn context_drawer(&self) -> Option> { - if !self.core.window.show_context { - return None; - } - - Some(match self.context_page { - ContextPage::Operations => self.operations(), - ContextPage::Properties => self.properties(), - ContextPage::Settings => self.settings(), - }) - } - - fn header_start(&self) -> Vec> { - vec![menu::menu_bar(&self.key_binds).into()] - } - - fn header_end(&self) -> Vec> { - vec![] - } - - /// Creates a view after each update. - fn view(&self) -> Element { - let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing; - - let mut tab_column = widget::column::with_capacity(1); - - if self.tab_model.iter().count() > 1 { - tab_column = tab_column.push( - widget::container( - widget::view_switcher::horizontal(&self.tab_model) - .button_height(32) - .button_spacing(space_xxs) - .on_activate(Message::TabActivate) - .on_close(|entity| Message::TabClose(Some(entity))), - ) - .style(style::Container::Background) - .width(Length::Fill), - ); - } - - let entity = self.tab_model.active(); - match self.tab_model.data::(entity) { - Some(tab) => { - let mut mouse_area = mouse_area::MouseArea::new( - tab.view(self.core()) - .map(move |message| Message::TabMessage(Some(entity), message)), - ) - .on_press(move |_point_opt| { - Message::TabMessage(Some(entity), tab::Message::Click(None)) - }); - if tab.context_menu.is_some() { - mouse_area = mouse_area - .on_right_press(move |_point_opt| Message::TabContextMenu(entity, None)); - } else { - mouse_area = mouse_area.on_right_press(move |point_opt| { - Message::TabContextMenu(entity, point_opt) - }); - } - let mut popover = widget::popover(mouse_area, menu::context_menu(entity, &tab)); - match tab.context_menu { - Some(point) => { - let rounded = Point::new(point.x.round(), point.y.round()); - popover = popover.position(rounded); - } - None => { - popover = popover.show_popup(false); - } - } - tab_column = tab_column.push(popover); - } - None => { - //TODO - } - } - - let content: Element<_> = tab_column.into(); - - // Uncomment to debug layout: - //content.explain(cosmic::iced::Color::WHITE) - content - } - - fn subscription(&self) -> Subscription { - struct ConfigSubscription; - struct ThemeSubscription; - struct WatcherSubscription; - - let mut subscriptions = vec![ - event::listen_with(|event, _status| match event { - Event::Keyboard(KeyEvent::KeyPressed { - key_code, - modifiers, - }) => Some(Message::Key(modifiers, key_code)), - Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => { - Some(Message::Modifiers(modifiers)) - } - _ => None, - }), - cosmic_config::config_subscription( - TypeId::of::(), - Self::APP_ID.into(), - CONFIG_VERSION, - ) - .map(|update| { - if !update.errors.is_empty() { - log::info!( - "errors loading config {:?}: {:?}", - update.keys, - update.errors - ); - } - Message::Config(update.config) - }), - cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>( - TypeId::of::(), - cosmic_theme::THEME_MODE_ID.into(), - cosmic_theme::ThemeMode::version(), - ) - .map(|update| { - if !update.errors.is_empty() { - log::info!( - "errors loading theme mode {:?}: {:?}", - update.keys, - update.errors - ); - } - Message::SystemThemeModeChange(update.config) - }), - subscription::channel( - TypeId::of::(), - 100, - |mut output| async move { - let watcher_res = { - let mut output = output.clone(); - //TODO: debounce - notify::recommended_watcher( - move |event_res: Result| match event_res { - Ok(event) => { - match &event.kind { - notify::EventKind::Access(_) - | notify::EventKind::Modify( - notify::event::ModifyKind::Metadata(_), - ) => { - // Data not mutated - return; - } - _ => {} - } - - match futures::executor::block_on(async { - output.send(Message::NotifyEvent(event)).await - }) { - Ok(()) => {} - Err(err) => { - log::warn!("failed to send notify event: {:?}", err); - } - } - } - Err(err) => { - log::warn!("failed to watch files: {:?}", err); - } - }, - ) - }; - - match watcher_res { - Ok(watcher) => { - match output - .send(Message::NotifyWatcher(WatcherWrapper { - watcher_opt: Some(watcher), - })) - .await - { - Ok(()) => {} - Err(err) => { - log::warn!("failed to send notify watcher: {:?}", err); - } - } - } - Err(err) => { - log::warn!("failed to create file watcher: {:?}", err); - } - } - - //TODO: how to properly kill this task? - loop { - tokio::time::sleep(time::Duration::new(1, 0)).await; - } - }, - ), - ]; - - for (id, (pending_operation, _)) in self.pending_operations.iter() { - //TODO: use recipe? - let id = *id; - let pending_operation = pending_operation.clone(); - subscriptions.push(subscription::channel( - id, - 16, - move |mut msg_tx| async move { - match pending_operation.perform(id, &mut msg_tx).await { - Ok(()) => { - msg_tx.send(Message::PendingComplete(id)).await; - } - Err(err) => { - msg_tx - .send(Message::PendingError(id, err.to_string())) - .await; - } - } - - loop { - tokio::time::sleep(time::Duration::new(1, 0)).await; - } - }, - )); - } - - Subscription::batch(subscriptions) - } -} - -// Utilities to build a temporary file hierarchy for tests. -// -// Ideally, tests would use the cap-std crate which limits path traversal. -#[cfg(test)] -mod test_utils { - use std::{ - cmp::Ordering, - fs::File, - io::{self, Write}, - iter, - path::Path, - }; - - use log::{debug, trace}; - use tempfile::{tempdir, TempDir}; - - use crate::tab::Item; - - use super::*; - - // Default number of files, directories, and nested directories for test file system - pub const NUM_FILES: usize = 2; - pub const NUM_DIRS: usize = 2; - pub const NUM_NESTED: usize = 1; - pub const NAME_LEN: usize = 5; - - /// Add `n` temporary files in `dir` - /// - /// Each file is assigned a numeric name from [0, n). - pub fn file_flat_hier>(dir: D, n: usize) -> io::Result> { - let dir = dir.as_ref(); - (0..n) - .map(|i| -> io::Result { - let name = i.to_string(); - let path = dir.join(&name); - - let mut file = File::create(path)?; - file.write_all(name.as_bytes())?; - - Ok(file) - }) - .collect() - } - - // Random alphanumeric String of length `len` - fn rand_string(len: usize) -> String { - (0..len).map(|_| fastrand::alphanumeric()).collect() - } - - /// Create a small, temporary file hierarchy. - pub fn simple_fs( - files: usize, - dirs: usize, - nested: usize, - name_len: usize, - ) -> io::Result { - // Files created inside of a TempDir are deleted with the directory - // TempDir won't leak resources as long as the destructor runs - let root = tempdir()?; - debug!("Root temp directory: {}", root.as_ref().display()); - - // All paths for directories and nested directories - let paths = (0..dirs).flat_map(|_| { - let root = root.as_ref(); - let current = rand_string(name_len); - - iter::once(root.join(¤t)).chain( - (0..nested).map(move |_| root.join(format!("{current}/{}", rand_string(name_len)))), - ) - }); - - // Create directories from `paths` and add a few files - for path in paths { - fs::create_dir_all(&path)?; - file_flat_hier(&path, files)?; - - for entry in path.read_dir()? { - let entry = entry?; - if entry.file_type()?.is_file() { - trace!("Created file: {}", entry.path().display()); - } - } - } - - Ok(root) - } - - /// Empty file hierarchy - pub fn empty_fs() -> io::Result { - tempdir() - } - - /// Sort files. - /// - /// Directories are placed before files. - /// Files are lexically sorted. - /// This is more or less copied right from the [Tab] code - pub fn sort_files(a: &Path, b: &Path) -> Ordering { - match (a.is_dir(), b.is_dir()) { - (true, false) => Ordering::Less, - (false, true) => Ordering::Greater, - _ => lexical_sort::natural_lexical_cmp( - a.file_name() - .expect("temp entries should have names") - .to_str() - .expect("temp entries should be valid UTF-8"), - b.file_name() - .expect("temp entries should have names") - .to_str() - .expect("temp entries should be valid UTF-8"), - ), - } - } - - /// Equality for [Path] and [Item]. - pub fn eq_path_item(path: &Path, item: &Item) -> bool { - let name = path - .file_name() - .expect("temp entries should have names") - .to_str() - .expect("temp entries should be valid UTF-8"); - let metadata = path.is_dir(); - - name == item.name && metadata == item.metadata.is_dir() && path == item.path - } + cosmic_files::main() } diff --git a/src/menu.rs b/src/menu.rs index 7f19ed9..14c655b 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -13,7 +13,12 @@ use cosmic::{ }; use std::collections::HashMap; -use crate::{fl, tab, Action, ContextPage, KeyBind, Location, Message, Tab}; +use crate::{ + app::{Action, ContextPage, Message}, + fl, + key_bind::KeyBind, + tab::{self, Location, Tab}, +}; macro_rules! menu_button { ($($x:expr),+ $(,)?) => ( diff --git a/src/operation.rs b/src/operation.rs index 4743f09..c4040e7 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -1,7 +1,7 @@ use cosmic::iced::futures::{channel::mpsc, SinkExt}; use std::{error::Error, future::Future, io, path::PathBuf, time}; -use crate::Message; +use crate::app::Message; fn err_str(err: T) -> String { err.to_string() diff --git a/src/tab.rs b/src/tab.rs index 979fd35..9a5938f 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -417,7 +417,7 @@ pub struct Item { } impl Item { - pub fn property_view(&self, core: &Core) -> Element { + pub fn property_view(&self, core: &Core) -> Element { let mut section = widget::settings::view_section(""); section = section.add(widget::settings::item::item_row(vec![ widget::icon::icon(self.icon_handle_list.clone()) @@ -1022,7 +1022,7 @@ mod tests { use test_log::test; use super::scan_path; - use crate::test_utils::{ + use crate::app::test_utils::{ empty_fs, eq_path_item, simple_fs, sort_files, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_NESTED, };