// Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only use cosmic::{ app::{message, Command, Core}, cosmic_config, cosmic_theme, executor, iced::{ event, futures::{self, SinkExt}, keyboard::{Event as KeyEvent, Key, Modifiers}, subscription::{self, Subscription}, widget::scrollable, window, Alignment, Event, Length, }, style, theme, widget::{self, segmented_button}, Application, ApplicationExt, Element, }; use notify::Watcher; use std::{ any::TypeId, collections::{BTreeMap, HashMap, HashSet, VecDeque}, env, fs, num::NonZeroU16, path::PathBuf, process, time, }; use crate::{ config::{AppTheme, Config, IconSizes, TabConfig, CONFIG_VERSION}, fl, home_dir, key_bind::{key_binds, KeyBind}, menu, 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 { About, Copy, Cut, EditLocation, HistoryNext, HistoryPrevious, ItemDown, ItemLeft, ItemRight, ItemUp, LocationUp, MoveToTrash, NewFile, NewFolder, Open, Operations, Paste, Properties, Rename, RestoreFromTrash, SelectAll, Settings, TabClose, TabNew, TabNext, TabPrev, TabViewGrid, TabViewList, ToggleShowHidden, WindowClose, WindowNew, } impl Action { pub fn message(self, entity_opt: Option) -> Message { match self { Action::About => Message::ToggleContextPage(ContextPage::About), Action::Copy => Message::Copy(entity_opt), Action::Cut => Message::Cut(entity_opt), Action::EditLocation => Message::EditLocation(entity_opt), Action::HistoryNext => Message::TabMessage(entity_opt, tab::Message::GoNext), Action::HistoryPrevious => Message::TabMessage(entity_opt, tab::Message::GoPrevious), Action::ItemDown => Message::TabMessage(entity_opt, tab::Message::ItemDown), Action::ItemLeft => Message::TabMessage(entity_opt, tab::Message::ItemLeft), Action::ItemRight => Message::TabMessage(entity_opt, tab::Message::ItemRight), Action::ItemUp => Message::TabMessage(entity_opt, tab::Message::ItemUp), Action::LocationUp => Message::TabMessage(entity_opt, tab::Message::LocationUp), Action::MoveToTrash => Message::MoveToTrash(entity_opt), Action::NewFile => Message::NewItem(entity_opt, false), Action::NewFolder => Message::NewItem(entity_opt, true), Action::Open => Message::TabMessage(entity_opt, tab::Message::Open), Action::Operations => Message::ToggleContextPage(ContextPage::Operations), Action::Paste => Message::Paste(entity_opt), Action::Properties => Message::ToggleContextPage(ContextPage::Properties), Action::Rename => Message::Rename(entity_opt), 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::ToggleShowHidden => Message::TabMessage(None, tab::Message::ToggleShowHidden), Action::WindowClose => Message::WindowClose, Action::WindowNew => Message::WindowNew, } } } /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] pub enum Message { AppTheme(AppTheme), Config(Config), Copy(Option), Cut(Option), DialogCancel, DialogComplete, DialogUpdate(DialogPage), EditLocation(Option), Key(Modifiers, Key), LaunchUrl(String), Modifiers(Modifiers), MoveToTrash(Option), NewItem(Option, bool), NotifyEvent(notify::Event), NotifyWatcher(WatcherWrapper), Paste(Option), PendingComplete(u64), PendingError(u64, String), PendingProgress(u64, f32), Rename(Option), RestoreFromTrash(Option), SelectAll(Option), SystemThemeModeChange(cosmic_theme::ThemeMode), TabActivate(segmented_button::Entity), TabNext, TabPrev, TabClose(Option), TabConfig(TabConfig), TabMessage(Option, tab::Message), TabNew, TabRescan(segmented_button::Entity, Vec), ToggleContextPage(ContextPage), WindowClose, WindowNew, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { About, Operations, Properties, Settings, } impl ContextPage { fn title(&self) -> String { match self { Self::About => String::new(), Self::Operations => fl!("operations"), Self::Properties => fl!("properties"), Self::Settings => fl!("settings"), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DialogPage { FailedOperation(u64), NewItem { parent: PathBuf, name: String, dir: bool, }, RenameItem { from: PathBuf, parent: PathBuf, name: String, dir: bool, }, } #[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, dialog_pages: VecDeque, dialog_text_input: widget::Id, 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(), self.config.tab.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)); } fn rescan_tab( &mut self, entity: segmented_button::Entity, location: Location, ) -> Command { let icon_sizes = self.config.tab.icon_sizes; Command::perform( async move { match tokio::task::spawn_blocking(move || location.scan(icon_sizes)).await { Ok(items) => message::app(Message::TabRescan(entity, items)), Err(err) => { log::warn!("failed to rescan: {}", err); message::none() } } }, |x| x, ) } fn rescan_trash(&mut self) -> Command { let mut needs_reload = Vec::new(); for entity in self.tab_model.iter() { if let Some(tab) = self.tab_model.data::(entity) { if let Location::Trash = &tab.location { needs_reload.push((entity, Location::Trash)); } } } let mut commands = Vec::with_capacity(needs_reload.len()); for (entity, location) in needs_reload { commands.push(self.rescan_tab(entity, location)); } Command::batch(commands) } fn update_config(&mut self) -> Command { cosmic::app::command::set_theme(self.config.app_theme.theme()) } 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, window::Id::MAIN) } 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 about(&self) -> Element { let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; let repository = "https://github.com/pop-os/cosmic-files"; let hash = env!("VERGEN_GIT_SHA"); let short_hash: String = hash.chars().take(7).collect(); let date = env!("VERGEN_GIT_COMMIT_DATE"); widget::column::with_children(vec![ widget::svg(widget::svg::Handle::from_memory( &include_bytes!( "../res/icons/hicolor/128x128/apps/com.system76.CosmicFiles.svg" )[..], )) .into(), widget::text::title3(fl!("cosmic-files")).into(), widget::button::link(repository) .on_press(Message::LaunchUrl(repository.to_string())) .padding(0) .into(), widget::button::link(fl!( "git-description", hash = short_hash.as_str(), date = date )) .on_press(Message::LaunchUrl(format!("{}/commits/{}", repository, hash))) .padding(0) .into(), ]) .align_items(Alignment::Center) .spacing(space_xxs) .into() } 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(items) = tab.items_opt() { for item in items.iter() { if item.selected { children.push(item.property_view(tab.config.icon_sizes)); // Only show one property view to avoid issues like hangs when generating // preview images on thousands of files break; } } } } widget::settings::view_column(children).into() } fn settings(&self) -> Element { // TODO: Should dialog be updated here too? widget::settings::view_column(vec![ widget::settings::view_section(fl!("appearance")) .add({ let app_theme_selected = match self.config.app_theme { AppTheme::Dark => 1, AppTheme::Light => 2, AppTheme::System => 0, }; 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, }) }, )) }) .add({ let tab_config = self.config.tab.clone(); let list: u16 = tab_config.icon_sizes.list.into(); widget::settings::item::builder(fl!("icon-size-list")) .description(format!("{}%", list)) .control( widget::slider(50..=500, list, move |list| { Message::TabConfig(TabConfig { icon_sizes: IconSizes { list: NonZeroU16::new(list).unwrap(), ..tab_config.icon_sizes }, ..tab_config }) }) .step(25u16) .width(Length::Fixed(100.0)), ) }) .add({ let tab_config = self.config.tab.clone(); let grid: u16 = tab_config.icon_sizes.grid.into(); widget::settings::item::builder(fl!("icon-size-grid")) .description(format!("{}%", grid)) .control( widget::slider(50..=500, grid, move |grid| { Message::TabConfig(TabConfig { icon_sizes: IconSizes { grid: NonZeroU16::new(grid).unwrap(), ..tab_config.icon_sizes }, ..tab_config }) }) .step(25u16) .width(Length::Fixed(100.0)), ) }) .into(), widget::settings::view_section(fl!("settings-tab")) .add({ let tab_config = self.config.tab.clone(); widget::settings::item::builder(fl!("settings-show-hidden")).toggler( tab_config.show_hidden, move |show_hidden| { Message::TabConfig(TabConfig { show_hidden, ..tab_config }) }, ) }) .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(mut core: Core, flags: Self::Flags) -> (Self, Command) { //TODO: make set_nav_bar_toggle_condensed pub core.nav_bar_toggle_condensed(); 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, dialog_pages: VecDeque::new(), dialog_text_input: widget::Id::unique(), 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() } fn on_escape(&mut self) -> Command { let entity = self.tab_model.active(); // Close dialog if open if self.dialog_pages.pop_front().is_some() { return Command::none(); } // Close menus and context panes in order per message // Why: It'd be weird to close everything all at once // Usually, the Escape key (for example) closes menus and panes one by one instead // of closing everything on one press // TODO: Close MenuBar too if let Some(tab) = self.tab_model.data_mut::(entity) { if tab.context_menu.is_some() { tab.context_menu = None; return Command::none(); } if tab.select_none() { return Command::none(); } } self.core.window.show_context = false; 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::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::DialogCancel => { self.dialog_pages.pop_front(); } Message::DialogComplete => { if let Some(dialog_page) = self.dialog_pages.pop_front() { match dialog_page { DialogPage::FailedOperation(id) => { log::warn!("TODO: retry operation {}", id); } DialogPage::NewItem { parent, name, dir } => { let path = parent.join(name); self.operation(if dir { Operation::NewFolder { path } } else { Operation::NewFile { path } }); } DialogPage::RenameItem { from, parent, name, .. } => { let to = parent.join(name); self.operation(Operation::Rename { from, to }); } } } } Message::DialogUpdate(dialog_page) => { //TODO: panicless way to do this? self.dialog_pages[0] = dialog_page; } Message::EditLocation(entity_opt) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(location) = self.tab_model.data::(entity).and_then(|tab| { if tab.edit_location.is_none() { Some(tab.location.clone()) } else { None } }) { return self.update(Message::TabMessage( Some(entity), tab::Message::EditLocation(Some(location)), )); } } Message::Key(modifiers, key) => { let entity = self.tab_model.active(); for (key_bind, action) in self.key_binds.iter() { if key_bind.matches(modifiers, &key) { return self.update(action.message(Some(entity))); } } } Message::LaunchUrl(url) => match open::that_detached(&url) { Ok(()) => {} Err(err) => { log::warn!("failed to open {:?}: {}", url, err); } }, 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 items) = tab.items_opt() { for item in items.iter() { if item.selected { paths.push(item.path.clone()); } } } } if !paths.is_empty() { self.operation(Operation::Delete { paths }); } } Message::NewItem(entity_opt, dir) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data_mut::(entity) { if let Location::Path(path) = &tab.location { self.dialog_pages.push_back(DialogPage::NewItem { parent: path.clone(), name: String::new(), dir, }); return widget::text_input::focus(self.dialog_text_input.clone()); } } } 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(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); } // Manually rescan any trash tabs after any operation is completed return self.rescan_trash(); } Message::PendingError(id, err) => { if let Some((op, _)) = self.pending_operations.remove(&id) { self.failed_operations.insert(id, (op, err)); self.dialog_pages.push_back(DialogPage::FailedOperation(id)); } // Manually rescan any trash tabs after any operation is completed return self.rescan_trash(); } Message::PendingProgress(id, new_progress) => { if let Some((_, progress)) = self.pending_operations.get_mut(&id) { *progress = new_progress; } } Message::Rename(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 Location::Path(parent) = &tab.location { if let Some(items) = tab.items_opt() { let mut selected = Vec::new(); for item in items.iter() { if item.selected { selected.push(item.path.clone()); } } if !selected.is_empty() { //TODO: batch rename for path in selected { let name = match path.file_name().and_then(|x| x.to_str()) { Some(some) => some.to_string(), None => continue, }; let dir = path.is_dir(); self.dialog_pages.push_back(DialogPage::RenameItem { from: path, parent: parent.clone(), name, dir, }); } return widget::text_input::focus(self.dialog_text_input.clone()); } } } } } 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(items) = tab.items_opt() { for item in items.iter() { 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) { tab.select_all(); } } 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::TabConfig(config) => { if config != self.config.tab { // Tabs are collected first to placate the borrowck let tabs: Vec<_> = self.tab_model.iter().collect(); // Update main conf and each tab with the new config let commands: Vec<_> = std::iter::once(self.update_config()) .chain(tabs.into_iter().map(|entity| { let config = config.clone(); self.update(Message::TabMessage( Some(entity), tab::Message::Config(config), )) })) .collect(); config_set!(tab, config); return Command::batch(commands); } } Message::TabMessage(entity_opt, tab_message) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); //TODO: move to Command? if let tab::Message::ContextMenu(_point_opt) = tab_message { // Disable side context page self.core.window.show_context = false; } let tab_commands = match self.tab_model.data_mut::(entity) { Some(tab) => tab.update(tab_message, self.modifiers), _ => Vec::new(), }; let mut commands = Vec::new(); for tab_command in tab_commands { match tab_command { tab::Command::Action(action) => { commands.push(self.update(action.message(Some(entity)))); } tab::Command::ChangeLocation(tab_title, tab_path) => { self.tab_model.text_set(entity, tab_title); commands.push(Command::batch([ self.update_title(), self.update_watcher(), self.rescan_tab(entity, tab_path), ])); } tab::Command::FocusButton(id) => { commands.push(widget::button::focus(id)); } tab::Command::FocusTextInput(id) => { commands.push(widget::text_input::focus(id)); } tab::Command::OpenFile(item_path) => { match open::that_detached(&item_path) { Ok(()) => (), Err(err) => { log::warn!("failed to open {:?}: {}", item_path, err); } } } tab::Command::Scroll(id, offset) => { commands.push(scrollable::scroll_to(id, offset)); } } } return Command::batch(commands); } 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.set_items(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::About => self.about(), ContextPage::Operations => self.operations(), ContextPage::Properties => self.properties(), ContextPage::Settings => self.settings(), }) } fn dialog(&self) -> Option> { let dialog_page = match self.dialog_pages.front() { Some(some) => some, None => return None, }; let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; let dialog = match dialog_page { DialogPage::FailedOperation(id) => { //TODO: try next dialog page (making sure index is used by Dialog messages)? let (operation, err) = self.failed_operations.get(id)?; //TODO: nice description of error widget::dialog("Failed operation") .body(format!("{:#?}\n{}", operation, err)) .icon(widget::icon::from_name("dialog-error").size(64)) //TODO: retry action .primary_action( widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), ) } DialogPage::NewItem { parent, name, dir } => { let mut dialog = widget::dialog(if *dir { fl!("create-new-folder") } else { fl!("create-new-file") }); let complete_maybe = if name.is_empty() { None } else if name == "." || name == ".." { dialog = dialog.tertiary_action(widget::text::body(fl!( "name-invalid", filename = name.as_str() ))); None } else if name.contains('/') { dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes"))); None } else { let path = parent.join(name); if path.exists() { if path.is_dir() { dialog = dialog .tertiary_action(widget::text::body(fl!("folder-already-exists"))); } else { dialog = dialog .tertiary_action(widget::text::body(fl!("file-already-exists"))); } None } else { if name.starts_with('.') { dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden"))); } Some(Message::DialogComplete) } }; dialog .primary_action( widget::button::suggested(fl!("save")) .on_press_maybe(complete_maybe.clone()), ) .secondary_action( widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), ) .control( widget::column::with_children(vec![ widget::text::body(if *dir { fl!("folder-name") } else { fl!("file-name") }) .into(), widget::text_input("", name.as_str()) .id(self.dialog_text_input.clone()) .on_input(move |name| { Message::DialogUpdate(DialogPage::NewItem { parent: parent.clone(), name, dir: *dir, }) }) .on_submit_maybe(complete_maybe) .into(), ]) .spacing(space_xxs), ) } DialogPage::RenameItem { from, parent, name, dir, } => { //TODO: combine logic with NewItem let mut dialog = widget::dialog(if *dir { fl!("rename-folder") } else { fl!("rename-file") }); let complete_maybe = if name.is_empty() { None } else if name == "." || name == ".." { dialog = dialog.tertiary_action(widget::text::body(fl!( "name-invalid", filename = name.as_str() ))); None } else if name.contains('/') { dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes"))); None } else { let path = parent.join(name); if path.exists() { if path.is_dir() { dialog = dialog .tertiary_action(widget::text::body(fl!("folder-already-exists"))); } else { dialog = dialog .tertiary_action(widget::text::body(fl!("file-already-exists"))); } None } else { if name.starts_with('.') { dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden"))); } Some(Message::DialogComplete) } }; dialog .primary_action( widget::button::suggested(fl!("rename")) .on_press_maybe(complete_maybe.clone()), ) .secondary_action( widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), ) .control( widget::column::with_children(vec![ widget::text::body(if *dir { fl!("folder-name") } else { fl!("file-name") }) .into(), widget::text_input("", name.as_str()) .id(self.dialog_text_input.clone()) .on_input(move |name| { Message::DialogUpdate(DialogPage::RenameItem { from: from.clone(), parent: parent.clone(), name, dir: *dir, }) }) .on_submit_maybe(complete_maybe) .into(), ]) .spacing(space_xxs), ) } }; Some(dialog.into()) } 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, .. } = theme::active().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::tab_bar::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 tab_view = tab .view(&self.key_binds) .map(move |message| Message::TabMessage(Some(entity), message)); tab_column = tab_column.push(tab_view); } 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, modifiers, .. }) => match status { event::Status::Ignored => Some(Message::Key(modifiers, key)), event::Status::Captured => None, }, 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(()) => { let _ = msg_tx.send(Message::PendingComplete(id)).await; } Err(err) => { let _ = msg_tx .send(Message::PendingError(id, err.to_string())) .await; } } loop { tokio::time::sleep(time::Duration::new(1, 0)).await; } }, )); } for entity in self.tab_model.iter() { if let Some(tab) = self.tab_model.data::(entity) { subscriptions.push( tab.subscription() .with(entity) .map(|(entity, tab_msg)| Message::TabMessage(Some(entity), tab_msg)), ); } } 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::{ config::{IconSizes, TabConfig}, 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_HIDDEN: usize = 1; 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) with a prefix. pub fn file_flat_hier>(dir: D, n: usize, prefix: &str) -> io::Result> { let dir = dir.as_ref(); (0..n) .map(|i| -> io::Result { let name = format!("{prefix}{i}"); 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. /// /// # Arguments /// /// * `files` - Number of files to create in temp directories /// * `hidden` - Number of hidden files to create /// * `dirs` - Number of directories to create /// * `nested` - Number of nested directories to create in new dirs /// * `name_len` - Length of randomized directory names pub fn simple_fs( files: usize, hidden: 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()); trace!("Creating {files} files and {hidden} hidden files in {dirs} temp dirs with {nested} nested temp dirs"); // 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)?; // Normal files file_flat_hier(&path, files, "")?; // Hidden files file_flat_hier(&path, hidden, ".")?; 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"), ), } } /// Read directory entries from `path` and sort. pub fn read_dir_sorted(path: &Path) -> io::Result> { let mut entries: Vec<_> = path .read_dir()? .map(|maybe_entry| maybe_entry.map(|entry| entry.path())) .collect::>()?; entries.sort_by(|a, b| sort_files(a, b)); Ok(entries) } /// Filter `path` for directories pub fn filter_dirs(path: &Path) -> io::Result> { Ok(path.read_dir()?.filter_map(|entry| { entry.ok().and_then(|entry| { let path = entry.path(); if path.is_dir() { Some(path) } else { None } }) })) } /// Boiler plate for Tab tests pub fn tab_click_new( files: usize, hidden: usize, dirs: usize, nested: usize, name_len: usize, ) -> io::Result<(TempDir, Tab)> { let fs = simple_fs(files, hidden, dirs, nested, name_len)?; let path = fs.path(); // New tab with items let location = Location::Path(path.to_owned()); let items = location.scan(IconSizes::default()); let mut tab = Tab::new(location, TabConfig::default()); tab.set_items(items); // Ensure correct number of directories as a sanity check let items = tab.items_opt().expect("tab should be populated with Items"); assert_eq!(NUM_DIRS, items.len()); Ok((fs, tab)) } /// 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 is_dir = path.is_dir(); // NOTE: I don't want to change `tab::hidden_attribute` to `pub(crate)` for // tests without asking #[cfg(not(target_os = "windows"))] let is_hidden = name.starts_with('.'); #[cfg(target_os = "windows")] let is_hidden = { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_HIDDEN: u32 = 2; let metadata = path.metadata().expect("fetching file metadata"); metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN }; name == item.name && is_dir == item.metadata.is_dir() && path == item.path && is_hidden == item.hidden } /// Asserts `tab`'s location changed to `path` pub fn assert_eq_tab_path(tab: &Tab, path: &Path) { // Paths should be the same let Location::Path(ref tab_path) = tab.location else { panic!("Expected tab's location to be a path"); }; assert_eq!( path, tab_path, "Tab's path is {} instead of being updated to {}", tab_path.display(), path.display() ); } /// Assert that tab's items are equal to a path's entries. pub fn assert_eq_tab_path_contents(tab: &Tab, path: &Path) { let Location::Path(ref tab_path) = tab.location else { panic!("Expected tab's location to be a path"); }; // Tab items are sorted so paths from read_dir must be too let entries = read_dir_sorted(path).expect("should be able to read paths from temp dir"); // Check lengths. // `items_opt` is optional and the directory at `path` may have zero entries // Therefore, this doesn't panic if `items_opt` is None let items_len = tab.items_opt().map(|items| items.len()).unwrap_or_default(); assert_eq!(entries.len(), items_len); let empty = Vec::new(); assert!( entries .into_iter() .zip(tab.items_opt().clone().unwrap_or(&empty)) .all(|(a, b)| eq_path_item(&a, &b)), "Path ({}) and Tab path ({}) don't have equal contents", path.display(), tab_path.display() ); } }