cosmic_files/
app.rs

1// Copyright 2023 System76 <info@system76.com>
2// SPDX-License-Identifier: GPL-3.0-only
3
4use 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/// Messages that are used specifically by our [`App`].
356#[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
810// The [`App`] stores application-specific state.
811pub 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    /// Maps array_id -> current running task count (updated by poll)
872    running_tasks: std::collections::HashMap<usize, usize>,
873    /// Maps array_id -> total task count (set once on job submission, never overwritten)
874    job_total_tasks: std::collections::HashMap<usize, usize>,
875    /// Total files queued across all active download batches
876    download_files_total: usize,
877    /// Files completed so far across all active download batches
878    download_files_done: usize,
879}
880
881impl App {
882    /// Returns true if the clipboard cache contains pasteable content
883    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        // Associate all paths to its MIME type
900        // This allows handling paths as groups if possible, such as launching a single video
901        // player that is passed every path.
902        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            // Use extract to dialog if all selected paths are supported archives
919            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            // First launch apps that can be launched directly
926            if mime == "application/x-desktop" {
927                #[cfg(feature = "desktop")]
928                {
929                    // Try opening desktop application
930                    Self::launch_desktop_entries(&paths);
931                    continue;
932                }
933            } else if mime == "application/x-executable" || mime == "application/vnd.appimage" {
934                // Try opening executable
935                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                                // If permission is denied, try marking as executable, then running
942                                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            // Try mime apps, which should be faster than xdg-open
957            if self.launch_from_mime_cache(&mime, &paths) {
958                continue;
959            }
960
961            // loop through subclasses if available
962            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            // Fall back to using open crate
971            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                        // More than one command: The app doesn't support lists of paths so each command
1071                        // is associated with one instance
1072                        //
1073                        // One command: Attempted to launch one app with multiple paths
1074                        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        // No app matched for mimes and paths
1086        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            // Largely copied from COSMIC app library
1093            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            // Count how many selected items are remote directories so we can
1182            // offer a SaveFile dialog (with an editable zip name) when needed.
1183            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                            // pick the one path that is a dir (best-effort by metadata)
1203                            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            // just one quadrant with overlap
1299            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    // This wrapper ensures that local folders use trash and remote folders permanently delete with a dialog
1396    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            //TODO: is there a smarter way to check this? (like checking for trash folders)
1402            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        // Use a task to send operations to the compio runtime thread.
1444        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    /// Will join operations together into a single task that will return a single
1470    /// Message::PendingResults message when all operations are complete.
1471    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                // Show toast for some operations
1511                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 a favorite for a path has been renamed or moved, update it.
1534                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        // Close progress notification if all relevant operations are finished
1559        if !self
1560            .pending_operations
1561            .values()
1562            .any(|(op, _)| op.show_progress_notification())
1563        {
1564            self.progress_operations.clear();
1565        }
1566        // Potentially show a notification
1567        commands.push(self.update_notification());
1568        // Rescan and select based on operation
1569        commands.push(self.rescan_operation_selection(op_sel));
1570        // Manually rescan any trash tabs after any operation is completed
1571        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                // Only show dialog if not cancelled
1582                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                // Remove from progress
1595                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        // Close progress notification if all relevant operations are finished
1609        if !self
1610            .pending_operations
1611            .values()
1612            .any(|(op, _)| op.show_progress_notification())
1613        {
1614            self.progress_operations.clear();
1615        }
1616        // Manually rescan any trash tabs after any operation is completed
1617        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                    // Close context menu
1626                    if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
1627                        tab.context_menu = None;
1628                    }
1629                }
1630                WindowKind::Desktop(entity) => {
1631                    // Remove the tab from the tab model
1632                    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                    // Ignore if path in selected or ignored paths
1654                    continue;
1655                }
1656
1657                // Return if there is a previous selection not matching
1658                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        // Tabs are collected first to placate the borrowck
1899        let tabs: Box<[_]> = self.tab_model.iter().collect();
1900        // Update main conf and each tab with the new config
1901        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        // Collect all mounter items
2053        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        // Sort by name lexically
2058        nav_items.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.1.name(), &b.1.name()));
2059        // Add items to nav model
2060        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        // Sort by name lexically
2091        client_items.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.1.name(), &b.1.name()));
2092        // Add items to nav model
2093        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        // Handle closing notification if there are no operations
2125        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                        //TODO: this is nasty
2131                        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            // Unwatch paths no longer used
2169            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            // Watch new paths
2183            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        //TODO: should any of this run in a command?
2200        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        //TODO: get height from theme?
2358        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                            // Only show one property view to avoid issues like hangs when generating
2463                            // preview images on thousands of files
2464                            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                                // At least two selected items
2478                                (Some(_), Some(_)) => {
2479                                    Some(tab.multi_preview_view(Some(&self.mime_app_cache)))
2480                                }
2481                                // Exactly one selected item
2482                                (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                                // No selected items
2505                                _ => 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        // TODO: Should dialog be updated here too?
2534        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    // Update favorites based on renaming or moving dirs.
2682    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
2727/// Implement [`Application`] to integrate with COSMIC.
2728impl Application for App {
2729    /// Default async executor to use with the app.
2730    type Executor = executor::Default;
2731
2732    /// Argument received
2733    type Flags = Flags;
2734
2735    /// Message type specific to our [`App`].
2736    type Message = Message;
2737
2738    /// The unique application ID to supply to the window manager.
2739    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    /// Creates the application, and optionally emits command on initialize.
2750    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        // Create a dedicated thread for the compio runtime to handle operations on.
2775        // Supports io_uring on Linux, IOPC on Windows, and polling everywhere else.
2776        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                            // TODO do we need to choose the correct mounter?
3081                            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                            // TODO do we need to choose the correct mounter?
3123                            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            // Persist state of preview page
3214            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        // Close dialog if open
3225        if let Some((_page, task)) = self.dialog_pages.pop_front() {
3226            return task;
3227        }
3228
3229        // Close gallery mode if open
3230        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        // Close menus and context panes in order per message
3238        // Why: It'd be weird to close everything all at once
3239        // Usually, the Escape key (for example) closes menus and panes one by one instead
3240        // of closing everything on one press
3241        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                    // Unfocus if there was a focused button
3267                    return widget::button::focus(widget::Id::unique());
3268                }
3269                return Task::none();
3270            }
3271        }
3272
3273        if self.search_get().is_some() {
3274            // Close search if open
3275            return self.search_set_active(None);
3276        }
3277
3278        Task::none()
3279    }
3280
3281    /// Handle application events here.
3282    fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
3283        // Helper for updating config values efficiently
3284        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                // check if the selected entity is in the current tab
3314                // else just use the selected entity and check its location
3315                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                    // Show details and military time are preserved for existing instances
3419                    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                //TODO: use special settings URL scheme instead?
3496                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                                        //TODO: error on trying to permanently delete non-trash file?
3517                                    }
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                    // Use the dialog ID to make it float
3554                    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                        //TODO: would it be better to make this a layer surface?
3567                        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                            // Use the dialog ID to make it float
3582                            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                            //TODO: re-use mounter_key?
3693                            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                            // When save_as_zip, selected_paths[0] is the full
3895                            // "dir/name.zip" path chosen by the user; otherwise
3896                            // it is the destination directory.
3897                            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                // Reset counters when all files are done
3939                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                    // Uncaptured keys with only shift modifiers go to the search or location box
3963                    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                                    // Try to add text to end of location
3983                                    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                                // Reset buffer if timeout elapsed
3998                                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                                // Accumulate character and select
4005                                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                    // Exit if window is closed and there are no pending operations
4028                    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                // Check for unmounted folders
4051                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                // Go back to home in any tabs that were unmounted
4075                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                // Insert new items
4105                self.mounter_items.insert(mounter_key, mounter_items);
4106
4107                // Update nav bar
4108                //TODO: this could change favorites IDs while they are in use
4109                self.update_nav_model();
4110
4111                // Update desktop tabs
4112                commands.push(self.update_desktop());
4113
4114                return Task::batch(commands);
4115            }
4116            Message::ClientItems(client_key, client_items) => {
4117                // Check for unconnected folders
4118                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                // Go back to home in any tabs that were unmounted
4142                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                // Insert new items
4172                self.client_items.insert(client_key, client_items);
4173
4174                // Update nav bar
4175                //TODO: this could change favorites IDs while they are in use
4176                self.update_nav_model();
4177
4178                // Update desktop tabs
4179                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                    // Automatically navigate to the mounted location
4187                    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                // Close context menu when clicking outside.
4233                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                //TODO: know which mounter to use for network drives
4296                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                //TODO: know which client to use for remote drives
4310                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 metadata or data changed, find the matching item and reload it
4521                                        //TODO: this could be further optimized by looking at what exactly changed
4522                                        if let Some(items) = &mut tab.items_opt {
4523                                            for item in items.iter_mut() {
4524                                                if item.path_opt() == Some(event_path) {
4525                                                    //TODO: reload more, like mime types?
4526                                                    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                                                    //TODO item.thumbnail_opt =
4546                                                }
4547                                            }
4548                                        }
4549                                    } else {
4550                                        // Any other events reload the whole tab
4551                                        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                    // TODO: Support multiple URLs
4663                    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                    // Use cached clipboard data if available (needed for Wayland popups)
4726                    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                            // Cache is empty, try reading from clipboard directly
4763                            // (works when triggered from main window, e.g., Ctrl+V)
4764                            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                        // No image data in clipboard, try video data
4800                        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                // Generate unique filename for the pasted image
4814                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                // Write image data to file
4819                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                        // No video data in clipboard, try text data
4835                        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                // Generate unique filename for the pasted video
4849                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                // Write video data to file
4854                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                // Generate unique filename for the pasted text
4875                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                // Write text data to file
4880                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                // Check if clipboard has any paste-able content and cache it
4891                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                                // Use the dialog ID to make it float
5043                                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(), // Force re-calculating of directory sizes
5054                                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                // Update trash icon if empty/full
5072                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                        //TODO: batch rename
5101                        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                                //TODO: error on trying to restore non-trash file?
5157                            }
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                // Activate new tab
5208                self.tab_model.activate(entity);
5209                if let Some(tab) = self.tab_model.data::<Tab>(entity) {
5210                    {
5211                        //Restore scroll
5212                        //TODO: why do scrollers with different IDs get the same scroll position?
5213                        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                    // Wraparound to 0 if i + 1 > num of tabs
5235                    % 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                    // Subtraction underflow => last tab; i.e. it wraps around
5249                    .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 the last tab is closed, close the window
5262                // Otherwise, activate closest item
5263                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                // Remove item
5280                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                            // converting an f32 to an i16 here by multiplying by 10 and casting to i16
5350                            // further resolution isn't necessary
5351                            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                            // clear the prefix selection buffer when changing location
5362                            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                                    // Open context menu
5374                                    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                                // Destroy previous popup
5426                                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                            //TODO: use handler for x-scheme-handler/trash and open trash:///
5515                            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                            //TODO: this will block for a few ms, run in background?
5530                            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                                // force reordering of inserted values so new settings are not dropped in the truncation step
5609                                _ = 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                                // TODO potentially configurable limit on max size?
5618                                if self.state.sort_names.len() > MAX_SORT_NAMES {
5619                                    // truncate is not a good fit because it drops the items at the end, which are newest...
5620                                    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                            // Ensure selected path is scrolled to after redraw
5673                            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                //TODO: ensure context menus are closed
5717                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                // Preview status is preserved across restarts
5726                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                // TODO: undo
5734            }
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                    // initialize command to spawn another instance of this application
5786                    let mut command = process::Command::new(&exe);
5787
5788                    // make the new window open at the same location as the currently active tab by
5789                    // passing respective command line arguments
5790                    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                    // spawn the new window
5817                    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                    // Close location editing if enabled
5987                    tab.edit_location = None;
5988                    // Close other context menus.
5989                    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                // Open the selected path in a new cosmic-files window.
6100                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                            //TODO: remove old surface?
6198                            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                // Forward cosmic messages
6273                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                // Check clipboard when window gains focus
6353                // HACK: Wait a moment for the data to be available.
6354                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                            // Exactly one item
6612                            (Some(item), None) => Some(
6613                                item.preview_actions()
6614                                    .map(move |x| Message::TabMessage(Some(entity), x)),
6615                            ),
6616                            // Zero or more than one item
6617                            _ => 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        //TODO: should gallery view just be a dialog?
6643        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                //TODO: try next dialog page (making sure index is used by Dialog messages)?
6781                let (operation, _, err) = self.failed_operations.get(id)?;
6782
6783                //TODO: nice description of error
6784                widget::dialog()
6785                    .title("Failed operation")
6786                    .body(format!("{operation:#?}\n{err}"))
6787                    .icon(icon::from_name("dialog-error").size(64))
6788                    //TODO: retry action
6789                    .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                //TODO: nice description of error
6803                widget::dialog()
6804                    .title("Failed operations")
6805                    .body(errors.join("\n\n"))
6806                    .icon(icon::from_name("dialog-error").size(64))
6807                    //TODO: retry action
6808                    .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                //TODO: use URI!
6870                let mut controls = widget::column::with_capacity(4);
6871                let mut id_assigned = false;
6872
6873                if let Some(username) = &auth.username_opt {
6874                    //TODO: what should submit do?
6875                    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                    //TODO: what should submit do?
6897                    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                    //TODO: what should submit do?
6919                    //TODO: button for showing password
6920                    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                    //TODO: what should submit do?
6941                    //TODO: button for showing password
6942                    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                //TODO: use URI!
6999                let mut controls = widget::column::with_capacity(4);
7000                let mut id_assigned = false;
7001
7002                if let Some(username) = &auth.username_opt {
7003                    //TODO: what should submit do?
7004                    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                    //TODO: what should submit do?
7026                    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                    //TODO: what should submit do?
7048                    //TODO: button for showing password
7049                    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                    //TODO: what should submit do?
7070                    //TODO: button for showing password
7071                    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                        // (32 (item_height) + 5.0 (custom button padding)) + (space_xxs (list item spacing) * 2)
7371                        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                //TODO: combine logic with NewItem
7447                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        // File operations section
7638        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            // Adjust the progress bar so it does not jump around when operations finish
7658            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        // Slurm job progress section
7741        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        // Active downloads section
7778        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                    //TODO: selected state is not appearing different
7826                    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    /// Creates a view after each update.
7855    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            //TODO
7914        }
7915
7916        // The toaster is added on top of an empty element to ensure that it does not override context menus
7917        tab_column = tab_column.push(widget::toaster(&self.toasts, widget::space::horizontal()));
7918
7919        let content: Element<_> = tab_column.into();
7920
7921        // Uncomment to debug layout:
7922        //content.explain(cosmic::iced::Color::WHITE)
7923        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                    // The toaster is added on top of an empty element to ensure that it does not override context menus
7965                    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                //TODO: distinct views per monitor in desktop mode
8007                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            //TODO: filter more events by window id
8046            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                                                    // Data not mutated
8133                                                    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                                                    // Data not mutated nor modify time changed
8142                                                    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                                        // Rescan on any event. We don't need to evaluate each event
8219                                        // because as long as the trash changed in any way we need to
8220                                        // rescan.
8221                                        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                                // Watch the "bins" themselves as well as the files folder where
8244                                // trashed items are placed. This allows us to avoid recursively
8245                                // watching the trash which is slow but also properly get events.
8246                                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                                // Don't drop the watcher
8261                                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                                        // Programs differ in how they modify the recents file so the
8298                                        // rescan is triggered on any event but access.
8299                                        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                                // Don't drop the watcher.
8335                                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            //TODO: inhibit suspend/shutdown?
8409
8410            if self.core.main_window_id().is_some() {
8411                // Force refresh the UI every 100ms while an operation is active.
8412                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                // Handle notification when window is closed and operations are in progress
8424                #[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// Utilities to build a temporary file hierarchy for tests.
8505//
8506// Ideally, tests would use the cap-std crate which limits path traversal.
8507#[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    // Default number of files, directories, and nested directories for test file system
8524    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    /// Add `n` temporary files in `dir`
8531    ///
8532    /// Each file is assigned a numeric name from [0, n) with a prefix.
8533    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    // Random alphanumeric String of length `len`
8549    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    /// Create a small, temporary file hierarchy.
8555    ///
8556    /// # Arguments
8557    ///
8558    /// * `files` - Number of files to create in temp directories
8559    /// * `hidden` - Number of hidden files to create
8560    /// * `dirs` - Number of directories to create
8561    /// * `nested` - Number of nested directories to create in new dirs
8562    /// * `name_len` - Length of randomized directory names
8563    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        // Files created inside of a TempDir are deleted with the directory
8571        // TempDir won't leak resources as long as the destructor runs
8572        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        // All paths for directories and nested directories
8579        let paths = iter::repeat_with(|| {
8580            let root = root.as_ref();
8581            let current = rand_string(name_len);
8582
8583            iter::once(root.join(&current)).chain(
8584                iter::repeat_with(move || {
8585                    let mut path = root.join(&current);
8586                    path.push(rand_string(name_len));
8587                    path
8588                })
8589                .take(nested),
8590            )
8591        })
8592        .take(dirs)
8593        .flatten();
8594
8595        // Create directories from `paths` and add a few files
8596        for path in paths {
8597            fs::create_dir_all(&path)?;
8598
8599            // Normal files
8600            file_flat_hier(&path, files, "")?;
8601            // Hidden files
8602            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    /// Empty file hierarchy
8616    pub fn empty_fs() -> io::Result<TempDir> {
8617        tempdir()
8618    }
8619
8620    /// Sort files.
8621    ///
8622    /// Directories are placed before files.
8623    /// Files are lexically sorted.
8624    /// This is more or less copied right from the [Tab] code
8625    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    /// Read directory entries from `path` and sort.
8643    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    /// Filter `path` for directories
8654    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    // Filter `path` for files
8664    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    /// Boiler plate for Tab tests
8674    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        // New tab with items
8685        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        // Ensure correct number of directories as a sanity check
8700        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    /// Equality for [Path] and [Item].
8707    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        // NOTE: I don't want to change `tab::hidden_attribute` to `pub(crate)` for
8716        // tests without asking
8717        #[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    /// Asserts `tab`'s location changed to `path`
8735    pub fn assert_eq_tab_path(tab: &Tab, path: &Path) {
8736        // Paths should be the same
8737        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}