1use cosmic::app::{self, Core, Task, context_drawer};
5use cosmic::cosmic_config::{self, ConfigSet};
6use cosmic::iced::clipboard::dnd::DndAction;
7use cosmic::iced::core::SmolStr;
8use cosmic::iced::core::widget::operation::focusable::unfocus;
9use cosmic::iced::futures::{self, SinkExt};
10use cosmic::iced::keyboard::key::Physical;
11use cosmic::iced::keyboard::{Event as KeyEvent, Key, Modifiers};
12#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
13use cosmic::iced::platform_specific::shell::wayland::commands::overlap_notify::overlap_notify;
14use cosmic::iced::runtime::{clipboard, task};
15use cosmic::iced::widget::button::focus;
16use cosmic::iced::widget::scrollable;
17use cosmic::iced::widget::scrollable::AbsoluteOffset;
18use cosmic::iced::window::{self, Event as WindowEvent, Id as WindowId};
19use cosmic::iced::{
20 self, Alignment, Event, Length, Size, Subscription, event, mouse, stream,
21};
22#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
23use cosmic::iced::{
24 Limits, Point,
25 event::wayland::{Event as WaylandEvent, OutputEvent, OverlapNotifyEvent},
26 platform_specific::runtime::wayland::layer_surface::{
27 IcedMargin, IcedOutput, SctkLayerSurfaceSettings,
28 },
29 platform_specific::shell::wayland::commands::layer_surface::{
30 Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, get_layer_surface,
31 },
32};
33use cosmic::widget::about::About;
34use cosmic::widget::dnd_destination::DragId;
35use cosmic::widget::menu::action::MenuAction;
36use cosmic::widget::menu::key_bind::KeyBind;
37use cosmic::widget::segmented_button::{self, Entity, ReorderEvent};
38use cosmic::widget::{self, icon, settings, space};
39use cosmic::{Application, ApplicationExt, Element, cosmic_theme, executor, style, surface, theme};
40use mime_guess::Mime;
41use notify_debouncer_full::notify::{self, RecommendedWatcher};
42use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
43use rustc_hash::{FxHashMap, FxHashSet};
44use slotmap::Key as SlotMapKey;
45use std::any::TypeId;
46use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
47use std::future::Future;
48use std::num::NonZeroU16;
49use std::path::{Path, PathBuf};
50use std::pin::Pin;
51use std::sync::{Arc, LazyLock};
52use std::time::{self, Duration, Instant};
53use std::{env, fmt, fs, io, process};
54use tokio::sync::mpsc;
55use trash::TrashItem;
56#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
57use wayland_client::{Proxy, protocol::wl_output::WlOutput};
58
59use crate::clipboard::{
60 ClipboardCache, ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage,
61 ClipboardPasteText, ClipboardPasteVideo,
62};
63use crate::config::{
64 AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TBConfig, TIME_CONFIG_ID, TabConfig,
65 TimeConfig, TypeToSearch,
66};
67use crate::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
68use crate::key_bind::key_binds;
69use crate::localize::LANGUAGE_SORTER;
70use crate::mime_app::{MimeApp, MimeAppCache, MimeAppMatch};
71#[cfg(feature = "desktop")]
72use crate::mime_app as mime_app;
73use crate::mounter::{
74 MOUNTERS, MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage,
75};
76use crate::russh::{
77 CLIENTS, ClientAuth, ClientItem, ClientItems, ClientKey, ClientMessage, SlurmJobId, same_uri,
78};
79use crate::operation::{
80 Controller, Operation, OperationError, OperationErrorType, OperationSelection, ReplaceResult,
81 copy_unique_path,
82};
83use crate::spawn_detached::spawn_detached;
84use crate::tab::{
85 self, HOVER_DURATION, HeadingOptions, ItemMetadata, Location, SORT_OPTION_FALLBACK,
86 SearchLocation, Tab,
87};
88use crate::trash::{Trash, TrashExt};
89use crate::zoom::{zoom_in_view, zoom_out_view, zoom_to_default};
90use crate::{FxOrderMap, context_action, fl, home_dir, menu, mime_icon};
91
92static PERMANENT_DELETE_BUTTON_ID: LazyLock<widget::Id> =
93 LazyLock::new(|| widget::Id::new("permanent-delete-button"));
94
95static DELETE_TRASH_BUTTON_ID: LazyLock<widget::Id> =
96 LazyLock::new(|| widget::Id::new("delete-trash-button"));
97
98static CONFIRM_OPEN_WITH_BUTTON_ID: LazyLock<widget::Id> =
99 LazyLock::new(|| widget::Id::new("confirm-open-with-button"));
100
101static CONFIRM_CONTEXT_ACTION_BUTTON_ID: LazyLock<widget::Id> =
102 LazyLock::new(|| widget::Id::new("confirm-context-action-button"));
103
104static EMPTY_TRASH_BUTTON_ID: LazyLock<widget::Id> =
105 LazyLock::new(|| widget::Id::new("empty-trash-button"));
106
107static SET_EXECUTABLE_AND_LAUNCH_CONFIRM_BUTTON_ID: LazyLock<widget::Id> =
108 LazyLock::new(|| widget::Id::new("set-executable-and-launch-confirm-button"));
109
110static FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID: LazyLock<widget::Id> =
111 LazyLock::new(|| widget::Id::new("favorite-path-error-remove-button"));
112
113static MOUNT_ERROR_TRY_AGAIN_BUTTON_ID: LazyLock<widget::Id> =
114 LazyLock::new(|| widget::Id::new("mount-error-try-again-button"));
115
116static CLIENT_ERROR_TRY_AGAIN_BUTTON_ID: LazyLock<widget::Id> =
117 LazyLock::new(|| widget::Id::new("client-error-try-again-button"));
118
119pub(crate) static REPLACE_BUTTON_ID: LazyLock<widget::Id> =
120 LazyLock::new(|| widget::Id::new("replace-button"));
121
122#[derive(Clone, Debug)]
123pub enum Mode {
124 App,
125 Desktop,
126}
127
128#[derive(Clone, Debug)]
129pub struct Flags {
130 pub config_handler: Option<cosmic_config::Config>,
131 pub config: Config,
132 pub state_handler: Option<cosmic_config::Config>,
133 pub state: State,
134 pub mode: Mode,
135 pub locations: Vec<Location>,
136 pub uris: Vec<url::Url>,
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub enum Action {
141 About,
142 AddToSidebar,
143 Compress,
144 Copy,
145 CopyPath,
146 CopyTo,
147 Cut,
148 CosmicSettingsDesktop,
149 CosmicSettingsDisplays,
150 CosmicSettingsWallpaper,
151 DesktopViewOptions,
152 Delete,
153 DownloadTo,
154 EditHistory,
155 EditLocation,
156 Eject,
157 EmptyTrash,
158 #[cfg(feature = "desktop")]
159 ExecEntryAction(usize),
160 ExtractHere,
161 ExtractTo,
162 Gallery,
163 HistoryNext,
164 HistoryPrevious,
165 ItemDown,
166 ItemLeft,
167 ItemPageDown,
168 ItemPageUp,
169 ItemRight,
170 ItemUp,
171 LocationUp,
172 MoveTo,
173 NewFile,
174 NewFolder,
175 Open,
176 OpenInNewTab,
177 OpenInNewWindow,
178 OpenItemLocation,
179 OpenTerminal,
180 OpenWith,
181 RunContextAction(usize),
182 Paste,
183 PermanentlyDelete,
184 Preview,
185 Reload,
186 RemoveFromRecents,
187 Rename,
188 RestoreFromTrash,
189 RunTbProfiler,
190 TbProfilerConfigError,
191 DeleteRemoteFiles,
192 SearchActivate,
193 SelectFirst,
194 SelectLast,
195 SelectAll,
196 SetSort(HeadingOptions, bool),
197 Settings,
198 TBSettings,
199 TabClose,
200 TabNew,
201 TabNext,
202 TabPrev,
203 TabViewGrid,
204 TabViewList,
205 ToggleFoldersFirst,
206 ToggleShowHidden,
207 ToggleShowSusceptible,
208 ToggleSort(HeadingOptions),
209 ToggleShowAsSamples,
210 WindowClose,
211 WindowNew,
212 ZoomDefault,
213 ZoomIn,
214 ZoomOut,
215 Recents,
216}
217
218impl Action {
219 const fn message(&self, entity_opt: Option<Entity>) -> Message {
220 match self {
221 Self::About => Message::ToggleContextPage(ContextPage::About),
222 Self::AddToSidebar => Message::AddToSidebar(entity_opt),
223 Self::Compress => Message::Compress(entity_opt),
224 Self::Copy => Message::Copy(entity_opt),
225 Self::CopyPath => Message::CopyPath(entity_opt),
226 Self::CopyTo => Message::CopyTo(entity_opt),
227 Self::Cut => Message::Cut(entity_opt),
228 Self::CosmicSettingsDesktop => Message::CosmicSettings("desktop"),
229 Self::CosmicSettingsDisplays => Message::CosmicSettings("displays"),
230 Self::CosmicSettingsWallpaper => Message::CosmicSettings("wallpaper"),
231 Self::Delete => Message::Delete(entity_opt),
232 Self::DesktopViewOptions => Message::DesktopViewOptions,
233 Self::DownloadTo => Message::DownloadTo(entity_opt),
234 Self::EditHistory => Message::ToggleContextPage(ContextPage::EditHistory),
235 Self::EditLocation => Message::TabMessage(entity_opt, tab::Message::EditLocationEnable),
236 Self::Eject => Message::Eject,
237 Self::EmptyTrash => Message::TabMessage(None, tab::Message::EmptyTrash),
238 Self::ExtractHere => Message::ExtractHere(entity_opt),
239 Self::ExtractTo => Message::ExtractTo(entity_opt),
240 #[cfg(feature = "desktop")]
241 Self::ExecEntryAction(action) => {
242 Message::TabMessage(entity_opt, tab::Message::ExecEntryAction(None, *action))
243 }
244 Self::Gallery => Message::TabMessage(entity_opt, tab::Message::GalleryToggle),
245 Self::HistoryNext => Message::TabMessage(entity_opt, tab::Message::GoNext),
246 Self::HistoryPrevious => Message::TabMessage(entity_opt, tab::Message::GoPrevious),
247 Self::ItemDown => Message::TabMessage(entity_opt, tab::Message::ItemDown),
248 Self::ItemLeft => Message::TabMessage(entity_opt, tab::Message::ItemLeft),
249 Self::ItemPageDown => Message::TabMessage(entity_opt, tab::Message::ItemPageDown),
250 Self::ItemPageUp => Message::TabMessage(entity_opt, tab::Message::ItemPageUp),
251 Self::ItemRight => Message::TabMessage(entity_opt, tab::Message::ItemRight),
252 Self::ItemUp => Message::TabMessage(entity_opt, tab::Message::ItemUp),
253 Self::LocationUp => Message::TabMessage(entity_opt, tab::Message::LocationUp),
254 Self::MoveTo => Message::MoveTo(entity_opt),
255 Self::NewFile => Message::NewItem(entity_opt, false),
256 Self::NewFolder => Message::NewItem(entity_opt, true),
257 Self::Open => Message::TabMessage(entity_opt, tab::Message::Open(None)),
258 Self::OpenInNewTab => Message::OpenInNewTab(entity_opt),
259 Self::OpenInNewWindow => Message::OpenInNewWindow(entity_opt),
260 Self::OpenItemLocation => Message::OpenItemLocation(entity_opt),
261 Self::OpenTerminal => Message::OpenTerminal(entity_opt),
262 Self::OpenWith => Message::OpenWithDialog(entity_opt),
263 Self::RunContextAction(action) => {
264 Message::TabMessage(entity_opt, tab::Message::RunContextAction(*action))
265 }
266 Self::Paste => Message::Paste(entity_opt),
267 Self::PermanentlyDelete => Message::PermanentlyDelete(entity_opt),
268 Self::Preview => Message::Preview(entity_opt),
269 Self::Reload => Message::TabMessage(entity_opt, tab::Message::Reload),
270 Self::RemoveFromRecents => Message::RemoveFromRecents(entity_opt),
271 Self::Rename => Message::Rename(entity_opt),
272 Self::RestoreFromTrash => Message::RestoreFromTrash(entity_opt),
273 Self::RunTbProfiler => Message::RunTbProfiler(entity_opt),
274 Self::TbProfilerConfigError => Message::TbProfilerConfigError,
275 Self::DeleteRemoteFiles => Message::DeleteRemoteFiles(entity_opt),
276 Self::SearchActivate => Message::SearchActivate,
277 Self::SelectAll => Message::TabMessage(entity_opt, tab::Message::SelectAll),
278 Self::SelectFirst => Message::TabMessage(entity_opt, tab::Message::SelectFirst),
279 Self::SelectLast => Message::TabMessage(entity_opt, tab::Message::SelectLast),
280 Self::SetSort(sort, dir) => {
281 Message::TabMessage(entity_opt, tab::Message::SetSort(*sort, *dir))
282 }
283 Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
284 Self::TBSettings => Message::ToggleContextPage(ContextPage::TBSettings),
285 Self::TabClose => Message::TabClose(entity_opt),
286 Self::TabNew => Message::TabNew,
287 Self::TabNext => Message::TabNext,
288 Self::TabPrev => Message::TabPrev,
289 Self::TabViewGrid => Message::TabView(entity_opt, tab::View::Grid),
290 Self::TabViewList => Message::TabView(entity_opt, tab::View::List),
291 Self::ToggleFoldersFirst => Message::ToggleFoldersFirst,
292 Self::ToggleShowHidden => Message::ToggleShowHidden,
293 Self::ToggleShowSusceptible => Message::ToggleShowSusceptible,
294 Self::ToggleSort(sort) => {
295 Message::TabMessage(entity_opt, tab::Message::ToggleSort(*sort))
296 }
297 Self::ToggleShowAsSamples => Message::ToggleShowAsSamples,
298 Self::WindowClose => Message::WindowClose,
299 Self::WindowNew => Message::WindowNew,
300 Self::ZoomDefault => Message::ZoomDefault(entity_opt),
301 Self::ZoomIn => Message::ZoomIn(entity_opt),
302 Self::ZoomOut => Message::ZoomOut(entity_opt),
303 Self::Recents => Message::Recents,
304 }
305 }
306}
307
308impl MenuAction for Action {
309 type Message = Message;
310
311 fn message(&self) -> Message {
312 self.message(None)
313 }
314}
315
316#[derive(Clone, Debug)]
317pub struct PreviewItem(pub Box<tab::Item>);
318
319impl PartialEq for PreviewItem {
320 fn eq(&self, other: &Self) -> bool {
321 self.0.location_opt == other.0.location_opt
322 }
323}
324
325impl Eq for PreviewItem {}
326
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub enum PreviewKind {
329 Custom(PreviewItem),
330 Location(Location),
331 Selected,
332}
333
334#[derive(Copy, Clone, Debug, Eq, PartialEq)]
335pub enum NavMenuAction {
336 ClearRecents,
337 EmptyTrash,
338 Open(segmented_button::Entity),
339 OpenWith(segmented_button::Entity),
340 OpenInNewTab(segmented_button::Entity),
341 OpenInNewWindow(segmented_button::Entity),
342 Preview(segmented_button::Entity),
343 RunContextAction(segmented_button::Entity, usize),
344 RemoveFromSidebar(segmented_button::Entity),
345}
346
347impl MenuAction for NavMenuAction {
348 type Message = cosmic::Action<Message>;
349
350 fn message(&self) -> Self::Message {
351 cosmic::Action::App(Message::NavMenuAction(*self))
352 }
353}
354
355#[derive(Clone, Debug)]
357pub enum Message {
358 AddToSidebar(Option<Entity>),
359 AppTheme(AppTheme),
360 ClientItems(ClientKey, ClientItems),
361 ClientResult(ClientKey, ClientItem, Result<bool, String>),
362 CloseToast(widget::ToastId),
363 Compress(Option<Entity>),
364 Config(Config),
365 Copy(Option<Entity>),
366 CopyPath(Option<Entity>),
367 CopyTo(Option<Entity>),
368 CopyToResult(DialogResult),
369 CosmicSettings(&'static str),
370 Cut(Option<Entity>),
371 Delete(Option<Entity>),
372 DeleteTbProfilerResults(String, TBConfig),
373 DesktopConfig(DesktopConfig),
374 DesktopViewOptions,
375 DesktopDialogs(bool),
376 DownloadTo(Option<Entity>),
377 DownloadToResult(DialogResult),
378 DownloadFileProgress,
379 DownloadComplete(Result<(), String>),
380 DialogCancel,
381 DialogComplete,
382 Eject,
383 FileDialogMessage(DialogMessage),
384 DialogPush(DialogPage, Option<widget::Id>),
385 DialogUpdate(DialogPage),
386 DialogUpdateComplete(DialogPage),
387 ExtractHere(Option<Entity>),
388 ExtractTo(Option<Entity>),
389 ExtractToResult(DialogResult),
390 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
391 Focused(window::Id),
392 Key(window::Id, Modifiers, Key, Physical, Option<SmolStr>),
393 LaunchUrl(String),
394 MaybeExit,
395 ModifiersChanged(window::Id, Modifiers),
396 MounterItems(MounterKey, MounterItems),
397 MountResult(MounterKey, MounterItem, Result<bool, String>),
398 Mouse(window::Id, mouse::Button),
399 MoveTo(Option<Entity>),
400 MoveToResult(DialogResult),
401 NavBarClose(Entity),
402 NavBarContext(Entity),
403 NavMenuAction(NavMenuAction),
404 DeleteRemoteFiles(Option<Entity>),
405 NetworkAuth(MounterKey, String, MounterAuth, mpsc::Sender<MounterAuth>),
406 NetworkDriveInput(String),
407 NetworkDriveOpenEntityAfterMount {
408 entity: Entity,
409 },
410 NetworkDriveOpenTabAfterMount {
411 location: Location,
412 },
413 NetworkDriveSubmit,
414 NetworkResult(MounterKey, String, Result<bool, String>),
415 NewItem(Option<Entity>, bool),
416 #[cfg(feature = "notify")]
417 Notification(Arc<Mutex<notify_rust::NotificationHandle>>),
418 NotifyEvents(Vec<DebouncedEvent>),
419 NotifyWatcher(WatcherWrapper),
420 OpenTerminal(Option<Entity>),
421 OpenInNewTab(Option<Entity>),
422 OpenInNewWindow(Option<Entity>),
423 OpenItemLocation(Option<Entity>),
424 OpenWithBrowse,
425 OpenWithDialog(Option<Entity>),
426 OpenWithSelection(usize),
427 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
428 Overlap(window::Id, OverlapNotifyEvent),
429 Paste(Option<Entity>),
430 PasteContents(PathBuf, ClipboardPaste),
431 PasteImage(PathBuf),
432 PasteImageContents(PathBuf, ClipboardPasteImage),
433 PasteText(PathBuf),
434 PasteTextContents(PathBuf, ClipboardPasteText),
435 PasteVideo(PathBuf),
436 PasteVideoContents(PathBuf, ClipboardPasteVideo),
437 CheckClipboard,
438 CheckClipboardImage,
439 CheckClipboardVideo,
440 CheckClipboardText,
441 RetryCheckClipboard(ClipboardCache),
442 ClipboardCached(ClipboardCache),
443 PendingCancel(u64),
444 PendingCancelAll,
445 PendingComplete(u64, OperationSelection),
446 PendingDismiss,
447 PendingError(u64, OperationError),
448 PendingResults(Vec<(u64, OperationSelection)>, Vec<(u64, OperationError)>),
449 PendingPause(u64, bool),
450 PendingPauseAll(bool),
451 PermanentlyDelete(Option<Entity>),
452 Preview(Option<Entity>),
453 ReloadMimeAppCache,
454 ReorderTab(ReorderEvent),
455 RescanRecents,
456 RescanTrash,
457 RemoteAuth(ClientKey, String, ClientAuth, mpsc::Sender<ClientAuth>),
458 RemoteDriveInput(String),
459 RemoteDriveOpenEntityAfterMount {
460 entity: Entity,
461 },
462 RemoteDriveSubmit,
463 RemoteResult(ClientKey, String, Result<bool, String>),
464 RemoveFromRecents(Option<Entity>),
465 Rename(Option<Entity>),
466 ReplaceResult(ReplaceResult),
467 RestoreFromTrash(Option<Entity>),
468 RunTbProfiler(Option<Entity>),
469 TbProfilerConfigError,
470 RunTbProfilerResult(ClientKey, String, Result<SlurmJobId, String>),
471 DeleteRemoteFilesResult(ClientKey, String, Result<String, String>),
472 JobStatusUpdate(ClientKey, String, usize, usize),
473 SaveSortNames,
474 ScrollTab(i16),
475 SearchActivate,
476 SearchClear,
477 SearchInput(String),
478 SetShowDetails(bool),
479 SetShowRecents(bool),
480 SetTypeToSearch(TypeToSearch),
481 SystemThemeModeChange,
482 Size(window::Id, Size),
483 TabActivate(Entity),
484 TabNext,
485 TabPrev,
486 TabClose(Option<Entity>),
487 TabConfig(TabConfig),
488 TabMessage(Option<Entity>, tab::Message),
489 TabNew,
490 TabRescan(
491 Entity,
492 Location,
493 Option<Box<tab::Item>>,
494 Vec<tab::Item>,
495 Option<Vec<PathBuf>>,
496 ),
497 TabView(Option<Entity>, tab::View),
498 TimeConfigChange(TimeConfig),
499 ToggleContextPage(ContextPage),
500 ToggleFoldersFirst,
501 ToggleShowHidden,
502 ToggleShowSusceptible,
503 ToggleShowAsSamples,
504 Undo(usize),
505 UndoTrash(widget::ToastId, Arc<[PathBuf]>),
506 UndoTrashStart(Vec<TrashItem>),
507 WindowClose,
508 WindowCloseRequested(window::Id),
509 WindowMaximize(window::Id, bool),
510 WindowNew,
511 ZoomDefault(Option<Entity>),
512 ZoomIn(Option<Entity>),
513 ZoomOut(Option<Entity>),
514 DndHoverLocTimeout(Location),
515 DndHoverTabTimeout(Entity),
516 DndEnterNav(Entity),
517 DndExitNav,
518 DndEnterTab(Entity, Vec<String>),
519 DndExitTab,
520 DndDropTab(Entity, Option<ClipboardPaste>, DndAction),
521 DndDropNav(Entity, Option<ClipboardPaste>, DndAction),
522 Recents,
523 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
524 OutputEvent(OutputEvent, WlOutput),
525 Cosmic(app::Action),
526 None,
527 Surface(surface::Action),
528 CutPaths(Vec<PathBuf>),
529 SetTbScriptPath(String),
530 SetTbOutDir(String),
531 SetTbDocxTemplatePath(String),
532 SetTbPair1Suffix(String),
533 SetTbPair2Suffix(String),
534 SetTbAb1ScanPath(String),
535 SetTbAb1CachePath(String),
536 SetTbAb1OutDirCsv(String),
537 SetTbAb1OutDirPdf(String),
538 SetNtfyTopic(String),
539 SetTbReportMaxAgeDays(String),
540 ScanAb1Directory,
541 Ab1ScanComplete(Vec<crate::sequencing::SampleSusceptibilityRecord>),
542}
543
544#[derive(Clone, Debug, Eq, PartialEq)]
545pub enum ContextPage {
546 About,
547 EditHistory,
548 NetworkDrive,
549 RemoteDrive,
550 Preview(Option<Entity>, PreviewKind),
551 Settings,
552 TBSettings,
553}
554
555#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
556pub enum ArchiveType {
557 Tgz,
558 #[default]
559 Zip,
560}
561
562impl ArchiveType {
563 pub const fn all() -> &'static [Self] {
564 &[Self::Tgz, Self::Zip]
565 }
566
567 pub const fn extension(&self) -> &str {
568 match self {
569 Self::Tgz => ".tgz",
570 Self::Zip => ".zip",
571 }
572 }
573}
574
575impl AsRef<str> for ArchiveType {
576 fn as_ref(&self) -> &str {
577 self.extension()
578 }
579}
580
581#[derive(Clone, Debug)]
582pub enum DialogPage {
583 Compress {
584 paths: Box<[PathBuf]>,
585 to: PathBuf,
586 name: String,
587 archive_type: ArchiveType,
588 password: Option<String>,
589 },
590 EmptyTrash,
591 FailedOperation(u64),
592 FailedOperations(Vec<u64>),
593 ExtractPassword {
594 id: u64,
595 password: String,
596 },
597 MountError {
598 mounter_key: MounterKey,
599 item: MounterItem,
600 error: String,
601 },
602 ClientError {
603 client_key: ClientKey,
604 item: ClientItem,
605 error: String,
606 },
607 NetworkAuth {
608 mounter_key: MounterKey,
609 uri: String,
610 auth: MounterAuth,
611 auth_tx: mpsc::Sender<MounterAuth>,
612 },
613 RemoteAuth {
614 client_key: ClientKey,
615 uri: String,
616 auth: ClientAuth,
617 auth_tx: mpsc::Sender<ClientAuth>,
618 },
619 NetworkError {
620 mounter_key: MounterKey,
621 uri: String,
622 error: String,
623 },
624 RemoteError {
625 client_key: ClientKey,
626 uri: String,
627 error: String,
628 },
629 RunTbProfilerStarted {
630 client_key: ClientKey,
631 uri: String,
632 job_id: usize,
633 tasks: usize,
634 },
635 RunTbProfilerError {
636 client_key: ClientKey,
637 uri: String,
638 error: String,
639 },
640 TbProfilerConfigError,
641 DeleteRemoteFilesSuccess {
642 client_key: ClientKey,
643 uri: String,
644 result: String,
645 },
646 DeleteRemoteFilesError {
647 client_key: ClientKey,
648 uri: String,
649 error: String,
650 },
651 NewItem {
652 parent: PathBuf,
653 name: String,
654 dir: bool,
655 },
656 RunContextAction {
657 action: usize,
658 paths: Box<[PathBuf]>,
659 },
660 OpenWith {
661 path: PathBuf,
662 mime: mime_guess::Mime,
663 selected: usize,
664 store_opt: Option<Arc<MimeApp>>,
665 },
666 PermanentlyDelete {
667 paths: Box<[PathBuf]>,
668 },
669 DeleteTrash {
670 items: Vec<TrashItem>,
671 },
672 RenameItem {
673 from: PathBuf,
674 parent: PathBuf,
675 name: String,
676 dir: bool,
677 },
678 Replace {
679 from: Box<tab::Item>,
680 to: Box<tab::Item>,
681 multiple: bool,
682 apply_to_all: bool,
683 conflict_count: usize,
684 tx: mpsc::Sender<ReplaceResult>,
685 },
686 SetExecutableAndLaunch {
687 path: PathBuf,
688 },
689 FavoritePathError {
690 path: PathBuf,
691 entity: Entity,
692 },
693}
694
695pub struct DialogPages {
696 pages: VecDeque<DialogPage>,
697}
698
699impl Default for DialogPages {
700 fn default() -> Self {
701 Self::new()
702 }
703}
704
705impl DialogPages {
706 pub const fn new() -> Self {
707 Self {
708 pages: VecDeque::new(),
709 }
710 }
711
712 pub fn front(&self) -> Option<&DialogPage> {
713 self.pages.front()
714 }
715
716 pub fn front_mut(&mut self) -> Option<&mut DialogPage> {
717 self.pages.front_mut()
718 }
719
720 pub fn push_back(&mut self, page: DialogPage) -> Task<Message> {
721 let task = if self.pages.is_empty() {
722 Task::done(cosmic::Action::App(Message::DesktopDialogs(true)))
723 } else {
724 Task::none()
725 };
726 self.pages.push_back(page);
727 task
728 }
729
730 pub fn push_front(&mut self, page: DialogPage) -> Task<Message> {
731 let task = if self.pages.is_empty() {
732 Task::done(cosmic::Action::App(Message::DesktopDialogs(true)))
733 } else {
734 Task::none()
735 };
736 self.pages.push_front(page);
737 task
738 }
739
740 #[must_use]
741 pub fn pop_front(&mut self) -> Option<(DialogPage, Task<Message>)> {
742 let page = self.pages.pop_front()?;
743 let task = if self.pages.is_empty() {
744 Task::done(cosmic::Action::App(Message::DesktopDialogs(false)))
745 } else {
746 Task::none()
747 };
748 Some((page, task))
749 }
750
751 pub fn update_front(&mut self, page: DialogPage) {
752 if !self.pages.is_empty() {
753 self.pages[0] = page;
754 }
755 }
756}
757
758pub struct FavoriteIndex(usize);
759
760pub struct MounterData(MounterKey, MounterItem);
761pub struct ClientData(ClientKey, ClientItem);
762
763#[derive(Clone, Debug)]
764pub enum WindowKind {
765 ContextMenu(Entity, widget::Id),
766 Desktop(Entity),
767 DesktopViewOptions,
768 Dialogs(widget::Id),
769 DownloadDialog(Option<(Box<[PathBuf]>, Vec<String>, bool)>),
770 FileDialog(Option<Box<[PathBuf]>>),
771 Preview(Option<Entity>, PreviewKind),
772}
773
774pub struct WatcherWrapper {
775 watcher_opt: Option<Debouncer<RecommendedWatcher, RecommendedCache>>,
776}
777
778impl Clone for WatcherWrapper {
779 fn clone(&self) -> Self {
780 Self { watcher_opt: None }
781 }
782}
783
784impl fmt::Debug for WatcherWrapper {
785 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786 f.debug_struct("WatcherWrapper").finish()
787 }
788}
789
790impl PartialEq for WatcherWrapper {
791 fn eq(&self, _other: &Self) -> bool {
792 false
793 }
794}
795
796struct Window {
797 kind: WindowKind,
798 modifiers: Modifiers,
799}
800
801impl Window {
802 fn new(kind: WindowKind) -> Self {
803 Self {
804 kind,
805 modifiers: Modifiers::empty(),
806 }
807 }
808}
809
810pub struct App {
812 core: Core,
813 about: About,
814 nav_bar_context_id: segmented_button::Entity,
815 nav_model: segmented_button::SingleSelectModel,
816 tab_model: segmented_button::Model<segmented_button::SingleSelect>,
817 config_handler: Option<cosmic_config::Config>,
818 state_handler: Option<cosmic_config::Config>,
819 config: Config,
820 state: State,
821 mode: Mode,
822 app_themes: Vec<String>,
823 compio_tx: mpsc::Sender<Pin<Box<dyn Future<Output = ()> + Send>>>,
824 context_page: ContextPage,
825 dialog_pages: DialogPages,
826 dialog_text_input: widget::Id,
827 key_binds: HashMap<KeyBind, Action>,
828 margin: FxHashMap<window::Id, (f32, f32, f32, f32)>,
829 mime_app_cache: MimeAppCache,
830 modifiers: Modifiers,
831 mounter_items: FxHashMap<MounterKey, MounterItems>,
832 client_items: FxHashMap<ClientKey, ClientItems>,
833 must_save_sort_names: bool,
834 network_drive_connecting: Option<(MounterKey, String)>,
835 network_drive_input: String,
836 remote_drive_connecting: Option<(ClientKey, String)>,
837 remote_drive_input: String,
838 #[cfg(feature = "notify")]
839 notification_opt: Option<Arc<Mutex<notify_rust::NotificationHandle>>>,
840 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
841 overlap: FxHashMap<String, (window::Id, Rectangle)>,
842 pending_operation_id: u64,
843 pending_operations: BTreeMap<u64, (Operation, Controller)>,
844 progress_operations: BTreeSet<u64>,
845 complete_operations: BTreeMap<u64, Operation>,
846 failed_operations: BTreeMap<u64, (Operation, Controller, String)>,
847 scrollable_id: widget::Id,
848 search_id: widget::Id,
849 size: Option<Size>,
850 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
851 layer_sizes: FxHashMap<window::Id, Size>,
852 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
853 surface_ids: FxHashMap<WlOutput, WindowId>,
854 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
855 surface_names: FxHashMap<WindowId, String>,
856 toasts: widget::toaster::Toasts<Message>,
857 watcher_opt: Option<(
858 Debouncer<RecommendedWatcher, RecommendedCache>,
859 FxHashSet<PathBuf>,
860 )>,
861 windows: FxHashMap<window::Id, Window>,
862 nav_dnd_hover: Option<(Location, Instant)>,
863 tab_dnd_hover: Option<(Entity, Instant)>,
864 type_select_prefix: String,
865 type_select_last_key: Option<Instant>,
866 nav_drag_id: DragId,
867 tab_drag_id: DragId,
868 auto_scroll_speed: Option<i16>,
869 file_dialog_opt: Option<Dialog<Message>>,
870 clipboard_cache: ClipboardCache,
871 running_tasks: std::collections::HashMap<usize, usize>,
873 job_total_tasks: std::collections::HashMap<usize, usize>,
875 download_files_total: usize,
877 download_files_done: usize,
879}
880
881impl App {
882 fn clipboard_has_content(&self) -> bool {
884 !matches!(self.clipboard_cache, ClipboardCache::Empty)
885 }
886
887 fn push_dialog(&mut self, page: DialogPage, focus_id: Option<widget::Id>) -> Task<Message> {
888 let t = self.dialog_pages.push_back(page);
889 if let Some(focus_id) = focus_id {
890 Task::batch([t, focus(focus_id)])
891 } else {
892 t
893 }
894 }
895
896 fn open_file(&mut self, paths: &[impl AsRef<Path>]) -> Task<Message> {
897 let mut tasks = Vec::new();
898
899 let mut groups: FxHashMap<Mime, Vec<PathBuf>> = FxHashMap::default();
903 let mut all_archives = true;
904 let supported_archive_types = crate::archive::SUPPORTED_ARCHIVE_TYPES;
905 for (mime, path) in paths.iter().map(|path| {
906 (
907 mime_icon::mime_for_path(path, None, false),
908 path.as_ref().to_owned(),
909 )
910 }) {
911 if all_archives && !supported_archive_types.iter().copied().any(|t| mime == t) {
912 all_archives = false;
913 }
914 groups.entry(mime).or_default().push(path);
915 }
916
917 if all_archives {
918 return self.extract_to(paths);
920 }
921
922 'outer: for (mime, paths) in groups {
923 log::debug!("Attempting to launch app\n\tfor: {mime}\n\twith: {paths:?}");
924
925 if mime == "application/x-desktop" {
927 #[cfg(feature = "desktop")]
928 {
929 Self::launch_desktop_entries(&paths);
931 continue;
932 }
933 } else if mime == "application/x-executable" || mime == "application/vnd.appimage" {
934 for path in paths {
936 let mut command = std::process::Command::new(&path);
937 match spawn_detached(&mut command) {
938 Ok(()) => {}
939 Err(err) => match err.kind() {
940 io::ErrorKind::PermissionDenied => {
941 tasks.push(self.push_dialog(
943 DialogPage::SetExecutableAndLaunch { path },
944 Some(SET_EXECUTABLE_AND_LAUNCH_CONFIRM_BUTTON_ID.clone()),
945 ));
946 }
947 _ => {
948 log::warn!("failed to execute {}: {}", path.display(), err);
949 }
950 },
951 }
952 }
953 continue;
954 }
955
956 if self.launch_from_mime_cache(&mime, &paths) {
958 continue;
959 }
960
961 if let Some(mime_sub_classes) = mime_icon::parent_mime_types(&mime) {
963 for sub_class in mime_sub_classes {
964 if self.launch_from_mime_cache(&sub_class, &paths) {
965 continue 'outer;
966 }
967 }
968 }
969
970 for path in paths {
972 match open::that_detached(&path) {
973 Ok(()) => {
974 if self.config.show_recents {
975 let _ = recently_used_xbel::update_recently_used(
976 &path,
977 Self::APP_ID.to_string(),
978 "cosmic-files".to_string(),
979 None,
980 );
981 }
982 }
983 Err(err) => {
984 log::warn!("failed to open {}: {}", path.display(), err);
985 }
986 }
987 }
988 }
989
990 Task::batch(tasks)
991 }
992
993 #[cfg(feature = "desktop")]
994 fn launch_desktop_entries(paths: &[impl AsRef<Path>]) {
995 use cosmic::desktop::fde::DesktopEntry;
996 let locales = cosmic::desktop::fde::get_languages_from_env();
997
998 for path in paths.iter().map(AsRef::as_ref) {
999 match DesktopEntry::from_path::<&str>(path, None) {
1000 Ok(entry) => match entry.exec() {
1001 Some(exec) => {
1002 match mime_app::exec_to_command(
1003 exec,
1004 entry.name(&locales).as_deref().unwrap_or_default(),
1005 Some(path),
1006 &[] as &[&str; 0],
1007 ) {
1008 Some(commands) => {
1009 let cwd_opt = entry.desktop_entry("Path");
1010
1011 for mut command in commands {
1012 if let Some(cwd) = cwd_opt {
1013 command.current_dir(cwd);
1014 }
1015
1016 if let Err(err) = spawn_detached(&mut command) {
1017 log::warn!("failed to execute {}: {}", path.display(), err);
1018 }
1019 }
1020 }
1021 None => {
1022 log::warn!(
1023 "failed to parse {}: invalid Desktop Entry/Exec",
1024 path.display()
1025 );
1026 }
1027 }
1028 }
1029 None => {
1030 log::warn!(
1031 "failed to parse {}: missing Desktop Entry/Exec",
1032 path.display()
1033 );
1034 }
1035 },
1036 Err(err) => {
1037 log::warn!("failed to parse {}: {}", path.display(), err);
1038 }
1039 }
1040 }
1041 }
1042
1043 fn launch_from_mime_cache<P>(&self, mime: &Mime, paths: &[P]) -> bool
1044 where
1045 P: std::fmt::Debug + AsRef<Path> + AsRef<std::ffi::OsStr>,
1046 {
1047 for app in self.mime_app_cache.get(mime) {
1048 let Some(commands) = app.command(paths) else {
1049 continue;
1050 };
1051 let len = commands.len();
1052
1053 for (i, mut command) in commands.into_iter().enumerate() {
1054 match spawn_detached(&mut command) {
1055 Ok(()) => {
1056 if self.config.show_recents {
1057 for path in paths {
1058 let _ = recently_used_xbel::update_recently_used(
1059 &path.into(),
1060 Self::APP_ID.to_string(),
1061 "cosmic-files".to_string(),
1062 None,
1063 );
1064 }
1065 }
1066
1067 return true;
1068 }
1069 Err(err) => {
1070 let path = if len > 1 {
1075 format!("{:?}", paths.get(i))
1076 } else {
1077 format!("{paths:?}")
1078 };
1079 log::warn!("failed to open {:?} with {:?}: {}", path, app.id, err);
1080 }
1081 }
1082 }
1083 }
1084
1085 false
1087 }
1088
1089 #[cfg(feature = "desktop")]
1090 fn exec_entry_action(entry: &cosmic::desktop::DesktopEntryData, action: usize) {
1091 if let Some(action) = entry.desktop_actions.get(action) {
1092 let mut exec = shlex::Shlex::new(&action.exec);
1094 match exec.next() {
1095 Some(cmd) if !cmd.contains('=') => {
1096 let mut proc = tokio::process::Command::new(cmd);
1097 proc.args(exec.filter(|arg| !arg.starts_with('%')));
1098 let _ = proc.spawn();
1099 }
1100 _ => (),
1101 }
1102 } else {
1103 log::warn!(
1104 "Invalid actions index `{action}` for desktop entry {}",
1105 entry.name
1106 );
1107 }
1108 }
1109
1110 fn destination_selection_dialog(
1111 &mut self,
1112 paths: &[impl AsRef<Path>],
1113 on_result: impl Fn(DialogResult) -> Message + 'static,
1114 title: impl Into<String>,
1115 accept_label: impl AsRef<str>,
1116 ) -> Task<Message> {
1117 if let Some(destination) = paths
1118 .first()
1119 .and_then(|first| first.as_ref().parent())
1120 .map(Path::to_path_buf)
1121 {
1122 let mut tasks = Vec::new();
1123 if let Some(old_dialog) = self.file_dialog_opt.take() {
1124 let old_id = old_dialog.window_id();
1125 self.windows.remove(&old_id);
1126 tasks.push(window::close(old_id));
1127 }
1128 let (mut dialog, dialog_task) = Dialog::new(
1129 DialogSettings::new()
1130 .kind(DialogKind::OpenFolder)
1131 .path(destination),
1132 Message::FileDialogMessage,
1133 on_result,
1134 );
1135 let set_title_task = dialog.set_title(title);
1136 dialog.set_accept_label(accept_label);
1137 self.windows.insert(
1138 dialog.window_id(),
1139 Window::new(WindowKind::FileDialog(Some(
1140 paths.iter().map(|x| x.as_ref().to_path_buf()).collect(),
1141 ))),
1142 );
1143 self.file_dialog_opt = Some(dialog);
1144 tasks.push(set_title_task);
1145 tasks.push(dialog_task);
1146 Task::batch(tasks)
1147 } else {
1148 Task::none()
1149 }
1150 }
1151
1152 fn extract_to(&mut self, paths: &[impl AsRef<Path>]) -> Task<Message> {
1153 self.destination_selection_dialog(
1154 paths,
1155 Message::ExtractToResult,
1156 fl!("extract-to-title"),
1157 fl!("extract-here"),
1158 )
1159 }
1160
1161 fn move_to(&mut self, paths: &[impl AsRef<Path>]) -> Task<Message> {
1162 self.destination_selection_dialog(
1163 paths,
1164 Message::MoveToResult,
1165 fl!("move-to-title"),
1166 fl!("move-to-button-label"),
1167 )
1168 }
1169
1170 fn copy_to(&mut self, paths: &[impl AsRef<Path>]) -> Task<Message> {
1171 self.destination_selection_dialog(
1172 paths,
1173 Message::CopyToResult,
1174 fl!("copy-to-title"),
1175 fl!("copy-to-button-label"),
1176 )
1177 }
1178
1179 fn download_to(&mut self, paths: &[impl AsRef<Path>], uris: &[String]) -> Task<Message> {
1180 if let Some(destination) = dirs::download_dir() {
1181 let entity = self.tab_model.active();
1184 let dir_count = self
1185 .tab_model
1186 .data::<Tab>(entity)
1187 .and_then(|tab| tab.items_opt())
1188 .map(|items| {
1189 items
1190 .iter()
1191 .filter(|item| item.selected && item.metadata.is_dir())
1192 .count()
1193 })
1194 .unwrap_or(0);
1195
1196 let save_as_zip = dir_count > 0;
1197 let dialog_kind = if save_as_zip {
1198 let default_name = if dir_count == 1 {
1199 paths
1200 .iter()
1201 .find(|_p| {
1202 true
1204 })
1205 .and_then(|p| p.as_ref().file_name())
1206 .map(|n| format!("{}.zip", n.to_string_lossy()))
1207 .unwrap_or_else(|| "folder.zip".to_string())
1208 } else {
1209 "folders.zip".to_string()
1210 };
1211 DialogKind::SaveFile {
1212 filename: default_name,
1213 }
1214 } else {
1215 DialogKind::OpenFolder
1216 };
1217
1218 let (mut dialog, dialog_task) = Dialog::new(
1219 DialogSettings::new().kind(dialog_kind).path(destination),
1220 Message::FileDialogMessage,
1221 Message::DownloadToResult,
1222 );
1223 let set_title_task = dialog.set_title(fl!("download-to-title"));
1224 dialog.set_accept_label(fl!("download"));
1225 self.windows.insert(
1226 dialog.window_id(),
1227 Window::new(WindowKind::DownloadDialog(Some((
1228 paths.iter().map(|x| x.as_ref().to_path_buf()).collect(),
1229 uris.iter().map(|x| x.to_string()).collect(),
1230 save_as_zip,
1231 )))),
1232 );
1233 self.file_dialog_opt = Some(dialog);
1234 Task::batch([set_title_task, dialog_task])
1235 } else {
1236 Task::none()
1237 }
1238 }
1239
1240 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
1241 fn handle_overlap(&mut self) {
1242 let mut overlaps: FxHashMap<_, _> = self
1243 .windows
1244 .keys()
1245 .map(|k| (*k, (0., 0., 0., 0.)))
1246 .collect();
1247 let mut sorted_overlaps: Box<[_]> = self.overlap.values().collect();
1248 sorted_overlaps
1249 .sort_by(|a, b| (b.1.width * b.1.height).total_cmp(&(a.1.width * b.1.height)));
1250
1251 for (w_id, overlap) in sorted_overlaps {
1252 let Some((bl, br, tl, tr, mut size)) = self.layer_sizes.get(w_id).map(|s| {
1253 (
1254 Rectangle::new(
1255 Point::new(0., s.height / 2.),
1256 Size::new(s.width / 2., s.height / 2.),
1257 ),
1258 Rectangle::new(
1259 Point::new(s.width / 2., s.height / 2.),
1260 Size::new(s.width / 2., s.height / 2.),
1261 ),
1262 Rectangle::new(Point::new(0., 0.), Size::new(s.width / 2., s.height / 2.)),
1263 Rectangle::new(
1264 Point::new(s.width / 2., 0.),
1265 Size::new(s.width / 2., s.height / 2.),
1266 ),
1267 *s,
1268 )
1269 }) else {
1270 continue;
1271 };
1272 let tl = tl.intersects(overlap);
1273 let tr = tr.intersects(overlap);
1274 let bl = bl.intersects(overlap);
1275 let br = br.intersects(overlap);
1276 let Some((top, left, bottom, right)) = overlaps.get_mut(w_id) else {
1277 continue;
1278 };
1279 if tl && tr {
1280 *top += overlap.height;
1281 }
1282 if tl && bl {
1283 *left += overlap.width;
1284 }
1285 if bl && br {
1286 *bottom += overlap.height;
1287 }
1288 if tr && br {
1289 *right += overlap.width;
1290 }
1291
1292 let min_dim =
1293 if overlap.width / size.width.max(1.) > overlap.height / size.height.max(1.) {
1294 (0., overlap.height)
1295 } else {
1296 (overlap.width, 0.)
1297 };
1298 if tl && !(tr || bl) {
1300 *top += min_dim.1;
1301 *left += min_dim.0;
1302
1303 size.height -= min_dim.1;
1304 size.width -= min_dim.0;
1305 }
1306 if tr && !(tl || br) {
1307 *top += min_dim.1;
1308 *right += min_dim.0;
1309
1310 size.height -= min_dim.1;
1311 size.width -= min_dim.0;
1312 }
1313 if bl && !(br || tl) {
1314 *bottom += min_dim.1;
1315 *left += min_dim.0;
1316
1317 size.height -= min_dim.1;
1318 size.width -= min_dim.0;
1319 }
1320 if br && !(bl || tr) {
1321 *bottom += min_dim.1;
1322 *right += min_dim.0;
1323
1324 size.height -= min_dim.1;
1325 size.width -= min_dim.0;
1326 }
1327 }
1328 self.margin = overlaps;
1329 }
1330
1331 fn open_tab_entity(
1332 &mut self,
1333 location: Location,
1334 activate: bool,
1335 selection_paths: Option<Vec<PathBuf>>,
1336 scrollable_id: widget::Id,
1337 window_id: Option<window::Id>,
1338 ) -> (Entity, Task<Message>) {
1339 let mut tab = Tab::new(
1340 location.clone(),
1341 self.config.tab,
1342 self.config.tb_config.clone(),
1343 self.config.thumb_cfg,
1344 Some(&self.state.sort_names),
1345 scrollable_id,
1346 window_id,
1347 );
1348 tab.mode = match self.mode {
1349 Mode::App => tab::Mode::App,
1350 Mode::Desktop => {
1351 tab.config.view = tab::View::Grid;
1352 tab::Mode::Desktop
1353 }
1354 };
1355
1356 let entity = self
1357 .tab_model
1358 .insert()
1359 .text(tab.title())
1360 .data(tab)
1361 .closable();
1362
1363 let entity = if activate {
1364 entity.activate().id()
1365 } else {
1366 entity.id()
1367 };
1368
1369 let mut tasks = Vec::with_capacity(4);
1370 if activate {
1371 tasks.push(task::widget(unfocus()));
1372 }
1373 tasks.push(self.update_title());
1374 tasks.push(self.update_watcher());
1375 tasks.push(self.update_tab(entity, location, selection_paths));
1376 (entity, Task::batch(tasks))
1377 }
1378
1379 fn open_tab(
1380 &mut self,
1381 location: Location,
1382 activate: bool,
1383 selection_paths: Option<Vec<PathBuf>>,
1384 ) -> Task<Message> {
1385 self.open_tab_entity(
1386 location,
1387 activate,
1388 selection_paths,
1389 self.scrollable_id.clone(),
1390 None,
1391 )
1392 .1
1393 }
1394
1395 fn delete(&mut self, paths: impl IntoIterator<Item = PathBuf>) -> Task<Message> {
1397 let mut dialog_paths = Vec::new();
1398 let mut trash_paths = Vec::new();
1399
1400 for path in paths {
1401 let can_trash = match path.metadata() {
1403 Ok(metadata) => matches!(tab::fs_kind(&metadata), tab::FsKind::Local),
1404 Err(err) => {
1405 log::warn!("failed to get metadata for {}: {}", path.display(), err);
1406 false
1407 }
1408 };
1409 if can_trash {
1410 trash_paths.push(path);
1411 } else {
1412 dialog_paths.push(path);
1413 }
1414 }
1415
1416 let mut tasks = Vec::new();
1417 if !dialog_paths.is_empty() {
1418 tasks.push(self.update(Message::DialogPush(
1419 DialogPage::PermanentlyDelete {
1420 paths: dialog_paths.into_boxed_slice(),
1421 },
1422 Some(PERMANENT_DELETE_BUTTON_ID.clone()),
1423 )));
1424 }
1425 if !trash_paths.is_empty() {
1426 tasks.push(self.operation(Operation::Delete { paths: trash_paths }));
1427 }
1428 Task::batch(tasks)
1429 }
1430
1431 fn operation(&mut self, operation: Operation) -> Task<Message> {
1432 let id = self.pending_operation_id;
1433 let controller = Controller::default();
1434 let compio_tx = self.compio_tx.clone();
1435
1436 self.pending_operation_id += 1;
1437 if operation.show_progress_notification() {
1438 self.progress_operations.insert(id);
1439 }
1440 self.pending_operations
1441 .insert(id, (operation.clone(), controller.clone()));
1442
1443 cosmic::Task::stream(cosmic::iced::stream::channel(4, move |msg_tx| async move {
1445 let (tx, rx) = tokio::sync::oneshot::channel();
1446
1447 let msg_tx = Arc::new(tokio::sync::Mutex::new(msg_tx));
1448
1449 let msg_tx_clone = msg_tx.clone();
1450
1451 _ = compio_tx
1452 .send(Box::pin(async move {
1453 let msg = match operation.perform(&msg_tx_clone, controller).await {
1454 Ok(result_paths) => Message::PendingComplete(id, result_paths),
1455 Err(err) => Message::PendingError(id, err),
1456 };
1457
1458 _ = tx.send(msg);
1459 }))
1460 .await;
1461
1462 if let Ok(msg) = rx.await {
1463 let _ = msg_tx.lock().await.send(msg).await;
1464 }
1465 }))
1466 .map(cosmic::Action::App)
1467 }
1468
1469 fn join_operations(&mut self, operations: Vec<Operation>) -> Task<Message> {
1472 Task::batch(
1473 operations
1474 .into_iter()
1475 .map(|operation| self.operation(operation)),
1476 )
1477 .collect()
1478 .map(|messages| {
1479 let results = messages.into_iter().fold(
1480 Message::PendingResults(Vec::new(), Vec::new()),
1481 |mut acc, message| {
1482 if let Message::PendingResults(completed, errors) = &mut acc {
1483 match message {
1484 cosmic::Action::App(Message::PendingComplete(id, selection)) => {
1485 completed.push((id, selection));
1486 }
1487 cosmic::Action::App(Message::PendingError(id, err)) => {
1488 errors.push((id, err));
1489 }
1490 _ => {}
1491 }
1492 }
1493 acc
1494 },
1495 );
1496 cosmic::Action::App(results)
1497 })
1498 }
1499
1500 fn handle_completed_operations(
1501 &mut self,
1502 completed: Vec<(u64, OperationSelection)>,
1503 ) -> Task<Message> {
1504 let mut commands = Vec::with_capacity(4 * completed.len());
1505 let mut op_sel = OperationSelection::default();
1506 for (id, op_sel_pending) in completed {
1507 op_sel.ignored.extend(op_sel_pending.ignored);
1508 op_sel.selected.extend(op_sel_pending.selected);
1509 if let Some((op, _)) = self.pending_operations.remove(&id) {
1510 if let Some(description) = op.toast() {
1512 if let Operation::Delete { ref paths } = op {
1513 let paths: Arc<[PathBuf]> = Arc::from(paths.as_slice());
1514 commands.push(
1515 self.toasts
1516 .push(
1517 widget::toaster::Toast::new(description)
1518 .action(fl!("undo"), move |tid| {
1519 Message::UndoTrash(tid, paths.clone())
1520 }),
1521 )
1522 .map(cosmic::Action::App),
1523 );
1524 } else {
1525 commands.push(
1526 self.toasts
1527 .push(widget::toaster::Toast::new(description))
1528 .map(cosmic::Action::App),
1529 );
1530 }
1531 }
1532
1533 if let Operation::Rename { ref from, ref to } = op {
1535 if self.update_favorites([(from, to)].as_slice()) {
1536 commands.push(self.update_config());
1537 }
1538 } else if let Operation::Move {
1539 ref paths, ref to, ..
1540 } = op
1541 {
1542 let path_changes: Box<[_]> = paths
1543 .iter()
1544 .filter_map(|from| from.file_name().map(|name| (from, to.join(name))))
1545 .collect();
1546 if self.update_favorites(&path_changes) {
1547 commands.push(self.update_config());
1548 }
1549 }
1550
1551 if matches!(op, Operation::RemoveFromRecents { .. }) {
1552 commands.push(self.rescan_recents());
1553 }
1554
1555 self.complete_operations.insert(id, op);
1556 }
1557 }
1558 if !self
1560 .pending_operations
1561 .values()
1562 .any(|(op, _)| op.show_progress_notification())
1563 {
1564 self.progress_operations.clear();
1565 }
1566 commands.push(self.update_notification());
1568 commands.push(self.rescan_operation_selection(op_sel));
1570 commands.push(self.rescan_trash());
1572
1573 Task::batch(commands)
1574 }
1575
1576 fn handle_operation_errors(&mut self, errors: Vec<(u64, OperationError)>) -> Task<Message> {
1577 let mut tasks = Vec::new();
1578 let mut failed = Vec::new();
1579 for (id, err) in errors.into_iter() {
1580 if let Some((op, controller)) = self.pending_operations.remove(&id) {
1581 if !controller.is_cancelled() {
1583 match err.kind {
1584 OperationErrorType::Generic(_) => failed.push(id),
1585 OperationErrorType::PasswordRequired => {
1586 tasks.push(self.dialog_pages.push_back(DialogPage::ExtractPassword {
1587 id,
1588 password: String::new(),
1589 }));
1590 }
1591 }
1592 }
1593
1594 self.progress_operations.remove(&id);
1596 self.failed_operations
1597 .insert(id, (op, controller, err.to_string()));
1598 }
1599 }
1600 if !failed.is_empty() {
1601 tasks.push(
1602 self.dialog_pages
1603 .push_back(DialogPage::FailedOperations(failed)),
1604 );
1605 tasks.push(widget::text_input::focus(self.dialog_text_input.clone()));
1606 }
1607
1608 if !self
1610 .pending_operations
1611 .values()
1612 .any(|(op, _)| op.show_progress_notification())
1613 {
1614 self.progress_operations.clear();
1615 }
1616 tasks.push(self.rescan_trash());
1618 Task::batch(tasks)
1619 }
1620
1621 fn remove_window(&mut self, id: &window::Id) {
1622 if let Some(window) = self.windows.remove(id) {
1623 match window.kind {
1624 WindowKind::ContextMenu(entity, _) => {
1625 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
1627 tab.context_menu = None;
1628 }
1629 }
1630 WindowKind::Desktop(entity) => {
1631 self.tab_model.remove(entity);
1633 }
1634 _ => {}
1635 }
1636 }
1637 }
1638
1639 fn rescan_operation_selection(&mut self, op_sel: OperationSelection) -> Task<Message> {
1640 log::info!("rescan_operation_selection {op_sel:?}");
1641 let entity = self.tab_model.active();
1642 let Some(tab) = self.tab_model.data::<Tab>(entity) else {
1643 return Task::none();
1644 };
1645 let Some(items) = tab.items_opt() else {
1646 return Task::none();
1647 };
1648 for item in items {
1649 if item.selected {
1650 if let Some(path) = item.path_opt()
1651 && (op_sel.selected.contains(path) || op_sel.ignored.contains(path))
1652 {
1653 continue;
1655 }
1656
1657 return Task::none();
1659 }
1660 }
1661 self.update_tab(entity, tab.location.clone(), Some(op_sel.selected))
1662 }
1663
1664 fn update_tab(
1665 &mut self,
1666 entity: Entity,
1667 location: Location,
1668 selection_paths: Option<Vec<PathBuf>>,
1669 ) -> Task<Message> {
1670 if let Location::Search(_, term, ..) = location {
1671 self.search_set(entity, Some(term), selection_paths)
1672 } else {
1673 self.rescan_tab(entity, location, selection_paths)
1674 }
1675 }
1676
1677 fn rescan_tab(
1678 &mut self,
1679 entity: Entity,
1680 location: Location,
1681 selection_paths: Option<Vec<PathBuf>>,
1682 ) -> Task<Message> {
1683 log::info!("rescan_tab {entity:?} {location:?} {selection_paths:?}");
1684 let icon_sizes = self.config.tab.icon_sizes;
1685 let _mounter_items = self.mounter_items.clone();
1686 let client_items = self.client_items.clone();
1687
1688 Task::future(async move {
1689 let location2 = location.clone();
1690 match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await {
1691 Ok((parent_item_opt, mut items)) => {
1692 #[cfg(feature = "gvfs")]
1693 {
1694 let mounter_paths: Box<[_]> = mounter_items
1695 .values()
1696 .flatten()
1697 .filter_map(MounterItem::path)
1698 .collect();
1699 if !mounter_paths.is_empty() {
1700 for item in &mut items {
1701 item.is_mount_point =
1702 item.path_opt().is_some_and(|p| mounter_paths.contains(p));
1703 }
1704 }
1705 }
1706 #[cfg(feature = "russh")]
1707 {
1708 let client_paths: Box<[_]> = client_items
1709 .values()
1710 .flatten()
1711 .filter_map(ClientItem::path)
1712 .collect();
1713 if !client_paths.is_empty() {
1714 for item in &mut items {
1715 item.is_client_point =
1716 item.path_opt().is_some_and(|p| client_paths.contains(p));
1717 }
1718 }
1719 }
1720
1721 cosmic::action::app(Message::TabRescan(
1722 entity,
1723 location,
1724 parent_item_opt,
1725 items,
1726 selection_paths,
1727 ))
1728 }
1729 Err(err) => {
1730 log::warn!("failed to rescan: {err}");
1731 cosmic::action::none()
1732 }
1733 }
1734 })
1735 }
1736
1737 fn rescan_trash(&mut self) -> Task<Message> {
1738 let needs_reload: Box<[_]> = self
1739 .tab_model
1740 .iter()
1741 .filter_map(|entity| {
1742 let tab = self.tab_model.data::<Tab>(entity)?;
1743 tab.location
1744 .is_trash()
1745 .then_some((entity, tab.location.clone()))
1746 })
1747 .collect();
1748
1749 let commands = needs_reload
1750 .into_iter()
1751 .map(|(entity, location)| self.update_tab(entity, location, None));
1752
1753 Task::batch(commands)
1754 }
1755
1756 fn rescan_recents(&mut self) -> Task<Message> {
1757 let needs_reload: Box<[_]> = self
1758 .tab_model
1759 .iter()
1760 .filter_map(|entity| {
1761 let tab = self.tab_model.data::<Tab>(entity)?;
1762 tab.location
1763 .is_recents()
1764 .then_some((entity, tab.location.clone()))
1765 })
1766 .collect();
1767
1768 let commands = needs_reload
1769 .into_iter()
1770 .map(|(entity, location)| self.update_tab(entity, location, None));
1771
1772 Task::batch(commands)
1773 }
1774
1775 fn search_get(&self) -> Option<&str> {
1776 let entity = self.tab_model.active();
1777 let tab = self.tab_model.data::<Tab>(entity)?;
1778 match &tab.location {
1779 Location::Search(_, term, ..) => Some(term),
1780 _ => None,
1781 }
1782 }
1783
1784 fn search_set_active(&mut self, term_opt: Option<String>) -> Task<Message> {
1785 let entity = self.tab_model.active();
1786 self.search_set(entity, term_opt, None)
1787 }
1788
1789 fn search_set(
1790 &mut self,
1791 tab: Entity,
1792 term_opt: Option<String>,
1793 selection_paths: Option<Vec<PathBuf>>,
1794 ) -> Task<Message> {
1795 let mut title_location_opt = None;
1796 if let Some(tab) = self.tab_model.data_mut::<Tab>(tab) {
1797 let location_opt = match term_opt {
1798 Some(term) => {
1799 let search_location = if let Some(path) = tab.location.path_opt() {
1800 Some(SearchLocation::Path(path.clone()))
1801 } else if tab.location.is_recents() {
1802 Some(SearchLocation::Recents)
1803 } else if tab.location.is_trash() {
1804 Some(SearchLocation::Trash)
1805 } else {
1806 None
1807 };
1808
1809 search_location.map(|search_location| {
1810 (
1811 Location::Search(
1812 search_location,
1813 term,
1814 tab.config.show_hidden,
1815 Instant::now(),
1816 ),
1817 true,
1818 )
1819 })
1820 }
1821 None => match &tab.location {
1822 Location::Search(search_location, ..) => match search_location {
1823 SearchLocation::Path(path) => Some((Location::Path(path.clone()), false)),
1824 SearchLocation::Recents => Some((Location::Recents, false)),
1825 SearchLocation::Trash => Some((Location::Trash, false)),
1826 },
1827 _ => None,
1828 },
1829 };
1830 if let Some((location, focus_search)) = location_opt {
1831 tab.change_location(&location, None);
1832 title_location_opt = Some((tab.title(), tab.location.clone(), focus_search));
1833 }
1834 }
1835 if let Some((title, location, focus_search)) = title_location_opt {
1836 self.tab_model.text_set(tab, title);
1837 return Task::batch([
1838 self.update_title(),
1839 self.update_watcher(),
1840 self.rescan_tab(tab, location, selection_paths),
1841 if focus_search {
1842 widget::text_input::focus(self.search_id.clone())
1843 } else {
1844 Task::none()
1845 },
1846 ]);
1847 }
1848 Task::none()
1849 }
1850
1851 fn selected_paths(
1852 &self,
1853 entity_opt: Option<Entity>,
1854 ) -> impl Iterator<Item = PathBuf> + use<'_> {
1855 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
1856 self.tab_model
1857 .data::<Tab>(entity)
1858 .into_iter()
1859 .flat_map(|tab| {
1860 tab.selected_locations()
1861 .into_iter()
1862 .filter_map(Location::into_path_opt)
1863 })
1864 }
1865
1866 fn selected_delete_paths(
1867 &self,
1868 entity_opt: Option<Entity>,
1869 ) -> impl Iterator<Item = PathBuf> + use<'_> {
1870 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
1871 self.tab_model
1872 .data::<Tab>(entity)
1873 .into_iter()
1874 .flat_map(|tab| tab.selected_delete_paths().into_iter())
1875 }
1876
1877 fn selected_uris(&self, entity_opt: Option<Entity>) -> impl Iterator<Item = String> + use<'_> {
1878 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
1879 self.tab_model
1880 .data::<Tab>(entity)
1881 .into_iter()
1882 .flat_map(|tab| {
1883 tab.selected_locations()
1884 .into_iter()
1885 .filter_map(Location::into_uri_opt)
1886 })
1887 }
1888
1889 fn set_cut(&mut self, entity_opt: Option<Entity>) {
1890 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
1891 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
1892 tab.cut_selected();
1893 }
1894 }
1895
1896 fn update_config(&mut self) -> Task<Message> {
1897 self.update_nav_model();
1898 let tabs: Box<[_]> = self.tab_model.iter().collect();
1900 let commands = std::iter::once(cosmic::command::set_theme(self.config.app_theme.theme()))
1902 .chain(tabs.into_iter().map(|entity| {
1903 self.update(Message::TabMessage(
1904 Some(entity),
1905 tab::Message::Config(self.config.tab),
1906 ))
1907 }));
1908 Task::batch(commands)
1909 }
1910
1911 fn update_desktop(&mut self) -> Task<Message> {
1912 let needs_reload: Box<[_]> = (self.tab_model.iter())
1913 .filter_map(|entity| {
1914 let tab = self.tab_model.data::<Tab>(entity)?;
1915 if let Location::Desktop(path, output, _) = &tab.location {
1916 Some((
1917 entity,
1918 Location::Desktop(path.clone(), output.clone(), self.config.desktop),
1919 ))
1920 } else {
1921 None
1922 }
1923 })
1924 .collect();
1925
1926 let mut commands = Vec::with_capacity(needs_reload.len());
1927 for (entity, location) in needs_reload {
1928 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
1929 tab.location = location.clone();
1930 }
1931 commands.push(self.update_tab(entity, location, None));
1932 }
1933 Task::batch(commands)
1934 }
1935
1936 fn activate_nav_model_location(&mut self, location: &Location) {
1937 let nav_bar_id = self.nav_model.iter().find(|&id| {
1938 self.nav_model
1939 .data::<Location>(id)
1940 .is_some_and(|l| l == location)
1941 });
1942
1943 if let Some(id) = nav_bar_id {
1944 self.nav_model.activate(id);
1945 } else {
1946 let active = self.nav_model.active();
1947 segmented_button::Selectable::deactivate(&mut self.nav_model, active);
1948 }
1949 }
1950
1951 fn close_context_menus(&mut self) -> Task<Message> {
1952 let active = self.tab_model.active();
1953 if let Some(tab) = self.tab_model.data_mut::<Tab>(active) {
1954 tab.location_context_menu_index = None;
1955 if tab.context_menu.is_some() {
1956 return self.update(Message::TabMessage(
1957 Some(active),
1958 tab::Message::ContextMenu(None, None),
1959 ));
1960 }
1961 }
1962
1963 Task::none()
1964 }
1965
1966 fn update_nav_model(&mut self) {
1967 let mut nav_model = segmented_button::ModelBuilder::default();
1968
1969 if self.config.show_recents {
1970 nav_model = nav_model.insert(|b| {
1971 b.text(fl!("recents"))
1972 .icon(icon::from_name("document-open-recent-symbolic"))
1973 .data(Location::Recents)
1974 });
1975 }
1976
1977 for (favorite_i, favorite) in self.config.favorites.iter().enumerate() {
1978 if let Some(path) = favorite.path_opt() {
1979 let name = if matches!(favorite, Favorite::Home) {
1980 fl!("home")
1981 } else if let Favorite::Network { name, .. } = favorite {
1982 name.clone()
1983 } else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
1984 file_name.to_string()
1985 } else {
1986 fl!("filesystem")
1987 };
1988 nav_model = nav_model.insert(move |b| {
1989 b.text(name.clone())
1990 .icon(
1991 icon::icon(if path.is_dir() {
1992 tab::folder_icon_symbolic(&path, 16)
1993 } else {
1994 icon::from_name("text-x-generic-symbolic").size(16).handle()
1995 })
1996 .size(16),
1997 )
1998 .data(match favorite {
1999 Favorite::Network { uri, name, path } => {
2000 Location::Network(uri.clone(), name.clone(), Some(path.to_owned()))
2001 }
2002 Favorite::Remote { uri, name, path } => {
2003 Location::Remote(uri.clone(), name.clone(), Some(path.to_owned()))
2004 }
2005 _ => Location::Path(path.clone()),
2006 })
2007 .data(FavoriteIndex(favorite_i))
2008 });
2009 }
2010 }
2011
2012 nav_model = nav_model.insert(|b| {
2013 b.text(fl!("trash"))
2014 .icon(icon::icon(Trash::icon_symbolic(16)))
2015 .data(Location::Trash)
2016 .divider_above()
2017 });
2018
2019 if !MOUNTERS.is_empty() {
2020 nav_model = nav_model.insert(|b| {
2021 b.text(fl!("networks"))
2022 .icon(icon::icon(
2023 icon::from_name("network-workgroup-symbolic")
2024 .size(16)
2025 .handle(),
2026 ))
2027 .data(Location::Network(
2028 "network:///".to_string(),
2029 fl!("networks"),
2030 None,
2031 ))
2032 .divider_above()
2033 });
2034 }
2035 if !CLIENTS.is_empty() {
2036 nav_model = nav_model.insert(|b| {
2037 b.text(fl!("remotes"))
2038 .icon(icon::icon(
2039 icon::from_name("network-workgroup-symbolic")
2040 .size(16)
2041 .handle(),
2042 ))
2043 .data(Location::Remote(
2044 "ssh:///".to_string(),
2045 fl!("remotes"),
2046 None,
2047 ))
2048 .divider_above()
2049 });
2050 }
2051
2052 let mut nav_items = Vec::new();
2054 for (key, items) in &self.mounter_items {
2055 nav_items.extend(items.iter().map(|item| (*key, item)));
2056 }
2057 nav_items.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.1.name(), &b.1.name()));
2059 for (i, (key, item)) in nav_items.into_iter().enumerate() {
2061 nav_model = nav_model.insert(|mut b| {
2062 b = b.text(item.name()).data(MounterData(key, item.clone()));
2063 let uri = item.uri();
2064 if let Some(path) = item.path() {
2065 if item.is_remote() {
2066 b = b.data(Location::Network(uri, item.name(), Some(path)));
2067 } else {
2068 b = b.data(Location::Path(path));
2069 }
2070 } else if !uri.is_empty() {
2071 b = b.data(Location::Network(uri, item.name(), None));
2072 }
2073 if let Some(icon) = item.icon(true) {
2074 b = b.icon(icon::icon(icon).size(16));
2075 }
2076 if item.is_mounted() {
2077 b = b.closable();
2078 }
2079 if i == 0 {
2080 b = b.divider_above();
2081 }
2082 b
2083 });
2084 }
2085
2086 let mut client_items = Vec::new();
2087 for (key, items) in &self.client_items {
2088 client_items.extend(items.iter().map(|item| (*key, item)));
2089 }
2090 client_items.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.1.name(), &b.1.name()));
2092 for (i, (key, item)) in client_items.into_iter().enumerate() {
2094 nav_model = nav_model.insert(|mut b| {
2095 b = b.text(item.name()).data(ClientData(key, item.clone()));
2096 let uri = item.uri();
2097 if let Some(path) = item.path() {
2098 b = b.data(Location::Remote(uri, item.name(), Some(path)));
2099 } else if !uri.is_empty() {
2100 b = b.data(Location::Remote(uri, item.name(), None));
2101 }
2102 if let Some(icon) = item.icon(true) {
2103 b = b.icon(icon::icon(icon).size(16));
2104 }
2105 if item.is_connected() {
2106 b = b.closable();
2107 }
2108 if i == 0 {
2109 b = b.divider_above();
2110 }
2111 b
2112 });
2113 }
2114
2115 self.nav_model = nav_model.build();
2116
2117 let tab_entity = self.tab_model.active();
2118 if let Some(tab) = self.tab_model.data::<Tab>(tab_entity) {
2119 self.activate_nav_model_location(&tab.location.clone());
2120 }
2121 }
2122
2123 fn update_notification(&mut self) -> Task<Message> {
2124 if self.pending_operations.is_empty() {
2126 #[cfg(feature = "notify")]
2127 if let Some(notification_arc) = self.notification_opt.take() {
2128 return Task::future(async move {
2129 tokio::task::spawn_blocking(move || {
2130 let notification_mutex = Arc::try_unwrap(notification_arc).unwrap();
2132 let notification = notification_mutex.into_inner().unwrap();
2133 notification.close();
2134 })
2135 .await
2136 .unwrap();
2137 cosmic::action::app(Message::MaybeExit)
2138 });
2139 }
2140 }
2141
2142 Task::none()
2143 }
2144
2145 fn update_title(&mut self) -> Task<Message> {
2146 let window_title = match self.tab_model.text(self.tab_model.active()) {
2147 Some(tab_title) => format!("{tab_title} — {}", fl!("cosmic-files")),
2148 None => fl!("cosmic-files"),
2149 };
2150 if let Some(window_id) = self.core.main_window_id() {
2151 self.set_window_title(window_title, window_id)
2152 } else {
2153 Task::none()
2154 }
2155 }
2156
2157 fn update_watcher(&mut self) -> Task<Message> {
2158 if let Some((mut watcher, old_paths)) = self.watcher_opt.take() {
2159 let new_paths: FxHashSet<_> = self
2160 .tab_model
2161 .iter()
2162 .filter_map(|entity| {
2163 let tab = self.tab_model.data::<Tab>(entity)?;
2164 tab.location.path_opt().cloned()
2165 })
2166 .collect();
2167
2168 for path in &old_paths {
2170 if !new_paths.contains(path) {
2171 match watcher.unwatch(path) {
2172 Ok(()) => {
2173 log::debug!("unwatching {}", path.display());
2174 }
2175 Err(err) => {
2176 log::debug!("failed to unwatch {}: {}", path.display(), err);
2177 }
2178 }
2179 }
2180 }
2181
2182 for path in &new_paths {
2184 if !old_paths.contains(path) {
2185 match watcher.watch(path, notify::RecursiveMode::NonRecursive) {
2186 Ok(()) => {
2187 log::debug!("watching {}", path.display());
2188 }
2189 Err(err) => {
2190 log::debug!("failed to watch {}: {}", path.display(), err);
2191 }
2192 }
2193 }
2194 }
2195
2196 self.watcher_opt = Some((watcher, new_paths));
2197 }
2198
2199 Task::none()
2201 }
2202
2203 fn network_drive(&self) -> Element<'_, Message> {
2204 let cosmic_theme::Spacing {
2205 space_xxs, space_m, ..
2206 } = theme::spacing();
2207 let mut table = widget::column::with_capacity(8);
2208 for (i, line) in fl!("network-drive-schemes").lines().enumerate() {
2209 let mut row = widget::row::with_capacity(2);
2210 for part in line.split(',') {
2211 row = row.push(
2212 widget::container(if i == 0 {
2213 widget::text::heading(part.to_string())
2214 } else {
2215 widget::text::body(part.to_string())
2216 })
2217 .width(Length::Fill)
2218 .padding(space_xxs),
2219 );
2220 }
2221 table = table.push(row);
2222 if i == 0 {
2223 table = table.push(widget::divider::horizontal::light());
2224 }
2225 }
2226 widget::column::with_children([
2227 widget::text::body(fl!("network-drive-description")).into(),
2228 table.into(),
2229 ])
2230 .spacing(space_m)
2231 .into()
2232 }
2233
2234 fn remote_drive(&self) -> Element<'_, Message> {
2235 let cosmic_theme::Spacing {
2236 space_xxs, space_m, ..
2237 } = theme::active().cosmic().spacing;
2238 let mut table = widget::column::with_capacity(8);
2239 for (i, line) in fl!("network-drive-schemes").lines().enumerate() {
2240 let mut row = widget::row::with_capacity(2);
2241 for part in line.split(',') {
2242 row = row.push(
2243 widget::container(if i == 0 {
2244 widget::text::heading(part.to_string())
2245 } else {
2246 widget::text::body(part.to_string())
2247 })
2248 .width(Length::Fill)
2249 .padding(space_xxs),
2250 );
2251 }
2252 table = table.push(row);
2253 if i == 0 {
2254 table = table.push(widget::divider::horizontal::light());
2255 }
2256 }
2257 widget::column::with_children([
2258 widget::text::body(fl!("network-drive-description")).into(),
2259 table.into(),
2260 ])
2261 .spacing(space_m)
2262 .into()
2263 }
2264
2265 fn desktop_view_options(&self) -> Element<'_, Message> {
2266 let cosmic_theme::Spacing {
2267 space_m, space_l, ..
2268 } = theme::spacing();
2269 let config = self.config.desktop;
2270
2271 let show_on_desktop = settings::section()
2272 .title(fl!("show-on-desktop"))
2273 .add(
2274 settings::item::builder(fl!("desktop-folder-content")).toggler(
2275 config.show_content,
2276 move |show_content| {
2277 Message::DesktopConfig(DesktopConfig {
2278 show_content,
2279 ..config
2280 })
2281 },
2282 ),
2283 )
2284 .add(settings::item::builder(fl!("mounted-drives")).toggler(
2285 config.show_mounted_drives,
2286 move |show_mounted_drives| {
2287 Message::DesktopConfig(DesktopConfig {
2288 show_mounted_drives,
2289 ..config
2290 })
2291 },
2292 ))
2293 .add(settings::item::builder(fl!("trash-folder-icon")).toggler(
2294 config.show_trash,
2295 move |show_trash| {
2296 Message::DesktopConfig(DesktopConfig {
2297 show_trash,
2298 ..config
2299 })
2300 },
2301 ))
2302 .add(
2303 widget::settings::item::builder(fl!("connected-drives")).toggler(
2304 config.show_connected_drives,
2305 move |show_connected_drives| {
2306 Message::DesktopConfig(DesktopConfig {
2307 show_connected_drives,
2308 ..config
2309 })
2310 },
2311 ));
2312
2313 let icon_size = config.icon_size;
2314 let grid_spacing = config.grid_spacing;
2315 let icon_size_and_spacing = settings::section()
2316 .title(fl!("icon-size-and-spacing"))
2317 .add(
2318 settings::item::builder(fl!("icon-size"))
2319 .description(format!("{icon_size}%"))
2320 .control(
2321 widget::slider(50..=500, icon_size.get(), move |new_value| {
2322 Message::DesktopConfig(DesktopConfig {
2323 icon_size: NonZeroU16::new(new_value).unwrap_or(icon_size),
2324 ..config
2325 })
2326 })
2327 .step(25u16),
2328 ),
2329 )
2330 .add(
2331 settings::item::builder(fl!("grid-spacing"))
2332 .description(format!("{grid_spacing}%"))
2333 .control(
2334 widget::slider(50..=500, grid_spacing.get(), move |new_value| {
2335 Message::DesktopConfig(DesktopConfig {
2336 grid_spacing: NonZeroU16::new(new_value).unwrap_or(grid_spacing),
2337 ..config
2338 })
2339 })
2340 .step(25u16),
2341 ),
2342 );
2343
2344 widget::column::with_capacity(2)
2345 .padding([0, space_l, space_l, space_l])
2346 .spacing(space_m)
2347 .push(show_on_desktop)
2348 .push(icon_size_and_spacing)
2349 .into()
2350 }
2351
2352 fn edit_history(&self) -> Element<'_, Message> {
2353 let cosmic_theme::Spacing { space_m, .. } = theme::spacing();
2354
2355 let mut children = Vec::new();
2356
2357 let progress_bar_height = Length::Fixed(4.0);
2359
2360 if !self.pending_operations.is_empty() {
2361 let mut section = widget::settings::section().title(fl!("pending"));
2362 for (id, (op, controller)) in self.pending_operations.iter().rev() {
2363 let progress = controller.progress();
2364 section = section.add(widget::column::with_children([
2365 widget::row::with_children([
2366 widget::determinate_linear(progress)
2367 .width(Length::Fill)
2368 .girth(progress_bar_height)
2369 .into(),
2370 if controller.is_paused() {
2371 widget::tooltip(
2372 widget::button::icon(icon::from_name(
2373 "media-playback-start-symbolic",
2374 ))
2375 .on_press(Message::PendingPause(*id, false))
2376 .padding(8),
2377 widget::text::body(fl!("resume")),
2378 widget::tooltip::Position::Top,
2379 )
2380 .into()
2381 } else {
2382 widget::tooltip(
2383 widget::button::icon(icon::from_name(
2384 "media-playback-pause-symbolic",
2385 ))
2386 .on_press(Message::PendingPause(*id, true))
2387 .padding(8),
2388 widget::text::body(fl!("pause")),
2389 widget::tooltip::Position::Top,
2390 )
2391 .into()
2392 },
2393 widget::tooltip(
2394 widget::button::icon(icon::from_name("window-close-symbolic"))
2395 .on_press(Message::PendingCancel(*id))
2396 .padding(8),
2397 widget::text::body(fl!("cancel")),
2398 widget::tooltip::Position::Top,
2399 )
2400 .into(),
2401 ])
2402 .align_y(Alignment::Center)
2403 .into(),
2404 widget::text::body(op.pending_text(progress, controller.state())).into(),
2405 ]));
2406 }
2407 children.push(section.into());
2408 }
2409
2410 if !self.failed_operations.is_empty() {
2411 let mut section = widget::settings::section().title(fl!("failed"));
2412 for (op, controller, error) in self.failed_operations.values().rev() {
2413 let progress = controller.progress();
2414 section = section.add(widget::column::with_children([
2415 widget::text::body(op.pending_text(progress, controller.state())).into(),
2416 widget::text::body(error).into(),
2417 ]));
2418 }
2419 children.push(section.into());
2420 }
2421
2422 if !self.complete_operations.is_empty() {
2423 let mut section = widget::settings::section().title(fl!("complete"));
2424 for op in self.complete_operations.values().rev() {
2425 section = section.add(widget::text::body(op.completed_text()));
2426 }
2427 children.push(section.into());
2428 }
2429
2430 if children.is_empty() {
2431 children.push(widget::text::body(fl!("no-history")).into());
2432 }
2433
2434 widget::column::with_children(children)
2435 .spacing(space_m)
2436 .into()
2437 }
2438
2439 fn preview<'a>(
2440 &'a self,
2441 entity_opt: &Option<Entity>,
2442 kind: &'a PreviewKind,
2443 context_drawer: bool,
2444 ) -> Element<'a, tab::Message> {
2445 let cosmic_theme::Spacing { space_l, .. } = theme::spacing();
2446
2447 let mut children = Vec::with_capacity(1);
2448 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
2449 let military_time = self.config.tab.military_time;
2450 match kind {
2451 PreviewKind::Custom(PreviewItem(item)) => {
2452 children.push(item.preview_view(Some(&self.mime_app_cache), military_time));
2453 }
2454 PreviewKind::Location(location) => {
2455 if let Some(tab) = self.tab_model.data::<Tab>(entity)
2456 && let Some(items) = tab.items_opt()
2457 {
2458 for item in items {
2459 if item.location_opt.as_ref() == Some(location) {
2460 children
2461 .push(item.preview_view(Some(&self.mime_app_cache), military_time));
2462 break;
2465 }
2466 }
2467 }
2468 }
2469 PreviewKind::Selected => {
2470 if let Some(tab) = self.tab_model.data::<Tab>(entity)
2471 && let Some(items) = tab.items_opt()
2472 {
2473 let preview_opt =
2474 {
2475 let mut selected = items.iter().filter(|item| item.selected);
2476 match (selected.next(), selected.next()) {
2477 (Some(_), Some(_)) => {
2479 Some(tab.multi_preview_view(Some(&self.mime_app_cache)))
2480 }
2481 (Some(item), None) => {
2483 if item.is_erm41() {
2484 Some(item.preview_erm41())
2485 } else if item.is_hsp65() {
2486 Some(item.preview_hsp65())
2487 } else if item.is_rpob() {
2488 Some(item.preview_rpob())
2489 } else if item.is_16s() {
2490 Some(item.preview_16s())
2491 } else if item.is_rrl_ntm() {
2492 Some(item.preview_rrl_ntm())
2493 } else if item.is_pnca() {
2494 Some(item.preview_pnca())
2495 } else if item.metadata.is_tbprofiler_json() {
2496 Some(item.preview_tbprofiler_json())
2497 } else {
2498 Some(item.preview_view(
2499 Some(&self.mime_app_cache),
2500 military_time,
2501 ))
2502 }
2503 }
2504 _ => None,
2506 }
2507 };
2508
2509 if let Some(preview) = preview_opt {
2510 children.push(preview);
2511 }
2512
2513 if children.is_empty()
2514 && let Some(item) = &tab.parent_item_opt
2515 {
2516 children.push(item.preview_view(Some(&self.mime_app_cache), military_time));
2517 }
2518 }
2519 }
2520 }
2521 widget::column::with_children(children)
2522 .padding(if context_drawer {
2523 [0, 0, 0, 0]
2524 } else {
2525 [0, space_l, space_l, space_l]
2526 })
2527 .into()
2528 }
2529
2530 fn settings(&self) -> Element<'_, Message> {
2531 let tab_config = self.config.tab;
2532
2533 settings::view_column(vec![
2535 settings::section()
2536 .title(fl!("appearance"))
2537 .add({
2538 let app_theme_selected = match self.config.app_theme {
2539 AppTheme::Dark => 1,
2540 AppTheme::Light => 2,
2541 AppTheme::System => 0,
2542 };
2543 settings::item::builder(fl!("theme")).control(widget::dropdown(
2544 &self.app_themes,
2545 Some(app_theme_selected),
2546 move |index| {
2547 Message::AppTheme(match index {
2548 1 => AppTheme::Dark,
2549 2 => AppTheme::Light,
2550 _ => AppTheme::System,
2551 })
2552 },
2553 ))
2554 })
2555 .into(),
2556 settings::section()
2557 .title(fl!("type-to-search"))
2558 .add(
2559 settings::item::builder(fl!("type-to-search-recursive")).radio(
2560 TypeToSearch::Recursive,
2561 Some(self.config.type_to_search),
2562 Message::SetTypeToSearch,
2563 ),
2564 )
2565 .add(
2566 settings::item::builder(fl!("type-to-search-enter-path")).radio(
2567 TypeToSearch::EnterPath,
2568 Some(self.config.type_to_search),
2569 Message::SetTypeToSearch,
2570 ),
2571 )
2572 .add(settings::item::builder(fl!("type-to-search-select")).radio(
2573 TypeToSearch::SelectByPrefix,
2574 Some(self.config.type_to_search),
2575 Message::SetTypeToSearch,
2576 ))
2577 .into(),
2578 settings::section()
2579 .title(fl!("other"))
2580 .add({
2581 settings::item::builder(fl!("single-click")).toggler(
2582 tab_config.single_click,
2583 move |single_click| {
2584 Message::TabConfig(TabConfig {
2585 single_click,
2586 ..tab_config
2587 })
2588 },
2589 )
2590 })
2591 .add({
2592 settings::item::builder(fl!("show-recents"))
2593 .toggler(self.config.show_recents, Message::SetShowRecents)
2594 })
2595 .into(),
2596 ])
2597 .into()
2598 }
2599
2600 fn tb_settings(&self) -> Element<'_, Message> {
2601 widget::settings::view_column(vec![
2602 widget::settings::section()
2603 .title("TB Profiler")
2604 .add(
2605 widget::settings::item::builder("TB Profiler script path").control(
2606 widget::text_input("", &self.config.tb_config.script_path)
2607 .on_input(Message::SetTbScriptPath),
2608 ),
2609 )
2610 .add(
2611 widget::settings::item::builder("TB Profiler output directory").control(
2612 widget::text_input("", &self.config.tb_config.out_dir)
2613 .on_input(Message::SetTbOutDir),
2614 ),
2615 )
2616 .add(
2617 widget::settings::item::builder("TB DOCX Template Path").control(
2618 widget::text_input("", &self.config.tb_config.docx_template_path)
2619 .on_input(Message::SetTbDocxTemplatePath),
2620 ),
2621 )
2622 .add(
2623 widget::settings::item::builder("Pair 1 suffix").control(
2624 widget::text_input("", &self.config.tb_config.pair1_suffix)
2625 .on_input(Message::SetTbPair1Suffix),
2626 ),
2627 )
2628 .add(
2629 widget::settings::item::builder("Pair 2 suffix").control(
2630 widget::text_input("", &self.config.tb_config.pair2_suffix)
2631 .on_input(Message::SetTbPair2Suffix),
2632 ),
2633 )
2634 .add(
2635 widget::settings::item::builder("AB1 scan directory").control(
2636 widget::text_input("", &self.config.tb_config.ab1_scan_path)
2637 .on_input(Message::SetTbAb1ScanPath),
2638 ),
2639 )
2640 .add(
2641 widget::settings::item::builder("AB1 cache file").control(
2642 widget::text_input("", &self.config.tb_config.ab1_cache_path)
2643 .on_input(Message::SetTbAb1CachePath),
2644 ),
2645 )
2646 .add(
2647 widget::settings::item::builder("AB1 CSV output directory").control(
2648 widget::text_input("", &self.config.tb_config.ab1_out_dir_csv)
2649 .on_input(Message::SetTbAb1OutDirCsv),
2650 ),
2651 )
2652 .add(
2653 widget::settings::item::builder("AB1 PDF output directory").control(
2654 widget::text_input("", &self.config.tb_config.ab1_out_dir_pdf)
2655 .on_input(Message::SetTbAb1OutDirPdf),
2656 ),
2657 )
2658 .add(
2659 widget::settings::item::builder("ntfy topic").control(
2660 widget::text_input(
2661 "my-lab-topic or https://ntfy.example.org/topic",
2662 &self.config.tb_config.ntfy_topic,
2663 )
2664 .on_input(Message::SetNtfyTopic),
2665 ),
2666 )
2667 .add(
2668 widget::settings::item::builder("Report max sample age (days)").control(
2669 widget::text_input(
2670 "60",
2671 self.config.tb_config.report_max_age_days.to_string(),
2672 )
2673 .on_input(Message::SetTbReportMaxAgeDays),
2674 ),
2675 )
2676 .into(),
2677 ])
2678 .into()
2679 }
2680
2681 fn update_favorites(&mut self, path_changes: &[(impl AsRef<Path>, impl AsRef<Path>)]) -> bool {
2683 let mut favorites_changed = false;
2684 let favorites = self
2685 .config
2686 .favorites
2687 .iter()
2688 .map(|favorite| {
2689 if let Favorite::Path(path) = favorite {
2690 for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref())) {
2691 if path.starts_with(from)
2692 && let Ok(relative) = path.strip_prefix(from)
2693 {
2694 favorites_changed = true;
2695 return Favorite::from_path(to.join(relative));
2696 }
2697 }
2698 }
2699 favorite.clone()
2700 })
2701 .collect();
2702
2703 if favorites_changed {
2704 if let Some(config_handler) = &self.config_handler {
2705 match self.config.set_favorites(config_handler, favorites) {
2706 Ok(updated) => {
2707 if updated {
2708 return true;
2709 }
2710 }
2711 Err(err) => {
2712 log::warn!("failed to update favorites after moving directories: {err:?}",);
2713 }
2714 }
2715 } else {
2716 self.config.favorites = favorites;
2717 log::warn!(
2718 "failed to update favorites after moving directories: no config handler",
2719 );
2720 }
2721 }
2722
2723 false
2724 }
2725}
2726
2727impl Application for App {
2729 type Executor = executor::Default;
2731
2732 type Flags = Flags;
2734
2735 type Message = Message;
2737
2738 const APP_ID: &'static str = "com.system76.CosmicFiles";
2740
2741 fn core(&self) -> &Core {
2742 &self.core
2743 }
2744
2745 fn core_mut(&mut self) -> &mut Core {
2746 &mut self.core
2747 }
2748
2749 fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Self::Message>) {
2751 core.window.context_is_overlay = false;
2752 match flags.mode {
2753 Mode::App => {
2754 core.window.show_context = flags.config.show_details;
2755 }
2756 Mode::Desktop => {
2757 core.window.content_container = false;
2758 core.window.show_window_menu = false;
2759 core.window.show_headerbar = false;
2760 core.window.sharp_corners = false;
2761 core.window.show_maximize = false;
2762 core.window.show_minimize = false;
2763 core.window.use_template = true;
2764 }
2765 }
2766
2767 let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")];
2768
2769 let key_binds = key_binds(&match flags.mode {
2770 Mode::App => tab::Mode::App,
2771 Mode::Desktop => tab::Mode::Desktop,
2772 });
2773
2774 let (compio_tx, mut compio_rx) = mpsc::channel(1);
2777 let tokio_handle = tokio::runtime::Handle::current();
2778 std::thread::spawn(move || {
2779 let _tokio = tokio_handle.enter();
2780 compio::runtime::RuntimeBuilder::new()
2781 .build()
2782 .unwrap()
2783 .block_on(async move {
2784 while let Some(task) = compio_rx.recv().await {
2785 compio::runtime::spawn(task).detach();
2786 }
2787 });
2788 });
2789
2790 let about = About::default()
2791 .name(fl!("cosmic-files"))
2792 .icon(icon::from_name(Self::APP_ID))
2793 .version(env!("CARGO_PKG_VERSION"))
2794 .author("System76")
2795 .comments(fl!("comment"))
2796 .license("GPL-3.0-only")
2797 .license_url("https://spdx.org/licenses/GPL-3.0-only")
2798 .developers([("Jeremy Soller", "jeremy@system76.com")])
2799 .links([
2800 (fl!("repository"), "https://github.com/pop-os/cosmic-files"),
2801 (
2802 fl!("support"),
2803 "https://github.com/pop-os/cosmic-files/issues",
2804 ),
2805 ]);
2806
2807 let mut app = Self {
2808 core,
2809 about,
2810 nav_bar_context_id: segmented_button::Entity::null(),
2811 nav_model: segmented_button::ModelBuilder::default().build(),
2812 tab_model: segmented_button::ModelBuilder::default().build(),
2813 config_handler: flags.config_handler,
2814 state_handler: flags.state_handler,
2815 config: flags.config,
2816 state: flags.state,
2817 mode: flags.mode,
2818 app_themes,
2819 compio_tx,
2820 context_page: ContextPage::Preview(None, PreviewKind::Selected),
2821 dialog_pages: DialogPages::new(),
2822 dialog_text_input: widget::Id::new("Dialog Text Input"),
2823 key_binds,
2824 margin: FxHashMap::default(),
2825 mime_app_cache: MimeAppCache::new(),
2826 modifiers: Modifiers::empty(),
2827 mounter_items: FxHashMap::default(),
2828 client_items: FxHashMap::default(),
2829 must_save_sort_names: false,
2830 network_drive_connecting: None,
2831 network_drive_input: String::new(),
2832 remote_drive_connecting: None,
2833 remote_drive_input: String::new(),
2834 #[cfg(feature = "notify")]
2835 notification_opt: None,
2836 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
2837 overlap: FxHashMap::default(),
2838 pending_operation_id: 0,
2839 pending_operations: BTreeMap::new(),
2840 progress_operations: BTreeSet::new(),
2841 complete_operations: BTreeMap::new(),
2842 failed_operations: BTreeMap::new(),
2843 scrollable_id: widget::Id::new("File Scrollable"),
2844 search_id: widget::Id::new("File Search"),
2845 size: None,
2846 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
2847 surface_ids: FxHashMap::default(),
2848 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
2849 surface_names: FxHashMap::default(),
2850 toasts: widget::toaster::Toasts::new(Message::CloseToast),
2851 watcher_opt: None,
2852 windows: FxHashMap::default(),
2853 nav_dnd_hover: None,
2854 tab_dnd_hover: None,
2855 type_select_prefix: String::new(),
2856 type_select_last_key: None,
2857 nav_drag_id: DragId::new(),
2858 tab_drag_id: DragId::new(),
2859 auto_scroll_speed: None,
2860 file_dialog_opt: None,
2861 clipboard_cache: ClipboardCache::Empty,
2862 running_tasks: std::collections::HashMap::new(),
2863 job_total_tasks: std::collections::HashMap::new(),
2864 download_files_total: 0,
2865 download_files_done: 0,
2866 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
2867 layer_sizes: FxHashMap::default(),
2868 };
2869
2870 let mut commands = vec![app.update_config(), app.update(Message::CheckClipboard)];
2871
2872 if !app.config.tb_config.ab1_scan_path.is_empty() {
2873 commands.push(app.update(Message::ScanAb1Directory));
2874 }
2875
2876 for location in flags.locations {
2877 if let Some(path) = location.path_opt()
2878 && path.is_file()
2879 && let Some(parent) = path.parent()
2880 {
2881 commands.push(app.open_tab(
2882 Location::Path(parent.to_path_buf()),
2883 true,
2884 Some(vec![path.clone()]),
2885 ));
2886 continue;
2887 }
2888 commands.push(app.open_tab(location, true, None));
2889 }
2890 for location in flags.uris {
2891 if let Some(e) = app.nav_model.iter().find(|e| {
2892 app.nav_model.data::<Location>(*e).is_some_and(
2893 |l| matches!(l, Location::Network(uri, ..) if *uri == *location.as_str()),
2894 )
2895 }) {
2896 commands.push(cosmic::task::message(cosmic::Action::App(
2897 Message::NetworkDriveOpenEntityAfterMount { entity: e },
2898 )));
2899 }
2900 if let Some(e) = app.nav_model.iter().find(|e| {
2901 app.nav_model.data::<Location>(*e).is_some_and(
2902 |l| matches!(l, Location::Remote(uri, ..) if *uri == *location.as_str()),
2903 )
2904 }) {
2905 commands.push(cosmic::task::message(cosmic::Action::App(
2906 Message::RemoteDriveOpenEntityAfterMount { entity: e },
2907 )));
2908 }
2909 }
2910
2911 if app.tab_model.entity_at(0).is_none() {
2912 if let Ok(current_dir) = env::current_dir() {
2913 commands.push(app.open_tab(Location::Path(current_dir), true, None));
2914 } else {
2915 commands.push(app.open_tab(Location::Path(home_dir()), true, None));
2916 }
2917 }
2918
2919 (app, Task::batch(commands))
2920 }
2921
2922 fn nav_bar(&self) -> Option<Element<'_, cosmic::Action<Self::Message>>> {
2923 if !self.core.nav_bar_active() {
2924 return None;
2925 }
2926
2927 let nav_model = self.nav_model()?;
2928
2929 let mut nav = cosmic::widget::nav_bar(nav_model, |entity| {
2930 cosmic::Action::Cosmic(cosmic::app::Action::NavBar(entity))
2931 })
2932 .drag_id(self.nav_drag_id)
2933 .on_dnd_enter(|entity, _| cosmic::Action::App(Message::DndEnterNav(entity)))
2934 .on_dnd_leave(|_| cosmic::Action::App(Message::DndExitNav))
2935 .on_dnd_drop(|entity, data, action| {
2936 cosmic::Action::App(Message::DndDropNav(entity, data, action))
2937 })
2938 .on_context(|entity| cosmic::Action::App(Message::NavBarContext(entity)))
2939 .on_close(|entity| cosmic::Action::App(Message::NavBarClose(entity)))
2940 .on_middle_press(|entity| {
2941 cosmic::Action::App(Message::NavMenuAction(NavMenuAction::OpenInNewTab(entity)))
2942 })
2943 .context_menu(self.nav_context_menu(self.nav_bar_context_id))
2944 .close_icon(icon::from_name("media-eject-symbolic").size(16).icon())
2945 .into_container();
2946
2947 if !self.core.is_condensed() {
2948 nav = nav.max_width(280);
2949 }
2950
2951 Some(Element::from(
2952 nav.width(Length::Shrink).height(Length::Fill),
2953 ))
2954 }
2955
2956 fn nav_context_menu(
2957 &self,
2958 entity: widget::nav_bar::Id,
2959 ) -> Option<Vec<widget::menu::Tree<cosmic::Action<Self::Message>>>> {
2960 let favorite_index_opt = self.nav_model.data::<FavoriteIndex>(entity);
2961 let location_opt = self.nav_model.data::<Location>(entity);
2962
2963 let mut items = Vec::with_capacity(7);
2964
2965 if location_opt
2966 .and_then(Location::path_opt)
2967 .is_some_and(|x| x.is_file())
2968 {
2969 items.push(cosmic::widget::menu::Item::Button(
2970 fl!("open"),
2971 None,
2972 NavMenuAction::Open(entity),
2973 ));
2974 items.push(cosmic::widget::menu::Item::Button(
2975 fl!("menu-open-with"),
2976 None,
2977 NavMenuAction::OpenWith(entity),
2978 ));
2979 } else {
2980 items.push(cosmic::widget::menu::Item::Button(
2981 fl!("open-in-new-tab"),
2982 None,
2983 NavMenuAction::OpenInNewTab(entity),
2984 ));
2985 items.push(cosmic::widget::menu::Item::Button(
2986 fl!("open-in-new-window"),
2987 None,
2988 NavMenuAction::OpenInNewWindow(entity),
2989 ));
2990 }
2991 if let Some(path) = location_opt.and_then(Location::path_opt) {
2992 let selected_dir = usize::from(path.is_dir());
2993 let action_items: Vec<_> = self
2994 .config
2995 .context_actions
2996 .iter()
2997 .enumerate()
2998 .filter(|(_, action)| action.matches_selection(1, selected_dir))
2999 .map(|(i, action)| {
3000 cosmic::widget::menu::Item::Button(
3001 action.name.clone(),
3002 None,
3003 NavMenuAction::RunContextAction(entity, i),
3004 )
3005 })
3006 .collect();
3007
3008 if !action_items.is_empty() {
3009 items.push(cosmic::widget::menu::Item::Divider);
3010 items.extend(action_items);
3011 }
3012 }
3013 items.push(cosmic::widget::menu::Item::Divider);
3014 if matches!(location_opt, Some(Location::Path(..))) {
3015 items.push(cosmic::widget::menu::Item::Button(
3016 fl!("show-details"),
3017 None,
3018 NavMenuAction::Preview(entity),
3019 ));
3020 }
3021 items.push(cosmic::widget::menu::Item::Divider);
3022 if favorite_index_opt.is_some() {
3023 items.push(cosmic::widget::menu::Item::Button(
3024 fl!("remove-from-sidebar"),
3025 None,
3026 NavMenuAction::RemoveFromSidebar(entity),
3027 ));
3028 }
3029
3030 if matches!(location_opt, Some(Location::Recents)) && tab::has_recents() {
3031 items.push(cosmic::widget::menu::Item::Button(
3032 fl!("clear-recents-history"),
3033 None,
3034 NavMenuAction::ClearRecents,
3035 ));
3036 }
3037
3038 if matches!(location_opt, Some(Location::Trash)) && !Trash::is_empty() {
3039 items.push(cosmic::widget::menu::Item::Button(
3040 fl!("empty-trash"),
3041 None,
3042 NavMenuAction::EmptyTrash,
3043 ));
3044 }
3045
3046 Some(cosmic::widget::menu::items(&HashMap::new(), items))
3047 }
3048
3049 fn nav_model(&self) -> Option<&segmented_button::SingleSelectModel> {
3050 match self.mode {
3051 Mode::App => Some(&self.nav_model),
3052 Mode::Desktop => None,
3053 }
3054 }
3055
3056 fn on_nav_select(&mut self, entity: Entity) -> Task<Self::Message> {
3057 self.nav_model.activate(entity);
3058 if let Some(location) = self.nav_model.data::<Location>(entity) {
3059 let should_open = match location {
3060 #[cfg(feature = "gvfs")]
3061 Location::Network(uri, name, Some(path))
3062 if !path.try_exists().unwrap_or_default() =>
3063 {
3064 let mut found = false;
3065
3066 if let Some(key) = self
3067 .mounter_items
3068 .iter()
3069 .find_map(|(k, items)| {
3070 items.iter().find_map(|item| {
3071 found |= item.path().is_some_and(|p| path.starts_with(&p))
3072 || item.name() == *name
3073 || item.uri() == *uri;
3074 (!item.is_mounted() && found).then_some(*k)
3075 })
3076 })
3077 .or(if found {
3078 None
3079 } else {
3080 self.mounter_items.keys().copied().next()
3082 })
3083 && let Some(mounter) = MOUNTERS.get(&key)
3084 {
3085 return mounter.network_drive(uri.clone()).map(move |()| {
3086 cosmic::Action::App(Message::NetworkDriveOpenEntityAfterMount {
3087 entity,
3088 })
3089 });
3090 }
3091
3092 log::warn!(
3093 "failed to open favorite, path does not exist: {}",
3094 path.display()
3095 );
3096 return self.push_dialog(
3097 DialogPage::FavoritePathError {
3098 path: path.clone(),
3099 entity,
3100 },
3101 Some(FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID.clone()),
3102 );
3103 }
3104 #[cfg(feature = "russh")]
3105 Location::Remote(uri, name, Some(path)) => {
3106 let mut found = false;
3107
3108 if let Some(key) = self
3109 .client_items
3110 .iter()
3111 .find_map(|(k, items)| {
3112 items.iter().find_map(|item| {
3113 found |= item.path().is_some_and(|p| path.starts_with(&p))
3114 || item.name() == *name
3115 || item.uri() == *uri;
3116 found.then_some(*k)
3117 })
3118 })
3119 .or(if found {
3120 None
3121 } else {
3122 self.client_items.keys().copied().next()
3124 })
3125 && let Some(client) = CLIENTS.get(&key)
3126 {
3127 return client.remote_drive(uri.clone()).map(move |()| {
3128 cosmic::Action::App(Message::RemoteDriveOpenEntityAfterMount { entity })
3129 });
3130 }
3131
3132 log::warn!(
3133 "failed to open favorite, path does not exist: {}",
3134 path.display()
3135 );
3136 return self.push_dialog(
3137 DialogPage::FavoritePathError {
3138 path: path.clone(),
3139 entity,
3140 },
3141 Some(FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID.clone()),
3142 );
3143 }
3144 Location::Path(path) | Location::Network(_, _, Some(path)) => {
3145 match path.try_exists() {
3146 Ok(true) => true,
3147 Ok(false) => {
3148 log::warn!(
3149 "failed to open favorite, path does not exist: {}",
3150 path.display()
3151 );
3152 return self.push_dialog(
3153 DialogPage::FavoritePathError {
3154 path: path.clone(),
3155 entity,
3156 },
3157 Some(FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID.clone()),
3158 );
3159 }
3160 Err(err) => {
3161 log::warn!(
3162 "failed to open favorite for path: {}, {}",
3163 path.display(),
3164 err
3165 );
3166 return self.push_dialog(
3167 DialogPage::FavoritePathError {
3168 path: path.clone(),
3169 entity,
3170 },
3171 Some(FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID.clone()),
3172 );
3173 }
3174 }
3175 }
3176
3177 _ => true,
3178 };
3179
3180 if should_open {
3181 log::info!("should_open is true for location: {:?}", location);
3182 let message = Message::TabMessage(None, tab::Message::Location(location.clone()));
3183 return self.update(message);
3184 }
3185 }
3186 if let Some(data) = self.nav_model.data::<MounterData>(entity)
3187 && let Some(mounter) = MOUNTERS.get(&data.0)
3188 {
3189 return mounter
3190 .mount(data.1.clone())
3191 .map(|()| cosmic::action::none());
3192 }
3193 if let Some(data) = self.nav_model.data::<ClientData>(entity)
3194 && let Some(client) = CLIENTS.get(&data.0)
3195 {
3196 return client
3197 .connect(data.1.clone())
3198 .map(|()| cosmic::action::none());
3199 }
3200 Task::none()
3201 }
3202
3203 fn on_app_exit(&mut self) -> Option<Message> {
3204 Some(Message::WindowClose)
3205 }
3206
3207 fn on_close_requested(&self, id: window::Id) -> Option<Self::Message> {
3208 Some(Message::WindowCloseRequested(id))
3209 }
3210
3211 fn on_context_drawer(&mut self) -> Task<Self::Message> {
3212 if let ContextPage::Preview(..) = self.context_page {
3213 if self.core.window.show_context != self.config.show_details {
3215 return self.update(Message::Preview(None));
3216 }
3217 }
3218 Task::none()
3219 }
3220
3221 fn on_escape(&mut self) -> Task<Self::Message> {
3222 let entity = self.tab_model.active();
3223
3224 if let Some((_page, task)) = self.dialog_pages.pop_front() {
3226 return task;
3227 }
3228
3229 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
3231 && tab.gallery
3232 {
3233 tab.gallery = false;
3234 return Task::none();
3235 }
3236
3237 if self.core.window.show_context {
3242 self.set_show_context(false);
3243 return cosmic::task::message(cosmic::action::app(Message::SetShowDetails(false)));
3244 }
3245 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
3246 if tab.location_context_menu_index.is_some() {
3247 tab.location_context_menu_index = None;
3248 return Task::none();
3249 }
3250
3251 if tab.context_menu.is_some() {
3252 return self.update(Message::TabMessage(
3253 Some(entity),
3254 tab::Message::ContextMenu(None, None),
3255 ));
3256 }
3257
3258 if tab.edit_location.is_some() {
3259 tab.edit_location = None;
3260 return Task::none();
3261 }
3262
3263 let had_focused_button = tab.select_focus_id().is_some();
3264 if tab.select_none() {
3265 if had_focused_button {
3266 return widget::button::focus(widget::Id::unique());
3268 }
3269 return Task::none();
3270 }
3271 }
3272
3273 if self.search_get().is_some() {
3274 return self.search_set_active(None);
3276 }
3277
3278 Task::none()
3279 }
3280
3281 fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
3283 macro_rules! config_set {
3285 ($name: ident, $value: expr) => {
3286 match &self.config_handler {
3287 Some(config_handler) => {
3288 match paste::paste! { self.config.[<set_ $name>](config_handler, $value) } {
3289 Ok(_) => {}
3290 Err(err) => {
3291 log::warn!(
3292 "failed to save config {:?}: {}",
3293 stringify!($name),
3294 err
3295 );
3296 }
3297 }
3298 }
3299 None => {
3300 self.config.$name = $value;
3301 log::warn!(
3302 "failed to save config {:?}: no config handler",
3303 stringify!($name)
3304 );
3305 }
3306 }
3307 };
3308 }
3309
3310 match message {
3311 Message::AddToSidebar(entity_opt) => {
3312 let mut favorites = self.config.favorites.clone();
3313 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
3316
3317 for path in self.selected_paths(entity_opt) {
3318 let is_network = self.tab_model.data::<Tab>(entity).and_then(|tab| {
3319 let in_current_tab = tab
3320 .location
3321 .path_opt()
3322 .zip(path.parent())
3323 .is_some_and(|(t_path, parent)| parent == t_path);
3324 let tab = if in_current_tab {
3325 self.tab_model
3326 .data::<Tab>(self.tab_model.active())
3327 .unwrap_or(tab)
3328 } else {
3329 tab
3330 };
3331
3332 let name = Location::Path(path.clone()).title();
3333 if let Location::Network(uri, _, _) = tab
3334 .items_opt
3335 .as_ref()
3336 .and_then(|items| items.iter().find(|&i| i.path_opt() == Some(&path)))
3337 .unwrap()
3338 .location_opt
3339 .as_ref()
3340 .unwrap()
3341 {
3342 Some((uri.clone(), name, path.clone()))
3343 } else {
3344 None
3345 }
3346 });
3347 let is_remote = self.tab_model.data::<Tab>(entity).and_then(|tab| {
3348 let in_current_tab = tab
3349 .location
3350 .path_opt()
3351 .zip(path.parent())
3352 .is_some_and(|(t_path, parent)| parent == t_path);
3353 let tab = if in_current_tab {
3354 self.tab_model
3355 .data::<Tab>(self.tab_model.active())
3356 .unwrap_or(tab)
3357 } else {
3358 tab
3359 };
3360
3361 let name = Location::Path(path.clone()).title();
3362 if let Location::Remote(uri, _, _) = tab
3363 .items_opt
3364 .as_ref()
3365 .and_then(|items| items.iter().find(|&i| i.path_opt() == Some(&path)))
3366 .unwrap()
3367 .location_opt
3368 .as_ref()
3369 .unwrap()
3370 {
3371 Some((uri.clone(), name, path.clone()))
3372 } else {
3373 None
3374 }
3375 });
3376 let name = Location::Path(path.clone()).title();
3377 let favorite = if let Some((uri, _, _)) = is_network.clone() {
3378 Favorite::Network { uri, name, path }
3379 } else if let Some((uri, _, _)) = is_remote.clone() {
3380 Favorite::Remote { uri, name, path }
3381 } else {
3382 Favorite::from_path(path)
3383 };
3384 if !favorites.contains(&favorite) {
3385 favorites.push(favorite);
3386 }
3387 }
3388 config_set!(favorites, favorites);
3389 return self.update_config();
3390 }
3391 Message::AppTheme(app_theme) => {
3392 config_set!(app_theme, app_theme);
3393 return self.update_config();
3394 }
3395 Message::Compress(entity_opt) => {
3396 let paths: Box<[_]> = self.selected_paths(entity_opt).collect();
3397 if let Some(current_path) = paths.first()
3398 && let Some(destination) = current_path.parent().zip(current_path.file_stem())
3399 {
3400 let to = destination.0.to_path_buf();
3401 let name = destination.1.to_str().unwrap_or_default().to_string();
3402 let archive_type = ArchiveType::default();
3403 return self.push_dialog(
3404 DialogPage::Compress {
3405 paths,
3406 to,
3407 name,
3408 archive_type,
3409 password: None,
3410 },
3411 Some(self.dialog_text_input.clone()),
3412 );
3413 }
3414 }
3415 Message::Config(config) => {
3416 if config != self.config {
3417 log::info!("update config");
3418 let show_details = self.config.show_details;
3420 let military_time = self.config.tab.military_time;
3421 self.config = config;
3422 self.config.show_details = show_details;
3423 self.config.tab.military_time = military_time;
3424 return self.update_config();
3425 }
3426 }
3427 Message::Copy(entity_opt) => {
3428 if let Some(entity) = entity_opt
3429 && let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
3430 {
3431 tab.refresh_cut(&[]);
3432 }
3433 let paths = self.selected_paths(entity_opt);
3434 self.clipboard_cache = ClipboardCache::Files(ClipboardPaste {
3435 paths: paths.map(|p| p.to_path_buf()).collect(),
3436 kind: ClipboardKind::Copy,
3437 });
3438 let contents =
3439 ClipboardCopy::new(ClipboardKind::Copy, self.selected_paths(entity_opt));
3440 return clipboard::write_data(contents);
3441 }
3442 Message::CopyPath(entity_opt) => {
3443 let paths = self.selected_paths(entity_opt);
3444 let path_strings: Vec<String> =
3445 paths.into_iter().map(|p| p.display().to_string()).collect();
3446 let text = path_strings.join("\n");
3447 return clipboard::write(text);
3448 }
3449 Message::CopyTo(entity_opt) => {
3450 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
3451 return self.copy_to(&selected_paths);
3452 }
3453 Message::CopyToResult(result) => {
3454 match result {
3455 DialogResult::Cancel => {}
3456 DialogResult::Open(selected_paths) => {
3457 let mut file_paths = None;
3458 if let Some(file_dialog) = &self.file_dialog_opt
3459 && let Some(window) = self.windows.remove(&file_dialog.window_id())
3460 && let WindowKind::FileDialog(paths) = window.kind
3461 {
3462 file_paths = paths;
3463 }
3464 if let Some(file_paths) = file_paths
3465 && !selected_paths.is_empty()
3466 {
3467 self.file_dialog_opt = None;
3468 return self.operation(Operation::Copy {
3469 paths: file_paths.to_vec(),
3470 to: selected_paths[0].clone(),
3471 });
3472 }
3473 }
3474 }
3475 self.file_dialog_opt = None;
3476 }
3477 Message::Cut(entity_opt) => {
3478 self.set_cut(entity_opt);
3479 let paths = self.selected_paths(entity_opt);
3480 self.clipboard_cache = ClipboardCache::Files(ClipboardPaste {
3481 paths: paths.map(|p| p.to_path_buf()).collect(),
3482 kind: ClipboardKind::Cut { is_dnd: false },
3483 });
3484 let contents = ClipboardCopy::new(
3485 ClipboardKind::Cut { is_dnd: false },
3486 self.selected_paths(entity_opt),
3487 );
3488
3489 return clipboard::write_data(contents);
3490 }
3491 Message::CloseToast(id) => {
3492 self.toasts.remove(id);
3493 }
3494 Message::CosmicSettings(arg) => {
3495 let mut command = process::Command::new("cosmic-settings");
3497 command.arg(arg);
3498 match spawn_detached(&mut command) {
3499 Ok(()) => {}
3500 Err(err) => {
3501 log::warn!("failed to run cosmic-settings {arg}: {err}");
3502 }
3503 }
3504 }
3505 Message::Delete(entity_opt) => {
3506 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
3507 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
3508 if tab.location.is_trash() {
3509 if let Some(items) = tab.items_opt() {
3510 let mut trash_items = Vec::new();
3511 for item in items {
3512 if item.selected {
3513 if let ItemMetadata::Trash { entry, .. } = &item.metadata {
3514 trash_items.push(entry.clone());
3515 } else {
3516 }
3518 }
3519 }
3520 if !trash_items.is_empty() {
3521 return self.update(Message::DialogPush(
3522 DialogPage::DeleteTrash { items: trash_items },
3523 Some(DELETE_TRASH_BUTTON_ID.clone()),
3524 ));
3525 }
3526 }
3527 } else {
3528 let paths: Box<[_]> = self.selected_delete_paths(entity_opt).collect();
3529 if !paths.is_empty() {
3530 return self.delete(paths);
3531 }
3532 }
3533 }
3534 }
3535 Message::DesktopConfig(config) => {
3536 if config != self.config.desktop {
3537 config_set!(desktop, config);
3538 return self.update_desktop();
3539 }
3540 }
3541 Message::DesktopViewOptions => {
3542 let settings = window::Settings {
3543 decorations: true,
3544 min_size: Some(Size::new(360.0, 180.0)),
3545 resizable: true,
3546 size: Size::new(480.0, 444.0),
3547 transparent: true,
3548 ..Default::default()
3549 };
3550
3551 #[cfg(target_os = "linux")]
3552 {
3553 settings.platform_specific.application_id =
3555 "com.system76.CosmicFilesDialog".to_string();
3556 }
3557
3558 let (id, command) = window::open(settings);
3559 self.windows
3560 .insert(id, Window::new(WindowKind::DesktopViewOptions));
3561 return command.map(|_id| cosmic::action::none());
3562 }
3563 Message::DesktopDialogs(show) => {
3564 if matches!(self.mode, Mode::Desktop) {
3565 if show {
3566 let settings = window::Settings {
3568 decorations: false,
3569 level: window::Level::AlwaysOnTop,
3570 max_size: Some(Size::new(1280.0, 640.0)),
3571 min_size: Some(Size::new(320.0, 180.0)),
3572 position: window::Position::Centered,
3573 resizable: false,
3574 size: Size::new(640.0, 320.0),
3575 transparent: true,
3576 ..Default::default()
3577 };
3578
3579 #[cfg(target_os = "linux")]
3580 {
3581 settings.platform_specific.application_id =
3583 "com.system76.CosmicFilesDialog".to_string();
3584 }
3585
3586 let (id, command) = window::open(settings);
3587 self.windows
3588 .insert(id, Window::new(WindowKind::Dialogs(widget::Id::unique())));
3589 return command.map(|_id| cosmic::Action::None);
3590 }
3591
3592 let tasks = self
3593 .windows
3594 .iter()
3595 .filter(|(_, window)| matches!(window.kind, WindowKind::Dialogs(_)))
3596 .map(|(id, _)| window::close(*id));
3597 return Task::batch(tasks);
3598 }
3599 }
3600 Message::DialogCancel => {
3601 if let Some((_page, task)) = self.dialog_pages.pop_front() {
3602 return task;
3603 }
3604 }
3605 Message::DialogComplete => {
3606 if let Some((dialog_page, task)) = self.dialog_pages.pop_front() {
3607 let mut tasks = vec![task];
3608 match dialog_page {
3609 DialogPage::Compress {
3610 paths,
3611 to,
3612 name,
3613 archive_type,
3614 password,
3615 } => {
3616 let extension = archive_type.extension();
3617 let name = format!("{name}{extension}");
3618 let to = to.join(name);
3619 tasks.push(self.operation(Operation::Compress {
3620 paths: paths.into_vec(),
3621 to,
3622 archive_type,
3623 password,
3624 }));
3625 }
3626 DialogPage::EmptyTrash => {
3627 tasks.push(self.operation(Operation::EmptyTrash));
3628 }
3629 DialogPage::FailedOperation(id) => {
3630 log::warn!("TODO: retry operation {id}");
3631 }
3632 DialogPage::FailedOperations(_ids) => {
3633 log::warn!("TODO: retry operations");
3634 }
3635 DialogPage::ExtractPassword { id, password } => {
3636 let (operation, _, _err) = self.failed_operations.get(&id).unwrap();
3637 let new_op = match &operation {
3638 Operation::Extract { to, paths, .. } => Operation::Extract {
3639 to: to.clone(),
3640 paths: paths.clone(),
3641 password: Some(password),
3642 },
3643 _ => unreachable!(),
3644 };
3645 tasks.push(self.operation(new_op));
3646 }
3647 DialogPage::MountError {
3648 mounter_key,
3649 item,
3650 error: _,
3651 } => {
3652 if let Some(mounter) = MOUNTERS.get(&mounter_key) {
3653 tasks.push(mounter.mount(item).map(|()| cosmic::action::none()));
3654 }
3655 }
3656 DialogPage::ClientError {
3657 client_key,
3658 item,
3659 error: _,
3660 } => {
3661 if let Some(client) = CLIENTS.get(&client_key) {
3662 tasks.push(client.connect(item).map(|_| cosmic::action::none()));
3663 }
3664 }
3665 DialogPage::NetworkAuth {
3666 mounter_key: _,
3667 uri: _,
3668 auth,
3669 auth_tx,
3670 } => {
3671 tasks.push(Task::future(async move {
3672 auth_tx.send(auth).await.unwrap();
3673 cosmic::action::none()
3674 }));
3675 }
3676 DialogPage::RemoteAuth {
3677 client_key: _,
3678 uri: _,
3679 auth,
3680 auth_tx,
3681 } => {
3682 tasks.push(Task::future(async move {
3683 auth_tx.send(auth).await.unwrap();
3684 cosmic::action::none()
3685 }));
3686 }
3687 DialogPage::NetworkError {
3688 mounter_key: _,
3689 uri,
3690 error: _,
3691 } => {
3692 tasks.push(self.update(Message::NetworkDriveInput(uri)));
3694 tasks.push(self.update(Message::NetworkDriveSubmit));
3695 }
3696 DialogPage::RemoteError {
3697 client_key: _,
3698 uri,
3699 error: _,
3700 } => {
3701 tasks.push(self.update(Message::RemoteDriveInput(uri)));
3702 tasks.push(self.update(Message::RemoteDriveSubmit));
3703 }
3704 DialogPage::RunTbProfilerStarted {
3705 client_key: _,
3706 uri: _,
3707 job_id: _,
3708 tasks: _,
3709 } => {
3710 tasks.push(Task::future(async move { cosmic::action::none() }));
3711 }
3712 DialogPage::RunTbProfilerError {
3713 client_key: _,
3714 uri: _,
3715 error: _,
3716 } => {
3717 tasks.push(Task::future(async move { cosmic::action::none() }));
3718 }
3719 DialogPage::TbProfilerConfigError => {}
3720 DialogPage::DeleteRemoteFilesSuccess {
3721 client_key: _,
3722 uri: _,
3723 result: _,
3724 } => {
3725 tasks.push(Task::future(async move { cosmic::action::none() }));
3726 }
3727 DialogPage::DeleteRemoteFilesError {
3728 client_key: _,
3729 uri: _,
3730 error: _,
3731 } => {
3732 tasks.push(Task::future(async move { cosmic::action::none() }));
3733 }
3734 DialogPage::NewItem { parent, name, dir } => {
3735 let path = parent.join(name);
3736 tasks.push(self.operation(if dir {
3737 Operation::NewFolder { path }
3738 } else {
3739 Operation::NewFile { path }
3740 }));
3741 }
3742 DialogPage::RunContextAction { action, paths } => {
3743 context_action::run(&self.config.context_actions, action, &paths);
3744 }
3745 DialogPage::OpenWith {
3746 path,
3747 mime,
3748 selected,
3749 ..
3750 } => {
3751 let available_apps = self.mime_app_cache.get_apps_for_mime(&mime, true);
3752
3753 if let Some((app, _)) = available_apps.get(selected) {
3754 if let Some(mut command) =
3755 app.command(&[&path]).and_then(|v| v.into_iter().next())
3756 {
3757 match spawn_detached(&mut command) {
3758 Ok(()) => {
3759 if self.config.show_recents {
3760 let _ = recently_used_xbel::update_recently_used(
3761 &path,
3762 Self::APP_ID.to_string(),
3763 "cosmic-files".to_string(),
3764 None,
3765 );
3766 }
3767 }
3768 Err(err) => {
3769 log::warn!(
3770 "failed to open {} with {:?}: {}",
3771 path.display(),
3772 app.id,
3773 err
3774 );
3775 }
3776 }
3777 } else {
3778 log::warn!(
3779 "failed to open {} with {:?}: failed to get command",
3780 path.display(),
3781 app.id
3782 );
3783 }
3784 }
3785 }
3786 DialogPage::PermanentlyDelete { paths } => {
3787 tasks.push(self.operation(Operation::PermanentlyDelete { paths }));
3788 }
3789 DialogPage::DeleteTrash { items } => {
3790 tasks.push(self.operation(Operation::DeleteTrash { items }));
3791 }
3792 DialogPage::RenameItem {
3793 from, parent, name, ..
3794 } => {
3795 let to = parent.join(name);
3796 tasks.push(self.operation(Operation::Rename { from, to }));
3797 }
3798 DialogPage::Replace { .. } => {
3799 log::warn!("replace dialog should be completed with replace result");
3800 }
3801 DialogPage::SetExecutableAndLaunch { path } => {
3802 tasks.push(self.operation(Operation::SetExecutableAndLaunch { path }));
3803 }
3804 DialogPage::FavoritePathError { entity, .. } => {
3805 if let Some(FavoriteIndex(favorite_i)) =
3806 self.nav_model.data::<FavoriteIndex>(entity)
3807 {
3808 let mut favorites = self.config.favorites.clone();
3809 favorites.remove(*favorite_i);
3810 config_set!(favorites, favorites);
3811 tasks.push(self.update_config());
3812 }
3813 }
3814 }
3815 return Task::batch(tasks);
3816 }
3817 }
3818 Message::DialogPush(dialog_page, focused_id) => {
3819 return self.push_dialog(dialog_page, focused_id);
3820 }
3821 Message::DialogUpdate(dialog_page) => {
3822 self.dialog_pages.update_front(dialog_page);
3823 }
3824 Message::DialogUpdateComplete(dialog_page) => {
3825 return Task::batch([
3826 self.update(Message::DialogUpdate(dialog_page)),
3827 self.update(Message::DialogComplete),
3828 ]);
3829 }
3830 Message::DownloadTo(entity_opt) => {
3831 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
3832 let selected_uris: Vec<_> = self.selected_uris(entity_opt).collect();
3833 return self.download_to(&selected_paths, &selected_uris);
3834 }
3835 Message::ExtractHere(entity_opt) => {
3836 let paths: Box<[_]> = self.selected_paths(entity_opt).collect();
3837 if let Some(destination) = paths
3838 .first()
3839 .and_then(|first| first.parent())
3840 .map(Path::to_path_buf)
3841 {
3842 return self.operation(Operation::Extract {
3843 paths,
3844 to: destination,
3845 password: None,
3846 });
3847 }
3848 }
3849 Message::ExtractTo(entity_opt) => {
3850 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
3851 return self.extract_to(&selected_paths);
3852 }
3853 Message::ExtractToResult(result) => {
3854 match result {
3855 DialogResult::Cancel => {}
3856 DialogResult::Open(selected_paths) => {
3857 let mut archive_paths = None;
3858 if let Some(file_dialog) = &self.file_dialog_opt
3859 && let Some(window) = self.windows.remove(&file_dialog.window_id())
3860 && let WindowKind::FileDialog(paths) = window.kind
3861 {
3862 archive_paths = paths;
3863 }
3864 if let Some(archive_paths) = archive_paths
3865 && !selected_paths.is_empty()
3866 {
3867 self.file_dialog_opt = None;
3868 return self.operation(Operation::Extract {
3869 paths: archive_paths,
3870 to: selected_paths[0].clone(),
3871 password: None,
3872 });
3873 }
3874 }
3875 }
3876 self.file_dialog_opt = None;
3877 }
3878 Message::DownloadToResult(result) => {
3879 match result {
3880 DialogResult::Cancel => {}
3881 DialogResult::Open(selected_paths) => {
3882 let mut from_paths_and_uris = None;
3883 if let Some(file_dialog) = &self.file_dialog_opt
3884 && let Some(window) = self.windows.remove(&file_dialog.window_id())
3885 && let WindowKind::DownloadDialog(paths_and_uris) = window.kind
3886 {
3887 from_paths_and_uris = paths_and_uris;
3888 }
3889 if let Some((download_paths, download_uris, save_as_zip)) =
3890 from_paths_and_uris
3891 && !selected_paths.is_empty()
3892 {
3893 self.file_dialog_opt = None;
3894 let (to, zip_output) = if save_as_zip {
3898 let full = selected_paths[0].clone();
3899 let dir = full
3900 .parent()
3901 .map(Path::to_path_buf)
3902 .unwrap_or_else(|| full.clone());
3903 (dir, Some(full))
3904 } else {
3905 (selected_paths[0].clone(), None)
3906 };
3907 if let Some((_key, client)) = CLIENTS.iter().next() {
3908 self.download_files_total += download_paths.len();
3909 return client
3910 .download_file(
3911 download_paths.clone(),
3912 download_uris.clone(),
3913 to.clone(),
3914 zip_output.clone(),
3915 )
3916 .map(|event| match event {
3917 crate::russh::DownloadEvent::FileCompleted => {
3918 cosmic::action::app(Message::DownloadFileProgress)
3919 }
3920 crate::russh::DownloadEvent::Complete(r) => {
3921 cosmic::action::app(Message::DownloadComplete(r))
3922 }
3923 });
3924 }
3925 }
3926 }
3927 }
3928 self.file_dialog_opt = None;
3929 return Task::perform(async {}, |_| cosmic::action::none());
3930 }
3931 Message::DownloadFileProgress => {
3932 self.download_files_done += 1;
3933 }
3934 Message::DownloadComplete(result) => {
3935 if let Err(err) = result {
3936 log::error!("Download failed: {}", err);
3937 }
3938 if self.download_files_done >= self.download_files_total {
3940 self.download_files_total = 0;
3941 self.download_files_done = 0;
3942 }
3943 }
3944 Message::FileDialogMessage(dialog_message) => {
3945 if let Some(dialog) = &mut self.file_dialog_opt {
3946 return dialog.update(dialog_message);
3947 }
3948 }
3949 Message::Key(window_id, modifiers, key, physical_key, text) => {
3950 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
3951 let in_surface_ids = self.surface_ids.values().any(|id| *id == window_id);
3952 #[cfg(not(all(feature = "wayland", feature = "desktop-applet")))]
3953 let in_surface_ids = false;
3954 if self.core.main_window_id() == Some(window_id) || in_surface_ids {
3955 let entity = self.tab_model.active();
3956 for (key_bind, action) in &self.key_binds {
3957 if key_bind.matches(modifiers, &key, Some(&physical_key)) {
3958 return self.update(action.message(Some(entity)));
3959 }
3960 }
3961
3962 if matches!(self.mode, Mode::App)
3964 && !modifiers.logo()
3965 && !modifiers.control()
3966 && !modifiers.alt()
3967 && matches!(key, Key::Character(_))
3968 && let Some(text) = text
3969 {
3970 match self.config.type_to_search {
3971 TypeToSearch::Recursive => {
3972 let mut term = self.search_get().unwrap_or_default().to_string();
3973 term.push_str(&text);
3974 return self.search_set_active(Some(term));
3975 }
3976 TypeToSearch::EnterPath => {
3977 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
3978 let location = tab
3979 .edit_location
3980 .as_ref()
3981 .map_or_else(|| &tab.location, |x| &x.location);
3982 if let Location::Network(uri, ..) = location {
3984 let mut uri_string = uri.clone();
3985 uri_string.push_str(&text);
3986 tab.edit_location =
3987 Some(location.with_uri(uri_string).into());
3988 } else if let Some(path) = location.path_opt() {
3989 let mut path_string = path.to_string_lossy().into_owned();
3990 path_string.push_str(&text);
3991 tab.edit_location =
3992 Some(location.with_path(path_string.into()).into());
3993 }
3994 }
3995 }
3996 TypeToSearch::SelectByPrefix => {
3997 if let Some(last_key) = self.type_select_last_key
3999 && last_key.elapsed() >= tab::TYPE_SELECT_TIMEOUT
4000 {
4001 self.type_select_prefix.clear();
4002 }
4003
4004 self.type_select_prefix.push_str(&text.to_lowercase());
4006 self.type_select_last_key = Some(Instant::now());
4007
4008 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
4009 tab.select_by_prefix(&self.type_select_prefix);
4010 if let Some(offset) = tab.select_focus_scroll() {
4011 return scrollable::scroll_to(
4012 tab.scrollable_id.clone(),
4013 AbsoluteOffset {
4014 x: Some(offset.x),
4015 y: Some(offset.y),
4016 },
4017 );
4018 }
4019 }
4020 }
4021 }
4022 }
4023 }
4024 }
4025 Message::MaybeExit => {
4026 if self.core.main_window_id().is_none() && self.pending_operations.is_empty() {
4027 process::exit(0);
4029 }
4030 }
4031 Message::LaunchUrl(url) => match open::that_detached(&url) {
4032 Ok(()) => {}
4033 Err(err) => {
4034 log::warn!("failed to open {url:?}: {err}");
4035 }
4036 },
4037 Message::ModifiersChanged(window_id, modifiers) => {
4038 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
4039 let in_surface_ids = self.surface_ids.values().any(|id| *id == window_id);
4040 #[cfg(not(all(feature = "wayland", feature = "desktop-applet")))]
4041 let in_surface_ids = false;
4042 if self.core.main_window_id() == Some(window_id) || in_surface_ids {
4043 self.modifiers = modifiers;
4044 }
4045 if let Some(window) = self.windows.get_mut(&window_id) {
4046 window.modifiers = modifiers;
4047 }
4048 }
4049 Message::MounterItems(mounter_key, mounter_items) => {
4050 let mut unmounted = Vec::new();
4052 if let Some(old_items) = self.mounter_items.get(&mounter_key) {
4053 for old_item in old_items {
4054 if let Some(old_path) = old_item.path()
4055 && old_item.is_mounted()
4056 {
4057 let mut still_mounted = false;
4058 for item in &mounter_items {
4059 if let Some(path) = item.path()
4060 && path == old_path
4061 && item.is_mounted()
4062 {
4063 still_mounted = true;
4064 break;
4065 }
4066 }
4067 if !still_mounted {
4068 unmounted.push(old_path);
4069 }
4070 }
4071 }
4072 }
4073
4074 let mut commands = Vec::new();
4076 {
4077 let home_location = Location::Path(home_dir());
4078 let entities: Box<[_]> = self.tab_model.iter().collect();
4079 for entity in entities {
4080 let title_opt = self.tab_model.data_mut::<Tab>(entity).and_then(|tab| {
4081 unmounted
4082 .iter()
4083 .any(|unmounted| {
4084 tab.location
4085 .path_opt()
4086 .is_some_and(|location| location.starts_with(unmounted))
4087 })
4088 .then(|| {
4089 tab.change_location(&home_location, None);
4090 tab.title()
4091 })
4092 });
4093 if let Some(title) = title_opt {
4094 self.tab_model.text_set(entity, title);
4095 commands.push(self.update_tab(entity, home_location.clone(), None));
4096 }
4097 }
4098 if !commands.is_empty() {
4099 commands.push(self.update_title());
4100 commands.push(self.update_watcher());
4101 }
4102 }
4103
4104 self.mounter_items.insert(mounter_key, mounter_items);
4106
4107 self.update_nav_model();
4110
4111 commands.push(self.update_desktop());
4113
4114 return Task::batch(commands);
4115 }
4116 Message::ClientItems(client_key, client_items) => {
4117 let mut disconnected = Vec::new();
4119 if let Some(old_items) = self.client_items.get(&client_key) {
4120 for old_item in old_items {
4121 if let Some(old_path) = old_item.path()
4122 && old_item.is_connected()
4123 {
4124 let mut still_connected = false;
4125 for item in &client_items {
4126 if let Some(path) = item.path()
4127 && path == old_path
4128 && item.is_connected()
4129 {
4130 still_connected = true;
4131 break;
4132 }
4133 }
4134 if !still_connected {
4135 disconnected.push(old_path);
4136 }
4137 }
4138 }
4139 }
4140
4141 let mut commands = Vec::new();
4143 {
4144 let home_location = Location::Path(home_dir());
4145 let entities: Box<[_]> = self.tab_model.iter().collect();
4146 for entity in entities {
4147 let title_opt = self.tab_model.data_mut::<Tab>(entity).and_then(|tab| {
4148 disconnected
4149 .iter()
4150 .any(|disconnected| {
4151 tab.location
4152 .path_opt()
4153 .is_some_and(|location| location.starts_with(disconnected))
4154 })
4155 .then(|| {
4156 tab.change_location(&home_location, None);
4157 tab.title()
4158 })
4159 });
4160 if let Some(title) = title_opt {
4161 self.tab_model.text_set(entity, title);
4162 commands.push(self.update_tab(entity, home_location.clone(), None));
4163 }
4164 }
4165 if !commands.is_empty() {
4166 commands.push(self.update_title());
4167 commands.push(self.update_watcher());
4168 }
4169 }
4170
4171 self.client_items.insert(client_key, client_items);
4173
4174 self.update_nav_model();
4177
4178 commands.push(self.update_desktop());
4180
4181 return Task::batch(commands);
4182 }
4183 Message::MountResult(mounter_key, item, res) => match res {
4184 Ok(true) => {
4185 log::info!("connected to {item:?}");
4186 if let Some(path) = item.path() {
4188 let location = if item.is_remote() {
4189 Location::Network(item.uri(), item.name(), Some(path))
4190 } else {
4191 Location::Path(path)
4192 };
4193 let message = Message::TabMessage(None, tab::Message::Location(location));
4194 return self.update(message);
4195 }
4196 }
4197 Ok(false) => {
4198 log::info!("cancelled connection to {item:?}");
4199 }
4200 Err(error) => {
4201 log::warn!("failed to connect to {item:?}: {error}");
4202 return self.push_dialog(
4203 DialogPage::MountError {
4204 mounter_key,
4205 item,
4206 error,
4207 },
4208 Some(MOUNT_ERROR_TRY_AGAIN_BUTTON_ID.clone()),
4209 );
4210 }
4211 },
4212 Message::ClientResult(client_key, item, res) => match res {
4213 Ok(true) => {
4214 log::info!("connected to {item:?}");
4215 }
4216 Ok(false) => {
4217 log::info!("cancelled connection to {item:?}");
4218 }
4219 Err(error) => {
4220 log::warn!("failed to connect to {item:?}: {error}");
4221 return self.push_dialog(
4222 DialogPage::ClientError {
4223 client_key,
4224 item,
4225 error,
4226 },
4227 Some(CLIENT_ERROR_TRY_AGAIN_BUTTON_ID.clone()),
4228 );
4229 }
4230 },
4231 Message::Mouse(window_id, _button) => {
4232 if self.core.main_window_id() == Some(window_id) {
4234 return self.close_context_menus();
4235 }
4236 }
4237 Message::MoveTo(entity_opt) => {
4238 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
4239 return self.move_to(&selected_paths);
4240 }
4241 Message::MoveToResult(result) => {
4242 match result {
4243 DialogResult::Cancel => {}
4244 DialogResult::Open(selected_paths) => {
4245 let mut file_paths = None;
4246 if let Some(file_dialog) = &self.file_dialog_opt
4247 && let Some(window) = self.windows.remove(&file_dialog.window_id())
4248 && let WindowKind::FileDialog(paths) = window.kind
4249 {
4250 file_paths = paths;
4251 }
4252 if let Some(file_paths) = file_paths
4253 && !selected_paths.is_empty()
4254 {
4255 self.file_dialog_opt = None;
4256 return self.operation(Operation::Move {
4257 paths: file_paths.to_vec(),
4258 to: selected_paths[0].clone(),
4259 cross_device_copy: false,
4260 });
4261 }
4262 }
4263 }
4264 self.file_dialog_opt = None;
4265 }
4266 Message::NetworkAuth(mounter_key, uri, auth, auth_tx) => {
4267 return self.push_dialog(
4268 DialogPage::NetworkAuth {
4269 mounter_key,
4270 uri,
4271 auth,
4272 auth_tx,
4273 },
4274 Some(self.dialog_text_input.clone()),
4275 );
4276 }
4277 Message::RemoteAuth(client_key, uri, auth, auth_tx) => {
4278 return self.push_dialog(
4279 DialogPage::RemoteAuth {
4280 client_key,
4281 uri,
4282 auth,
4283 auth_tx,
4284 },
4285 Some(self.dialog_text_input.clone()),
4286 );
4287 }
4288 Message::NetworkDriveInput(input) => {
4289 self.network_drive_input = input;
4290 }
4291 Message::RemoteDriveInput(input) => {
4292 self.remote_drive_input = input;
4293 }
4294 Message::NetworkDriveSubmit => {
4295 if let Some((mounter_key, mounter)) = MOUNTERS.iter().next() {
4297 self.network_drive_connecting =
4298 Some((*mounter_key, self.network_drive_input.clone()));
4299 return mounter
4300 .network_drive(self.network_drive_input.clone())
4301 .map(|()| cosmic::action::none());
4302 }
4303 log::warn!(
4304 "no mounter found for connecting to {:?}",
4305 self.network_drive_input
4306 );
4307 }
4308 Message::RemoteDriveSubmit => {
4309 if let Some((client_key, client)) = CLIENTS.iter().next() {
4311 self.remote_drive_connecting =
4312 Some((*client_key, self.remote_drive_input.clone()));
4313 return client
4314 .remote_drive(self.remote_drive_input.clone())
4315 .map(|()| cosmic::action::none());
4316 }
4317 log::warn!(
4318 "no client found for connecting to {:?}",
4319 self.remote_drive_input
4320 );
4321 }
4322 Message::NetworkResult(mounter_key, uri, res) => {
4323 if self
4324 .network_drive_connecting
4325 .as_ref()
4326 .is_some_and(|(m, u)| *m == mounter_key && *u == uri)
4327 {
4328 self.network_drive_connecting = None;
4329 }
4330 match res {
4331 Ok(true) => {
4332 log::info!("connected to {uri:?}");
4333 if matches!(self.context_page, ContextPage::NetworkDrive) {
4334 self.set_show_context(false);
4335 }
4336 }
4337 Ok(false) => {
4338 log::info!("cancelled connection to {uri:?}");
4339 }
4340 Err(error) => {
4341 log::warn!("failed to connect to {uri:?}: {error}");
4342 return self.dialog_pages.push_back(DialogPage::NetworkError {
4343 mounter_key,
4344 uri,
4345 error,
4346 });
4347 }
4348 }
4349 }
4350 Message::RemoteResult(client_key, uri, res) => {
4351 if self
4352 .remote_drive_connecting
4353 .as_ref()
4354 .is_some_and(|(m, u)| *m == client_key && same_uri(u, &uri))
4355 {
4356 self.remote_drive_connecting = None;
4357 }
4358 match res {
4359 Ok(true) => {
4360 log::info!("connected to {uri:?}");
4361 if matches!(self.context_page, ContextPage::RemoteDrive) {
4362 self.set_show_context(false);
4363 }
4364 }
4365 Ok(false) => {
4366 log::info!("cancelled connection to {uri:?}");
4367 }
4368 Err(error) => {
4369 log::warn!("failed to connect to {uri:?}: {error}");
4370 return self.dialog_pages.push_back(DialogPage::RemoteError {
4371 client_key,
4372 uri,
4373 error,
4374 });
4375 }
4376 }
4377 }
4378 Message::RunTbProfiler(entity_opt) => {
4379 if let Some((_client_key, client)) = CLIENTS.iter().next() {
4380 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
4381 let selected_uris: Vec<_> = self.selected_uris(entity_opt).collect();
4382 return client
4383 .run_tb_profiler(
4384 selected_paths,
4385 selected_uris,
4386 self.config.tb_config.clone(),
4387 )
4388 .map(|()| cosmic::action::none());
4389 }
4390 }
4391 Message::TbProfilerConfigError => {
4392 log::error!("TB Profiler pair suffixes are not configured");
4393 return self
4394 .dialog_pages
4395 .push_back(DialogPage::TbProfilerConfigError);
4396 }
4397 Message::DeleteRemoteFiles(entity_opt) => {
4398 if let Some((_client_key, client)) = CLIENTS.iter().next() {
4399 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
4400 let selected_uris: Vec<_> = self.selected_uris(entity_opt).collect();
4401 return client
4402 .delete_remote_files(selected_paths, selected_uris)
4403 .map(|()| cosmic::action::none());
4404 }
4405 }
4406 Message::RunTbProfilerResult(client_key, uri, res) => match res {
4407 Ok(slurm_job_id) => {
4408 log::info!(
4409 "TbProfiler started successfully for {uri:?}: job_id={}, tasks={}, running={}",
4410 slurm_job_id.array_id,
4411 slurm_job_id.tasks,
4412 slurm_job_id.running_tasks
4413 );
4414 self.running_tasks
4415 .insert(slurm_job_id.array_id, slurm_job_id.running_tasks);
4416 self.job_total_tasks
4417 .insert(slurm_job_id.array_id, slurm_job_id.tasks);
4418 let poll_task = CLIENTS
4419 .get(&client_key)
4420 .map(|c| {
4421 c.poll_job_status(slurm_job_id.array_id, uri.clone())
4422 .map(|()| cosmic::action::none())
4423 })
4424 .unwrap_or_else(Task::none);
4425 let dialog_task =
4426 self.dialog_pages
4427 .push_back(DialogPage::RunTbProfilerStarted {
4428 client_key,
4429 uri,
4430 job_id: slurm_job_id.array_id,
4431 tasks: slurm_job_id.tasks,
4432 });
4433 return Task::batch([poll_task, dialog_task]);
4434 }
4435 Err(error) => {
4436 log::warn!("failed to run TbProfiler for {uri:?}: {error}");
4437 return self.dialog_pages.push_back(DialogPage::RunTbProfilerError {
4438 client_key,
4439 uri,
4440 error,
4441 });
4442 }
4443 },
4444 Message::DeleteRemoteFilesResult(client_key, uri, res) => match res {
4445 Ok(result) => {
4446 log::info!("Remote files deleted successfully for {uri:?}: {result}");
4447 return self
4448 .dialog_pages
4449 .push_back(DialogPage::DeleteRemoteFilesSuccess {
4450 client_key,
4451 uri,
4452 result,
4453 });
4454 }
4455 Err(error) => {
4456 log::warn!("failed to delete remote files for {uri:?}: {error}");
4457 return self
4458 .dialog_pages
4459 .push_back(DialogPage::DeleteRemoteFilesError {
4460 client_key,
4461 uri,
4462 error,
4463 });
4464 }
4465 },
4466 Message::JobStatusUpdate(_client_key, _uri, array_id, running_tasks) => {
4467 log::info!("Job {array_id} running tasks: {running_tasks}");
4468 if running_tasks == 0 {
4469 self.running_tasks.remove(&array_id);
4470 self.job_total_tasks.remove(&array_id);
4471 } else {
4472 self.running_tasks.insert(array_id, running_tasks);
4473 }
4474 return Task::none();
4475 }
4476 Message::DeleteTbProfilerResults(uri, tb_config) => {
4477 if let Some((_client_key, client)) = CLIENTS.iter().next() {
4478 return client.delete_tb_profiler_results(uri, tb_config).map(|()| {
4479 cosmic::action::app(Message::TabMessage(None, tab::Message::Reload))
4480 });
4481 }
4482 }
4483 Message::NewItem(entity_opt, dir) => {
4484 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
4485 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
4486 && let Some(path) = tab.location.path_opt()
4487 {
4488 return Task::batch([
4489 self.dialog_pages.push_back(DialogPage::NewItem {
4490 parent: path.clone(),
4491 name: String::new(),
4492 dir,
4493 }),
4494 widget::text_input::focus(self.dialog_text_input.clone()),
4495 ]);
4496 }
4497 }
4498 #[cfg(feature = "notify")]
4499 Message::Notification(notification) => {
4500 self.notification_opt = Some(notification);
4501 }
4502 Message::NotifyEvents(events) => {
4503 log::debug!("{events:?}");
4504
4505 let mut needs_reload = Vec::new();
4506 let entities: Box<[_]> = self.tab_model.iter().collect();
4507 for entity in entities {
4508 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
4509 && let Some(path) = tab.location.path_opt()
4510 {
4511 let mut contains_change = false;
4512 for event in &events {
4513 for event_path in &event.paths {
4514 if event_path.starts_with(path) {
4515 if let notify::EventKind::Modify(
4516 notify::event::ModifyKind::Metadata(_)
4517 | notify::event::ModifyKind::Data(_),
4518 ) = event.kind
4519 {
4520 if let Some(items) = &mut tab.items_opt {
4523 for item in items.iter_mut() {
4524 if item.path_opt() == Some(event_path) {
4525 match fs::metadata(event_path) {
4527 Ok(new_metadata) => {
4528 if let ItemMetadata::Path {
4529 metadata,
4530 ..
4531 } = &mut item.metadata
4532 {
4533 *metadata = new_metadata;
4534 }
4535 }
4536
4537 Err(err) => {
4538 log::warn!(
4539 "failed to reload metadata for {}: {}",
4540 path.display(),
4541 err
4542 );
4543 }
4544 }
4545 }
4547 }
4548 }
4549 } else {
4550 contains_change = true;
4552 break;
4553 }
4554 }
4555 }
4556 }
4557 if contains_change {
4558 needs_reload.push((entity, tab.location.clone()));
4559 }
4560 }
4561 }
4562
4563 let commands = needs_reload
4564 .into_iter()
4565 .map(|(entity, location)| self.update_tab(entity, location, None));
4566 return Task::batch(commands);
4567 }
4568 Message::NotifyWatcher(mut watcher_wrapper) => match watcher_wrapper.watcher_opt.take()
4569 {
4570 Some(watcher) => {
4571 self.watcher_opt = Some((watcher, FxHashSet::default()));
4572 return self.update_watcher();
4573 }
4574 None => {
4575 log::warn!("message did not contain notify watcher");
4576 }
4577 },
4578 Message::OpenTerminal(entity_opt) => {
4579 if let Some(terminal) = self.mime_app_cache.terminal() {
4580 let mut paths = Box::from([]);
4581 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
4582 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
4583 && let Some(path) = tab.location.path_opt()
4584 {
4585 if let Some(items) = tab.items_opt() {
4586 paths = items
4587 .iter()
4588 .filter_map(
4589 |item| {
4590 if item.selected { item.path_opt() } else { None }
4591 },
4592 )
4593 .collect();
4594 }
4595 if paths.is_empty() {
4596 paths = Box::from([path]);
4597 }
4598 }
4599 for path in paths {
4600 if let Some(mut command) = terminal
4601 .command::<&str>(&[])
4602 .and_then(|v| v.into_iter().next())
4603 {
4604 command.current_dir(path);
4605 if let Err(err) = spawn_detached(&mut command) {
4606 log::warn!(
4607 "failed to open {} with terminal {:?}: {}",
4608 path.display(),
4609 terminal.id,
4610 err
4611 );
4612 }
4613 } else {
4614 log::warn!("failed to get command for {:?}", terminal.id);
4615 }
4616 }
4617 }
4618 }
4619 Message::OpenInNewTab(entity_opt) => {
4620 let selected_paths: Box<[_]> = self
4621 .selected_paths(entity_opt)
4622 .filter(|p| p.is_dir())
4623 .collect();
4624 return Task::batch(
4625 selected_paths
4626 .into_iter()
4627 .map(|path| self.open_tab(Location::Path(path), false, None)),
4628 );
4629 }
4630 Message::OpenInNewWindow(entity_opt) => match env::current_exe() {
4631 Ok(exe) => self
4632 .selected_paths(entity_opt)
4633 .filter(|p| p.is_dir())
4634 .for_each(|path| match process::Command::new(&exe).arg(path).spawn() {
4635 Ok(_child) => {}
4636 Err(err) => {
4637 log::error!("failed to execute {}: {}", exe.display(), err);
4638 }
4639 }),
4640 Err(err) => {
4641 log::error!("failed to get current executable path: {err}");
4642 }
4643 },
4644 Message::OpenItemLocation(entity_opt) => {
4645 let selected_paths: Box<[_]> = self.selected_paths(entity_opt).collect();
4646 return Task::batch(selected_paths.into_iter().filter_map(|path| {
4647 path.parent()
4648 .map(Path::to_path_buf)
4649 .map(|parent| self.open_tab(Location::Path(parent), true, Some(vec![path])))
4650 }));
4651 }
4652 Message::OpenWithBrowse => match self.dialog_pages.pop_front() {
4653 Some((
4654 DialogPage::OpenWith {
4655 mime,
4656 store_opt: Some(app),
4657 ..
4658 },
4659 task,
4660 )) => {
4661 let url = format!("mime:///{mime}");
4662 if let Some(mut command) =
4664 app.command(&[&url]).and_then(|v| v.into_iter().next())
4665 {
4666 if let Err(err) = spawn_detached(&mut command) {
4667 log::warn!("failed to open {:?} with {:?}: {}", url, app.id, err);
4668 }
4669 } else {
4670 log::warn!(
4671 "failed to open {:?} with {:?}: failed to get command",
4672 url,
4673 app.id
4674 );
4675 }
4676 return task;
4677 }
4678 Some((dialog_page, task)) => {
4679 log::warn!("tried to open with browse from the wrong dialog");
4680 return Task::batch([task, self.dialog_pages.push_front(dialog_page)]);
4681 }
4682 None => {}
4683 },
4684 Message::OpenWithDialog(entity_opt) => {
4685 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
4686 if let Some(tab) = self.tab_model.data::<Tab>(entity)
4687 && let Some(items) = tab.items_opt()
4688 {
4689 for item in items {
4690 if !item.selected {
4691 continue;
4692 }
4693 let Some(path) = item.path_opt() else {
4694 continue;
4695 };
4696 return self.push_dialog(
4697 DialogPage::OpenWith {
4698 path: path.clone(),
4699 mime: item.mime.clone(),
4700 selected: 0,
4701 store_opt: "x-scheme-handler/mime"
4702 .parse::<mime_guess::Mime>()
4703 .ok()
4704 .and_then(|mime| {
4705 self.mime_app_cache.get(&mime).first().cloned()
4706 }),
4707 },
4708 Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
4709 );
4710 }
4711 }
4712 }
4713 Message::OpenWithSelection(index) => {
4714 if let Some(DialogPage::OpenWith { selected, .. }) = self.dialog_pages.front_mut() {
4715 *selected = index;
4716 }
4717 }
4718 Message::Paste(entity_opt) => {
4719 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
4720 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
4721 && let Some(path) = tab.location.path_opt()
4722 {
4723 let to = path.clone();
4724
4725 match &self.clipboard_cache {
4727 ClipboardCache::Files(contents) => {
4728 if contents.paths.is_empty() {
4729 return iced::Task::future(tokio::time::sleep(
4730 std::time::Duration::from_millis(300),
4731 ))
4732 .discard()
4733 .chain(
4734 clipboard::read_data::<ClipboardPaste>().map(
4735 move |contents_opt| match contents_opt {
4736 Some(contents) => cosmic::action::app(
4737 Message::PasteContents(to.clone(), contents),
4738 ),
4739 None => {
4740 cosmic::action::app(Message::PasteImage(to.clone()))
4741 }
4742 },
4743 ),
4744 );
4745 }
4746 return self
4747 .update(Message::PasteContents(to.clone(), contents.clone()));
4748 }
4749 ClipboardCache::Image(contents) => {
4750 return self
4751 .update(Message::PasteImageContents(to.clone(), contents.clone()));
4752 }
4753 ClipboardCache::Video(contents) => {
4754 return self
4755 .update(Message::PasteVideoContents(to.clone(), contents.clone()));
4756 }
4757 ClipboardCache::Text(contents) => {
4758 return self
4759 .update(Message::PasteTextContents(to.clone(), contents.clone()));
4760 }
4761 ClipboardCache::Empty => {
4762 return clipboard::read_data::<ClipboardPaste>().map(
4765 move |contents_opt| match contents_opt {
4766 Some(contents) => cosmic::action::app(Message::PasteContents(
4767 to.clone(),
4768 contents,
4769 )),
4770 None => cosmic::action::app(Message::PasteImage(to.clone())),
4771 },
4772 );
4773 }
4774 }
4775 }
4776 }
4777 Message::PasteContents(to, mut contents) => {
4778 contents.paths.retain(|p| *p != to);
4779 if !contents.paths.is_empty() {
4780 return match contents.kind {
4781 ClipboardKind::Copy => self.operation(Operation::Copy {
4782 paths: contents.paths,
4783 to,
4784 }),
4785 ClipboardKind::Cut { is_dnd } => self.operation(Operation::Move {
4786 paths: contents.paths,
4787 to,
4788 cross_device_copy: is_dnd,
4789 }),
4790 };
4791 }
4792 }
4793 Message::PasteImage(to) => {
4794 return clipboard::read_data::<ClipboardPasteImage>().map(move |contents_opt| {
4795 match contents_opt {
4796 Some(contents) => {
4797 cosmic::action::app(Message::PasteImageContents(to.clone(), contents))
4798 }
4799 None => cosmic::action::app(Message::PasteVideo(to.clone())),
4801 }
4802 });
4803 }
4804 Message::PasteImageContents(to, contents) => {
4805 let Some(extension) = contents.extension() else {
4806 log::warn!(
4807 "Ignoring paste: unknown image MIME type {:?}",
4808 contents.mime_type
4809 );
4810 return Task::none();
4811 };
4812
4813 let base_name = format!("{}.{}", fl!("pasted-image"), extension);
4815 let base_path = to.join(&base_name);
4816 let final_path = copy_unique_path(&base_path, &to);
4817
4818 match fs::write(&final_path, &contents.data) {
4820 Ok(_) => {
4821 log::info!("Pasted image saved to {:?}", final_path);
4822 }
4823 Err(err) => {
4824 log::error!("Failed to save pasted image: {}", err);
4825 }
4826 }
4827 }
4828 Message::PasteVideo(to) => {
4829 return clipboard::read_data::<ClipboardPasteVideo>().map(move |contents_opt| {
4830 match contents_opt {
4831 Some(contents) => {
4832 cosmic::action::app(Message::PasteVideoContents(to.clone(), contents))
4833 }
4834 None => cosmic::action::app(Message::PasteText(to.clone())),
4836 }
4837 });
4838 }
4839 Message::PasteVideoContents(to, contents) => {
4840 let Some(extension) = contents.extension() else {
4841 log::warn!(
4842 "Ignoring paste: unknown video MIME type {:?}",
4843 contents.mime_type
4844 );
4845 return Task::none();
4846 };
4847
4848 let base_name = format!("{}.{}", fl!("pasted-video"), extension);
4850 let base_path = to.join(&base_name);
4851 let final_path = copy_unique_path(&base_path, &to);
4852
4853 match fs::write(&final_path, &contents.data) {
4855 Ok(_) => {
4856 log::info!("Pasted video saved to {:?}", final_path);
4857 }
4858 Err(err) => {
4859 log::error!("Failed to save pasted video: {}", err);
4860 }
4861 }
4862 }
4863 Message::PasteText(to) => {
4864 return clipboard::read_data::<ClipboardPasteText>().map(move |contents_opt| {
4865 match contents_opt {
4866 Some(contents) => {
4867 cosmic::action::app(Message::PasteTextContents(to.clone(), contents))
4868 }
4869 None => cosmic::action::none(),
4870 }
4871 });
4872 }
4873 Message::PasteTextContents(to, contents) => {
4874 let base_name = format!("{}.txt", fl!("pasted-text"));
4876 let base_path = to.join(&base_name);
4877 let final_path = copy_unique_path(&base_path, &to);
4878
4879 match fs::write(&final_path, &contents.data) {
4881 Ok(_) => {
4882 log::info!("Pasted text saved to {:?}", final_path);
4883 }
4884 Err(err) => {
4885 log::error!("Failed to save pasted text: {}", err);
4886 }
4887 }
4888 }
4889 Message::CheckClipboard => {
4890 return clipboard::read_data::<ClipboardPaste>().map(|contents_opt| {
4892 match contents_opt {
4893 Some(contents) if contents.paths.is_empty() => cosmic::action::app(
4894 Message::RetryCheckClipboard(ClipboardCache::Files(contents)),
4895 ),
4896 Some(contents) => cosmic::action::app(Message::ClipboardCached(
4897 ClipboardCache::Files(contents),
4898 )),
4899 _ => cosmic::action::app(Message::CheckClipboardImage),
4900 }
4901 });
4902 }
4903 Message::CheckClipboardImage => {
4904 return clipboard::read_data::<ClipboardPasteImage>().map(|contents_opt| {
4905 match contents_opt {
4906 Some(contents) => cosmic::action::app(Message::ClipboardCached(
4907 ClipboardCache::Image(contents),
4908 )),
4909 None => cosmic::action::app(Message::CheckClipboardVideo),
4910 }
4911 });
4912 }
4913 Message::CheckClipboardVideo => {
4914 return clipboard::read_data::<ClipboardPasteVideo>().map(|contents_opt| {
4915 match contents_opt {
4916 Some(contents) => cosmic::action::app(Message::ClipboardCached(
4917 ClipboardCache::Video(contents),
4918 )),
4919 None => cosmic::action::app(Message::CheckClipboardText),
4920 }
4921 });
4922 }
4923 Message::CheckClipboardText => {
4924 return clipboard::read_data::<ClipboardPasteText>().map(|contents_opt| {
4925 cosmic::action::app(Message::ClipboardCached(match contents_opt {
4926 Some(contents) => ClipboardCache::Text(contents),
4927 None => ClipboardCache::Empty,
4928 }))
4929 });
4930 }
4931 Message::RetryCheckClipboard(cache) => {
4932 let mut cmds = Vec::new();
4933 cmds.push(self.update(Message::ClipboardCached(cache)));
4934
4935 cmds.push(
4936 iced::Task::future(tokio::time::sleep(Duration::from_millis(300)))
4937 .discard()
4938 .chain(
4939 clipboard::read_data::<ClipboardPaste>().map(|contents_opt| {
4940 match contents_opt {
4941 Some(contents) if !contents.paths.is_empty() => {
4942 cosmic::action::app(Message::ClipboardCached(
4943 ClipboardCache::Files(contents),
4944 ))
4945 }
4946 _ => cosmic::action::app(Message::CheckClipboardImage),
4947 }
4948 }),
4949 ),
4950 );
4951 return Task::batch(cmds);
4952 }
4953 Message::ClipboardCached(cache) => {
4954 self.clipboard_cache = cache;
4955 }
4956 Message::PendingCancel(id) => {
4957 if let Some((_, controller)) = self.pending_operations.get(&id) {
4958 controller.cancel();
4959 self.progress_operations.remove(&id);
4960 }
4961 }
4962 Message::PendingCancelAll => {
4963 for (id, (_, controller)) in &self.pending_operations {
4964 controller.cancel();
4965 self.progress_operations.remove(id);
4966 }
4967 }
4968 Message::PendingComplete(id, op_sel) => {
4969 return self.handle_completed_operations(vec![(id, op_sel)]);
4970 }
4971 Message::PendingDismiss => {
4972 self.progress_operations.clear();
4973 }
4974 Message::PendingError(id, err) => {
4975 return self.handle_operation_errors(vec![(id, err)]);
4976 }
4977 Message::PendingResults(completed, errors) => {
4978 return Task::batch(vec![
4979 self.handle_completed_operations(completed),
4980 self.handle_operation_errors(errors),
4981 ]);
4982 }
4983 Message::PendingPause(id, pause) => {
4984 if let Some((_, controller)) = self.pending_operations.get(&id) {
4985 if pause {
4986 controller.pause();
4987 } else {
4988 controller.unpause();
4989 }
4990 }
4991 }
4992 Message::PendingPauseAll(pause) => {
4993 for (_, controller) in self.pending_operations.values() {
4994 if pause {
4995 controller.pause();
4996 } else {
4997 controller.unpause();
4998 }
4999 }
5000 }
5001 Message::PermanentlyDelete(entity_opt) => {
5002 let paths: Box<[_]> = self.selected_delete_paths(entity_opt).collect();
5003 if !paths.is_empty() {
5004 return self.push_dialog(
5005 DialogPage::PermanentlyDelete { paths },
5006 Some(PERMANENT_DELETE_BUTTON_ID.clone()),
5007 );
5008 }
5009 }
5010 Message::Preview(entity_opt) => {
5011 match self.mode {
5012 Mode::App => {
5013 let show_details = !self.config.show_details;
5014 self.context_page = ContextPage::Preview(None, PreviewKind::Selected);
5015 self.core.window.show_context = show_details;
5016 return cosmic::task::message(Message::SetShowDetails(show_details));
5017 }
5018 Mode::Desktop => {
5019 let preview_kind = {
5020 let mut selected_paths = self.selected_paths(entity_opt);
5021 match (selected_paths.next(), selected_paths.next()) {
5022 (Some(_), Some(_)) => Some(PreviewKind::Selected),
5023 (Some(path), None) => {
5024 Some(PreviewKind::Location(Location::Path(path)))
5025 }
5026 _ => None,
5027 }
5028 };
5029
5030 if let Some(preview_kind) = preview_kind {
5031 let settings = window::Settings {
5032 decorations: true,
5033 min_size: Some(Size::new(360.0, 180.0)),
5034 resizable: true,
5035 size: Size::new(480.0, 600.0),
5036 transparent: true,
5037 ..Default::default()
5038 };
5039
5040 #[cfg(target_os = "linux")]
5041 {
5042 settings.platform_specific.application_id =
5044 "com.system76.CosmicFilesDialog".to_string();
5045 }
5046
5047 let (id, command) = window::open(settings);
5048 self.windows.insert(
5049 id,
5050 Window::new(WindowKind::Preview(entity_opt, preview_kind)),
5051 );
5052 return Task::batch([
5053 self.update_desktop(), command.map(|_id| cosmic::action::none()),
5055 ]);
5056 }
5057 }
5058 }
5059 }
5060 Message::RemoveFromRecents(entity_opt) => {
5061 let paths: Box<[_]> = self.selected_paths(entity_opt).collect();
5062 return self.operation(Operation::RemoveFromRecents { paths });
5063 }
5064 Message::ReloadMimeAppCache => {
5065 self.mime_app_cache.reload();
5066 }
5067 Message::RescanRecents => {
5068 return self.rescan_recents();
5069 }
5070 Message::RescanTrash => {
5071 let maybe_entity = self.nav_model.iter().find(|&entity| {
5073 self.nav_model
5074 .data::<Location>(entity)
5075 .is_some_and(|loc| matches!(loc, Location::Trash))
5076 });
5077 if let Some(entity) = maybe_entity {
5078 self.nav_model
5079 .icon_set(entity, icon::icon(Trash::icon_symbolic(16)));
5080 }
5081
5082 return Task::batch([self.rescan_trash(), self.update_desktop()]);
5083 }
5084 Message::Rename(entity_opt) => {
5085 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5086 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
5087 && let Some(items) = tab.items_opt()
5088 {
5089 let selected: Box<[_]> = items
5090 .iter()
5091 .filter_map(|item| {
5092 if item.selected {
5093 item.path_opt().cloned()
5094 } else {
5095 None
5096 }
5097 })
5098 .collect();
5099 if !selected.is_empty() {
5100 let mut last_name = String::new();
5102 let tasks: Vec<_> = selected
5103 .into_iter()
5104 .filter_map(|path| {
5105 let parent = path.parent()?.to_path_buf();
5106 let name = path.file_name()?.to_str()?.to_string();
5107 let dir = path.is_dir();
5108 last_name = name.clone();
5109 Some(self.dialog_pages.push_back(DialogPage::RenameItem {
5110 from: path,
5111 parent,
5112 name,
5113 dir,
5114 }))
5115 })
5116 .collect();
5117 let tasks = tasks.into_iter().chain([
5118 widget::text_input::focus(self.dialog_text_input.clone()),
5119 widget::text_input::select_until_last(
5120 self.dialog_text_input.clone(),
5121 &last_name,
5122 '.',
5123 ),
5124 ]);
5125 return Task::batch(tasks);
5126 }
5127 }
5128 }
5129 Message::ReplaceResult(replace_result) => {
5130 if let Some((dialog_page, task)) = self.dialog_pages.pop_front() {
5131 match dialog_page {
5132 DialogPage::Replace { tx, .. } => {
5133 return Task::future(async move {
5134 let _ = tx.send(replace_result).await;
5135 cosmic::action::none()
5136 });
5137 }
5138 other => {
5139 log::warn!("tried to send replace result to the wrong dialog");
5140 return Task::batch([task, self.dialog_pages.push_front(other)]);
5141 }
5142 }
5143 }
5144 }
5145 Message::RestoreFromTrash(entity_opt) => {
5146 let mut trash_items = Vec::new();
5147 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5148 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
5149 && let Some(items) = tab.items_opt()
5150 {
5151 for item in items {
5152 if item.selected {
5153 if let ItemMetadata::Trash { entry, .. } = &item.metadata {
5154 trash_items.push(entry.clone());
5155 } else {
5156 }
5158 }
5159 }
5160 }
5161 if !trash_items.is_empty() {
5162 return self.operation(Operation::Restore { items: trash_items });
5163 }
5164 }
5165 Message::ScrollTab(scroll_speed) => {
5166 let entity = self.tab_model.active();
5167 return self.update(Message::TabMessage(
5168 Some(entity),
5169 tab::Message::ScrollTab(f32::from(scroll_speed) / 10.0),
5170 ));
5171 }
5172 Message::SearchActivate => {
5173 let mut tasks = vec![self.close_context_menus()];
5174
5175 if self.search_get().is_none() {
5176 tasks.push(self.search_set_active(Some(String::new())));
5177 } else {
5178 tasks.push(widget::text_input::focus(self.search_id.clone()));
5179 };
5180
5181 return Task::batch(tasks);
5182 }
5183 Message::SearchClear => {
5184 return Task::batch([self.close_context_menus(), self.search_set_active(None)]);
5185 }
5186 Message::SearchInput(input) => {
5187 return self.search_set_active(Some(input));
5188 }
5189 Message::SetShowDetails(show_details) => {
5190 config_set!(show_details, show_details);
5191 return self.update_config();
5192 }
5193 Message::SetShowRecents(show_recents) => {
5194 config_set!(show_recents, show_recents);
5195 return self.update_config();
5196 }
5197 Message::SetTypeToSearch(type_to_search) => {
5198 config_set!(type_to_search, type_to_search);
5199 return self.update_config();
5200 }
5201 Message::SystemThemeModeChange => {
5202 return self.update_config();
5203 }
5204 Message::TabActivate(entity) => {
5205 let mut tasks = vec![self.close_context_menus()];
5206
5207 self.tab_model.activate(entity);
5209 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
5210 {
5211 let scroll = tab.scroll_opt.unwrap_or_default();
5214 tasks.push(scrollable::scroll_to(
5215 tab.scrollable_id.clone(),
5216 AbsoluteOffset {
5217 x: Some(scroll.x),
5218 y: Some(scroll.y),
5219 },
5220 ));
5221 }
5222 self.activate_nav_model_location(&tab.location.clone());
5223 }
5224 tasks.push(self.update_title());
5225 return Task::batch(tasks);
5226 }
5227 Message::TabNext => {
5228 let len = self.tab_model.len();
5229 let pos = (self
5230 .tab_model
5231 .position(self.tab_model.active())
5232 .expect("should always be at least one tab open")
5233 + 1)
5234 % len as u16;
5236
5237 let entity = self.tab_model.entity_at(pos);
5238 if let Some(entity) = entity {
5239 return self.update(Message::TabActivate(entity));
5240 }
5241 }
5242 Message::TabPrev => {
5243 let pos = self
5244 .tab_model
5245 .position(self.tab_model.active())
5246 .expect("should always be at least one tab open")
5247 .checked_sub(1)
5248 .unwrap_or_else(|| (self.tab_model.len() as u16).saturating_sub(1));
5250
5251 let entity = self.tab_model.entity_at(pos);
5252 if let Some(entity) = entity {
5253 return self.update(Message::TabActivate(entity));
5254 }
5255 }
5256 Message::TabClose(entity_opt) => {
5257 let mut tasks = Vec::with_capacity(2);
5258
5259 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5260
5261 if self.tab_model.len() == 1 {
5264 tasks.push(Task::future(async move {
5265 cosmic::action::app(Message::WindowClose)
5266 }));
5267 } else if let Some(position) = self.tab_model.position(entity) {
5268 let new_position = if position > 0 {
5269 position - 1
5270 } else {
5271 position + 1
5272 };
5273
5274 if let Some(new_entity) = self.tab_model.entity_at(new_position) {
5275 tasks.push(self.update(Message::TabActivate(new_entity)));
5276 }
5277 }
5278
5279 self.tab_model.remove(entity);
5281
5282 tasks.push(self.update_watcher());
5283
5284 return Task::batch(tasks);
5285 }
5286 Message::TabConfig(config) => {
5287 if config != self.config.tab {
5288 config_set!(tab, config);
5289 return self.update_config();
5290 }
5291 }
5292 Message::ToggleFoldersFirst => {
5293 let mut config = self.config.tab;
5294 config.folders_first = !config.folders_first;
5295 return self.update(Message::TabConfig(config));
5296 }
5297 Message::ToggleShowHidden => {
5298 let mut config = self.config.tab;
5299 config.show_hidden = !config.show_hidden;
5300 return self.update(Message::TabConfig(config));
5301 }
5302 Message::ToggleShowSusceptible => {
5303 let mut config = self.config.tab;
5304 config.show_susceptible = !config.show_susceptible;
5305 return self.update(Message::TabConfig(config));
5306 }
5307 Message::ToggleShowAsSamples => {
5308 let mut config = self.config.tab;
5309 config.show_as_samples = !config.show_as_samples;
5310 return self.update(Message::TabConfig(config));
5311 }
5312 Message::TabMessage(entity_opt, tab_message) => {
5313 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5314
5315 let tab_commands = match self.tab_model.data_mut::<Tab>(entity) {
5316 Some(tab) => tab.update(tab_message, self.modifiers),
5317 _ => Vec::new(),
5318 };
5319
5320 let mut commands = Vec::new();
5321 for tab_command in tab_commands {
5322 match tab_command {
5323 tab::Command::Action(action) => {
5324 commands.push(self.update(action.message(Some(entity))));
5325 }
5326 tab::Command::AddNetworkDrive => {
5327 self.context_page = ContextPage::NetworkDrive;
5328 self.set_show_context(true);
5329 }
5330 tab::Command::AddRemoteDrive => {
5331 self.context_page = ContextPage::RemoteDrive;
5332 self.set_show_context(true);
5333 }
5334 tab::Command::DeleteTbProfilerResults(uri, tb_config) => {
5335 commands.push(
5336 self.update(Message::DeleteTbProfilerResults(uri, tb_config)),
5337 );
5338 }
5339 tab::Command::AddToSidebar(path) => {
5340 let mut favorites = self.config.favorites.clone();
5341 let favorite = Favorite::from_path(path);
5342 if !favorites.contains(&favorite) {
5343 favorites.push(favorite);
5344 }
5345 config_set!(favorites, favorites);
5346 commands.push(self.update_config());
5347 }
5348 tab::Command::AutoScroll(scroll_speed) => {
5349 if let Some(scroll_speed_float) = scroll_speed {
5352 self.auto_scroll_speed = Some((scroll_speed_float * 10.0) as i16);
5353 } else {
5354 self.auto_scroll_speed = None;
5355 }
5356 }
5357 tab::Command::ChangeLocation(tab_title, tab_path, selection_paths) => {
5358 self.activate_nav_model_location(&tab_path);
5359
5360 self.tab_model.text_set(entity, tab_title);
5361 self.type_select_prefix.clear();
5363 commands.push(Task::batch([
5364 self.update_title(),
5365 self.update_watcher(),
5366 self.update_tab(entity, tab_path, selection_paths),
5367 ]));
5368 }
5369 tab::Command::ContextMenu(_point_opt, _parent_id) => {
5370 #[cfg(feature = "wayland")]
5371 if let Some(point) = point_opt {
5372 if crate::is_wayland() {
5373 use cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{
5375 Anchor, Gravity,
5376 };
5377 use cosmic::iced::runtime::platform_specific::wayland::popup::{
5378 SctkPopupSettings, SctkPositioner,
5379 };
5380 let window_id = WindowId::unique();
5381 self.windows.insert(
5382 window_id,
5383 Window::new(WindowKind::ContextMenu(
5384 entity,
5385 widget::Id::unique(),
5386 )),
5387 );
5388 commands.push(self.update(Message::CheckClipboard));
5389 commands.push(self.update(Message::Surface(
5390 cosmic::surface::action::app_popup(
5391 move |app: &mut Self| -> SctkPopupSettings {
5392 let anchor_rect = Rectangle {
5393 x: point.x as i32,
5394 y: point.y as i32,
5395 width: 1,
5396 height: 1,
5397 };
5398 let positioner = SctkPositioner {
5399 size: None,
5400 anchor_rect,
5401 anchor: Anchor::None,
5402 gravity: Gravity::BottomRight,
5403 reactive: true,
5404 ..Default::default()
5405 };
5406 SctkPopupSettings {
5407 parent: parent_id.unwrap_or(
5408 app.core
5409 .main_window_id()
5410 .unwrap_or(WindowId::NONE),
5411 ),
5412 id: window_id,
5413 positioner,
5414 parent_size: None,
5415 grab: true,
5416 close_with_children: false,
5417 input_zone: None,
5418 }
5419 },
5420 None,
5421 ),
5422 )));
5423 }
5424 } else {
5425 let mut window_ids = Vec::new();
5427 for (window_id, window) in &self.windows {
5428 if let WindowKind::ContextMenu(e, _) = &window.kind
5429 && *e == entity
5430 {
5431 window_ids.push(*window_id);
5432 }
5433 }
5434 for window_id in window_ids {
5435 commands.push(self.update(Message::Surface(
5436 cosmic::surface::action::destroy_popup(window_id),
5437 )));
5438 }
5439 }
5440 }
5441 tab::Command::Delete(paths) => commands.push(self.delete(paths)),
5442 tab::Command::DownloadFile(paths, uris) => {
5443 commands.push(self.download_to(&paths, &uris))
5444 }
5445 tab::Command::DropFiles(to, from) => {
5446 commands.push(self.update(Message::PasteContents(to, from)));
5447 }
5448 tab::Command::ClearRecents => {
5449 match recently_used_xbel::clear_recently_used() {
5450 Ok(()) => {}
5451 Err(err) => {
5452 log::warn!("failed to clear recents history: {}", err);
5453 }
5454 }
5455 }
5456 tab::Command::EmptyTrash => {
5457 return self.push_dialog(
5458 DialogPage::EmptyTrash,
5459 Some(EMPTY_TRASH_BUTTON_ID.clone()),
5460 );
5461 }
5462 #[cfg(feature = "desktop")]
5463 tab::Command::ExecEntryAction(entry, action) => {
5464 Self::exec_entry_action(&entry, action);
5465 }
5466 tab::Command::RunContextAction(action) => {
5467 let paths: Box<[_]> = self.selected_paths(Some(entity)).collect();
5468 if let Some(preset) = self.config.context_actions.get(action) {
5469 if preset.confirm {
5470 commands.push(self.push_dialog(
5471 DialogPage::RunContextAction { action, paths },
5472 Some(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()),
5473 ));
5474 } else {
5475 context_action::run(
5476 &self.config.context_actions,
5477 action,
5478 &paths,
5479 );
5480 }
5481 } else {
5482 log::warn!("invalid context action index `{action}`");
5483 }
5484 }
5485 tab::Command::Iced(iced_command) => {
5486 commands.push(iced_command.0.map(move |x| {
5487 cosmic::action::app(Message::TabMessage(Some(entity), x))
5488 }));
5489 }
5490 tab::Command::OpenFile(paths) => commands.push(self.open_file(&paths)),
5491 tab::Command::OpenInNewTab(path) => {
5492 commands.push(self.close_context_menus());
5493 commands.push(self.open_tab(Location::Path(path), false, None));
5494 }
5495 tab::Command::OpenUriInNewTab(uri, name, path) => {
5496 commands.push(self.open_tab(
5497 Location::Remote(uri, name, path),
5498 false,
5499 None,
5500 ));
5501 }
5502 tab::Command::OpenInNewWindow(path) => match env::current_exe() {
5503 Ok(exe) => match process::Command::new(&exe).arg(path).spawn() {
5504 Ok(_child) => {}
5505 Err(err) => {
5506 log::error!("failed to execute {}: {}", exe.display(), err);
5507 }
5508 },
5509 Err(err) => {
5510 log::error!("failed to get current executable path: {err}");
5511 }
5512 },
5513 tab::Command::OpenTrash => {
5514 let mut command = process::Command::new("cosmic-files");
5516 command.arg("--trash");
5517 match spawn_detached(&mut command) {
5518 Ok(()) => {}
5519 Err(err) => {
5520 log::warn!("failed to run cosmic-files --trash: {err}");
5521 }
5522 }
5523 }
5524 tab::Command::Preview(kind) => {
5525 self.context_page = ContextPage::Preview(Some(entity), kind);
5526 self.set_show_context(true);
5527 }
5528 tab::Command::SetOpenWith(mime, id) => {
5529 self.mime_app_cache.set_default(mime, id);
5531 }
5532 tab::Command::SetPermissions(path, mode) => {
5533 commands.push(self.operation(Operation::SetPermissions { path, mode }));
5534 }
5535 tab::Command::SetMultiplePermissions(permissions) => {
5536 commands.push(
5537 self.join_operations(
5538 permissions
5539 .into_iter()
5540 .map(|(path, mode)| Operation::SetPermissions {
5541 path,
5542 mode,
5543 })
5544 .collect(),
5545 ),
5546 );
5547 }
5548 tab::Command::WindowDrag => {
5549 if let Some(window_id) = self.core.main_window_id() {
5550 commands.push(window::drag(window_id));
5551 }
5552 }
5553 tab::Command::WindowToggleMaximize => {
5554 if let Some(window_id) = self.core.main_window_id() {
5555 commands.push(window::toggle_maximize(window_id));
5556 }
5557 }
5558 tab::Command::OpenSeqAlignment(hit) => {
5559 let text = hit.format_pairwise_alignment();
5560 match tempfile::Builder::new()
5561 .prefix("alignment_")
5562 .suffix(".txt")
5563 .tempfile()
5564 {
5565 Ok(mut f) => {
5566 use std::io::Write as _;
5567 let _ = f.write_all(text.as_bytes());
5568 let path = f.into_temp_path();
5569 let path = path.keep().unwrap_or_default();
5570 if let Err(err) = open::that_detached(&path) {
5571 log::warn!("failed to open alignment viewer: {err}");
5572 }
5573 }
5574 Err(err) => {
5575 log::warn!("failed to create temp file for alignment: {err}")
5576 }
5577 }
5578 }
5579 tab::Command::OpenSpeciesAlignment(hit) => {
5580 let text = hit.format_pairwise_alignment();
5581 match tempfile::Builder::new()
5582 .prefix("alignment_")
5583 .suffix(".txt")
5584 .tempfile()
5585 {
5586 Ok(mut f) => {
5587 use std::io::Write as _;
5588 let _ = f.write_all(text.as_bytes());
5589 let path = f.into_temp_path();
5590 let path = path.keep().unwrap_or_default();
5591 if let Err(err) = open::that_detached(&path) {
5592 log::warn!("failed to open alignment viewer: {err}");
5593 }
5594 }
5595 Err(err) => {
5596 log::warn!("failed to create temp file for alignment: {err}")
5597 }
5598 }
5599 }
5600 tab::Command::SetSort(location, heading_options, direction) => {
5601 let default_sort = tab::SORT_OPTION_FALLBACK
5602 .get(&location)
5603 .copied()
5604 .unwrap_or((HeadingOptions::Name, true));
5605 let changed = if default_sort == (heading_options, direction) {
5606 self.state.sort_names.remove(&location).is_some()
5607 } else {
5608 _ = self.state.sort_names.remove(&location);
5610 _ = self
5611 .state
5612 .sort_names
5613 .insert(location, (heading_options, direction))
5614 .is_none_or(|old| old != (heading_options, direction));
5615
5616 const MAX_SORT_NAMES: usize = 999;
5617 if self.state.sort_names.len() > MAX_SORT_NAMES {
5619 self.state.sort_names = self
5621 .state
5622 .sort_names
5623 .split_off(self.state.sort_names.len() - MAX_SORT_NAMES);
5624 }
5625
5626 true
5627 };
5628
5629 if !self.must_save_sort_names & changed {
5630 self.must_save_sort_names = true;
5631 return cosmic::Task::future(async move {
5632 tokio::time::sleep(Duration::from_secs(1)).await;
5633 cosmic::action::app(Message::SaveSortNames)
5634 });
5635 }
5636 }
5637 }
5638 }
5639 return Task::batch(commands);
5640 }
5641 Message::TabNew => {
5642 let active = self.tab_model.active();
5643 let location = match self.tab_model.data::<Tab>(active) {
5644 Some(tab) => tab.location.clone(),
5645 None => Location::Path(home_dir()),
5646 };
5647 return self.open_tab(location, true, None);
5648 }
5649 Message::TabRescan(entity, mut location, parent_item_opt, items, selection_paths) => {
5650 location = location.normalize();
5651 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
5652 tab.location = tab.location.normalize();
5653 if location == tab.location {
5654 tab.parent_item_opt = parent_item_opt;
5655 tab.set_items(items);
5656 let location_str = location.to_string();
5657 let sort = self
5658 .state
5659 .sort_names
5660 .get(&location_str)
5661 .or_else(|| SORT_OPTION_FALLBACK.get(&location_str))
5662 .unwrap_or(&(HeadingOptions::Name, true));
5663
5664 tab.sort_name = sort.0;
5665 tab.sort_direction = sort.1;
5666
5667 let mut tasks = Vec::with_capacity(2);
5668
5669 if let Some(selection_paths) = selection_paths {
5670 tab.select_paths(selection_paths);
5671
5672 tasks.push(Task::done(cosmic::action::app(Message::TabMessage(
5674 Some(entity),
5675 tab::Message::ScrollToFocused,
5676 ))));
5677 }
5678
5679 tasks.push(clipboard::read_data::<ClipboardPaste>().map(|p| {
5680 cosmic::action::app(Message::CutPaths(match p {
5681 Some(s) => match s.kind {
5682 ClipboardKind::Copy => Vec::new(),
5683 ClipboardKind::Cut { .. } => s.paths,
5684 },
5685 None => Vec::new(),
5686 }))
5687 }));
5688
5689 return Task::batch(tasks);
5690 }
5691 }
5692 }
5693 Message::TabView(entity_opt, view) => {
5694 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5695 if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
5696 if matches!(tab.mode, tab::Mode::Desktop) {
5697 return Task::none();
5698 }
5699
5700 tab.config.view = view;
5701 }
5702 let mut config = self.config.tab;
5703 config.view = view;
5704 return self.update(Message::TabConfig(config));
5705 }
5706 Message::CutPaths(paths) => {
5707 if let Some(tab) = self.tab_model.active_data_mut::<Tab>() {
5708 tab.refresh_cut(&paths);
5709 }
5710 }
5711 Message::TimeConfigChange(time_config) => {
5712 self.config.tab.military_time = time_config.military_time;
5713 return self.update_config();
5714 }
5715 Message::ToggleContextPage(context_page) => {
5716 if self.context_page == context_page
5718 || matches!(self.context_page, ContextPage::Preview(_, _))
5719 {
5720 self.set_show_context(!self.core.window.show_context);
5721 } else {
5722 self.set_show_context(true);
5723 }
5724 self.context_page = context_page;
5725 if matches!(self.context_page, ContextPage::Preview(_, _)) {
5727 return cosmic::task::message(cosmic::action::app(Message::SetShowDetails(
5728 self.core.window.show_context,
5729 )));
5730 }
5731 }
5732 Message::Undo(_id) => {
5733 }
5735 Message::UndoTrash(id, recently_trashed) => {
5736 self.toasts.remove(id);
5737
5738 let mut paths = Vec::with_capacity(recently_trashed.len());
5739 let icon_sizes = self.config.tab.icon_sizes;
5740
5741 return cosmic::task::future(async move {
5742 match tokio::task::spawn_blocking(move || Location::Trash.scan(icon_sizes))
5743 .await
5744 {
5745 Ok((_parent_item_opt, items)) => {
5746 for path in &*recently_trashed {
5747 for item in &items {
5748 if let ItemMetadata::Trash { ref entry, .. } = item.metadata {
5749 let original_path = entry.original_path();
5750 if &original_path == path {
5751 paths.push(entry.clone());
5752 }
5753 }
5754 }
5755 }
5756 }
5757 Err(err) => {
5758 log::warn!("failed to rescan: {err}");
5759 }
5760 }
5761
5762 Message::UndoTrashStart(paths)
5763 });
5764 }
5765 Message::UndoTrashStart(items) => {
5766 return self.operation(Operation::Restore { items });
5767 }
5768 Message::WindowClose => {
5769 if let Some(window_id) = self.core.main_window_id() {
5770 self.core.set_main_window_id(None);
5771 return Task::batch([
5772 window::close(window_id),
5773 Task::future(async move { cosmic::action::app(Message::MaybeExit) }),
5774 ]);
5775 }
5776 }
5777 Message::WindowCloseRequested(id) => {
5778 self.remove_window(&id);
5779 }
5780 Message::WindowMaximize(id, maximized) => {
5781 return window::maximize(id, maximized);
5782 }
5783 Message::WindowNew => match env::current_exe() {
5784 Ok(exe) => {
5785 let mut command = process::Command::new(&exe);
5787
5788 let entity = self.tab_model.active();
5791 let active_tab_location =
5792 self.tab_model.data::<Tab>(entity).map(|tab| &tab.location);
5793 match active_tab_location {
5794 Some(
5795 Location::Desktop(path, ..)
5796 | Location::Path(path)
5797 | Location::Search(SearchLocation::Path(path), ..),
5798 ) => {
5799 command.arg(path);
5800 }
5801 Some(Location::Network(uri, ..)) => {
5802 command.arg(uri);
5803 }
5804 Some(Location::Recents | Location::Search(SearchLocation::Recents, ..)) => {
5805 command.arg("--recents");
5806 }
5807 Some(Location::Trash | Location::Search(SearchLocation::Trash, ..)) => {
5808 command.arg("--trash");
5809 }
5810 Some(Location::Remote(uri, ..)) => {
5811 command.arg(uri);
5812 }
5813 None => {}
5814 };
5815
5816 match command.spawn() {
5818 Ok(_child) => {}
5819 Err(err) => {
5820 log::error!("failed to execute {}: {}", exe.display(), err);
5821 }
5822 }
5823 }
5824 Err(err) => {
5825 log::error!("failed to get current executable path: {err}");
5826 }
5827 },
5828 Message::ZoomDefault(entity_opt) => {
5829 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5830 let mut config = self.config.tab;
5831 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
5832 zoom_to_default(tab.config.view, &mut config.icon_sizes);
5833 }
5834 return self.update(Message::TabConfig(config));
5835 }
5836 Message::ZoomIn(entity_opt) => {
5837 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5838 let mut config = self.config.tab;
5839 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
5840 zoom_in_view(tab.config.view, &mut config.icon_sizes);
5841 }
5842 return self.update(Message::TabConfig(config));
5843 }
5844 Message::ZoomOut(entity_opt) => {
5845 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
5846 let mut config = self.config.tab;
5847 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
5848 zoom_out_view(tab.config.view, &mut config.icon_sizes);
5849 }
5850 return self.update(Message::TabConfig(config));
5851 }
5852 Message::DndEnterNav(entity) => {
5853 if let Some(location) = self.nav_model.data::<Location>(entity) {
5854 self.nav_dnd_hover = Some((location.clone(), Instant::now()));
5855 let location = location.clone();
5856 return Task::perform(tokio::time::sleep(HOVER_DURATION), move |()| {
5857 cosmic::Action::App(Message::DndHoverLocTimeout(location.clone()))
5858 });
5859 }
5860 }
5861 Message::DndExitNav => {
5862 self.nav_dnd_hover = None;
5863 }
5864 Message::DndDropNav(entity, data, action) => {
5865 self.nav_dnd_hover = None;
5866 if let Some((location, data)) = self.nav_model.data::<Location>(entity).zip(data) {
5867 let kind = match action {
5868 DndAction::Move => ClipboardKind::Cut { is_dnd: true },
5869 _ => ClipboardKind::Copy,
5870 };
5871 let ret = match location {
5872 Location::Path(p) => self.update(Message::PasteContents(
5873 p.clone(),
5874 ClipboardPaste {
5875 kind,
5876 paths: data.paths,
5877 },
5878 )),
5879 Location::Trash if matches!(action, DndAction::Move) => {
5880 self.delete(data.paths)
5881 }
5882 _ => {
5883 log::warn!("Copy to trash is not supported.");
5884 Task::none()
5885 }
5886 };
5887 return ret;
5888 }
5889 }
5890 Message::DndHoverLocTimeout(location) => {
5891 if self
5892 .nav_dnd_hover
5893 .as_ref()
5894 .is_some_and(|(loc, i)| *loc == location && i.elapsed() >= HOVER_DURATION)
5895 {
5896 self.nav_dnd_hover = None;
5897 let entity = self.tab_model.active();
5898 let title_opt = match self.tab_model.data_mut::<Tab>(entity) {
5899 Some(tab) => {
5900 tab.change_location(&location, None);
5901 Some(tab.title())
5902 }
5903 None => None,
5904 };
5905 if let Some(title) = title_opt {
5906 self.tab_model.text_set(entity, title);
5907 return Task::batch([
5908 self.update_title(),
5909 self.update_watcher(),
5910 self.update_tab(entity, location, None),
5911 ]);
5912 }
5913 }
5914 }
5915 Message::DndEnterTab(entity, mimes) => {
5916 if mimes.iter().all(|m| m.as_str() != "x-cosmic-files/tab-dnd") {
5917 self.tab_dnd_hover = Some((entity, Instant::now()));
5918 return Task::perform(tokio::time::sleep(HOVER_DURATION), move |()| {
5919 cosmic::Action::App(Message::DndHoverTabTimeout(entity))
5920 });
5921 }
5922 }
5923 Message::DndExitTab => {
5924 self.nav_dnd_hover = None;
5925 }
5926 Message::DndDropTab(entity, data, action) => {
5927 self.nav_dnd_hover = None;
5928 if let Some((tab, data)) = self.tab_model.data::<Tab>(entity).zip(data) {
5929 let kind = match action {
5930 DndAction::Move => ClipboardKind::Cut { is_dnd: true },
5931 _ => ClipboardKind::Copy,
5932 };
5933 let ret = match &tab.location {
5934 Location::Trash if matches!(action, DndAction::Move) => {
5935 self.delete(data.paths)
5936 }
5937 _ => {
5938 if let Some(path) = tab.location.path_opt() {
5939 self.update(Message::PasteContents(
5940 path.clone(),
5941 ClipboardPaste {
5942 kind,
5943 paths: data.paths,
5944 },
5945 ))
5946 } else {
5947 log::warn!("{:?} to {:?} is not supported.", action, tab.location);
5948 Task::none()
5949 }
5950 }
5951 };
5952 return ret;
5953 }
5954 }
5955 Message::DndHoverTabTimeout(entity) => {
5956 if self
5957 .tab_dnd_hover
5958 .as_ref()
5959 .is_some_and(|(e, i)| *e == entity && i.elapsed() >= HOVER_DURATION)
5960 {
5961 self.tab_dnd_hover = None;
5962 return self.update(Message::TabActivate(entity));
5963 }
5964 }
5965 Message::NavBarClose(entity) => {
5966 if let Some(data) = self.nav_model.data::<MounterData>(entity)
5967 && let Some(mounter) = MOUNTERS.get(&data.0)
5968 {
5969 return mounter
5970 .unmount(data.1.clone())
5971 .map(|()| cosmic::action::none());
5972 }
5973 if let Some(data) = self.nav_model.data::<ClientData>(entity)
5974 && let Some(client) = CLIENTS.get(&data.0)
5975 {
5976 return client
5977 .disconnect(data.1.clone())
5978 .map(|()| cosmic::action::none());
5979 }
5980 }
5981 Message::NavBarContext(entity) => {
5982 self.nav_bar_context_id = entity;
5983
5984 let tab_entity = self.tab_model.active();
5985 if let Some(tab) = self.tab_model.data_mut::<Tab>(tab_entity) {
5986 tab.edit_location = None;
5988 tab.location_context_menu_index = None;
5990 return Task::done(cosmic::Action::App(Message::TabMessage(
5991 Some(tab_entity),
5992 tab::Message::ContextMenu(None, None),
5993 )));
5994 }
5995 }
5996 Message::NavMenuAction(action) => match action {
5997 NavMenuAction::ClearRecents => match recently_used_xbel::clear_recently_used() {
5998 Ok(()) => {}
5999 Err(err) => {
6000 log::warn!("failed to clear recents history: {}", err);
6001 }
6002 },
6003 NavMenuAction::EmptyTrash => {
6004 return self
6005 .push_dialog(DialogPage::EmptyTrash, Some(EMPTY_TRASH_BUTTON_ID.clone()));
6006 }
6007 NavMenuAction::Open(entity) => {
6008 if let Some(path) = self
6009 .nav_model
6010 .data::<Location>(entity)
6011 .and_then(Location::path_opt)
6012 .cloned()
6013 {
6014 return self.open_file(&[path]);
6015 }
6016 }
6017 NavMenuAction::OpenWith(entity) => {
6018 if let Some(path) = self
6019 .nav_model
6020 .data::<Location>(entity)
6021 .and_then(Location::path_opt)
6022 .cloned()
6023 {
6024 match tab::item_from_path(&path, IconSizes::default()) {
6025 Ok(item) => {
6026 return self.push_dialog(
6027 DialogPage::OpenWith {
6028 path,
6029 mime: item.mime,
6030 selected: 0,
6031 store_opt: "x-scheme-handler/mime"
6032 .parse::<mime_guess::Mime>()
6033 .ok()
6034 .and_then(|mime| {
6035 self.mime_app_cache.get(&mime).first().cloned()
6036 }),
6037 },
6038 None,
6039 );
6040 }
6041 Err(err) => {
6042 log::warn!(
6043 "failed to get item for path {}: {}",
6044 path.display(),
6045 err
6046 );
6047 }
6048 }
6049 }
6050 }
6051 NavMenuAction::RunContextAction(entity, action) => {
6052 if let Some(path) = self
6053 .nav_model
6054 .data::<Location>(entity)
6055 .and_then(Location::path_opt)
6056 .cloned()
6057 {
6058 let paths = vec![path];
6059 if let Some(preset) = self.config.context_actions.get(action) {
6060 if preset.confirm {
6061 return self.push_dialog(
6062 DialogPage::RunContextAction {
6063 action,
6064 paths: paths.into_boxed_slice(),
6065 },
6066 Some(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()),
6067 );
6068 }
6069 context_action::run(&self.config.context_actions, action, &paths);
6070 } else {
6071 log::warn!("invalid context action index `{action}`");
6072 }
6073 }
6074 }
6075 NavMenuAction::OpenInNewTab(entity) => {
6076 let open_task = match self.nav_model.data::<Location>(entity) {
6077 Some(Location::Network(uri, display_name, path)) => self.open_tab(
6078 Location::Network(uri.clone(), display_name.clone(), path.clone()),
6079 false,
6080 None,
6081 ),
6082 Some(Location::Path(path)) => {
6083 self.open_tab(Location::Path(path.clone()), false, None)
6084 }
6085 Some(Location::Recents) => self.open_tab(Location::Recents, false, None),
6086 Some(Location::Trash) => self.open_tab(Location::Trash, false, None),
6087 #[cfg(feature = "russh")]
6088 Some(Location::Remote(uri, name, path)) => self.open_tab(
6089 Location::Remote(uri.clone(), name.clone(), path.clone()),
6090 false,
6091 None,
6092 ),
6093 _ => Task::none(),
6094 };
6095
6096 return Task::batch([self.close_context_menus(), open_task]);
6097 }
6098
6099 NavMenuAction::OpenInNewWindow(entity) => 'open_in_new_window: {
6101 if let Some(location) = self.nav_model.data::<Location>(entity) {
6102 match env::current_exe() {
6103 Ok(exe) => {
6104 let mut command = process::Command::new(&exe);
6105 match location {
6106 Location::Path(path) => {
6107 command.arg(path);
6108 }
6109 Location::Trash => {
6110 command.arg("--trash");
6111 }
6112 Location::Network(uri, _, Some(_)) => {
6113 command.arg(uri);
6114 }
6115 Location::Network(..) => {
6116 command.arg("--network");
6117 }
6118 Location::Recents => {
6119 command.arg("--recents");
6120 }
6121 Location::Remote(uri, ..) => {
6122 command.arg(uri);
6123 }
6124 _ => {
6125 log::error!(
6126 "unsupported location for open in new window: {location:?}"
6127 );
6128 break 'open_in_new_window;
6129 }
6130 }
6131 match command.spawn() {
6132 Ok(_child) => {}
6133 Err(err) => {
6134 log::error!("failed to execute {}: {}", exe.display(), err);
6135 }
6136 }
6137 }
6138 Err(err) => {
6139 log::error!("failed to get current executable path: {err}");
6140 }
6141 }
6142 }
6143 }
6144
6145 NavMenuAction::Preview(entity) => {
6146 if let Some(path) = self
6147 .nav_model
6148 .data::<Location>(entity)
6149 .and_then(Location::path_opt)
6150 {
6151 match tab::item_from_path(path, IconSizes::default()) {
6152 Ok(item) => {
6153 self.context_page = ContextPage::Preview(
6154 None,
6155 PreviewKind::Custom(PreviewItem(Box::new(item))),
6156 );
6157 self.set_show_context(true);
6158 }
6159 Err(err) => {
6160 log::warn!(
6161 "failed to get item from path {}: {}",
6162 path.display(),
6163 err
6164 );
6165 }
6166 }
6167 }
6168 }
6169
6170 NavMenuAction::RemoveFromSidebar(entity) => {
6171 if let Some(FavoriteIndex(favorite_i)) =
6172 self.nav_model.data::<FavoriteIndex>(entity)
6173 {
6174 let mut favorites = self.config.favorites.clone();
6175 favorites.remove(*favorite_i);
6176 config_set!(favorites, favorites);
6177 return self.update_config();
6178 }
6179 }
6180 },
6181 Message::Recents => {
6182 if self.config.show_recents {
6183 return self.open_tab(Location::Recents, false, None);
6184 }
6185 }
6186 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
6187 Message::OutputEvent(output_event, output) => {
6188 match output_event {
6189 OutputEvent::Created(output_info_opt) => {
6190 let output_id = output.id();
6191 log::info!("output {output_id}: created");
6192
6193 let surface_id = WindowId::unique();
6194 if let Some(old_surface_id) =
6195 self.surface_ids.insert(output.clone(), surface_id)
6196 {
6197 log::warn!(
6199 "output {output_id}: already had surface ID {old_surface_id:?}"
6200 );
6201 }
6202
6203 let display = match output_info_opt {
6204 Some(output_info) => match output_info.name {
6205 Some(output_name) => {
6206 self.surface_names.insert(surface_id, output_name.clone());
6207 output_name
6208 }
6209 None => {
6210 log::warn!("output {output_id}: no output name");
6211 String::new()
6212 }
6213 },
6214 None => {
6215 log::warn!("output {output_id}: no output info");
6216 String::new()
6217 }
6218 };
6219
6220 let (entity, command) = self.open_tab_entity(
6221 Location::Desktop(crate::desktop_dir(), display, self.config.desktop),
6222 false,
6223 None,
6224 widget::Id::unique(),
6225 Some(surface_id),
6226 );
6227 self.windows
6228 .insert(surface_id, Window::new(WindowKind::Desktop(entity)));
6229 return Task::batch([
6230 command,
6231 get_layer_surface(SctkLayerSurfaceSettings {
6232 id: surface_id,
6233 layer: Layer::Bottom,
6234 keyboard_interactivity: KeyboardInteractivity::OnDemand,
6235 input_zone: None,
6236 anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT,
6237 output: IcedOutput::Output(output),
6238 namespace: "cosmic-files-applet".into(),
6239 size: Some((None, None)),
6240 margin: IcedMargin {
6241 top: 0,
6242 bottom: 0,
6243 left: 0,
6244 right: 0,
6245 },
6246 exclusive_zone: 0,
6247 size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
6248 }),
6249 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
6250 overlap_notify(surface_id, true),
6251 ]);
6252 }
6253 OutputEvent::Removed => {
6254 log::info!("output {}: removed", output.id());
6255 match self.surface_ids.remove(&output) {
6256 Some(surface_id) => {
6257 self.remove_window(&surface_id);
6258 self.surface_names.remove(&surface_id);
6259 return destroy_layer_surface(surface_id);
6260 }
6261 None => {
6262 log::warn!("output {}: no surface found", output.id());
6263 }
6264 }
6265 }
6266 OutputEvent::InfoUpdate(_output_info) => {
6267 log::info!("output {}: info update", output.id());
6268 }
6269 }
6270 }
6271 Message::Cosmic(cosmic) => {
6272 return Task::perform(async move { cosmic }, cosmic::action::cosmic);
6274 }
6275 Message::None => {}
6276 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
6277 Message::Overlap(w_id, overlap_notify_event) => match overlap_notify_event {
6278 OverlapNotifyEvent::OverlapLayerAdd {
6279 identifier,
6280 namespace,
6281 logical_rect,
6282 exclusive,
6283 ..
6284 } => {
6285 if exclusive > 0 || namespace == "Dock" || namespace == "Panel" {
6286 self.overlap.insert(identifier, (w_id, logical_rect));
6287 self.handle_overlap();
6288 }
6289 }
6290 OverlapNotifyEvent::OverlapLayerRemove { identifier } => {
6291 self.overlap.remove(&identifier);
6292 self.handle_overlap();
6293 }
6294 _ => {}
6295 },
6296 Message::Size(window_id, size) => {
6297 if self.core.main_window_id() == Some(window_id) {
6298 self.size = Some(size);
6299 } else {
6300 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
6301 self.layer_sizes.insert(window_id, size);
6302 }
6303 }
6304 Message::Eject => {
6305 #[cfg(feature = "gvfs")]
6306 {
6307 let mut paths = self.selected_paths(None);
6308 if let Some(p) = paths.next() {
6309 {
6310 for (k, mounter_items) in &self.mounter_items {
6311 if let Some(mounter) = MOUNTERS.get(k)
6312 && let Some(item) = mounter_items
6313 .iter()
6314 .find(|&item| item.path().is_some_and(|path| path == p))
6315 {
6316 return mounter
6317 .unmount(item.clone())
6318 .map(|()| cosmic::action::none());
6319 }
6320 }
6321 }
6322 }
6323 }
6324 #[cfg(feature = "russh")]
6325 {
6326 let mut paths = self.selected_paths(None);
6327 if let Some(p) = paths.next() {
6328 {
6329 for (k, client_items) in &self.client_items {
6330 if let Some(client) = CLIENTS.get(k)
6331 && let Some(item) = client_items
6332 .iter()
6333 .find(|&item| item.path().is_some_and(|path| path == p))
6334 {
6335 return client
6336 .disconnect(item.clone())
6337 .map(|()| cosmic::action::none());
6338 }
6339 }
6340 }
6341 }
6342 }
6343 }
6344 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
6345 Message::Focused(id) => {
6346 if let Some(w) = self.windows.get(&id) {
6347 match &w.kind {
6348 WindowKind::Desktop(entity) => self.tab_model.activate(*entity),
6349 _ => {}
6350 };
6351 }
6352 return cosmic::task::future(async {
6355 _ = tokio::time::sleep(Duration::from_millis(300)).await;
6356 cosmic::action::app(Message::CheckClipboard)
6357 });
6358 }
6359 Message::Surface(action) => {
6360 return cosmic::task::message(cosmic::Action::Cosmic(
6361 cosmic::app::Action::Surface(action),
6362 ));
6363 }
6364 Message::SaveSortNames => {
6365 self.must_save_sort_names = false;
6366 if let Some(state_handler) = self.state_handler.as_ref()
6367 && let Err(err) = state_handler
6368 .set::<&FxOrderMap<String, (HeadingOptions, bool)>>(
6369 "sort_names",
6370 &self.state.sort_names,
6371 )
6372 {
6373 log::warn!("Failed to save sort names: {err:?}");
6374 }
6375 }
6376 Message::NetworkDriveOpenEntityAfterMount { entity } => {
6377 return self.on_nav_select(entity);
6378 }
6379 Message::RemoteDriveOpenEntityAfterMount { entity } => {
6380 self.nav_model.activate(entity);
6381 if let Some(location) = self.nav_model.data::<Location>(entity) {
6382 log::info!("RemoteDriveOpenEntityAfterMount location: {:?}", location);
6383 let message =
6384 Message::TabMessage(None, tab::Message::Location(location.clone()));
6385 return self.update(message);
6386 }
6387 }
6388 Message::NetworkDriveOpenTabAfterMount { location } => {
6389 return self.open_tab(location, false, None);
6390 }
6391 Message::ReorderTab(ReorderEvent {
6392 dragged,
6393 target,
6394 position,
6395 }) => {
6396 _ = self.tab_model.reorder(dragged, target, position);
6397 }
6398 Message::SetTbScriptPath(script_path) => {
6399 let mut tb_config = self.config.tb_config.clone();
6400 tb_config.script_path = script_path;
6401 config_set!(tb_config, tb_config);
6402 return self.update_config();
6403 }
6404 Message::SetTbOutDir(out_dir) => {
6405 let mut tb_config = self.config.tb_config.clone();
6406 tb_config.out_dir = out_dir;
6407 config_set!(tb_config, tb_config);
6408 return self.update_config();
6409 }
6410 Message::SetTbDocxTemplatePath(docx_template_path) => {
6411 let mut tb_config = self.config.tb_config.clone();
6412 tb_config.docx_template_path = docx_template_path;
6413 config_set!(tb_config, tb_config);
6414 return self.update_config();
6415 }
6416 Message::SetTbPair1Suffix(pair1_suffix) => {
6417 let mut tb_config = self.config.tb_config.clone();
6418 tb_config.pair1_suffix = pair1_suffix;
6419 config_set!(tb_config, tb_config);
6420 return self.update_config();
6421 }
6422 Message::SetTbPair2Suffix(pair2_suffix) => {
6423 let mut tb_config = self.config.tb_config.clone();
6424 tb_config.pair2_suffix = pair2_suffix;
6425 config_set!(tb_config, tb_config);
6426 return self.update_config();
6427 }
6428 Message::SetTbAb1ScanPath(path) => {
6429 let mut tb_config = self.config.tb_config.clone();
6430 tb_config.ab1_scan_path = path;
6431 config_set!(tb_config, tb_config);
6432 return self.update_config();
6433 }
6434 Message::SetTbAb1CachePath(path) => {
6435 let mut tb_config = self.config.tb_config.clone();
6436 tb_config.ab1_cache_path = path;
6437 config_set!(tb_config, tb_config);
6438 return self.update_config();
6439 }
6440 Message::SetTbAb1OutDirCsv(path) => {
6441 let mut tb_config = self.config.tb_config.clone();
6442 tb_config.ab1_out_dir_csv = path;
6443 config_set!(tb_config, tb_config);
6444 return self.update_config();
6445 }
6446 Message::SetTbAb1OutDirPdf(path) => {
6447 let mut tb_config = self.config.tb_config.clone();
6448 tb_config.ab1_out_dir_pdf = path;
6449 config_set!(tb_config, tb_config);
6450 return self.update_config();
6451 }
6452 Message::SetNtfyTopic(v) => {
6453 let mut tb_config = self.config.tb_config.clone();
6454 tb_config.ntfy_topic = v;
6455 config_set!(tb_config, tb_config);
6456 return self.update_config();
6457 }
6458 Message::SetTbReportMaxAgeDays(v) => {
6459 if let Ok(days) = v.parse::<u32>() {
6460 let mut tb_config = self.config.tb_config.clone();
6461 tb_config.report_max_age_days = days;
6462 config_set!(tb_config, tb_config);
6463 return self.update_config();
6464 }
6465 }
6466 Message::ScanAb1Directory => {
6467 let scan_path = self.config.tb_config.ab1_scan_path.clone();
6468 if scan_path.is_empty() {
6469 return Task::none();
6470 }
6471 let ab1_cache_path = self.config.tb_config.ab1_cache_path.clone();
6472 let max_age_days = self.config.tb_config.report_max_age_days;
6473 return Task::future(async move {
6474 let records = tokio::task::spawn_blocking(move || {
6475 let cache_path = if !ab1_cache_path.is_empty() {
6476 let p = std::path::PathBuf::from(ab1_cache_path);
6477 Some(if p.is_dir() { p.join("ab1_scan_cache.json") } else { p })
6478 } else {
6479 Some(std::path::PathBuf::from(scan_path.clone()).join("ab1_scan_cache.json"))
6480 };
6481 crate::sequencing::batch::scan_ab1_directory(
6482 std::path::PathBuf::from(scan_path),
6483 cache_path,
6484 max_age_days,
6485 )
6486 })
6487 .await
6488 .unwrap_or_default();
6489 cosmic::action::app(Message::Ab1ScanComplete(records))
6490 });
6491 }
6492 Message::Ab1ScanComplete(records) => {
6493 let ab1_out_dir_csv = self.config.tb_config.ab1_out_dir_csv.clone();
6494 let scan_path = self.config.tb_config.ab1_scan_path.clone();
6495 let out_path = if !ab1_out_dir_csv.is_empty() {
6496 std::path::PathBuf::from(&ab1_out_dir_csv).join("ab1_susceptibility_report.csv")
6497 } else {
6498 std::path::PathBuf::from(&scan_path).join("ab1_susceptibility_report.csv")
6499 };
6500 if let Err(e) = crate::sequencing::batch::write_ab1_csv(&records, &out_path) {
6501 log::warn!("AB1 CSV write failed: {e}");
6502 } else {
6503 log::info!(
6504 "AB1 scan complete: {} records → {}",
6505 records.len(),
6506 out_path.display()
6507 );
6508 }
6509 let rare_path = out_path.with_file_name("rare_mutations.csv");
6510 if let Err(e) = crate::sequencing::batch::write_rare_mutations_csv(&records, &rare_path) {
6511 log::warn!("Rare mutations CSV write failed: {e}");
6512 } else {
6513 log::info!("Rare mutations CSV → {}", rare_path.display());
6514 }
6515
6516 let pdf_bytes = crate::sequencing::build_report_pdf(
6517 &records,
6518 self.config.tb_config.report_max_age_days,
6519 );
6520 let pdf_path = out_path.with_file_name("ab1_susceptibility_report.pdf");
6521 if let Err(e) = std::fs::write(&pdf_path, &pdf_bytes) {
6522 log::warn!("PDF write failed: {e}");
6523 } else {
6524 log::info!("PDF report → {}", pdf_path.display());
6525 }
6526
6527 let topic = self.config.tb_config.ntfy_topic.clone();
6528 if !topic.is_empty() {
6529 let n = records.len();
6530 tokio::spawn(async move {
6531 if let Err(e) = crate::sequencing::ntfy_notify::send_report_ntfy(&topic, pdf_bytes, n).await {
6532 log::warn!("ntfy notification failed: {e}");
6533 }
6534 });
6535 }
6536 }
6537 }
6538
6539 Task::none()
6540 }
6541
6542 fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Message>> {
6543 if !self.core.window.show_context {
6544 return None;
6545 }
6546
6547 Some(match &self.context_page {
6548 ContextPage::About => context_drawer::about(
6549 &self.about,
6550 |url| Message::LaunchUrl(url.to_string()),
6551 Message::ToggleContextPage(ContextPage::About),
6552 ),
6553 ContextPage::EditHistory => context_drawer::context_drawer(
6554 self.edit_history(),
6555 Message::ToggleContextPage(ContextPage::EditHistory),
6556 )
6557 .title(fl!("edit-history")),
6558 ContextPage::NetworkDrive => {
6559 let mut text_input =
6560 widget::text_input(fl!("enter-server-address"), &self.network_drive_input);
6561 let button = if self.network_drive_connecting.is_some() {
6562 widget::button::standard(fl!("connecting"))
6563 } else {
6564 text_input = text_input
6565 .on_input(Message::NetworkDriveInput)
6566 .on_submit(|_| Message::NetworkDriveSubmit);
6567 widget::button::standard(fl!("connect")).on_press(Message::NetworkDriveSubmit)
6568 };
6569 context_drawer::context_drawer(
6570 self.network_drive(),
6571 Message::ToggleContextPage(ContextPage::NetworkDrive),
6572 )
6573 .title(fl!("add-network-drive"))
6574 .header(text_input)
6575 .footer(widget::row::with_children([
6576 widget::space::horizontal().into(),
6577 button.into(),
6578 ]))
6579 }
6580 ContextPage::RemoteDrive => {
6581 let mut text_input =
6582 widget::text_input(fl!("enter-server-address"), &self.remote_drive_input);
6583 let button = if self.remote_drive_connecting.is_some() {
6584 widget::button::standard(fl!("connecting"))
6585 } else {
6586 text_input = text_input
6587 .on_input(Message::RemoteDriveInput)
6588 .on_submit(|_| Message::RemoteDriveSubmit);
6589 widget::button::standard(fl!("connect")).on_press(Message::RemoteDriveSubmit)
6590 };
6591 context_drawer::context_drawer(
6592 self.remote_drive(),
6593 Message::ToggleContextPage(ContextPage::RemoteDrive),
6594 )
6595 .title(fl!("add-remote-drive"))
6596 .header(text_input)
6597 .footer(widget::row::with_children([
6598 widget::space::horizontal().into(),
6599 button.into(),
6600 ]))
6601 }
6602 ContextPage::Preview(entity_opt, kind) => {
6603 let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
6604 let actions = self
6605 .tab_model
6606 .data::<Tab>(entity)
6607 .and_then(|tab| {
6608 let mut selected = tab.items_opt()?.iter().filter(|item| item.selected);
6609
6610 match (selected.next(), selected.next()) {
6611 (Some(item), None) => Some(
6613 item.preview_actions()
6614 .map(move |x| Message::TabMessage(Some(entity), x)),
6615 ),
6616 _ => None,
6618 }
6619 })
6620 .unwrap_or_else(|| widget::space::horizontal().into());
6621 context_drawer::context_drawer(
6622 self.preview(entity_opt, kind, true)
6623 .map(move |x| Message::TabMessage(Some(entity), x)),
6624 Message::ToggleContextPage(ContextPage::Preview(Some(entity), kind.clone())),
6625 )
6626 .actions(actions)
6627 }
6628 ContextPage::Settings => context_drawer::context_drawer(
6629 self.settings(),
6630 Message::ToggleContextPage(ContextPage::Settings),
6631 )
6632 .title(fl!("settings")),
6633 ContextPage::TBSettings => context_drawer::context_drawer(
6634 self.tb_settings(),
6635 Message::ToggleContextPage(ContextPage::TBSettings),
6636 )
6637 .title(fl!("tb-profiler-settings")),
6638 })
6639 }
6640
6641 fn dialog(&self) -> Option<Element<'_, Message>> {
6642 let entity = self.tab_model.active();
6644 if let Some(tab) = self.tab_model.data::<Tab>(entity)
6645 && tab.gallery
6646 {
6647 return Some(
6648 tab.gallery_view()
6649 .map(move |x| Message::TabMessage(Some(entity), x)),
6650 );
6651 }
6652 let dialog_page = self.dialog_pages.front()?;
6653
6654 let cosmic_theme::Spacing {
6655 space_xxs, space_s, ..
6656 } = theme::spacing();
6657
6658 let dialog = match dialog_page {
6659 DialogPage::Compress {
6660 paths,
6661 to,
6662 name,
6663 archive_type,
6664 password,
6665 } => {
6666 let mut dialog = widget::dialog().title(fl!("create-archive"));
6667
6668 let complete_maybe = if name.is_empty() {
6669 None
6670 } else if name == "." || name == ".." {
6671 dialog = dialog.tertiary_action(widget::text::body(fl!(
6672 "name-invalid",
6673 filename = name.as_str()
6674 )));
6675 None
6676 } else if name.contains('/') {
6677 dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes")));
6678 None
6679 } else {
6680 let extension = archive_type.extension();
6681 let name = format!("{name}{extension}");
6682 let path = to.join(&name);
6683 if path.exists() {
6684 dialog =
6685 dialog.tertiary_action(widget::text::body(fl!("file-already-exists")));
6686 None
6687 } else {
6688 if name.starts_with('.') {
6689 dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden")));
6690 }
6691 Some(Message::DialogComplete)
6692 }
6693 };
6694
6695 let archive_types = ArchiveType::all();
6696 let selected = archive_types.iter().position(|&x| x == *archive_type);
6697 dialog = dialog
6698 .primary_action(
6699 widget::button::suggested(fl!("create"))
6700 .on_press_maybe(complete_maybe.clone()),
6701 )
6702 .secondary_action(
6703 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6704 )
6705 .control(
6706 widget::column::with_children([
6707 widget::text::body(fl!("file-name")).into(),
6708 widget::row::with_children([
6709 widget::text_input("", name.as_str())
6710 .id(self.dialog_text_input.clone())
6711 .on_input(move |name| {
6712 Message::DialogUpdate(DialogPage::Compress {
6713 paths: paths.clone(),
6714 to: to.clone(),
6715 name,
6716 archive_type: *archive_type,
6717 password: password.clone(),
6718 })
6719 })
6720 .on_submit_maybe(
6721 complete_maybe.clone().map(|maybe| move |_| maybe.clone()),
6722 )
6723 .into(),
6724 Element::from(widget::dropdown(
6725 archive_types,
6726 selected,
6727 move |index| index,
6728 ))
6729 .map(|index| {
6730 Message::DialogUpdate(DialogPage::Compress {
6731 paths: paths.clone(),
6732 to: to.clone(),
6733 name: name.clone(),
6734 archive_type: archive_types[index],
6735 password: password.clone(),
6736 })
6737 }),
6738 ])
6739 .align_y(Alignment::Center)
6740 .spacing(space_xxs)
6741 .into(),
6742 ])
6743 .spacing(space_xxs),
6744 );
6745
6746 if *archive_type == ArchiveType::Zip {
6747 let password_unwrapped = password.clone().unwrap_or_default();
6748 dialog = dialog.control(widget::column::with_children([
6749 widget::text::body(fl!("password")).into(),
6750 widget::text_input("", password_unwrapped)
6751 .password()
6752 .on_input(move |password_unwrapped| {
6753 Message::DialogUpdate(DialogPage::Compress {
6754 paths: paths.clone(),
6755 to: to.clone(),
6756 name: name.clone(),
6757 archive_type: *archive_type,
6758 password: Some(password_unwrapped),
6759 })
6760 })
6761 .on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
6762 .into(),
6763 ]));
6764 }
6765
6766 dialog
6767 }
6768 DialogPage::EmptyTrash => widget::dialog()
6769 .title(fl!("empty-trash-title"))
6770 .body(fl!("empty-trash-warning"))
6771 .primary_action(
6772 widget::button::suggested(fl!("empty-trash"))
6773 .on_press(Message::DialogComplete)
6774 .id(EMPTY_TRASH_BUTTON_ID.clone()),
6775 )
6776 .secondary_action(
6777 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6778 ),
6779 DialogPage::FailedOperation(id) => {
6780 let (operation, _, err) = self.failed_operations.get(id)?;
6782
6783 widget::dialog()
6785 .title("Failed operation")
6786 .body(format!("{operation:#?}\n{err}"))
6787 .icon(icon::from_name("dialog-error").size(64))
6788 .primary_action(
6790 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6791 )
6792 }
6793 DialogPage::FailedOperations(ids) => {
6794 let errors: Vec<String> = ids
6795 .iter()
6796 .filter_map(|id| match self.failed_operations.get(id) {
6797 Some((operation, _, err)) => Some(format!("{operation:#?}\n{err}")),
6798 _ => None,
6799 })
6800 .collect();
6801
6802 widget::dialog()
6804 .title("Failed operations")
6805 .body(errors.join("\n\n"))
6806 .icon(icon::from_name("dialog-error").size(64))
6807 .primary_action(
6809 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6810 )
6811 }
6812 DialogPage::ExtractPassword { id, password } => widget::dialog()
6813 .title(fl!("extract-password-required"))
6814 .icon(icon::from_name("dialog-error").size(64))
6815 .control(
6816 widget::text_input("", password)
6817 .password()
6818 .on_input(move |password| {
6819 Message::DialogUpdate(DialogPage::ExtractPassword { id: *id, password })
6820 })
6821 .on_submit(|_| Message::DialogComplete)
6822 .id(self.dialog_text_input.clone()),
6823 )
6824 .primary_action(
6825 widget::button::suggested(fl!("extract-here"))
6826 .on_press(Message::DialogComplete),
6827 )
6828 .secondary_action(
6829 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6830 ),
6831 DialogPage::MountError {
6832 mounter_key: _,
6833 item: _,
6834 error,
6835 } => widget::dialog()
6836 .title(fl!("mount-error"))
6837 .body(error)
6838 .icon(icon::from_name("dialog-error").size(64))
6839 .primary_action(
6840 widget::button::standard(fl!("try-again"))
6841 .on_press(Message::DialogComplete)
6842 .id(MOUNT_ERROR_TRY_AGAIN_BUTTON_ID.clone()),
6843 )
6844 .secondary_action(
6845 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6846 ),
6847 DialogPage::ClientError {
6848 client_key: _,
6849 item: _,
6850 error,
6851 } => widget::dialog()
6852 .title(fl!("mount-error"))
6853 .body(error)
6854 .icon(icon::from_name("dialog-error").size(64))
6855 .primary_action(
6856 widget::button::standard(fl!("try-again"))
6857 .on_press(Message::DialogComplete)
6858 .id(CLIENT_ERROR_TRY_AGAIN_BUTTON_ID.clone()),
6859 )
6860 .secondary_action(
6861 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6862 ),
6863 DialogPage::NetworkAuth {
6864 mounter_key,
6865 uri,
6866 auth,
6867 auth_tx,
6868 } => {
6869 let mut controls = widget::column::with_capacity(4);
6871 let mut id_assigned = false;
6872
6873 if let Some(username) = &auth.username_opt {
6874 let mut input = widget::text_input(fl!("username"), username)
6876 .on_input(move |value| {
6877 Message::DialogUpdate(DialogPage::NetworkAuth {
6878 mounter_key: *mounter_key,
6879 uri: uri.clone(),
6880 auth: MounterAuth {
6881 username_opt: Some(value),
6882 ..auth.clone()
6883 },
6884 auth_tx: auth_tx.clone(),
6885 })
6886 })
6887 .on_submit(|_| Message::DialogComplete);
6888 if !id_assigned {
6889 input = input.id(self.dialog_text_input.clone());
6890 id_assigned = true;
6891 }
6892 controls = controls.push(input);
6893 }
6894
6895 if let Some(domain) = &auth.domain_opt {
6896 let mut input = widget::text_input(fl!("domain"), domain)
6898 .on_input(move |value| {
6899 Message::DialogUpdate(DialogPage::NetworkAuth {
6900 mounter_key: *mounter_key,
6901 uri: uri.clone(),
6902 auth: MounterAuth {
6903 domain_opt: Some(value),
6904 ..auth.clone()
6905 },
6906 auth_tx: auth_tx.clone(),
6907 })
6908 })
6909 .on_submit(|_| Message::DialogComplete);
6910 if !id_assigned {
6911 input = input.id(self.dialog_text_input.clone());
6912 id_assigned = true;
6913 }
6914 controls = controls.push(input);
6915 }
6916
6917 if let Some(password) = &auth.password_opt {
6918 let mut input = widget::secure_input(fl!("password"), password, None, true)
6921 .on_input(move |value| {
6922 Message::DialogUpdate(DialogPage::NetworkAuth {
6923 mounter_key: *mounter_key,
6924 uri: uri.clone(),
6925 auth: MounterAuth {
6926 password_opt: Some(value),
6927 ..auth.clone()
6928 },
6929 auth_tx: auth_tx.clone(),
6930 })
6931 })
6932 .on_submit(|_| Message::DialogComplete);
6933 if !id_assigned {
6934 input = input.id(self.dialog_text_input.clone());
6935 }
6936 controls = controls.push(input);
6937 }
6938
6939 if let Some(remember) = &auth.remember_opt {
6940 controls = controls.push(
6943 widget::checkbox(*remember)
6944 .label(fl!("remember-password"))
6945 .on_toggle(move |value| {
6946 Message::DialogUpdate(DialogPage::NetworkAuth {
6947 mounter_key: *mounter_key,
6948 uri: uri.clone(),
6949 auth: MounterAuth {
6950 remember_opt: Some(value),
6951 ..auth.clone()
6952 },
6953 auth_tx: auth_tx.clone(),
6954 })
6955 }),
6956 );
6957 }
6958
6959 let mut parts = auth.message.splitn(2, '\n');
6960 let title = parts.next().unwrap_or_default();
6961 let body = parts.next().unwrap_or_default();
6962
6963 let mut widget = widget::dialog()
6964 .title(title)
6965 .body(body)
6966 .control(controls.spacing(space_s))
6967 .primary_action(
6968 widget::button::suggested(fl!("connect")).on_press(Message::DialogComplete),
6969 )
6970 .secondary_action(
6971 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
6972 );
6973
6974 if let Some(_anonymous) = &auth.anonymous_opt {
6975 widget = widget.tertiary_action(
6976 widget::button::text(fl!("connect-anonymously")).on_press(
6977 Message::DialogUpdateComplete(DialogPage::NetworkAuth {
6978 mounter_key: *mounter_key,
6979 uri: uri.clone(),
6980 auth: MounterAuth {
6981 anonymous_opt: Some(true),
6982 ..auth.clone()
6983 },
6984 auth_tx: auth_tx.clone(),
6985 }),
6986 ),
6987 );
6988 }
6989
6990 widget
6991 }
6992 DialogPage::RemoteAuth {
6993 client_key,
6994 uri,
6995 auth,
6996 auth_tx,
6997 } => {
6998 let mut controls = widget::column::with_capacity(4);
7000 let mut id_assigned = false;
7001
7002 if let Some(username) = &auth.username_opt {
7003 let mut input = widget::text_input(fl!("username"), username)
7005 .on_input(move |value| {
7006 Message::DialogUpdate(DialogPage::RemoteAuth {
7007 client_key: *client_key,
7008 uri: uri.clone(),
7009 auth: ClientAuth {
7010 username_opt: Some(value),
7011 ..auth.clone()
7012 },
7013 auth_tx: auth_tx.clone(),
7014 })
7015 })
7016 .on_submit(|_| Message::DialogComplete);
7017 if !id_assigned {
7018 input = input.id(self.dialog_text_input.clone());
7019 id_assigned = true;
7020 }
7021 controls = controls.push(input);
7022 }
7023
7024 if let Some(domain) = &auth.domain_opt {
7025 let mut input = widget::text_input(fl!("domain"), domain)
7027 .on_input(move |value| {
7028 Message::DialogUpdate(DialogPage::RemoteAuth {
7029 client_key: *client_key,
7030 uri: uri.clone(),
7031 auth: ClientAuth {
7032 domain_opt: Some(value),
7033 ..auth.clone()
7034 },
7035 auth_tx: auth_tx.clone(),
7036 })
7037 })
7038 .on_submit(|_| Message::DialogComplete);
7039 if !id_assigned {
7040 input = input.id(self.dialog_text_input.clone());
7041 id_assigned = true;
7042 }
7043 controls = controls.push(input);
7044 }
7045
7046 if let Some(password) = &auth.password_opt {
7047 let mut input = widget::secure_input(fl!("password"), password, None, true)
7050 .on_input(move |value| {
7051 Message::DialogUpdate(DialogPage::RemoteAuth {
7052 client_key: *client_key,
7053 uri: uri.clone(),
7054 auth: ClientAuth {
7055 password_opt: Some(value),
7056 ..auth.clone()
7057 },
7058 auth_tx: auth_tx.clone(),
7059 })
7060 })
7061 .on_submit(|_| Message::DialogComplete);
7062 if !id_assigned {
7063 input = input.id(self.dialog_text_input.clone());
7064 }
7065 controls = controls.push(input);
7066 }
7067
7068 if let Some(remember) = &auth.remember_opt {
7069 controls = controls.push(
7072 widget::checkbox(*remember)
7073 .label(fl!("remember-password"))
7074 .on_toggle(move |value| {
7075 Message::DialogUpdate(DialogPage::RemoteAuth {
7076 client_key: *client_key,
7077 uri: uri.clone(),
7078 auth: ClientAuth {
7079 remember_opt: Some(value),
7080 ..auth.clone()
7081 },
7082 auth_tx: auth_tx.clone(),
7083 })
7084 }),
7085 );
7086 }
7087
7088 let mut parts = auth.message.splitn(2, '\n');
7089 let title = parts.next().unwrap_or_default();
7090 let body = parts.next().unwrap_or_default();
7091
7092 let mut widget = widget::dialog()
7093 .title(title)
7094 .body(body)
7095 .control(controls.spacing(space_s))
7096 .primary_action(
7097 widget::button::suggested(fl!("connect")).on_press(Message::DialogComplete),
7098 )
7099 .secondary_action(
7100 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7101 );
7102
7103 if let Some(_anonymous) = &auth.anonymous_opt {
7104 widget = widget.tertiary_action(
7105 widget::button::text(fl!("connect-anonymously")).on_press(
7106 Message::DialogUpdateComplete(DialogPage::RemoteAuth {
7107 client_key: *client_key,
7108 uri: uri.clone(),
7109 auth: ClientAuth {
7110 anonymous_opt: Some(true),
7111 ..auth.clone()
7112 },
7113 auth_tx: auth_tx.clone(),
7114 }),
7115 ),
7116 );
7117 }
7118
7119 widget
7120 }
7121 DialogPage::NetworkError {
7122 mounter_key: _,
7123 uri: _,
7124 error,
7125 } => widget::dialog()
7126 .title(fl!("network-drive-error"))
7127 .body(error)
7128 .icon(icon::from_name("dialog-error").size(64))
7129 .primary_action(
7130 widget::button::standard(fl!("try-again")).on_press(Message::DialogComplete),
7131 )
7132 .secondary_action(
7133 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7134 ),
7135 DialogPage::RemoteError {
7136 client_key: _,
7137 uri: _,
7138 error,
7139 } => widget::dialog()
7140 .title(fl!("remote-access-error"))
7141 .body(error)
7142 .icon(icon::from_name("dialog-error").size(64))
7143 .primary_action(
7144 widget::button::standard(fl!("try-again")).on_press(Message::DialogComplete),
7145 )
7146 .secondary_action(
7147 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7148 ),
7149 DialogPage::RunTbProfilerStarted {
7150 client_key: _,
7151 uri: _,
7152 job_id,
7153 tasks,
7154 } => widget::dialog()
7155 .title(fl!("tb-profiler-success"))
7156 .body(format!("Job ID: {job_id}\nTasks: {tasks}"))
7157 .icon(icon::from_name("dialog-success").size(64))
7158 .primary_action(
7159 widget::button::standard(fl!("ok")).on_press(Message::DialogCancel),
7160 ),
7161 DialogPage::RunTbProfilerError {
7162 client_key: _,
7163 uri: _,
7164 error,
7165 } => widget::dialog()
7166 .title(fl!("tb-profiler-error"))
7167 .body(error)
7168 .icon(icon::from_name("dialog-error").size(64))
7169 .primary_action(
7170 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7171 ),
7172 DialogPage::TbProfilerConfigError => widget::dialog()
7173 .title(fl!("tb-profiler-config-error"))
7174 .body(fl!("tb-profiler-config-error-body"))
7175 .icon(icon::from_name("dialog-error").size(64))
7176 .primary_action(
7177 widget::button::standard(fl!("ok")).on_press(Message::DialogCancel),
7178 ),
7179 DialogPage::DeleteRemoteFilesSuccess {
7180 client_key: _,
7181 uri: _,
7182 result,
7183 } => widget::dialog()
7184 .title(fl!("delete-remote-files-success"))
7185 .body(result)
7186 .icon(icon::from_name("dialog-success").size(64))
7187 .primary_action(
7188 widget::button::standard(fl!("ok")).on_press(Message::DialogCancel),
7189 ),
7190 DialogPage::DeleteRemoteFilesError {
7191 client_key: _,
7192 uri: _,
7193 error,
7194 } => widget::dialog()
7195 .title(fl!("delete-remote-files-error"))
7196 .body(error)
7197 .icon(icon::from_name("dialog-error").size(64))
7198 .primary_action(
7199 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7200 ),
7201 DialogPage::NewItem { parent, name, dir } => {
7202 let mut dialog = widget::dialog().title(if *dir {
7203 fl!("create-new-folder")
7204 } else {
7205 fl!("create-new-file")
7206 });
7207
7208 let complete_maybe = if name.is_empty() {
7209 None
7210 } else if name == "." || name == ".." {
7211 dialog = dialog.tertiary_action(widget::text::body(fl!(
7212 "name-invalid",
7213 filename = name.as_str()
7214 )));
7215 None
7216 } else if name.contains('/') {
7217 dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes")));
7218 None
7219 } else {
7220 let path = parent.join(name);
7221 if path.exists() {
7222 if path.is_dir() {
7223 dialog = dialog
7224 .tertiary_action(widget::text::body(fl!("folder-already-exists")));
7225 } else {
7226 dialog = dialog
7227 .tertiary_action(widget::text::body(fl!("file-already-exists")));
7228 }
7229 None
7230 } else {
7231 if name.starts_with('.') {
7232 dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden")));
7233 }
7234 Some(Message::DialogComplete)
7235 }
7236 };
7237
7238 dialog
7239 .primary_action(
7240 widget::button::suggested(fl!("save"))
7241 .on_press_maybe(complete_maybe.clone()),
7242 )
7243 .secondary_action(
7244 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7245 )
7246 .control(
7247 widget::column::with_children([
7248 widget::text::body(if *dir {
7249 fl!("folder-name")
7250 } else {
7251 fl!("file-name")
7252 })
7253 .into(),
7254 widget::text_input("", name.as_str())
7255 .id(self.dialog_text_input.clone())
7256 .on_input(move |name| {
7257 Message::DialogUpdate(DialogPage::NewItem {
7258 parent: parent.clone(),
7259 name,
7260 dir: *dir,
7261 })
7262 })
7263 .on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
7264 .into(),
7265 ])
7266 .spacing(space_xxs),
7267 )
7268 }
7269 DialogPage::RunContextAction { action, paths } => {
7270 let name = self
7271 .config
7272 .context_actions
7273 .get(*action)
7274 .map_or_else(|| fl!("context-action"), |preset| preset.name.clone());
7275
7276 widget::dialog()
7277 .title(fl!("context-action-confirm-title", name = name))
7278 .body(fl!("context-action-confirm-warning", items = paths.len()))
7279 .icon(icon::from_name("dialog-error").size(64))
7280 .primary_action(
7281 widget::button::suggested(fl!("run"))
7282 .on_press(Message::DialogComplete)
7283 .id(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()),
7284 )
7285 .secondary_action(
7286 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7287 )
7288 }
7289 DialogPage::OpenWith {
7290 path,
7291 mime,
7292 selected,
7293 store_opt,
7294 ..
7295 } => {
7296 let name = match path.file_name() {
7297 Some(file_name) => file_name.to_str(),
7298 None => path.as_os_str().to_str(),
7299 };
7300
7301 let mut column = widget::list_column();
7302 let available_apps = self.mime_app_cache.get_apps_for_mime(mime, true);
7303 let item_height = 32.0;
7304 let mut displayed_default = false;
7305 let mut last_kind = MimeAppMatch::Exact;
7306 for (i, &(app, kind)) in available_apps.iter().enumerate() {
7307 if kind != last_kind {
7308 match kind {
7309 MimeAppMatch::Related => {
7310 column = column.add(widget::text::heading(fl!("related-apps")));
7311 }
7312 MimeAppMatch::Other => {
7313 column = column.add(widget::text::heading(fl!("other-apps")));
7314 }
7315 _ => {}
7316 }
7317 last_kind = kind;
7318 }
7319 column = column.add(
7320 widget::mouse_area(
7321 widget::button::custom(
7322 widget::row::with_children([
7323 icon(app.icon()).size(32).into(),
7324 if app.is_default(mime) && !displayed_default {
7325 displayed_default = true;
7326 widget::text::body(fl!(
7327 "default-app",
7328 name = Some(app.name.as_str())
7329 ))
7330 .into()
7331 } else {
7332 widget::text::body(app.name.clone()).into()
7333 },
7334 widget::space::horizontal().into(),
7335 if *selected == i {
7336 icon::from_name("checkbox-checked-symbolic").size(16).into()
7337 } else {
7338 widget::space::horizontal()
7339 .width(Length::Fixed(16.0))
7340 .into()
7341 },
7342 ])
7343 .spacing(space_s)
7344 .height(Length::Fixed(item_height))
7345 .align_y(Alignment::Center),
7346 )
7347 .width(Length::Fill)
7348 .class(theme::Button::MenuItem)
7349 .force_enabled(true),
7350 )
7351 .on_press(Message::OpenWithSelection(i))
7352 .on_double_press(Message::DialogComplete),
7353 );
7354 }
7355
7356 let mut dialog = widget::dialog()
7357 .title(fl!("open-with-title", name = name))
7358 .primary_action(
7359 widget::button::suggested(fl!("open"))
7360 .on_press(Message::DialogComplete)
7361 .id(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
7362 )
7363 .secondary_action(
7364 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7365 )
7366 .control(widget::scrollable(column).height({
7367 let max_size = self
7368 .size
7369 .map_or(480.0, |size| (size.height - 256.0).min(480.0));
7370 let scrollable_height = available_apps.len() as f32
7372 * f32::from(space_xxs).mul_add(2.0, item_height + 5.0);
7373
7374 if scrollable_height > max_size {
7375 Length::Fixed(max_size)
7376 } else {
7377 Length::Shrink
7378 }
7379 }));
7380
7381 if let Some(app) = store_opt {
7382 dialog = dialog.tertiary_action(
7383 widget::button::text(fl!("browse-store", store = app.name.as_str()))
7384 .on_press(Message::OpenWithBrowse),
7385 );
7386 }
7387
7388 dialog
7389 }
7390 DialogPage::PermanentlyDelete { paths } => {
7391 let target = if paths.len() == 1 {
7392 format!(
7393 "\"{}\"",
7394 paths[0].file_name().map_or_else(
7395 || paths[0].to_string_lossy(),
7396 std::ffi::OsStr::to_string_lossy
7397 )
7398 )
7399 } else {
7400 fl!("selected-items", items = paths.len())
7401 };
7402
7403 widget::dialog()
7404 .title(fl!("permanently-delete-question"))
7405 .primary_action(
7406 widget::button::destructive(fl!("delete"))
7407 .on_press(Message::DialogComplete)
7408 .id(PERMANENT_DELETE_BUTTON_ID.clone()),
7409 )
7410 .secondary_action(
7411 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7412 )
7413 .control(widget::text(fl!(
7414 "permanently-delete-warning",
7415 target = target
7416 )))
7417 }
7418 DialogPage::DeleteTrash { items } => {
7419 let target = if items.len() == 1 {
7420 format!("\"{}\"", items[0].name.to_string_lossy())
7421 } else {
7422 fl!("selected-items", items = items.len())
7423 };
7424
7425 widget::dialog()
7426 .title(fl!("permanently-delete-question"))
7427 .primary_action(
7428 widget::button::destructive(fl!("delete"))
7429 .on_press(Message::DialogComplete)
7430 .id(DELETE_TRASH_BUTTON_ID.clone()),
7431 )
7432 .secondary_action(
7433 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7434 )
7435 .control(widget::text(fl!(
7436 "permanently-delete-warning",
7437 target = target
7438 )))
7439 }
7440 DialogPage::RenameItem {
7441 from,
7442 parent,
7443 name,
7444 dir,
7445 } => {
7446 let mut dialog = widget::dialog().title(if *dir {
7448 fl!("rename-folder")
7449 } else {
7450 fl!("rename-file")
7451 });
7452
7453 let complete_maybe = if name.is_empty() {
7454 None
7455 } else if name == "." || name == ".." {
7456 dialog = dialog.tertiary_action(widget::text::body(fl!(
7457 "name-invalid",
7458 filename = name.as_str()
7459 )));
7460 None
7461 } else if name.contains('/') {
7462 dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes")));
7463 None
7464 } else {
7465 let path = parent.join(name);
7466 if *from != path && path.exists() {
7467 if path.is_dir() {
7468 dialog = dialog
7469 .tertiary_action(widget::text::body(fl!("folder-already-exists")));
7470 } else {
7471 dialog = dialog
7472 .tertiary_action(widget::text::body(fl!("file-already-exists")));
7473 }
7474 None
7475 } else {
7476 if name.starts_with('.') {
7477 dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden")));
7478 }
7479 Some(Message::DialogComplete)
7480 }
7481 };
7482
7483 dialog
7484 .primary_action(
7485 widget::button::suggested(fl!("rename-confirm"))
7486 .on_press_maybe(complete_maybe.clone()),
7487 )
7488 .secondary_action(
7489 widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
7490 )
7491 .control(
7492 widget::column::with_children([
7493 widget::text::body(if *dir {
7494 fl!("folder-name")
7495 } else {
7496 fl!("file-name")
7497 })
7498 .into(),
7499 widget::text_input("", name.as_str())
7500 .id(self.dialog_text_input.clone())
7501 .double_click_select_delimiter('.')
7502 .on_input(move |name| {
7503 Message::DialogUpdate(DialogPage::RenameItem {
7504 from: from.clone(),
7505 parent: parent.clone(),
7506 name,
7507 dir: *dir,
7508 })
7509 })
7510 .on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
7511 .into(),
7512 ])
7513 .spacing(space_xxs),
7514 )
7515 }
7516 DialogPage::Replace {
7517 from,
7518 to,
7519 multiple,
7520 apply_to_all,
7521 conflict_count,
7522 tx,
7523 } => {
7524 let military_time = self.config.tab.military_time;
7525 let dialog = widget::dialog()
7526 .title(fl!("replace-title", filename = to.name.as_str()))
7527 .body(fl!("replace-warning-operation"))
7528 .control(
7529 to.replace_view(fl!("original-file"), military_time)
7530 .map(|x| Message::TabMessage(None, x)),
7531 )
7532 .control(
7533 from.replace_view(fl!("replace-with"), military_time)
7534 .map(|x| Message::TabMessage(None, x)),
7535 )
7536 .primary_action(
7537 widget::button::suggested(fl!("replace"))
7538 .on_press(Message::ReplaceResult(ReplaceResult::Replace(
7539 *apply_to_all,
7540 )))
7541 .id(REPLACE_BUTTON_ID.clone()),
7542 );
7543 if *multiple {
7544 dialog
7545 .control(
7546 widget::checkbox(*apply_to_all)
7547 .label(format!("{} ({})", fl!("apply-to-all"), *conflict_count))
7548 .on_toggle(|apply_to_all| {
7549 Message::DialogUpdate(DialogPage::Replace {
7550 from: from.clone(),
7551 to: to.clone(),
7552 multiple: *multiple,
7553 apply_to_all,
7554 conflict_count: *conflict_count,
7555 tx: tx.clone(),
7556 })
7557 }),
7558 )
7559 .secondary_action(
7560 widget::button::standard(fl!("skip")).on_press(Message::ReplaceResult(
7561 ReplaceResult::Skip(*apply_to_all),
7562 )),
7563 )
7564 .tertiary_action(
7565 widget::button::text(fl!("cancel"))
7566 .on_press(Message::ReplaceResult(ReplaceResult::Cancel)),
7567 )
7568 } else {
7569 dialog
7570 .secondary_action(
7571 widget::button::standard(fl!("cancel"))
7572 .on_press(Message::ReplaceResult(ReplaceResult::Cancel)),
7573 )
7574 .tertiary_action(
7575 widget::button::text(fl!("keep-both"))
7576 .on_press(Message::ReplaceResult(ReplaceResult::KeepBoth)),
7577 )
7578 }
7579 }
7580 DialogPage::SetExecutableAndLaunch { path } => {
7581 let name = match path.file_name() {
7582 Some(file_name) => file_name.to_str(),
7583 None => path.as_os_str().to_str(),
7584 };
7585 widget::dialog()
7586 .title(fl!("set-executable-and-launch"))
7587 .primary_action(
7588 widget::button::text(fl!("set-and-launch"))
7589 .class(theme::Button::Suggested)
7590 .on_press(Message::DialogComplete)
7591 .id(SET_EXECUTABLE_AND_LAUNCH_CONFIRM_BUTTON_ID.clone()),
7592 )
7593 .secondary_action(
7594 widget::button::text(fl!("cancel"))
7595 .class(theme::Button::Standard)
7596 .on_press(Message::DialogCancel),
7597 )
7598 .control(widget::text::text(fl!(
7599 "set-executable-and-launch-description",
7600 name = name
7601 )))
7602 }
7603 DialogPage::FavoritePathError { path, .. } => widget::dialog()
7604 .title(fl!("favorite-path-error"))
7605 .body(fl!(
7606 "favorite-path-error-description",
7607 path = path.as_os_str().to_str()
7608 ))
7609 .icon(icon::from_name("dialog-error").size(64))
7610 .primary_action(
7611 widget::button::destructive(fl!("remove"))
7612 .on_press(Message::DialogComplete)
7613 .id(FAVORITE_PATH_ERROR_REMOVE_BUTTON_ID.clone()),
7614 )
7615 .secondary_action(
7616 widget::button::standard(fl!("keep")).on_press(Message::DialogCancel),
7617 ),
7618 };
7619 Some(dialog.into())
7620 }
7621
7622 fn footer(&self) -> Option<Element<'_, Message>> {
7623 if self.progress_operations.is_empty()
7624 && self.running_tasks.is_empty()
7625 && self.download_files_total == 0
7626 {
7627 return None;
7628 }
7629
7630 let cosmic_theme::Spacing {
7631 space_xs, space_s, ..
7632 } = theme::spacing();
7633
7634 let progress_bar_height = Length::Fixed(4.0);
7635 let mut children: Vec<Element<'_, Message>> = Vec::new();
7636
7637 if !self.progress_operations.is_empty() {
7639 let mut title = String::new();
7640 let mut total_progress = 0.0;
7641 let mut count = 0;
7642 let mut all_paused = true;
7643 for (op, controller) in self.pending_operations.values() {
7644 if !controller.is_paused() {
7645 all_paused = false;
7646 }
7647 if op.show_progress_notification() {
7648 let progress = controller.progress();
7649 if title.is_empty() {
7650 title = op.pending_text(progress, controller.state());
7651 }
7652 total_progress += progress;
7653 count += 1;
7654 }
7655 }
7656 let running = count;
7657 for id in &self.progress_operations {
7659 if self.complete_operations.contains_key(id) {
7660 total_progress += 1.0;
7661 count += 1;
7662 }
7663 }
7664 let finished = count - running;
7665 total_progress /= count as f32;
7666 if running >= 1 && (running > 1 || finished > 0) {
7667 if finished > 0 {
7668 title = fl!(
7669 "operations-running-finished",
7670 running = running,
7671 finished = finished,
7672 percent = ((total_progress * 100.0) as i32)
7673 );
7674 } else {
7675 title = fl!(
7676 "operations-running",
7677 running = running,
7678 percent = ((total_progress * 100.0) as i32)
7679 );
7680 }
7681 }
7682
7683 let progress_bar = widget::determinate_linear(total_progress)
7684 .width(Length::Fill)
7685 .girth(progress_bar_height);
7686
7687 children.push(
7688 widget::row::with_children([
7689 progress_bar.into(),
7690 if all_paused {
7691 widget::tooltip(
7692 widget::button::icon(icon::from_name("media-playback-start-symbolic"))
7693 .on_press(Message::PendingPauseAll(false))
7694 .padding(8),
7695 widget::text::body(fl!("resume")),
7696 widget::tooltip::Position::Top,
7697 )
7698 .into()
7699 } else {
7700 widget::tooltip(
7701 widget::button::icon(icon::from_name("media-playback-pause-symbolic"))
7702 .on_press(Message::PendingPauseAll(true))
7703 .padding(8),
7704 widget::text::body(fl!("pause")),
7705 widget::tooltip::Position::Top,
7706 )
7707 .into()
7708 },
7709 widget::tooltip(
7710 widget::button::icon(icon::from_name("window-close-symbolic"))
7711 .on_press(Message::PendingCancelAll)
7712 .padding(8),
7713 widget::text::body(fl!("cancel")),
7714 widget::tooltip::Position::Top,
7715 )
7716 .into(),
7717 ])
7718 .align_y(Alignment::Center)
7719 .into(),
7720 );
7721 children.push(widget::text::body(title).into());
7722 children.push(widget::space::vertical().height(space_s).into());
7723 children.push(
7724 widget::row::with_children([
7725 widget::button::link(fl!("details"))
7726 .on_press(Message::ToggleContextPage(ContextPage::EditHistory))
7727 .padding(0)
7728 .trailing_icon(true)
7729 .into(),
7730 widget::space::horizontal().into(),
7731 widget::button::standard(fl!("dismiss"))
7732 .on_press(Message::PendingDismiss)
7733 .into(),
7734 ])
7735 .align_y(Alignment::Center)
7736 .into(),
7737 );
7738 }
7739
7740 if !self.running_tasks.is_empty() {
7742 if !children.is_empty() {
7743 children.push(widget::space::vertical().height(space_s).into());
7744 }
7745 let total_running: usize = self.running_tasks.values().sum();
7746 let total_tasks: usize = self.job_total_tasks.values().sum();
7747 let completed = total_tasks.saturating_sub(total_running);
7748 let progress = if total_tasks > 0 {
7749 completed as f32 / total_tasks as f32
7750 } else {
7751 0.0
7752 };
7753 let job_count = self.running_tasks.len();
7754 let title = if job_count == 1 {
7755 let array_id = self.running_tasks.keys().next().copied().unwrap_or(0);
7756 format!(
7757 "Job {array_id}: {completed}/{total_tasks} tasks completed ({total_running} running)"
7758 )
7759 } else {
7760 format!(
7761 "{job_count} jobs: {completed}/{total_tasks} tasks completed ({total_running} running)"
7762 )
7763 };
7764
7765 let progress_bar = widget::determinate_linear(progress)
7766 .width(Length::Fill)
7767 .girth(progress_bar_height);
7768
7769 children.push(
7770 widget::row::with_children([progress_bar.into()])
7771 .align_y(Alignment::Center)
7772 .into(),
7773 );
7774 children.push(widget::text::body(title).into());
7775 }
7776
7777 if self.download_files_total > 0 {
7779 if !children.is_empty() {
7780 children.push(widget::space::vertical().height(space_s).into());
7781 }
7782 let done = self.download_files_done;
7783 let total = self.download_files_total;
7784 let progress = done as f32 / total as f32;
7785 let title = if total == 1 {
7786 format!("Downloading file... ({done}/{total})")
7787 } else {
7788 format!("Downloading {total} files... ({done}/{total})")
7789 };
7790 let progress_bar = widget::determinate_linear(progress)
7791 .width(Length::Fill)
7792 .girth(progress_bar_height);
7793 children.push(
7794 widget::row::with_children([progress_bar.into()])
7795 .align_y(Alignment::Center)
7796 .into(),
7797 );
7798 children.push(widget::text::body(title).into());
7799 }
7800
7801 let container = widget::layer_container(widget::column::with_children(children))
7802 .padding([8, space_xs])
7803 .layer(cosmic_theme::Layer::Primary);
7804
7805 Some(container.into())
7806 }
7807
7808 fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
7809 vec![menu::menu_bar(
7810 &self.core,
7811 self.tab_model.active_data::<Tab>(),
7812 &self.config,
7813 &self.modifiers,
7814 &self.key_binds,
7815 self.clipboard_has_content(),
7816 )]
7817 }
7818
7819 fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
7820 let mut elements = Vec::with_capacity(2);
7821
7822 if let Some(term) = self.search_get() {
7823 if self.core.is_condensed() {
7824 elements.push(
7825 widget::button::icon(icon::from_name("system-search-symbolic"))
7827 .on_press(Message::SearchClear)
7828 .padding(8)
7829 .selected(true)
7830 .into(),
7831 );
7832 } else {
7833 elements.push(
7834 widget::text_input::search_input("", term)
7835 .width(Length::Fixed(240.0))
7836 .id(self.search_id.clone())
7837 .on_clear(Message::SearchClear)
7838 .on_input(Message::SearchInput)
7839 .into(),
7840 );
7841 }
7842 } else {
7843 elements.push(
7844 widget::button::icon(icon::from_name("system-search-symbolic"))
7845 .on_press(Message::SearchActivate)
7846 .padding(8)
7847 .into(),
7848 );
7849 }
7850
7851 elements
7852 }
7853
7854 fn view(&self) -> Element<'_, Self::Message> {
7856 let cosmic_theme::Spacing {
7857 space_xxs, space_s, ..
7858 } = theme::spacing();
7859
7860 let mut tab_column = widget::column::with_capacity(4);
7861
7862 if self.core.is_condensed()
7863 && let Some(term) = self.search_get()
7864 {
7865 tab_column = tab_column.push(
7866 widget::container(
7867 widget::text_input::search_input("", term)
7868 .width(Length::Fill)
7869 .id(self.search_id.clone())
7870 .on_clear(Message::SearchClear)
7871 .on_input(Message::SearchInput),
7872 )
7873 .padding(space_xxs),
7874 );
7875 }
7876
7877 if self.tab_model.len() > 1 {
7878 tab_column = tab_column.push(
7879 widget::container(
7880 widget::tab_bar::horizontal(&self.tab_model)
7881 .button_height(32)
7882 .button_spacing(space_xxs)
7883 .enable_tab_drag(String::from("x-cosmic-files/tab-dnd"))
7884 .on_reorder(Message::ReorderTab)
7885 .tab_drag_threshold(25.)
7886 .on_activate(Message::TabActivate)
7887 .on_close(|entity| Message::TabClose(Some(entity)))
7888 .on_dnd_enter(Message::DndEnterTab)
7889 .on_dnd_leave(|_| Message::DndExitTab)
7890 .on_dnd_drop(|entity, data, action| {
7891 Message::DndDropTab(entity, data, action)
7892 })
7893 .drag_id(self.tab_drag_id),
7894 )
7895 .class(style::Container::Background)
7896 .width(Length::Fill)
7897 .padding([0, space_s]),
7898 );
7899 }
7900
7901 let entity = self.tab_model.active();
7902 if let Some(tab) = self.tab_model.data::<Tab>(entity) {
7903 let tab_view = tab
7904 .view(
7905 &self.key_binds,
7906 &self.modifiers,
7907 self.clipboard_has_content(),
7908 &self.config.context_actions,
7909 )
7910 .map(move |message| Message::TabMessage(Some(entity), message));
7911 tab_column = tab_column.push(tab_view);
7912 } else {
7913 }
7915
7916 tab_column = tab_column.push(widget::toaster(&self.toasts, widget::space::horizontal()));
7918
7919 let content: Element<_> = tab_column.into();
7920
7921 content
7924 }
7925
7926 fn view_window(&self, id: WindowId) -> Element<'_, Self::Message> {
7927 let content = match self.windows.get(&id) {
7928 Some(window) => match &window.kind {
7929 WindowKind::ContextMenu(entity, id) => match self.tab_model.data::<Tab>(*entity) {
7930 Some(tab) => {
7931 return widget::autosize::autosize(
7932 menu::context_menu(
7933 tab,
7934 &self.key_binds,
7935 &window.modifiers,
7936 self.clipboard_has_content(),
7937 &self.config.tb_config,
7938 &self.config.context_actions,
7939 )
7940 .map(|x| Message::TabMessage(Some(*entity), x)),
7941 id.clone(),
7942 )
7943 .into();
7944 }
7945 None => widget::text("Unknown tab ID").into(),
7946 },
7947 WindowKind::Desktop(entity) => {
7948 let mut tab_column = widget::column::with_capacity(3);
7949
7950 let tab_view = match self.tab_model.data::<Tab>(*entity) {
7951 Some(tab) => tab
7952 .view(
7953 &self.key_binds,
7954 &window.modifiers,
7955 self.clipboard_has_content(),
7956 &self.config.context_actions,
7957 )
7958 .map(move |message| Message::TabMessage(Some(*entity), message)),
7959 None => widget::space::vertical().into(),
7960 };
7961
7962 tab_column = tab_column.push(tab_view);
7963
7964 tab_column =
7966 tab_column.push(widget::toaster(&self.toasts, widget::space::horizontal()));
7967 return if let Some(margin) = self.margin.get(&id) {
7968 if margin.0 >= 0. || margin.2 >= 0. {
7969 tab_column = widget::column::with_children([
7970 space::vertical().height(margin.0).into(),
7971 tab_column.into(),
7972 space::vertical().height(margin.2).into(),
7973 ]);
7974 }
7975 if margin.1 >= 0. || margin.3 >= 0. {
7976 Element::from(widget::row::with_children([
7977 space::horizontal().width(margin.1).into(),
7978 tab_column.into(),
7979 space::horizontal().width(margin.3).into(),
7980 ]))
7981 } else {
7982 tab_column.into()
7983 }
7984 } else {
7985 tab_column.into()
7986 };
7987 }
7988 WindowKind::DesktopViewOptions => self.desktop_view_options(),
7989 WindowKind::Dialogs(id) => match self.dialog() {
7990 Some(element) => return widget::autosize::autosize(element, id.clone()).into(),
7991 None => widget::space::horizontal().into(),
7992 },
7993 WindowKind::Preview(entity_opt, kind) => self
7994 .preview(entity_opt, kind, false)
7995 .map(|x| Message::TabMessage(*entity_opt, x)),
7996 WindowKind::FileDialog(..) => match &self.file_dialog_opt {
7997 Some(dialog) => return dialog.view(id),
7998 None => widget::text("Unknown window ID").into(),
7999 },
8000 WindowKind::DownloadDialog(..) => match &self.file_dialog_opt {
8001 Some(dialog) => return dialog.view(id),
8002 None => widget::text("Unknown window ID").into(),
8003 },
8004 },
8005 None => {
8006 return self.view_main().map(|message| match message {
8008 cosmic::Action::App(app) => app,
8009 cosmic::Action::Cosmic(cosmic) => Message::Cosmic(cosmic),
8010 cosmic::Action::None => Message::None,
8011 });
8012 }
8013 };
8014
8015 widget::container(widget::id_container(
8016 widget::scrollable(content),
8017 widget::Id::new("main container for files"),
8018 ))
8019 .width(Length::Fill)
8020 .height(Length::Fill)
8021 .class(theme::Container::WindowBackground)
8022 .into()
8023 }
8024
8025 fn system_theme_update(
8026 &mut self,
8027 _keys: &[&'static str],
8028 _new_theme: &cosmic::cosmic_theme::Theme,
8029 ) -> Task<Self::Message> {
8030 self.update(Message::SystemThemeModeChange)
8031 }
8032
8033 fn subscription(&self) -> Subscription<Self::Message> {
8034 struct WatcherSubscription;
8035 struct TrashWatcherSubscription;
8036 struct TimeSubscription;
8037 #[cfg(all(
8038 not(feature = "desktop-applet"),
8039 not(target_os = "ios"),
8040 not(target_os = "android")
8041 ))]
8042 struct RecentsWatcherSubscription;
8043
8044 let mut subscriptions = vec![
8045 event::listen_with(|event, status, window_id| match event {
8047 Event::Mouse(mouse::Event::ButtonPressed(button)) => match status {
8048 event::Status::Ignored => Some(Message::Mouse(window_id, button)),
8049 event::Status::Captured => None,
8050 },
8051 Event::Keyboard(KeyEvent::KeyPressed {
8052 key,
8053 physical_key,
8054 modifiers,
8055 text,
8056 ..
8057 }) => match status {
8058 event::Status::Ignored => {
8059 Some(Message::Key(window_id, modifiers, key, physical_key, text))
8060 }
8061 event::Status::Captured => None,
8062 },
8063 Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => {
8064 Some(Message::ModifiersChanged(window_id, modifiers))
8065 }
8066 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
8067 Event::Window(WindowEvent::Focused) => Some(Message::Focused(window_id)),
8068 #[cfg(not(all(feature = "wayland", feature = "desktop-applet")))]
8069 Event::Window(WindowEvent::Focused) => Some(Message::CheckClipboard),
8070 Event::Window(WindowEvent::CloseRequested) => Some(Message::WindowClose),
8071 Event::Window(WindowEvent::Opened { position: _, size }) => {
8072 Some(Message::Size(window_id, size))
8073 }
8074 Event::Window(WindowEvent::Resized(s)) => Some(Message::Size(window_id, s)),
8075 #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
8076 Event::PlatformSpecific(event::PlatformSpecific::Wayland(wayland_event)) => {
8077 match wayland_event {
8078 WaylandEvent::Output(output_event, output) => {
8079 Some(Message::OutputEvent(output_event, output))
8080 }
8081 #[cfg(feature = "desktop")]
8082 WaylandEvent::OverlapNotify(event, ..) => {
8083 Some(Message::Overlap(window_id, event))
8084 }
8085 _ => None,
8086 }
8087 }
8088 _ => None,
8089 }),
8090 Config::subscription().map(|update| {
8091 if !update.errors.is_empty() {
8092 log::info!(
8093 "errors loading config {:?}: {:?}",
8094 update.keys,
8095 update.errors
8096 );
8097 }
8098 Message::Config(update.config)
8099 }),
8100 cosmic_config::config_subscription::<_, TimeConfig>(
8101 TypeId::of::<TimeSubscription>(),
8102 TIME_CONFIG_ID.into(),
8103 1,
8104 )
8105 .map(|update| {
8106 if !update.errors.is_empty() {
8107 log::info!(
8108 "errors loading time config {:?}: {:?}",
8109 update.keys,
8110 update.errors
8111 );
8112 }
8113 Message::TimeConfigChange(update.config)
8114 }),
8115 Subscription::run_with(TypeId::of::<WatcherSubscription>(), |_| {
8116 stream::channel(
8117 100,
8118 |mut output: futures::channel::mpsc::Sender<Message>| async move {
8119 let watcher_res = {
8120 let mut output = output.clone();
8121 new_debouncer(
8122 time::Duration::from_millis(250),
8123 Some(time::Duration::from_millis(250)),
8124 move |events_res: notify_debouncer_full::DebounceEventResult| {
8125 match events_res {
8126 Ok(mut events) => {
8127 log::debug!("{events:?}");
8128
8129 events.retain(|event| {
8130 match &event.kind {
8131 notify::EventKind::Access(_) => {
8132 false
8134 }
8135 notify::EventKind::Modify(
8136 notify::event::ModifyKind::Metadata(e),
8137 ) if (*e != notify::event::MetadataKind::Any
8138 && *e
8139 != notify::event::MetadataKind::WriteTime) =>
8140 {
8141 false
8143 }
8144 _ => true
8145 }
8146 });
8147
8148 if !events.is_empty() {
8149 match futures::executor::block_on(async {
8150 output.send(Message::NotifyEvents(events)).await
8151 }) {
8152 Ok(()) => {}
8153 Err(err) => {
8154 log::warn!(
8155 "failed to send notify events: {err:?}"
8156 );
8157 }
8158 }
8159 }
8160 }
8161 Err(err) => {
8162 log::warn!("failed to watch files: {err:?}");
8163 }
8164 }
8165 },
8166 )
8167 };
8168
8169 match watcher_res {
8170 Ok(watcher) => {
8171 match output
8172 .send(Message::NotifyWatcher(WatcherWrapper {
8173 watcher_opt: Some(watcher),
8174 }))
8175 .await
8176 {
8177 Ok(()) => {}
8178 Err(err) => {
8179 log::warn!("failed to send notify watcher: {err:?}");
8180 }
8181 }
8182 }
8183 Err(err) => {
8184 log::warn!("failed to create file watcher: {err:?}");
8185 }
8186 }
8187
8188 std::future::pending().await
8189 },
8190 )
8191 }),
8192 #[cfg(feature = "desktop")]
8193 Subscription::run(|| {
8194 stream::channel(
8195 1,
8196 |mut output: futures::channel::mpsc::Sender<Message>| async move {
8197 mime_app::watch(move || {
8198 futures::executor::block_on(async {
8199 _ = output.send(Message::ReloadMimeAppCache).await;
8200 })
8201 })
8202 .await;
8203
8204 std::future::pending().await
8205 },
8206 )
8207 }),
8208 Subscription::run_with(TypeId::of::<TrashWatcherSubscription>(), |_| {
8209 stream::channel(
8210 1,
8211 |mut output: futures::channel::mpsc::Sender<Message>| async move {
8212 let watcher_res = new_debouncer(
8213 time::Duration::from_millis(250),
8214 Some(time::Duration::from_millis(250)),
8215 move |event_res: notify_debouncer_full::DebounceEventResult| {
8216 match event_res {
8217 Ok(events) => {
8218 let should_rescan =
8222 events.iter().any(|event| !event.kind.is_access());
8223
8224 if should_rescan
8225 && let Err(e) = futures::executor::block_on(async {
8226 output.send(Message::RescanTrash).await
8227 })
8228 {
8229 log::warn!(
8230 "trash needs to be rescanned but sending message failed: {e:?}"
8231 );
8232 }
8233 }
8234 Err(e) => {
8235 log::warn!("failed to watch trash bin for changes: {e:?}");
8236 }
8237 }
8238 },
8239 );
8240
8241 match (watcher_res, Trash::folders()) {
8242 (Ok(mut watcher), Ok(trash_bins)) => {
8243 let trash_paths = trash_bins
8247 .into_iter()
8248 .flat_map(|path| [path.join("files"), path]);
8249 for path in trash_paths {
8250 if let Err(e) =
8251 watcher.watch(&path, notify::RecursiveMode::NonRecursive)
8252 {
8253 log::warn!(
8254 "failed to add trash bin `{}` to watcher: {e:?}",
8255 path.display()
8256 );
8257 }
8258 }
8259
8260 std::future::pending().await
8262 }
8263 (Err(e), _) => {
8264 log::warn!("failed to create new watcher for trash bin: {e:?}");
8265 }
8266 (_, Err(e)) => {
8267 log::warn!("could not find any valid trash bins to watch: {e:?}");
8268 }
8269 }
8270
8271 std::future::pending().await
8272 },
8273 )
8274 }),
8275 #[cfg(all(
8276 not(feature = "desktop-applet"),
8277 not(target_os = "ios"),
8278 not(target_os = "android")
8279 ))]
8280 Subscription::run_with(TypeId::of::<RecentsWatcherSubscription>(), |_| {
8281 stream::channel(
8282 1,
8283 |mut output: futures::channel::mpsc::Sender<Message>| async move {
8284 let Some(recents_path) = recently_used_xbel::dir() else {
8285 log::warn!(
8286 "failed to watch recents changes: .recently_used.xbel does not exist"
8287 );
8288 return std::future::pending().await;
8289 };
8290
8291 let watcher_res = new_debouncer(
8292 time::Duration::from_millis(250),
8293 Some(time::Duration::from_millis(250)),
8294 move |event_res: notify_debouncer_full::DebounceEventResult| {
8295 match event_res {
8296 Ok(events) => {
8297 if events.iter().any(|event| {
8300 let kind = event.kind;
8301 kind.is_create()
8302 || kind.is_modify()
8303 || kind.is_remove()
8304 || kind.is_other()
8305 }) && let Err(e) = futures::executor::block_on(async {
8306 output.send(Message::RescanRecents).await
8307 }) {
8308 log::warn!(
8309 "open recents tabs need to be updated but sending message failed: {e:?}"
8310 );
8311 }
8312 }
8313 Err(e) => {
8314 log::warn!(
8315 "failed to watch recents file for changes: {e:?}"
8316 )
8317 }
8318 }
8319 },
8320 );
8321
8322 match watcher_res {
8323 Ok(mut watcher) => {
8324 if let Err(e) = watcher
8325 .watch(&recents_path, notify::RecursiveMode::NonRecursive)
8326 {
8327 log::warn!(
8328 "failed to add recents file `{}` to watcher: {}",
8329 recents_path.display(),
8330 e
8331 );
8332 }
8333
8334 std::future::pending::<()>().await;
8336 }
8337 Err(e) => {
8338 log::warn!("failed to create new watcher for recents file: {e:?}")
8339 }
8340 }
8341
8342 std::future::pending().await
8343 },
8344 )
8345 }),
8346 ];
8347
8348 if let Some(scroll_speed) = self.auto_scroll_speed {
8349 subscriptions.push(
8350 iced::time::every(time::Duration::from_millis(10))
8351 .with(scroll_speed)
8352 .map(|(scroll_speed, _)| Message::ScrollTab(scroll_speed)),
8353 );
8354 }
8355
8356 if !self.config.tb_config.ab1_scan_path.is_empty() {
8357 subscriptions.push(
8358 iced::time::every(time::Duration::from_secs(2 * 60 * 60))
8359 .map(|_| Message::ScanAb1Directory),
8360 );
8361 }
8362
8363 subscriptions.extend(MOUNTERS.iter().map(|(key, mounter)| {
8364 mounter
8365 .subscription()
8366 .with(*key)
8367 .map(|(key, mounter_message)| match mounter_message {
8368 MounterMessage::Items(items) => Message::MounterItems(key, items),
8369 MounterMessage::MountResult(item, res) => Message::MountResult(key, item, res),
8370 MounterMessage::NetworkAuth(uri, auth, auth_tx) => {
8371 Message::NetworkAuth(key, uri, auth, auth_tx)
8372 }
8373 MounterMessage::NetworkResult(uri, res) => {
8374 Message::NetworkResult(key, uri, res)
8375 }
8376 })
8377 }));
8378
8379 for (key, mounter) in CLIENTS.iter() {
8380 subscriptions.push(
8381 mounter.subscription().with(*key).map(
8382 |(key, client_message)| match client_message {
8383 ClientMessage::Items(items) => Message::ClientItems(key, items),
8384 ClientMessage::ClientResult(item, res) => {
8385 Message::ClientResult(key, item, res)
8386 }
8387 ClientMessage::RemoteAuth(uri, auth, auth_tx) => {
8388 Message::RemoteAuth(key, uri, auth, auth_tx)
8389 }
8390 ClientMessage::RemoteResult(uri, res) => {
8391 Message::RemoteResult(key, uri, res)
8392 }
8393 ClientMessage::RunTbProfilerResult(uri, res) => {
8394 Message::RunTbProfilerResult(key, uri, res)
8395 }
8396 ClientMessage::DeleteRemoteFilesResult(uri, res) => {
8397 Message::DeleteRemoteFilesResult(key, uri, res)
8398 }
8399 ClientMessage::JobStatusUpdate(uri, array_id, running_tasks) => {
8400 Message::JobStatusUpdate(key, uri, array_id, running_tasks)
8401 }
8402 },
8403 ),
8404 );
8405 }
8406
8407 if !self.pending_operations.is_empty() {
8408 if self.core.main_window_id().is_some() {
8411 if self
8413 .pending_operations
8414 .values()
8415 .any(|(_, controller)| !controller.is_paused())
8416 {
8417 subscriptions.push(
8418 cosmic::iced::time::every(Duration::from_millis(100))
8419 .map(|_| Message::None),
8420 );
8421 }
8422 } else {
8423 #[cfg(feature = "notify")]
8425 {
8426 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8427 struct NotificationSubscription;
8428 subscriptions.push(Subscription::run_with(
8429 TypeId::of::<NotificationSubscription>(),
8430 |_| {
8431 stream::channel(
8432 1,
8433 move |mut msg_tx: futures::channel::mpsc::Sender<_>| async move {
8434 tokio::task::spawn_blocking(move || {
8435 match notify_rust::Notification::new()
8436 .summary(&fl!("notification-in-progress"))
8437 .timeout(notify_rust::Timeout::Never)
8438 .show()
8439 {
8440 Ok(notification) => {
8441 let _ = futures::executor::block_on(async {
8442 msg_tx
8443 .send(Message::Notification(Arc::new(
8444 Mutex::new(notification),
8445 )))
8446 .await
8447 });
8448 }
8449 Err(err) => {
8450 log::warn!("failed to create notification: {err}");
8451 }
8452 }
8453 })
8454 .await
8455 .unwrap();
8456
8457 std::future::pending().await
8458 },
8459 )
8460 },
8461 ));
8462 }
8463 }
8464 }
8465
8466 let mut selected_previews = Vec::new();
8467 match self.mode {
8468 Mode::App => {
8469 if self.core.window.show_context
8470 && let ContextPage::Preview(entity_opt, PreviewKind::Selected) =
8471 self.context_page
8472 {
8473 selected_previews
8474 .push(Some(entity_opt.unwrap_or_else(|| self.tab_model.active())));
8475 }
8476 }
8477 Mode::Desktop => {
8478 for window_kind in self.windows.values().map(|window| &window.kind) {
8479 if let WindowKind::Preview(entity_opt, _) = window_kind {
8480 selected_previews
8481 .push(Some(entity_opt.unwrap_or_else(|| self.tab_model.active())));
8482 }
8483 }
8484 }
8485 }
8486
8487 subscriptions.extend(self.tab_model.iter().filter_map(|entity| {
8488 let tab = self.tab_model.data::<Tab>(entity)?;
8489 Some(
8490 tab.subscription(
8491 selected_previews
8492 .iter()
8493 .any(|preview| preview.as_ref() == Some(entity).as_ref()),
8494 )
8495 .with(entity)
8496 .map(|(entity, tab_msg)| Message::TabMessage(Some(entity), tab_msg)),
8497 )
8498 }));
8499
8500 Subscription::batch(subscriptions)
8501 }
8502}
8503
8504#[cfg(test)]
8508pub(crate) mod test_utils {
8509 use std::cmp::Ordering;
8510 use std::fs::File;
8511 use std::io::{self, Write};
8512 use std::iter;
8513 use std::path::Path;
8514
8515 use log::{debug, trace};
8516 use tempfile::{TempDir, tempdir};
8517
8518 use crate::config::{IconSizes, TabConfig, ThumbCfg};
8519 use crate::tab::Item;
8520
8521 use super::*;
8522
8523 pub const NUM_FILES: usize = 2;
8525 pub const NUM_HIDDEN: usize = 1;
8526 pub const NUM_DIRS: usize = 2;
8527 pub const NUM_NESTED: usize = 1;
8528 pub const NAME_LEN: usize = 5;
8529
8530 pub fn file_flat_hier<D: AsRef<Path>>(dir: D, n: usize, prefix: &str) -> io::Result<Vec<File>> {
8534 let dir = dir.as_ref();
8535 (0..n)
8536 .map(|i| -> io::Result<File> {
8537 let name = format!("{prefix}{i}");
8538 let path = dir.join(&name);
8539
8540 let mut file = File::create(path)?;
8541 file.write_all(name.as_bytes())?;
8542
8543 Ok(file)
8544 })
8545 .collect()
8546 }
8547
8548 fn rand_string(len: usize) -> String {
8550 let mut rng = fastrand::Rng::new();
8551 iter::repeat_with(|| rng.alphanumeric()).take(len).collect()
8552 }
8553
8554 pub fn simple_fs(
8564 files: usize,
8565 hidden: usize,
8566 dirs: usize,
8567 nested: usize,
8568 name_len: usize,
8569 ) -> io::Result<TempDir> {
8570 let root = tempdir()?;
8573 debug!("Root temp directory: {}", root.as_ref().display());
8574 trace!(
8575 "Creating {files} files and {hidden} hidden files in {dirs} temp dirs with {nested} nested temp dirs"
8576 );
8577
8578 let paths = iter::repeat_with(|| {
8580 let root = root.as_ref();
8581 let current = rand_string(name_len);
8582
8583 iter::once(root.join(¤t)).chain(
8584 iter::repeat_with(move || {
8585 let mut path = root.join(¤t);
8586 path.push(rand_string(name_len));
8587 path
8588 })
8589 .take(nested),
8590 )
8591 })
8592 .take(dirs)
8593 .flatten();
8594
8595 for path in paths {
8597 fs::create_dir_all(&path)?;
8598
8599 file_flat_hier(&path, files, "")?;
8601 file_flat_hier(&path, hidden, ".")?;
8603
8604 for entry in path.read_dir()? {
8605 let entry = entry?;
8606 if entry.file_type()?.is_file() {
8607 trace!("Created file: {}", entry.path().display());
8608 }
8609 }
8610 }
8611
8612 Ok(root)
8613 }
8614
8615 pub fn empty_fs() -> io::Result<TempDir> {
8617 tempdir()
8618 }
8619
8620 pub fn sort_files(a: &Path, b: &Path) -> Ordering {
8626 match (a.is_dir(), b.is_dir()) {
8627 (true, false) => Ordering::Less,
8628 (false, true) => Ordering::Greater,
8629 _ => LANGUAGE_SORTER.compare(
8630 a.file_name()
8631 .expect("temp entries should have names")
8632 .to_str()
8633 .expect("temp entries should be valid UTF-8"),
8634 b.file_name()
8635 .expect("temp entries should have names")
8636 .to_str()
8637 .expect("temp entries should be valid UTF-8"),
8638 ),
8639 }
8640 }
8641
8642 pub fn read_dir_sorted(path: &Path) -> io::Result<Vec<PathBuf>> {
8644 let mut entries: Vec<_> = path
8645 .read_dir()?
8646 .map(|maybe_entry| maybe_entry.map(|entry| entry.path()))
8647 .collect::<io::Result<_>>()?;
8648 entries.sort_by(|a, b| sort_files(a, b));
8649
8650 Ok(entries)
8651 }
8652
8653 pub fn filter_dirs(path: &Path) -> io::Result<impl Iterator<Item = PathBuf> + use<>> {
8655 Ok(path.read_dir()?.filter_map(|entry| {
8656 entry.ok().and_then(|entry| {
8657 let path = entry.path();
8658 path.is_dir().then_some(path)
8659 })
8660 }))
8661 }
8662
8663 pub fn filter_files(path: &Path) -> io::Result<impl Iterator<Item = PathBuf> + use<>> {
8665 Ok(path.read_dir()?.filter_map(|entry| {
8666 entry.ok().and_then(|entry| {
8667 let path = entry.path();
8668 path.is_file().then_some(path)
8669 })
8670 }))
8671 }
8672
8673 pub fn tab_click_new(
8675 files: usize,
8676 hidden: usize,
8677 dirs: usize,
8678 nested: usize,
8679 name_len: usize,
8680 ) -> io::Result<(TempDir, Tab)> {
8681 let fs = simple_fs(files, hidden, dirs, nested, name_len)?;
8682 let path = fs.path();
8683
8684 let location = Location::Path(path.to_owned());
8686 let (parent_item_opt, items) = location.scan(IconSizes::default());
8687 let mut tab = Tab::new(
8688 location,
8689 TabConfig::default(),
8690 TBConfig::default(),
8691 ThumbCfg::default(),
8692 None,
8693 widget::Id::unique(),
8694 None,
8695 );
8696 tab.parent_item_opt = parent_item_opt;
8697 tab.set_items(items);
8698
8699 let items = tab.items_opt().expect("tab should be populated with Items");
8701 assert_eq!(NUM_DIRS, items.len());
8702
8703 Ok((fs, tab))
8704 }
8705
8706 pub fn eq_path_item(path: &Path, item: &Item) -> bool {
8708 let name = path
8709 .file_name()
8710 .expect("temp entries should have names")
8711 .to_str()
8712 .expect("temp entries should be valid UTF-8");
8713 let is_dir = path.is_dir();
8714
8715 #[cfg(not(target_os = "windows"))]
8718 let is_hidden = name.starts_with('.');
8719
8720 #[cfg(target_os = "windows")]
8721 let is_hidden = {
8722 use std::os::windows::fs::MetadataExt;
8723 const FILE_ATTRIBUTE_HIDDEN: u32 = 2;
8724 let metadata = path.metadata().expect("fetching file metadata");
8725 metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
8726 };
8727
8728 name == item.name
8729 && is_dir == item.metadata.is_dir()
8730 && path == item.path_opt().expect("item should have path")
8731 && is_hidden == item.hidden
8732 }
8733
8734 pub fn assert_eq_tab_path(tab: &Tab, path: &Path) {
8736 let Some(tab_path) = tab.location.path_opt() else {
8738 panic!("Expected tab's location to be a path");
8739 };
8740
8741 assert_eq!(
8742 path,
8743 tab_path,
8744 "Tab's path is {} instead of being updated to {}",
8745 tab_path.display(),
8746 path.display()
8747 );
8748 }
8749}