cosmic_files/
tab.rs

1#[cfg(feature = "desktop")]
2use cosmic::desktop::fde::{DesktopEntry, get_languages_from_env};
3use cosmic::iced::advanced::graphics;
4use cosmic::iced::advanced::text::{self, Paragraph};
5use cosmic::iced::alignment::Vertical;
6use cosmic::iced::clipboard::dnd::DndAction;
7use cosmic::iced::core::mouse::ScrollDelta;
8use cosmic::iced::core::widget::tree;
9use cosmic::iced::futures::{self, SinkExt};
10use cosmic::iced::keyboard::Modifiers;
11use cosmic::iced::widget::scrollable::{self, AbsoluteOffset, Viewport};
12use cosmic::iced::widget::{rule, stack};
13use cosmic::iced::{
14    Alignment, Border, Color, ContentFit, Length, Point, Rectangle, Size, Subscription, Vector,
15    padding, stream, window,
16};
17use cosmic::widget::menu::action::MenuAction;
18use cosmic::widget::menu::key_bind::KeyBind;
19use cosmic::widget::{self, DndDestination, DndSource, Id, RcElementWrapper, Widget, space};
20use cosmic::{Apply, Element, cosmic_theme, font, theme};
21use icu::datetime::input::DateTime;
22use icu::datetime::options::TimePrecision;
23use icu::datetime::{DateTimeFormatter, DateTimeFormatterPreferences, fieldsets};
24use icu::locale::preferences::extensions::unicode::keywords::HourCycle;
25use image::{DynamicImage, ImageReader};
26use jiff_icu::ConvertFrom;
27use mime_guess::{Mime, mime};
28use rustc_hash::FxHashMap;
29use serde::{Deserialize, Serialize};
30use std::borrow::Cow;
31use std::cell::Cell;
32use std::cmp::{Ordering, Reverse};
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::error::Error;
35use std::fmt::{self, Display};
36use std::fs::{self, File, Metadata};
37use std::hash::Hash;
38use std::io::{BufRead, BufReader, Read};
39#[cfg(unix)]
40use std::os::unix::fs::MetadataExt;
41use std::path::{self, Path, PathBuf};
42use std::sync::{Arc, LazyLock, RwLock, atomic};
43use std::time::{Duration, Instant, SystemTime};
44use tempfile::NamedTempFile;
45use tokio::sync::mpsc;
46use trash::{TrashItem, TrashItemMetadata, TrashItemSize};
47use walkdir::WalkDir;
48
49use crate::app::{Action, PreviewItem, PreviewKind};
50use crate::clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste};
51use crate::config::{
52    ContextActionPreset, DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TBConfig,
53    TabConfig, ThumbCfg,
54};
55use crate::dialog::DialogKind;
56use crate::large_image::{
57    LargeImageManager, decode_large_image, exceeds_memory_limit, should_use_dedicated_worker,
58    should_use_tiling,
59};
60use crate::localize::{LANGUAGE_SORTER, LOCALE};
61use crate::mime_icon::{mime_for_path, mime_icon};
62use crate::mounter::MOUNTERS;
63use crate::operation::{Controller, OperationError};
64use crate::russh::CLIENTS;
65use crate::sequencing::rrl::{
66    RrlPosition2058_2059, RrlSusceptibilityCalls, is_susceptible_rrl,
67    is_susceptible_rrl_by_snp_calls_rare,
68};
69use crate::sequencing::rrs::{
70    RrsSusceptibilityCalls, is_susceptible_rrs, is_susceptible_rrs_by_snp_calls_rare,
71};
72use crate::sequencing::{
73    Ab1Channels, SeqData, SeqIdHit, SusceptibilityCalls,
74    erm41::{Erm41Position28, Erm41SusceptibilityCalls, is_susceptible_erm41},
75    parse_ab1_quality, parse_ab1_sequence,
76    pnca::{PncaSusceptibilityCalls, is_susceptible_pnca},
77    tb_data::{DrVariant, TB_ECOLI_MAPPING, TbProfilerJson},
78    trim_to_min_quality,
79};
80use crate::thumbnail_cacher::{CachedThumbnail, ThumbnailCacher, ThumbnailSize};
81use crate::thumbnailer::thumbnailer;
82use crate::trash::{Trash, TrashExt};
83use crate::{FxOrderMap, fl, menu, mime_app, mouse_area};
84
85pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500);
86pub const HOVER_DURATION: Duration = Duration::from_millis(1600);
87pub const TYPE_SELECT_TIMEOUT: Duration = Duration::from_millis(1000);
88//TODO: best limit for search items
89const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20);
90const MAX_SEARCH_RESULTS: usize = 200;
91//TODO: configurable thumbnail size?
92const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32);
93/// Maximum bytes of text to pass to the editor for preview; caps shaping work to avoid blocking.
94/// Files larger than this get a truncated preview (first N bytes only).
95const TEXT_PREVIEW_MAX_BYTES: usize = 256 * 1024; // 256 KiB
96/// Maximum file size (bytes) to attempt text preview; files larger than this are skipped entirely.
97const TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 8 * 1000 * 1000; // 8 MiB
98
99// Thumbnail generation semaphore - limits parallel thumbnail workers
100// Uses 4 workers for balanced throughput and memory usage
101pub static THUMB_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
102    LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get().min(4)));
103
104pub(crate) static SORT_OPTION_FALLBACK: LazyLock<FxHashMap<String, (HeadingOptions, bool)>> =
105    LazyLock::new(|| {
106        FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| {
107            (
108                Location::Path(dir).normalize().to_string(),
109                (HeadingOptions::Modified, false),
110            )
111        }))
112    });
113
114#[cfg_attr(not(unix), allow(dead_code))]
115static MODE_NAMES: LazyLock<Vec<String>> = LazyLock::new(|| {
116    vec![
117        // Mode 0
118        fl!("none"),
119        // Mode 1
120        fl!("execute-only"),
121        // Mode 2
122        fl!("write-only"),
123        // Mode 3
124        fl!("write-execute"),
125        // Mode 4
126        fl!("read-only"),
127        // Mode 5
128        fl!("read-execute"),
129        // Mode 6
130        fl!("read-write"),
131        // Mode 7
132        fl!("read-write-execute"),
133    ]
134});
135
136static SPECIAL_DIRS: LazyLock<FxHashMap<PathBuf, &'static str>> = LazyLock::new(|| {
137    let mut special_dirs = FxHashMap::default();
138    if let Some(dir) = dirs::document_dir() {
139        special_dirs.insert(dir, "folder-documents");
140    }
141    if let Some(dir) = dirs::download_dir() {
142        special_dirs.insert(dir, "folder-download");
143    }
144    if let Some(dir) = dirs::audio_dir() {
145        special_dirs.insert(dir, "folder-music");
146    }
147    if let Some(dir) = dirs::picture_dir() {
148        special_dirs.insert(dir, "folder-pictures");
149    }
150    if let Some(dir) = dirs::public_dir() {
151        special_dirs.insert(dir, "folder-publicshare");
152    }
153    if let Some(dir) = dirs::template_dir() {
154        special_dirs.insert(dir, "folder-templates");
155    }
156    if let Some(dir) = dirs::video_dir() {
157        special_dirs.insert(dir, "folder-videos");
158    }
159    if let Some(dir) = dirs::desktop_dir() {
160        special_dirs.insert(dir, "user-desktop");
161    }
162    if let Some(dir) = dirs::home_dir() {
163        special_dirs.insert(dir, "user-home");
164    }
165    special_dirs
166});
167
168#[allow(clippy::too_many_arguments)]
169fn button_appearance(
170    theme: &theme::Theme,
171    selected: bool,
172    highlighted: bool,
173    cut: bool,
174    focused: bool,
175    accent: bool,
176    condensed_radius: bool,
177    desktop: bool,
178) -> widget::button::Style {
179    let cosmic = theme.cosmic();
180    let mut appearance = widget::button::Style::new();
181    if selected {
182        if accent {
183            appearance.background = Some(Color::from(cosmic.accent_color()).into());
184            appearance.icon_color = Some(Color::from(cosmic.on_accent_color()));
185            if cut {
186                appearance.text_color = Some(Color::from(cosmic.accent.on_disabled));
187            } else {
188                appearance.text_color = Some(Color::from(cosmic.on_accent_color()));
189            }
190        } else {
191            appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
192        }
193    } else if highlighted {
194        if accent {
195            appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
196            appearance.icon_color = Some(Color::from(cosmic.on_bg_component_color()));
197            appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
198            if cut {
199                appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
200            } else {
201                appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
202            }
203        } else {
204            appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
205        }
206    } else if desktop {
207        appearance.background = Some(Color::from(cosmic.bg_color()).into());
208        appearance.icon_color = Some(Color::from(cosmic.on_bg_color()));
209        if cut {
210            appearance.text_color = Some(Color::from(cosmic.background.component.disabled));
211        } else {
212            appearance.text_color = Some(Color::from(cosmic.on_bg_color()));
213        }
214    } else if cut {
215        appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
216    }
217    if focused && accent {
218        appearance.outline_width = 1.0;
219        appearance.outline_color = Color::from(cosmic.accent_color());
220        appearance.border_width = 2.0;
221        appearance.border_color = Color::TRANSPARENT;
222    }
223    if condensed_radius {
224        appearance.border_radius = cosmic.radius_xs().into();
225    } else {
226        appearance.border_radius = cosmic.radius_s().into();
227    }
228    appearance
229}
230
231fn button_style(
232    selected: bool,
233    highlighted: bool,
234    cut: bool,
235    accent: bool,
236    condensed_radius: bool,
237    desktop: bool,
238) -> theme::Button {
239    //TODO: move to libcosmic?
240    theme::Button::Custom {
241        active: Box::new(move |focused, theme| {
242            button_appearance(
243                theme,
244                selected,
245                highlighted,
246                cut,
247                focused,
248                accent,
249                condensed_radius,
250                desktop,
251            )
252        }),
253        disabled: Box::new(move |theme| {
254            button_appearance(
255                theme,
256                selected,
257                highlighted,
258                cut,
259                false,
260                accent,
261                condensed_radius,
262                desktop,
263            )
264        }),
265        hovered: Box::new(move |focused, theme| {
266            button_appearance(
267                theme,
268                selected,
269                highlighted,
270                cut,
271                focused,
272                accent,
273                condensed_radius,
274                desktop,
275            )
276        }),
277        pressed: Box::new(move |focused, theme| {
278            button_appearance(
279                theme,
280                selected,
281                highlighted,
282                cut,
283                focused,
284                accent,
285                condensed_radius,
286                desktop,
287            )
288        }),
289    }
290}
291
292pub fn folder_icon(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
293    widget::icon::from_name(SPECIAL_DIRS.get(path).map_or("folder", |x| *x))
294        .prefer_svg(true)
295        .size(icon_size)
296        .handle()
297}
298
299pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
300    widget::icon::from_name(format!(
301        "{}-symbolic",
302        SPECIAL_DIRS.get(path).map_or("folder", |x| *x)
303    ))
304    .size(icon_size)
305    .handle()
306}
307
308//TODO: replace with Path::has_trailing_sep when stable
309fn has_trailing_sep(path: &Path) -> bool {
310    path.as_os_str()
311        .as_encoded_bytes()
312        .last()
313        .copied()
314        .is_some_and(|b| path::is_separator(b as char))
315}
316
317fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
318    let parent = if has_trailing_sep(path) && path.is_dir() {
319        // Show completions inside existing child directory instead of parent
320        path
321    } else {
322        path.parent()
323            .ok_or_else(|| format!("path has no parent {}", path.display()))?
324    };
325
326    let child_os = path.strip_prefix(parent)?;
327    let child = child_os
328        .to_str()
329        .ok_or_else(|| format!("invalid UTF-8 {}", child_os.display()))?;
330
331    let pattern = format!("^{}", regex::escape(child));
332    let regex = regex::RegexBuilder::new(&pattern)
333        .case_insensitive(true)
334        .build()?;
335
336    let mut completions = Vec::new();
337    for entry_res in fs::read_dir(parent)? {
338        let entry = entry_res?;
339        let file_name_os = entry.file_name();
340        let Some(file_name) = file_name_os.to_str() else {
341            continue;
342        };
343        // Don't list hidden files before entering a pattern
344        if pattern == "^" && file_name.starts_with('.') {
345            continue;
346        }
347        if regex.is_match(file_name) {
348            completions.push((file_name.to_string(), entry.path()));
349        }
350    }
351
352    completions.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.0, &b.0));
353    //TODO: make the list scrollable?
354    completions.truncate(8);
355    Ok(completions)
356}
357
358//TODO: translate, add more levels?
359fn format_size(size: u64) -> String {
360    const KB: u64 = 1000;
361    const MB: u64 = 1000 * KB;
362    const GB: u64 = 1000 * MB;
363    const TB: u64 = 1000 * GB;
364
365    if size >= TB {
366        format!("{:.1} TB", size as f64 / TB as f64)
367    } else if size >= GB {
368        format!("{:.1} GB", size as f64 / GB as f64)
369    } else if size >= MB {
370        format!("{:.1} MB", size as f64 / MB as f64)
371    } else if size >= KB {
372        format!("{:.1} KB", size as f64 / KB as f64)
373    } else {
374        format!("{size} B")
375    }
376}
377
378#[cfg_attr(not(unix), allow(dead_code))]
379const MODE_SHIFT_USER: u32 = 6;
380#[cfg_attr(not(unix), allow(dead_code))]
381const MODE_SHIFT_GROUP: u32 = 3;
382#[cfg_attr(not(unix), allow(dead_code))]
383const MODE_SHIFT_OTHER: u32 = 0;
384
385#[cfg_attr(not(unix), allow(dead_code))]
386const fn get_mode_part(mode: u32, shift: u32) -> u32 {
387    (mode >> shift) & 0o7
388}
389
390fn set_mode_part(mode: u32, shift: u32, bits: u32) -> u32 {
391    assert!(bits <= 0o7);
392    (mode & !(0o7 << shift)) | (bits << shift)
393}
394
395fn date_time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::YMDT> {
396    let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
397    prefs.hour_cycle = Some(if military_time {
398        HourCycle::H23
399    } else {
400        HourCycle::H12
401    });
402
403    let mut fs = fieldsets::YMDT::medium();
404    fs = fs.with_time_precision(TimePrecision::Minute);
405
406    DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
407}
408
409fn time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::T> {
410    let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
411    prefs.hour_cycle = Some(if military_time {
412        HourCycle::H23
413    } else {
414        HourCycle::H12
415    });
416
417    let mut fs = fieldsets::T::medium();
418    fs = fs.with_time_precision(TimePrecision::Minute);
419
420    DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
421}
422
423struct FormatTime<'a> {
424    pub time: SystemTime,
425    pub date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
426    pub time_formatter: &'a DateTimeFormatter<fieldsets::T>,
427}
428
429impl<'a> FormatTime<'a> {
430    fn from_secs(
431        secs: i64,
432        date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
433        time_formatter: &'a DateTimeFormatter<fieldsets::T>,
434    ) -> Option<Self> {
435        // This looks convoluted because we need to ensure the units match up
436        let secs: u64 = secs.try_into().ok()?;
437        let now = SystemTime::now();
438        let filetime_diff = now
439            .duration_since(SystemTime::UNIX_EPOCH)
440            .map(|from_epoch| from_epoch.as_secs())
441            .ok()
442            .and_then(|now_secs| now_secs.checked_sub(secs))
443            .map(Duration::from_secs)?;
444        now.checked_sub(filetime_diff).map(|time| Self {
445            time,
446            date_time_formatter,
447            time_formatter,
448        })
449    }
450}
451
452impl Display for FormatTime<'_> {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        let zoned = jiff::Zoned::try_from(self.time).unwrap();
455        let now = jiff::Zoned::now();
456        let icu_datetime = DateTime::convert_from(zoned.datetime());
457        if zoned.date() == now.date() {
458            f.write_str(fl!("today").as_str())?;
459            f.write_str(", ")?;
460            self.time_formatter.format(&icu_datetime).fmt(f)
461        } else {
462            self.date_time_formatter.format(&icu_datetime).fmt(f)
463        }
464    }
465}
466
467const fn format_time<'a>(
468    time: SystemTime,
469    date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
470    time_formatter: &'a DateTimeFormatter<fieldsets::T>,
471) -> FormatTime<'a> {
472    FormatTime {
473        time,
474        date_time_formatter,
475        time_formatter,
476    }
477}
478
479#[cfg(not(target_os = "windows"))]
480fn hidden_attribute(_metadata: &Metadata) -> bool {
481    false
482}
483
484#[cfg(target_os = "windows")]
485fn hidden_attribute(metadata: &Metadata) -> bool {
486    use std::os::windows::fs::MetadataExt;
487    // https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
488    const FILE_ATTRIBUTE_HIDDEN: u32 = 2;
489    metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
490}
491
492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
493pub enum FsKind {
494    Local,
495    Remote,
496    Gvfs,
497}
498
499#[cfg(target_os = "linux")]
500pub fn fs_kind(metadata: &Metadata) -> FsKind {
501    //TODO: method to reload remote filesystems dynamically
502    //TODO: fix for https://github.com/eminence/procfs/issues/262
503    static DEVICES: LazyLock<FxHashMap<u64, FsKind>> = LazyLock::new(|| {
504        let mut devices = FxHashMap::default();
505        match procfs::process::Process::myself() {
506            Ok(process) => match process.mountinfo() {
507                Ok(mount_infos) => {
508                    devices = FxHashMap::from_iter(mount_infos.iter().filter_map(|mount_info| {
509                        let mut parts = mount_info.majmin.split(':');
510                        let major_str = parts.next()?;
511                        let minor_str = parts.next()?;
512                        let major = major_str.parse::<libc::c_uint>().ok()?;
513                        let minor = minor_str.parse::<libc::c_uint>().ok()?;
514                        let dev = libc::makedev(major, minor);
515                        // Network and distributed filesystem types
516                        // Based on common remote filesystem types found in /proc/mounts
517                        let kind = match mount_info.fs_type.as_str() {
518                            // SMB/CIFS variants
519                            "cifs" | "smb" | "smb2" | "smbfs" => FsKind::Remote,
520
521                            // NFS variants
522                            "nfs" | "nfs4" => FsKind::Remote,
523
524                            // FUSE-based remote filesystems
525                            "fuse.rclone" | "fuse.sshfs" | "fuse.davfs2" | "fuse.ceph"
526                            | "fuse.glusterfs" | "fuse.s3fs" | "fuse.goofys" | "fuse.gcsfuse"
527                            | "fuse.afp" | "fuse.afpfs" => FsKind::Remote,
528
529                            // Other network protocols
530                            "afs" | "coda" | "ncpfs" | "davfs" | "davfs2" | "shfs" => {
531                                FsKind::Remote
532                            }
533
534                            // Cluster/distributed filesystems
535                            "ceph" | "glusterfs" | "lustre" | "gfs" | "gfs2" | "ocfs2" => {
536                                FsKind::Remote
537                            }
538
539                            // GVFS (GNOME Virtual File System)
540                            "fuse.gvfsd-fuse" => FsKind::Gvfs,
541
542                            // Everything else is local
543                            _ => FsKind::Local,
544                        };
545                        Some((dev, kind))
546                    }));
547                }
548                Err(err) => {
549                    log::warn!("failed to get mount info: {err}");
550                }
551            },
552            Err(err) => {
553                log::warn!("failed to get process info: {err}");
554            }
555        }
556        devices
557    });
558    DEVICES.get(&metadata.dev()).map_or(FsKind::Local, |x| *x)
559}
560
561#[cfg(not(target_os = "linux"))]
562pub fn fs_kind(_metadata: &Metadata) -> FsKind {
563    //TODO: support BSD, macOS, Windows?
564    FsKind::Local
565}
566
567#[cfg(not(feature = "desktop"))]
568fn get_desktop_file_display_name(_path: &Path) -> Option<String> {
569    None
570}
571
572#[cfg(feature = "desktop")]
573fn get_desktop_file_display_name(path: &Path) -> Option<String> {
574    let locales = get_languages_from_env();
575    let entry = match DesktopEntry::from_path(path, Some(&locales)) {
576        Ok(ok) => ok,
577        Err(err) => {
578            log::warn!("failed to parse {}: {}", path.display(), err);
579            return None;
580        }
581    };
582
583    entry.name(&locales).map(|s| s.into_owned())
584}
585
586#[cfg(not(windows))]
587#[cfg(not(feature = "desktop"))]
588fn get_desktop_file_icon(path: &Path) -> Option<String> {
589    None
590}
591
592#[cfg(feature = "desktop")]
593fn get_desktop_file_icon(path: &Path) -> Option<String> {
594    let entry = match DesktopEntry::from_path::<&str>(path, None) {
595        Ok(ok) => ok,
596        Err(err) => {
597            log::warn!("failed to parse {}: {}", path.display(), err);
598            return None;
599        }
600    };
601
602    entry.icon().map(str::to_string)
603}
604
605#[cfg(windows)]
606fn get_desktop_file_icon(_path: &Path) -> Option<String> {
607    // Windows does not support .desktop files
608    None
609}
610
611/// Creates an icon handle from a desktop file's Icon field value.
612/// Supports both icon names (looked up in theme) and absolute paths (used directly).
613fn desktop_icon_handle(icon: &str, size: u16) -> widget::icon::Handle {
614    let icon_path = Path::new(icon);
615    if icon_path.is_absolute() && icon_path.exists() {
616        widget::icon::from_path(icon_path.to_path_buf())
617    } else {
618        widget::icon::from_name(icon)
619            .prefer_svg(true)
620            .size(size)
621            .handle()
622    }
623}
624
625#[cfg(feature = "desktop")]
626pub fn parse_desktop_file(path: &Path) -> (Option<String>, Option<String>) {
627    let locales = get_languages_from_env();
628    let entry = match DesktopEntry::from_path(path, Some(&locales)) {
629        Ok(ok) => ok,
630        Err(err) => {
631            log::warn!("failed to parse {}: {}", path.display(), err);
632            return (None, None);
633        }
634    };
635    (
636        entry.name(&locales).map(|s| s.into_owned()),
637        entry.icon().map(str::to_string),
638    )
639}
640
641#[cfg(windows)]
642pub fn parse_desktop_file(path: &Path) -> (Option<String>, Option<String>) {
643    // Windows has no .desktop files, so fall back to filename
644    let name = path
645        .file_stem()
646        .and_then(|s| s.to_str())
647        .map(|s| s.to_string());
648
649    (name, None)
650}
651
652fn display_name_for_file(path: &Path, name: &str, get_from_gvfs: bool, is_desktop: bool) -> String {
653    if is_desktop {
654        return get_desktop_file_display_name(path).map_or_else(
655            || Item::display_name(name),
656            |desktop_name| Item::display_name(desktop_name.as_str()),
657        );
658    } else if get_from_gvfs {
659        #[cfg(feature = "gvfs")]
660        {
661            let file = gio::File::for_path(path);
662            if let Ok(info) = gio::prelude::FileExt::query_info(
663                &file,
664                "standard::display-name",
665                gio::FileQueryInfoFlags::NONE,
666                gio::Cancellable::NONE,
667            ) {
668                return Item::display_name(info.display_name().as_str());
669            }
670        }
671    }
672    Item::display_name(name)
673}
674
675#[cfg(feature = "gvfs")]
676pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
677    let file_name = file_info
678        .attribute_as_string(gio::FILE_ATTRIBUTE_STANDARD_NAME)
679        .unwrap_or_default();
680    let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
681    let mut is_desktop = false;
682    let remote = file_info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE);
683    let is_dir = matches!(file_info.file_type(), gio::FileType::Directory);
684
685    let size_opt = (!is_dir).then_some(file_info.size() as u64);
686
687    let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = if is_dir {
688        (
689            //TODO: make this a static
690            "inode/directory".parse().unwrap(),
691            folder_icon(&path, sizes.grid()),
692            folder_icon(&path, sizes.list()),
693            folder_icon(&path, sizes.list_condensed()),
694        )
695    } else {
696        // ALWAYS assume we're remote for mime guessing here, since gvfs reading can be expensive
697        // @todo - expose this as a config option?
698        let mime = mime_for_path(&path, None, true);
699
700        //TODO: clean this up, implement for trash
701        let icon_name_opt = if mime == "application/x-desktop" {
702            is_desktop = true;
703            get_desktop_file_icon(&path)
704        } else {
705            None
706        };
707        if let Some(icon_name) = icon_name_opt {
708            (
709                mime,
710                desktop_icon_handle(&icon_name, sizes.grid()),
711                desktop_icon_handle(&icon_name, sizes.list()),
712                desktop_icon_handle(&icon_name, sizes.list_condensed()),
713            )
714        } else {
715            (
716                mime.clone(),
717                mime_icon(mime.clone(), sizes.grid()),
718                mime_icon(mime.clone(), sizes.list()),
719                mime_icon(mime, sizes.list_condensed()),
720            )
721        }
722    };
723
724    let mut children_opt = None;
725    let mut dir_size = DirSize::NotDirectory;
726    if is_dir && !remote {
727        dir_size = DirSize::Calculating(Controller::default());
728        //TODO: calculate children in the background (and make it cancellable?)
729        match fs::read_dir(&path) {
730            Ok(entries) => {
731                children_opt = Some(entries.count());
732            }
733            Err(err) => {
734                log::warn!("failed to read directory {}: {}", path.display(), err);
735            }
736        }
737    }
738
739    let display_name = display_name_for_file(&path, &file_info.display_name(), false, is_desktop);
740    let hidden = file_name.starts_with('.');
741
742    Item {
743        name: file_name.into(),
744        display_name,
745        is_mount_point: false,
746        is_client_point: false,
747        metadata: ItemMetadata::GvfsPath {
748            mtime,
749            size_opt,
750            children_opt,
751        },
752        hidden,
753        image_dimensions: (!remote && mime.type_() == mime::IMAGE)
754            .then(|| image::image_dimensions(&path).ok())
755            .flatten(),
756        location_opt: Some(Location::Path(path)),
757        mime,
758        icon_handle_grid,
759        icon_handle_list,
760        icon_handle_list_condensed,
761        thumbnail_opt: if remote {
762            Some(ItemThumbnail::NotImage)
763        } else {
764            None
765        },
766        button_id: widget::Id::unique(),
767        pos_opt: Cell::new(None),
768        rect_opt: Cell::new(None),
769        selected: false,
770        highlighted: false,
771        overlaps_drag_rect: false,
772        dir_size,
773        cut: false,
774    }
775}
776
777pub fn item_from_search_item(search_item: SearchItem, sizes: IconSizes) -> Item {
778    match search_item {
779        SearchItem::Path(path, name, metadata) => item_from_entry(path, name, metadata, sizes),
780        SearchItem::Trash(entry, metadata) => item_from_trash_entry(entry, metadata, sizes),
781    }
782}
783
784pub fn item_from_entry(
785    path: PathBuf,
786    name: String,
787    metadata: fs::Metadata,
788    sizes: IconSizes,
789) -> Item {
790    let mut is_desktop = false;
791    let is_gvfs = false;
792
793    let hidden = name.starts_with('.') || hidden_attribute(&metadata);
794
795    let remote = match fs_kind(&metadata) {
796        FsKind::Local => false,
797        FsKind::Remote => true,
798        #[cfg(feature = "gvfs")]
799        FsKind::Gvfs => {
800            is_gvfs = true;
801            let file = gio::File::for_path(&path);
802
803            match gio::prelude::FileExt::query_filesystem_info(
804                &file,
805                gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
806                gio::Cancellable::NONE,
807            ) {
808                Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
809                Err(err) => {
810                    log::warn!(
811                        "failed to get GIO filesystem info for {}: {}",
812                        path.display(),
813                        err
814                    );
815                    true
816                }
817            }
818        }
819        #[cfg(not(feature = "gvfs"))]
820        FsKind::Gvfs => {
821            log::info!(
822                "gvfs feature not enabled, info may be inaccurate for {}",
823                path.display()
824            );
825            true
826        }
827    };
828
829    let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
830        if metadata.is_dir() {
831            (
832                //TODO: make this a static
833                "inode/directory".parse().unwrap(),
834                folder_icon(&path, sizes.grid()),
835                folder_icon(&path, sizes.list()),
836                folder_icon(&path, sizes.list_condensed()),
837            )
838        } else {
839            let mime = mime_for_path(&path, Some(&metadata), remote);
840            //TODO: clean this up, implement for trash
841            let icon_name_opt = if mime == "application/x-desktop" {
842                is_desktop = true;
843                get_desktop_file_icon(&path)
844            } else {
845                None
846            };
847            if let Some(icon_name) = icon_name_opt {
848                (
849                    mime,
850                    desktop_icon_handle(&icon_name, sizes.grid()),
851                    desktop_icon_handle(&icon_name, sizes.list()),
852                    desktop_icon_handle(&icon_name, sizes.list_condensed()),
853                )
854            } else {
855                (
856                    mime.clone(),
857                    mime_icon(mime.clone(), sizes.grid()),
858                    mime_icon(mime.clone(), sizes.list()),
859                    mime_icon(mime, sizes.list_condensed()),
860                )
861            }
862        };
863
864    let mut children_opt = None;
865    let mut dir_size = DirSize::NotDirectory;
866    if metadata.is_dir() && !remote {
867        dir_size = DirSize::Calculating(Controller::default());
868        //TODO: calculate children in the background (and make it cancellable?)
869        match fs::read_dir(&path) {
870            Ok(entries) => {
871                children_opt = Some(entries.count());
872            }
873            Err(err) => {
874                log::warn!("failed to read directory {}: {}", path.display(), err);
875            }
876        }
877    }
878
879    let tbprofilerjson_opt = if !remote && !metadata.is_dir() && mime == mime::APPLICATION_JSON {
880        fs::read_to_string(&path)
881            .ok()
882            .and_then(|s| serde_json::from_str(&s).ok())
883    } else {
884        None
885    };
886    let is_ab1 = path
887        .extension()
888        .map(|e| e.eq_ignore_ascii_case("ab1"))
889        .unwrap_or(false);
890    let sequence_opt = if is_ab1 && !remote {
891        fs::read(&path).ok().map(|bytes| {
892            let ab1_seq = parse_ab1_sequence(&bytes);
893            let ab1_qual = parse_ab1_quality(&bytes);
894            let (length, trimmed_length, trimmed_avg_quality_opt) =
895                if let Some(seq) = ab1_seq.as_ref() {
896                    let length = seq.len();
897                    let trimmed: &[u8] = match &ab1_qual {
898                        Some(qual) => match trim_to_min_quality(seq, qual, 20) {
899                            Some(trimmed) => {
900                                let removed = seq.len() - trimmed.len();
901                                if removed > 0 {
902                                    log::info!(
903                                        "{}: quality trimming removed {} of {} bases",
904                                        path.display(),
905                                        removed,
906                                        seq.len()
907                                    );
908                                } else {
909                                    log::info!(
910                                        "{}: quality trimming removed no bases, using full sequence",
911                                        path.display()
912                                    );
913                                }
914                                trimmed
915                            }
916                            None => {
917                                log::warn!(
918                                    "{}: quality trimming found no bases above threshold, using full sequence",
919                                    path.display()
920                                );
921                                seq.as_slice()
922                            }
923                        },
924                        None => seq.as_slice(),
925                    };
926                    let trimmed_length = trimmed.len();
927                    let trimmed_avg_quality_opt = ab1_qual
928                        .as_ref()
929                        .filter(|_| trimmed_length > 0)
930                        .map(|qual| {
931                            let start = trimmed.as_ptr() as usize - seq.as_ptr() as usize;
932                            let sum: u32 = qual[start..start + trimmed_length]
933                                .iter()
934                                .map(|&q| q as u32)
935                                .sum();
936                            sum as f32 / trimmed_length as f32
937                        });
938                    (length, trimmed_length, trimmed_avg_quality_opt)
939                } else {
940                    (0, 0, None)
941                };
942            // Look up pre-computed alignment results from the background scan cache.
943            // This avoids running Smith-Waterman on the UI thread.
944            let seq_id_hits = crate::sequencing::batch::AB1_SEQ_CACHE
945                .read()
946                .ok()
947                .and_then(|guard| guard.get(&path).cloned())
948                .unwrap_or_default();
949            let chromatogram_opt = Ab1Channels::from_bytes(&bytes);
950            SeqData {
951                chromatogram_opt,
952                seq_id_hits,
953                length,
954                trimmed_length,
955                trimmed_avg_quality_opt,
956            }
957        })
958    } else {
959        None
960    };
961
962    let is_susceptible = sequence_opt.as_ref().and_then(|s| {
963        let hit = s.seq_id_hits.first()?;
964        let erm41_result =
965            is_susceptible_erm41(hit.erm41_position_28_opt.as_ref(), &hit.erm41_snp_calls);
966        if erm41_result.is_some() {
967            return erm41_result;
968        }
969        let rrl_result =
970            is_susceptible_rrl(hit.rrl_position_2058_2059_opt.as_ref(), &hit.rrl_snp_calls);
971        if rrl_result.is_some() {
972            return rrl_result;
973        }
974        let rrs_result = is_susceptible_rrs(&hit.rrs_snp_calls);
975        if rrs_result.is_some() {
976            return rrs_result;
977        }
978        is_susceptible_pnca(&hit.pnca_snp_calls)
979    });
980
981    let susceptibility_calls = sequence_opt
982        .as_ref()
983        .and_then(|s| s.seq_id_hits.first())
984        .map(|hit| SusceptibilityCalls {
985            erm41: Erm41SusceptibilityCalls {
986                position_28: hit.erm41_position_28_opt,
987                lof_snp_calls: hit.erm41_snp_calls.clone(),
988                is_susceptible: is_susceptible_erm41(
989                    hit.erm41_position_28_opt.as_ref(),
990                    &hit.erm41_snp_calls,
991                ),
992            },
993            rrl: RrlSusceptibilityCalls {
994                position_2058_2059: hit.rrl_position_2058_2059_opt,
995                snp_calls: hit.rrl_snp_calls.clone(),
996                is_susceptible: is_susceptible_rrl(
997                    hit.rrl_position_2058_2059_opt.as_ref(),
998                    &hit.rrl_snp_calls,
999                ),
1000                is_susceptible_rare: is_susceptible_rrl_by_snp_calls_rare(
1001                    hit.rrl_position_2058_2059_opt.as_ref(),
1002                    &hit.rrl_snp_calls,
1003                ),
1004            },
1005            rrs: RrsSusceptibilityCalls {
1006                snp_calls: hit.rrs_snp_calls.clone(),
1007                is_susceptible: is_susceptible_rrs(&hit.rrs_snp_calls),
1008                is_susceptible_rare: is_susceptible_rrs_by_snp_calls_rare(&hit.rrs_snp_calls),
1009            },
1010            pnca: PncaSusceptibilityCalls {
1011                snp_calls: hit.pnca_snp_calls.clone(),
1012                is_susceptible: is_susceptible_pnca(&hit.pnca_snp_calls),
1013            },
1014        })
1015        .unwrap_or_default();
1016
1017    let display_name = display_name_for_file(&path, &name, is_gvfs, is_desktop);
1018
1019    Item {
1020        name,
1021        display_name,
1022        is_mount_point: false,
1023        is_client_point: false,
1024        metadata: ItemMetadata::Path {
1025            metadata,
1026            children_opt,
1027            tbprofilerjson_opt,
1028            is_ab1,
1029            sequence_opt,
1030            is_tbprofiler_result_as_sample: false,
1031            is_tbprofiler_groupable_raw_result_file: false,
1032            sample_json_path_opt: None,
1033            sample_csv_path_opt: None,
1034            sample_docx_path_opt: None,
1035            is_susceptible,
1036            susceptibility_calls,
1037        },
1038        hidden,
1039        location_opt: Some(Location::Path(path)),
1040        image_dimensions: None,
1041        mime,
1042        icon_handle_grid,
1043        icon_handle_list,
1044        icon_handle_list_condensed,
1045        thumbnail_opt: remote.then_some(ItemThumbnail::NotImage),
1046        button_id: widget::Id::unique(),
1047        pos_opt: Cell::new(None),
1048        rect_opt: Cell::new(None),
1049        selected: false,
1050        highlighted: false,
1051        overlaps_drag_rect: false,
1052        dir_size,
1053        cut: false,
1054    }
1055}
1056
1057pub fn item_from_trash_entry(
1058    entry: TrashItem,
1059    metadata: TrashItemMetadata,
1060    sizes: IconSizes,
1061) -> Item {
1062    let original_path = entry.original_path();
1063    let name = entry.name.to_string_lossy().into_owned();
1064    let display_name = Item::display_name(&name);
1065
1066    let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size
1067    {
1068        trash::TrashItemSize::Entries(_) => (
1069            //TODO: make this a static
1070            "inode/directory".parse().unwrap(),
1071            folder_icon(&original_path, sizes.grid()),
1072            folder_icon(&original_path, sizes.list()),
1073            folder_icon(&original_path, sizes.list_condensed()),
1074        ),
1075        trash::TrashItemSize::Bytes(_) => {
1076            // This passes remote = true so it does not read from the original path
1077            let mime = mime_for_path(&original_path, None, true);
1078            (
1079                mime.clone(),
1080                mime_icon(mime.clone(), sizes.grid()),
1081                mime_icon(mime.clone(), sizes.list()),
1082                mime_icon(mime, sizes.list_condensed()),
1083            )
1084        }
1085    };
1086
1087    Item {
1088        name,
1089        display_name,
1090        is_mount_point: false,
1091        is_client_point: false,
1092        metadata: ItemMetadata::Trash { metadata, entry },
1093        hidden: false,
1094        location_opt: None,
1095        image_dimensions: (mime.type_() == mime::IMAGE)
1096            .then(|| image::image_dimensions(&original_path).ok())
1097            .flatten(),
1098        mime,
1099        icon_handle_grid,
1100        icon_handle_list,
1101        icon_handle_list_condensed,
1102        thumbnail_opt: Some(ItemThumbnail::NotImage),
1103        button_id: widget::Id::unique(),
1104        pos_opt: Cell::new(None),
1105        rect_opt: Cell::new(None),
1106        selected: false,
1107        highlighted: false,
1108        overlaps_drag_rect: false,
1109        dir_size: DirSize::NotDirectory,
1110        cut: false,
1111    }
1112}
1113
1114fn get_filename_from_path(path: &Path) -> Result<String, String> {
1115    Ok(match path.file_name() {
1116        Some(name_os) => name_os
1117            .to_str()
1118            .ok_or_else(|| {
1119                format!(
1120                    "failed to parse file name for {}: {name_os:?} is not valid UTF-8",
1121                    path.display()
1122                )
1123            })?
1124            .to_string(),
1125        None => fl!("filesystem"),
1126    })
1127}
1128
1129pub fn item_from_path<P: Into<PathBuf>>(path: P, sizes: IconSizes) -> Result<Item, String> {
1130    let path = path.into();
1131    let name = get_filename_from_path(&path)?;
1132    let metadata = fs::metadata(&path)
1133        .map_err(|err| format!("failed to read metadata for {}: {}", path.display(), err))?;
1134    Ok(item_from_entry(path, name, metadata, sizes))
1135}
1136
1137pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
1138    let mut items = Vec::new();
1139    let mut hidden_files = Box::from([]);
1140    let remote_scannable = false;
1141
1142    #[cfg(feature = "gvfs")]
1143    {
1144        if let Ok(path_meta) = fs::metadata(tab_path)
1145            && fs_kind(&path_meta) == FsKind::Gvfs
1146        {
1147            let file = gio::File::for_path(tab_path);
1148
1149            // gio crate expects a comma delimited string
1150            let attr_string = [
1151                gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(),
1152                gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE.as_str(),
1153                gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(),
1154                gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(),
1155                gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
1156                gio::FILE_ATTRIBUTE_STANDARD_NAME.as_str(),
1157            ]
1158            .join(",");
1159
1160            match gio::prelude::FileExt::enumerate_children(
1161                &file,
1162                attr_string.as_str(),
1163                gio::FileQueryInfoFlags::NONE,
1164                gio::Cancellable::NONE,
1165            ) {
1166                Ok(res) => {
1167                    remote_scannable = true;
1168                    items = res
1169                        .filter_map(|file| {
1170                            let file = file.ok()?;
1171                            Some(item_from_gvfs_info(tab_path.join(file.name()), file, sizes))
1172                        })
1173                        .collect();
1174                }
1175                Err(err) => {
1176                    log::warn!(
1177                        "could not enumerate {} via gio: {}",
1178                        tab_path.display(),
1179                        err
1180                    );
1181                }
1182            }
1183        }
1184    }
1185
1186    if !remote_scannable {
1187        match fs::read_dir(tab_path) {
1188            Ok(entries) => {
1189                items = entries
1190                    .filter_map(|entry_res| {
1191                        let entry = entry_res
1192                            .inspect_err(|err| {
1193                                log::warn!(
1194                                    "failed to read entry in {}: {}",
1195                                    tab_path.display(),
1196                                    err
1197                                )
1198                            })
1199                            .ok()?;
1200
1201                        let path = entry.path();
1202
1203                        let name = entry
1204                            .file_name()
1205                            .into_string()
1206                            .inspect_err(|name_os| {
1207                                log::warn!(
1208                                    "failed to parse entry at {}: {:?} is not valid UTF-8",
1209                                    path.display(),
1210                                    name_os
1211                                )
1212                            })
1213                            .ok()?;
1214
1215                        if name == ".hidden" && path.is_file() {
1216                            hidden_files = parse_hidden_file(&path);
1217                        }
1218
1219                        let metadata = fs::metadata(&path)
1220                            .inspect_err(|err| {
1221                                log::warn!(
1222                                    "failed to read metadata for entry at {}: {}",
1223                                    path.display(),
1224                                    err
1225                                )
1226                            })
1227                            .ok()?;
1228
1229                        Some(item_from_entry(path, name, metadata, sizes))
1230                    })
1231                    .collect();
1232            }
1233            Err(err) => {
1234                log::warn!("failed to read directory {}: {}", tab_path.display(), err);
1235            }
1236        }
1237    }
1238    items.sort_unstable_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) {
1239        (true, false) => Ordering::Less,
1240        (false, true) => Ordering::Greater,
1241        _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name),
1242    });
1243    for item in &mut items {
1244        if hidden_files.contains(&item.name) {
1245            item.hidden = true;
1246        }
1247    }
1248
1249    // Two-pass grouping: collect .results.* files into virtual sample items,
1250    // mirroring the remote scan behaviour so ToggleShowAsSamples works locally.
1251    struct LocalSampleFiles {
1252        json: Option<PathBuf>,
1253        csv: Option<PathBuf>,
1254        docx: Option<PathBuf>,
1255    }
1256
1257    let mut samples: HashMap<String, LocalSampleFiles> = HashMap::new();
1258    for item in &mut items {
1259        let name = item.name.clone();
1260        let path = item.path_opt().cloned();
1261        if let ItemMetadata::Path {
1262            is_tbprofiler_groupable_raw_result_file,
1263            ..
1264        } = &mut item.metadata
1265            && let Some((sample_id, suffix)) = name.split_once(".results.")
1266        {
1267            let entry = samples
1268                .entry(sample_id.to_string())
1269                .or_insert(LocalSampleFiles {
1270                    json: None,
1271                    csv: None,
1272                    docx: None,
1273                });
1274            match suffix {
1275                "json" => entry.json = path,
1276                "csv" => entry.csv = path,
1277                "docx" => entry.docx = path,
1278                _ => {}
1279            }
1280            *is_tbprofiler_groupable_raw_result_file = true;
1281        }
1282    }
1283    for item in &mut items {
1284        let name = item.name.clone();
1285        if let ItemMetadata::Path {
1286            is_tbprofiler_groupable_raw_result_file,
1287            ..
1288        } = &mut item.metadata
1289            && *is_tbprofiler_groupable_raw_result_file
1290        {
1291            let sample_id = name.find('.').map(|i| &name[..i]);
1292            if sample_id.is_none_or(|id| samples.get(id).is_none_or(|f| f.json.is_none())) {
1293                *is_tbprofiler_groupable_raw_result_file = false;
1294            }
1295        }
1296    }
1297    // Propagate susceptibility to raw sample files so filtering works in file view too
1298    let sample_susceptibility: HashMap<String, bool> = samples
1299        .iter()
1300        .filter_map(|(id, files)| {
1301            let p = files.json.as_ref()?;
1302            let json: TbProfilerJson = fs::read_to_string(p)
1303                .ok()
1304                .and_then(|s| serde_json::from_str(&s).ok())?;
1305            Some((
1306                id.clone(),
1307                json.dr_variants.iter().all(|v| v.is_susceptible()),
1308            ))
1309        })
1310        .collect();
1311    for item in &mut items {
1312        let name = item.name.clone();
1313        if let ItemMetadata::Path {
1314            is_tbprofiler_groupable_raw_result_file,
1315            is_susceptible,
1316            ..
1317        } = &mut item.metadata
1318            && *is_tbprofiler_groupable_raw_result_file
1319            && let Some(id) = name.find('.').map(|i| &name[..i])
1320            && let Some(&sus) = sample_susceptibility.get(id)
1321        {
1322            *is_susceptible = Some(sus);
1323        }
1324    }
1325
1326    for (sample_id, files) in samples {
1327        if files.json.is_none() {
1328            continue;
1329        }
1330        let representative = files.json.as_deref().unwrap_or(tab_path);
1331        let Ok(fs_meta) = fs::metadata(representative) else {
1332            continue;
1333        };
1334        let tbprofilerjson_opt: Option<TbProfilerJson> = files.json.as_ref().and_then(|p| {
1335            fs::read_to_string(p)
1336                .ok()
1337                .and_then(|s| serde_json::from_str(&s).ok())
1338        });
1339        let is_susceptible = tbprofilerjson_opt
1340            .as_ref()
1341            .map(|j| j.dr_variants.iter().all(|v| v.is_susceptible()));
1342        let location = Location::Path(tab_path.join(&sample_id));
1343        let metadata = ItemMetadata::Path {
1344            metadata: fs_meta,
1345            children_opt: None,
1346            tbprofilerjson_opt,
1347            is_ab1: false,
1348            sequence_opt: None,
1349            is_tbprofiler_result_as_sample: true,
1350            is_tbprofiler_groupable_raw_result_file: false,
1351            sample_json_path_opt: files.json,
1352            sample_csv_path_opt: files.csv,
1353            sample_docx_path_opt: files.docx,
1354            is_susceptible,
1355            susceptibility_calls: SusceptibilityCalls::default(),
1356        };
1357        items.push(Item {
1358            name: sample_id.clone(),
1359            display_name: sample_id,
1360            metadata,
1361            hidden: false,
1362            location_opt: Some(location),
1363            image_dimensions: None,
1364            mime: mime::APPLICATION_JSON,
1365            icon_handle_grid: mime_icon(mime::APPLICATION_JSON, sizes.grid()),
1366            icon_handle_list: mime_icon(mime::APPLICATION_JSON, sizes.list()),
1367            icon_handle_list_condensed: mime_icon(mime::APPLICATION_JSON, sizes.list_condensed()),
1368            thumbnail_opt: Some(ItemThumbnail::NotImage),
1369            button_id: widget::Id::unique(),
1370            pos_opt: Cell::new(None),
1371            rect_opt: Cell::new(None),
1372            selected: false,
1373            highlighted: false,
1374            overlaps_drag_rect: false,
1375            dir_size: DirSize::NotDirectory,
1376            cut: false,
1377            is_mount_point: false,
1378            is_client_point: false,
1379        });
1380    }
1381
1382    items
1383}
1384
1385pub fn scan_search<F: Fn(SearchItem) -> bool + Sync>(
1386    search_location: &SearchLocation,
1387    term: &str,
1388    show_hidden: bool,
1389    callback: F,
1390) {
1391    if term.is_empty() {
1392        return;
1393    }
1394
1395    let pattern = regex::escape(term);
1396    let regex = match regex::RegexBuilder::new(&pattern)
1397        .case_insensitive(true)
1398        .build()
1399    {
1400        Ok(ok) => ok,
1401        Err(err) => {
1402            log::warn!("failed to parse regex {pattern:?}: {err}");
1403            return;
1404        }
1405    };
1406
1407    match search_location {
1408        SearchLocation::Path(tab_path) => {
1409            ignore::WalkBuilder::new(tab_path)
1410                .standard_filters(false)
1411                .hidden(!show_hidden)
1412                //TODO: only use this on supported targets
1413                .same_file_system(true)
1414                .build_parallel()
1415                .run(|| {
1416                    Box::new(|entry_res| {
1417                        let Ok(entry) = entry_res else {
1418                            // Skip invalid entries
1419                            return ignore::WalkState::Skip;
1420                        };
1421
1422                        let Some(file_name) = entry.file_name().to_str() else {
1423                            // Skip anything with an invalid name
1424                            return ignore::WalkState::Skip;
1425                        };
1426
1427                        if regex.is_match(file_name) {
1428                            let path = entry.path();
1429
1430                            let metadata = match entry.metadata() {
1431                                Ok(ok) => ok,
1432                                Err(err) => {
1433                                    log::warn!(
1434                                        "failed to read metadata for entry at {}: {}",
1435                                        path.display(),
1436                                        err
1437                                    );
1438                                    return ignore::WalkState::Continue;
1439                                }
1440                            };
1441
1442                            if !callback(SearchItem::Path(
1443                                path.to_path_buf(),
1444                                file_name.to_string(),
1445                                metadata,
1446                            )) {
1447                                return ignore::WalkState::Quit;
1448                            }
1449                        }
1450
1451                        ignore::WalkState::Continue
1452                    })
1453                });
1454        }
1455        SearchLocation::Recents => {
1456            let recent_files = match recently_used_xbel::parse_file() {
1457                Ok(recent_files) => recent_files,
1458                Err(err) => {
1459                    log::warn!("Error reading recent files: {err:?}");
1460                    return;
1461                }
1462            };
1463
1464            for bookmark in recent_files.bookmarks {
1465                let path = uri_to_path(bookmark.href);
1466                if let Some(path) = path
1467                    && path.exists()
1468                {
1469                    let file_name = path.file_name();
1470                    if let Some(file_name) = file_name {
1471                        let file_name = file_name.to_string_lossy();
1472                        if regex.is_match(&file_name) {
1473                            match path.metadata() {
1474                                Ok(metadata) => {
1475                                    if !callback(SearchItem::Path(
1476                                        path.to_path_buf(),
1477                                        file_name.to_string(),
1478                                        metadata,
1479                                    )) {
1480                                        break;
1481                                    }
1482                                }
1483                                Err(err) => {
1484                                    log::warn!(
1485                                        "failed to read metadata for entry at {}: {}",
1486                                        path.display(),
1487                                        err
1488                                    );
1489                                }
1490                            };
1491                        }
1492                    }
1493                }
1494            }
1495        }
1496        SearchLocation::Trash => {
1497            Trash::scan_search(callback, &regex);
1498        }
1499    }
1500}
1501
1502fn uri_to_path(uri: String) -> Option<PathBuf> {
1503    uri.parse::<url::Url>().ok().and_then(|url| {
1504        //TODO support for external drive or cloud?
1505        if url.scheme() == "file" {
1506            url.to_file_path().ok()
1507        } else {
1508            None
1509        }
1510    })
1511}
1512
1513pub fn has_recents() -> bool {
1514    match recently_used_xbel::parse_file() {
1515        Ok(recent_files) => !recent_files.bookmarks.is_empty(),
1516        Err(_) => false,
1517    }
1518}
1519
1520pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
1521    let recent_files = match recently_used_xbel::parse_file() {
1522        Ok(recent_files) => recent_files,
1523        Err(err) => {
1524            log::warn!("Error reading recent files: {err:?}");
1525            return Vec::new();
1526        }
1527    };
1528    let mut recents: Vec<_> = recent_files
1529        .bookmarks
1530        .into_iter()
1531        .filter_map(|bookmark| {
1532            let path = uri_to_path(bookmark.href)?;
1533            let last_edit = bookmark.modified.parse::<jiff::Timestamp>().ok()?;
1534            let last_visit = bookmark.visited.parse::<jiff::Timestamp>().ok()?;
1535
1536            if path.exists() {
1537                let file_name = path.file_name()?;
1538                let name = file_name.to_string_lossy().to_string();
1539
1540                let metadata = match path.metadata() {
1541                    Ok(ok) => ok,
1542                    Err(err) => {
1543                        log::warn!(
1544                            "failed to read metadata for entry at {}: {}",
1545                            path.display(),
1546                            err
1547                        );
1548                        return None;
1549                    }
1550                };
1551
1552                let item = item_from_entry(path, name, metadata, sizes);
1553                Some((item, last_edit.min(last_visit)))
1554            } else {
1555                log::warn!("recent file path does not exist: {}", path.display());
1556                None
1557            }
1558        })
1559        .collect();
1560
1561    recents.sort_by_key(|recent| Reverse(recent.1));
1562
1563    recents.into_iter().take(50).map(|(item, _)| item).collect()
1564}
1565
1566pub fn scan_network(uri: &str, sizes: IconSizes) -> Vec<Item> {
1567    for mounter in MOUNTERS.values() {
1568        match mounter.network_scan(uri, sizes) {
1569            Some(Ok(items)) => return items,
1570            Some(Err(err)) => {
1571                log::warn!("failed to scan {uri:?}: {err}");
1572            }
1573            None => {}
1574        }
1575    }
1576    Vec::new()
1577}
1578
1579pub fn scan_remote(uri: &str, sizes: IconSizes) -> Vec<Item> {
1580    for (_key, client) in CLIENTS.iter() {
1581        match client.remote_scan(uri, sizes) {
1582            Some(Ok(items)) => return items,
1583            Some(Err(err)) => {
1584                log::warn!("failed to scan remote {:?}: {}", uri, err);
1585            }
1586            None => {}
1587        }
1588    }
1589    Vec::new()
1590}
1591
1592pub fn item_from_remote(uri: &str, sizes: IconSizes) -> Result<Item, String> {
1593    for (_key, client) in CLIENTS.iter() {
1594        match client.remote_parent_item(uri, sizes) {
1595            Some(Ok(item)) => return Ok(item),
1596            Some(Err(err)) => {
1597                log::warn!("failed to scan remote {:?}: {}", uri, err);
1598            }
1599            None => {}
1600        }
1601    }
1602    Err(format!("no client found for remote uri: {}", uri))
1603}
1604
1605//TODO: organize desktop items based on display
1606pub fn scan_desktop(
1607    tab_path: &PathBuf,
1608    _display: &str,
1609    desktop_config: DesktopConfig,
1610    mut sizes: IconSizes,
1611) -> Vec<Item> {
1612    sizes.grid = desktop_config.icon_size;
1613
1614    let mut items = Vec::new();
1615
1616    if desktop_config.show_content {
1617        items.extend(scan_path(tab_path, sizes));
1618    }
1619
1620    if desktop_config.show_mounted_drives {
1621        for mounter in MOUNTERS.values() {
1622            let Some(mounter_items) = mounter.items(sizes) else {
1623                continue;
1624            };
1625            items.extend(mounter_items.into_iter().filter_map(|mounter_item| {
1626                let path = mounter_item.path()?;
1627                // Get most item data from path
1628                let mut item = match item_from_path(&path, sizes) {
1629                    Ok(item) => item,
1630                    Err(err) => {
1631                        log::warn!(
1632                            "failed to get item from mounter item {}: {}",
1633                            path.display(),
1634                            err
1635                        );
1636                        return None;
1637                    }
1638                };
1639
1640                //Override some data with mounter information
1641                item.name = mounter_item.name();
1642                item.display_name = Item::display_name(&item.name);
1643
1644                //TODO: use icon size for mounter item icon
1645                if let Some(icon) = mounter_item.icon(false) {
1646                    item.icon_handle_grid.clone_from(&icon);
1647                    item.icon_handle_list.clone_from(&icon);
1648                    item.icon_handle_list_condensed = icon;
1649                }
1650
1651                Some(item)
1652            }));
1653        }
1654    }
1655
1656    if desktop_config.show_connected_drives {
1657        for client in CLIENTS.values() {
1658            let Some(client_items) = client.items(sizes) else {
1659                continue;
1660            };
1661            items.extend(client_items.into_iter().filter_map(|client_item| {
1662                let path = client_item.path()?;
1663                // Get most item data from path
1664                let mut item = match item_from_path(&path, sizes) {
1665                    Ok(item) => item,
1666                    Err(err) => {
1667                        log::warn!(
1668                            "failed to get item from mounter item {}: {}",
1669                            path.display(),
1670                            err
1671                        );
1672                        return None;
1673                    }
1674                };
1675
1676                //Override some data with client information
1677                item.name = client_item.name();
1678                item.display_name = Item::display_name(&item.name);
1679
1680                //TODO: use icon size for client item icon
1681                if let Some(icon) = client_item.icon(false) {
1682                    item.icon_handle_grid.clone_from(&icon);
1683                    item.icon_handle_list.clone_from(&icon);
1684                    item.icon_handle_list_condensed = icon;
1685                }
1686
1687                Some(item)
1688            }));
1689        }
1690    }
1691
1692    if desktop_config.show_trash {
1693        let name = fl!("trash");
1694        let display_name = Item::display_name(&name);
1695
1696        let metadata = ItemMetadata::SimpleDir {
1697            entries: Trash::entries() as u64,
1698        };
1699
1700        let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
1701            (
1702                "inode/directory".parse().unwrap(),
1703                Trash::icon(sizes.grid()),
1704                Trash::icon(sizes.list()),
1705                Trash::icon(sizes.list_condensed()),
1706            )
1707        };
1708
1709        items.push(Item {
1710            name,
1711            display_name,
1712            is_mount_point: false,
1713            is_client_point: false,
1714            metadata,
1715            hidden: false,
1716            location_opt: Some(Location::Trash),
1717            image_dimensions: None,
1718            mime,
1719            icon_handle_grid,
1720            icon_handle_list,
1721            icon_handle_list_condensed,
1722            thumbnail_opt: Some(ItemThumbnail::NotImage),
1723            button_id: widget::Id::unique(),
1724            pos_opt: Cell::new(None),
1725            rect_opt: Cell::new(None),
1726            selected: false,
1727            highlighted: false,
1728            overlaps_drag_rect: false,
1729            dir_size: DirSize::NotDirectory,
1730            cut: false,
1731        });
1732    }
1733
1734    items
1735}
1736
1737#[derive(Clone, Debug)]
1738pub struct EditLocation {
1739    pub location: Location,
1740    pub completions: Option<Vec<(String, PathBuf)>>,
1741    pub selected: Option<usize>,
1742}
1743
1744impl EditLocation {
1745    pub fn resolve(&self) -> Option<Location> {
1746        if let Location::Network(uri, ..) = &self.location {
1747            MOUNTERS
1748                .values()
1749                .find_map(|mounter| mounter.dir_info(uri))
1750                .map(|(uri, display_name, path_opt)| Location::Network(uri, display_name, path_opt))
1751        } else if let Location::Remote(uri, ..) = &self.location {
1752            CLIENTS
1753                .values()
1754                .find_map(|client| client.dir_info(uri))
1755                .map(|(uri, display_name, path_opt)| Location::Remote(uri, display_name, path_opt))
1756        } else {
1757            let Some(selected) = self.selected else {
1758                return Some(self.location.clone());
1759            };
1760            let completions = self.completions.as_ref()?;
1761            let completion = completions.get(selected)?;
1762            Some(self.location.with_path(completion.1.clone()).normalize())
1763        }
1764    }
1765
1766    pub fn select(&mut self, forwards: bool) {
1767        if let Some(completions) = &self.completions {
1768            if completions.is_empty() {
1769                self.selected = None;
1770            } else {
1771                let mut selected = if forwards {
1772                    self.selected.and_then(|x| x.checked_add(1)).unwrap_or(0)
1773                } else {
1774                    self.selected
1775                        .and_then(|x| x.checked_sub(1))
1776                        .unwrap_or(completions.len() - 1)
1777                };
1778                if selected >= completions.len() {
1779                    selected = 0;
1780                }
1781                self.selected = Some(selected);
1782
1783                // Automatically resolve if there is only one completion
1784                if completions.len() == 1
1785                    && let Some(resolved) = self.resolve()
1786                {
1787                    self.location = resolved;
1788                    self.selected = None;
1789                }
1790            }
1791        } else {
1792            self.selected = None;
1793        }
1794    }
1795}
1796
1797#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1798pub enum SearchLocation {
1799    Path(PathBuf),
1800    Recents,
1801    Trash,
1802}
1803
1804impl std::fmt::Display for SearchLocation {
1805    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1806        match self {
1807            Self::Path(path) => write!(f, "{}", path.display()),
1808            Self::Recents => write!(f, "recents"),
1809            Self::Trash => write!(f, "trash"),
1810        }
1811    }
1812}
1813
1814#[derive(Clone, Debug)]
1815pub enum SearchItem {
1816    Path(PathBuf, String, fs::Metadata),
1817    Trash(TrashItem, TrashItemMetadata),
1818}
1819
1820impl From<Location> for EditLocation {
1821    fn from(location: Location) -> Self {
1822        Self {
1823            location,
1824            completions: None,
1825            selected: None,
1826        }
1827    }
1828}
1829
1830#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1831pub enum Location {
1832    Desktop(PathBuf, String, DesktopConfig),
1833    Network(String, String, Option<PathBuf>),
1834    Path(PathBuf),
1835    Recents,
1836    Remote(String, String, Option<PathBuf>),
1837    Search(SearchLocation, String, bool, Instant),
1838    Trash,
1839}
1840
1841impl std::fmt::Display for Location {
1842    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1843        match self {
1844            Self::Desktop(path, display, ..) => {
1845                write!(f, "{} on display {display}", path.display())
1846            }
1847            Self::Network(uri, ..) => write!(f, "{uri}"),
1848            Self::Path(path) => write!(f, "{}", path.display()),
1849            Self::Recents => write!(f, "recents"),
1850            Self::Remote(..) => write!(f, "remote"),
1851            Self::Search(location, term, ..) => {
1852                write!(f, "search {} for {}", location, term)
1853            }
1854            Self::Trash => write!(f, "trash"),
1855        }
1856    }
1857}
1858
1859impl Location {
1860    pub fn normalize(&self) -> Self {
1861        if let Location::Network(uri, ..) = self {
1862            if !uri.ends_with('/') {
1863                let mut uri = uri.clone();
1864                uri.push('/');
1865                self.with_uri(uri)
1866            } else {
1867                self.clone()
1868            }
1869        } else if let Location::Remote(uri, ..) = self {
1870            if !uri.ends_with('/') {
1871                let mut uri = uri.clone();
1872                uri.push('/');
1873                self.with_uri(uri)
1874            } else {
1875                self.clone()
1876            }
1877        } else if let Some(mut path) = self.path_opt().cloned() {
1878            // Canonicalize path, if possible
1879            if let Ok(canonical) = fs::canonicalize(&path) {
1880                path = canonical;
1881            }
1882            // Add trailing slash if location is a directory
1883            if path.is_dir() {
1884                path.push("");
1885            }
1886            self.with_path(path)
1887        } else {
1888            self.clone()
1889        }
1890    }
1891
1892    pub fn ancestors(&self) -> Vec<(Self, String)> {
1893        self.path_opt().map_or_else(Default::default, |path| {
1894            path.ancestors()
1895                .scan(false, |found_home, ancestor| {
1896                    (!*found_home).then(|| {
1897                        let (name, is_home) = folder_name(ancestor);
1898                        *found_home = is_home;
1899                        (self.with_path(ancestor.to_path_buf()), name)
1900                    })
1901                })
1902                .collect()
1903        })
1904    }
1905
1906    pub const fn path_opt(&self) -> Option<&PathBuf> {
1907        match self {
1908            Self::Desktop(path, ..) => Some(path),
1909            Self::Path(path) => Some(path),
1910            Self::Search(SearchLocation::Path(path), ..) => Some(path),
1911            Self::Network(_, _, path) => path.as_ref(),
1912            Self::Remote(_, _, path) => path.as_ref(),
1913            _ => None,
1914        }
1915    }
1916
1917    pub(crate) fn into_path_opt(self) -> Option<PathBuf> {
1918        match self {
1919            Self::Desktop(path, ..) => Some(path),
1920            Self::Path(path) => Some(path),
1921            Self::Search(SearchLocation::Path(path), ..) => Some(path),
1922            Self::Network(_, _, path) => path,
1923            Self::Remote(_, _, path) => path,
1924            _ => None,
1925        }
1926    }
1927
1928    pub(crate) fn into_uri_opt(self) -> Option<String> {
1929        match self {
1930            Self::Remote(uri, _, _) => Some(uri),
1931            _ => None,
1932        }
1933    }
1934
1935    pub fn with_path(&self, path: PathBuf) -> Self {
1936        let path = Self::expand_tilde(path);
1937        match self {
1938            Self::Desktop(_, display, desktop_config) => {
1939                Self::Desktop(path, display.clone(), *desktop_config)
1940            }
1941            Self::Path(..) => Self::Path(path),
1942            Self::Search(SearchLocation::Path(_), term, show_hidden, time) => Self::Search(
1943                SearchLocation::Path(path),
1944                term.clone(),
1945                *show_hidden,
1946                *time,
1947            ),
1948            Self::Remote(uri, ..) => {
1949                if let Ok(mut url) = url::Url::parse(uri)
1950                    && let Some(path_str) = path.to_str()
1951                {
1952                    url.set_path(path_str);
1953                    let name = path
1954                        .file_name()
1955                        .and_then(|n| n.to_str())
1956                        .unwrap_or("/")
1957                        .to_string();
1958                    return Self::Remote(url.to_string(), name, Some(path));
1959                }
1960                self.clone()
1961            }
1962
1963            other => other.clone(),
1964        }
1965    }
1966
1967    pub fn with_uri(&self, uri: String) -> Self {
1968        if let Self::Network(_, name, path) = self {
1969            Self::Network(uri, name.clone(), path.clone())
1970        } else if let Self::Remote(_, name, path) = self {
1971            Self::Remote(uri, name.clone(), path.clone())
1972        } else {
1973            self.clone()
1974        }
1975    }
1976
1977    pub fn scan(&self, sizes: IconSizes) -> (Option<Box<Item>>, Vec<Item>) {
1978        let items = match self {
1979            Self::Desktop(path, display, desktop_config) => {
1980                scan_desktop(path, display, *desktop_config, sizes)
1981            }
1982            Self::Path(path) => scan_path(path, sizes),
1983            Self::Search(..) => {
1984                // Search is done incrementally
1985                Vec::new()
1986            }
1987            Self::Trash => Trash::scan(sizes),
1988            Self::Recents => scan_recents(sizes),
1989            Self::Network(uri, _, _) => scan_network(uri, sizes),
1990            Self::Remote(uri, _, _) => scan_remote(uri, sizes),
1991        };
1992        let parent_item_opt = match self {
1993            Self::Remote(uri, _, path_opt) => {
1994                match path_opt {
1995                    Some(path) => match item_from_remote(uri, sizes) {
1996                        Ok(item) => Some(Box::new(item)),
1997                        Err(err) => {
1998                            log::warn!("failed to get item for {}: {}", path.display(), err);
1999                            None
2000                        }
2001                    },
2002                    // If no path, try to get item from remote
2003                    None => None,
2004                }
2005            }
2006            _ => {
2007                match self.path_opt() {
2008                    Some(path) => match item_from_path(path, sizes) {
2009                        Ok(item) => Some(Box::new(item)),
2010                        Err(err) => {
2011                            log::warn!("failed to get item for {}: {}", path.display(), err);
2012                            None
2013                        }
2014                    },
2015                    //TODO: support other locations?
2016                    None => None,
2017                }
2018            }
2019        };
2020        (parent_item_opt, items)
2021    }
2022
2023    pub fn title(&self) -> String {
2024        match self {
2025            Self::Desktop(path, _, _) => {
2026                let (name, _) = folder_name(path);
2027                name
2028            }
2029            Self::Path(path) => {
2030                let (name, _) = folder_name(path);
2031                name
2032            }
2033            Self::Search(location, term, ..) => {
2034                let name = match location {
2035                    SearchLocation::Path(path) => folder_name(path).0,
2036                    SearchLocation::Trash => fl!("trash"),
2037                    SearchLocation::Recents => fl!("recents"),
2038                };
2039
2040                //TODO: translate
2041                format!("Search \"{term}\": {name}")
2042            }
2043            Self::Trash => {
2044                fl!("trash")
2045            }
2046            Self::Recents => {
2047                fl!("recents")
2048            }
2049            Self::Network(display_name, ..) => display_name.clone(),
2050            Self::Remote(_, name, _) => name.clone(),
2051        }
2052    }
2053
2054    /// Expand a path that starts with "~" with the
2055    /// user's home directory
2056    pub fn expand_tilde(path: PathBuf) -> PathBuf {
2057        let mut components = path.components();
2058        match components.next() {
2059            Some(path::Component::Normal(os_str)) if os_str == "~" => {
2060                if let Some(home) = dirs::home_dir() {
2061                    home.join(components.as_path())
2062                } else {
2063                    path
2064                }
2065            }
2066            _ => path,
2067        }
2068    }
2069
2070    pub fn is_trash(&self) -> bool {
2071        matches!(
2072            self,
2073            Location::Trash | Location::Search(SearchLocation::Trash, ..)
2074        )
2075    }
2076
2077    pub fn is_recents(&self) -> bool {
2078        matches!(
2079            self,
2080            Location::Recents | Location::Search(SearchLocation::Recents, ..)
2081        )
2082    }
2083
2084    /// Returns true if this location supports paste operations (not Trash)
2085    pub fn supports_paste(&self) -> bool {
2086        matches!(
2087            self,
2088            Self::Desktop(..)
2089                | Self::Path(..)
2090                | Self::Search(..)
2091                | Self::Recents
2092                | Self::Network(_, _, Some(_))
2093        )
2094    }
2095}
2096
2097pub struct TaskWrapper(pub cosmic::Task<Message>);
2098
2099impl From<cosmic::Task<Message>> for TaskWrapper {
2100    fn from(task: cosmic::Task<Message>) -> Self {
2101        Self(task)
2102    }
2103}
2104
2105impl fmt::Debug for TaskWrapper {
2106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2107        f.debug_struct("TaskWrapper").finish()
2108    }
2109}
2110
2111#[derive(Debug)]
2112pub enum Command {
2113    Action(Action),
2114    AddNetworkDrive,
2115    AddRemoteDrive,
2116    DeleteTbProfilerResults(String, TBConfig),
2117    AddToSidebar(PathBuf),
2118    AutoScroll(Option<f32>),
2119    ChangeLocation(String, Location, Option<Vec<PathBuf>>),
2120    ContextMenu(Option<Point>, Option<window::Id>),
2121    Delete(Vec<PathBuf>),
2122    DownloadFile(Vec<PathBuf>, Vec<String>),
2123    DropFiles(PathBuf, ClipboardPaste),
2124    ClearRecents,
2125    EmptyTrash,
2126    #[cfg(feature = "desktop")]
2127    ExecEntryAction(cosmic::desktop::DesktopEntryData, usize),
2128    Iced(TaskWrapper),
2129    OpenFile(Vec<PathBuf>),
2130    OpenSeqAlignment(Box<SeqIdHit>),
2131    OpenSpeciesAlignment(Box<SeqIdHit>),
2132    OpenInNewTab(PathBuf),
2133    OpenUriInNewTab(String, String, Option<PathBuf>),
2134    OpenInNewWindow(PathBuf),
2135    OpenTrash,
2136    Preview(PreviewKind),
2137    RunContextAction(usize),
2138    SetOpenWith(Mime, String),
2139    SetPermissions(PathBuf, u32),
2140    SetMultiplePermissions(Vec<(PathBuf, u32)>),
2141    SetSort(String, HeadingOptions, bool),
2142    WindowDrag,
2143    WindowToggleMaximize,
2144}
2145
2146#[derive(Clone, Debug)]
2147pub enum Message {
2148    AddNetworkDrive,
2149    AddRemoteDrive,
2150    DeleteTbProfilerResults(String, TBConfig),
2151    AutoScroll(Option<f32>),
2152    Click(Option<usize>),
2153    DoubleClick(Option<usize>),
2154    ClickRelease(Option<usize>),
2155    Config(TabConfig),
2156    ContextAction(Action),
2157    ContextMenu(Option<Point>, Option<window::Id>),
2158    LocationContextMenuPoint(Option<Point>),
2159    LocationContextMenuIndex(Option<Point>, Option<usize>),
2160    LocationMenuAction(LocationMenuAction),
2161    Download(Option<(PathBuf, String)>),
2162    DownloadMany(Vec<PathBuf>, Vec<String>),
2163    Drag(Option<Rectangle>),
2164    DragEnd,
2165    EditLocation(Option<EditLocation>),
2166    EditLocationComplete(usize),
2167    EditLocationEnable,
2168    EditLocationSubmit,
2169    EditLocationTab,
2170    OpenInNewTab(PathBuf),
2171    ClearRecents,
2172    EmptyTrash,
2173    #[cfg(feature = "desktop")]
2174    ExecEntryAction(Option<PathBuf>, usize),
2175    Gallery(bool),
2176    GalleryPrevious,
2177    GalleryNext,
2178    GalleryToggle,
2179    GoNext,
2180    GoPrevious,
2181    ItemDown,
2182    ItemLeft,
2183    ItemPageDown,
2184    ItemPageUp,
2185    ItemRight,
2186    ItemUp,
2187    Location(Location),
2188    LocationUp,
2189    Open(Option<PathBuf>),
2190    Reload,
2191    RightClick(Option<Point>, Option<usize>),
2192    MiddleClick(usize),
2193    Resize(Rectangle),
2194    Scroll(Viewport),
2195    ScrollTab(f32),
2196    ScrollToFocused,
2197    SearchContext(Location, SearchContextWrapper),
2198    SearchReady(bool),
2199    SelectAll,
2200    SelectFirst,
2201    SelectLast,
2202    SetOpenWith(Mime, String),
2203    RunContextAction(usize),
2204    SetPermissions(PathBuf, u32),
2205    ShiftPermissions(Option<(PathBuf, u32)>, u32, u32),
2206    SetSort(HeadingOptions, bool),
2207    TabComplete(PathBuf, Vec<(String, PathBuf)>),
2208    Thumbnail(PathBuf, ItemThumbnail),
2209    ToggleSort(HeadingOptions),
2210    Drop(Option<(Location, ClipboardPaste)>),
2211    DndHover(Location),
2212    DndEnter(Location),
2213    DndLeave(Location),
2214    WindowDrag,
2215    WindowToggleMaximize,
2216    ZoomIn,
2217    ZoomOut,
2218    HighlightDeactivate(usize),
2219    HighlightActivate(usize),
2220    DirectorySize(PathBuf, DirSize),
2221    ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation
2222    OpenSeqAlignment(Box<SeqIdHit>),
2223    OpenSpeciesAlignment(Box<SeqIdHit>),
2224}
2225
2226#[derive(Copy, Clone, Debug, Eq, PartialEq)]
2227pub enum LocationMenuAction {
2228    OpenInNewTab(usize),
2229    OpenInNewWindow(usize),
2230    Preview(usize),
2231    AddToSidebar(usize),
2232}
2233
2234impl MenuAction for LocationMenuAction {
2235    type Message = Message;
2236
2237    fn message(&self) -> Self::Message {
2238        Message::LocationMenuAction(*self)
2239    }
2240}
2241
2242#[derive(Clone, Debug)]
2243pub enum DirSize {
2244    Calculating(Controller),
2245    Directory(u64),
2246    NotDirectory,
2247    Error(String),
2248}
2249
2250#[derive(Clone, Debug)]
2251#[allow(clippy::large_enum_variant)]
2252pub enum ItemMetadata {
2253    Path {
2254        metadata: Metadata,
2255        children_opt: Option<usize>,
2256        tbprofilerjson_opt: Option<TbProfilerJson>,
2257        is_ab1: bool,
2258        sequence_opt: Option<SeqData>,
2259        is_tbprofiler_result_as_sample: bool,
2260        is_tbprofiler_groupable_raw_result_file: bool,
2261        sample_json_path_opt: Option<PathBuf>,
2262        sample_csv_path_opt: Option<PathBuf>,
2263        sample_docx_path_opt: Option<PathBuf>,
2264        is_susceptible: Option<bool>,
2265        susceptibility_calls: SusceptibilityCalls,
2266    },
2267    Trash {
2268        metadata: trash::TrashItemMetadata,
2269        entry: trash::TrashItem,
2270    },
2271    SimpleDir {
2272        entries: u64,
2273    },
2274    SimpleFile {
2275        size: u64,
2276    },
2277    #[cfg(feature = "gvfs")]
2278    GvfsPath {
2279        mtime: u64,
2280        size_opt: Option<u64>,
2281        children_opt: Option<usize>,
2282    },
2283    #[cfg(feature = "russh")]
2284    RusshPath {
2285        mtime: u64,
2286        size_opt: Option<u64>,
2287        children_opt: Option<usize>,
2288        is_tbprofiler_json: bool,
2289        tbprofilerjson_opt: Option<TbProfilerJson>,
2290        is_tbprofiler_result_as_sample: bool,
2291        is_tbprofiler_groupable_raw_result_file: bool,
2292        sample_json_path_opt: Option<PathBuf>,
2293        sample_csv_path_opt: Option<PathBuf>,
2294        sample_docx_path_opt: Option<PathBuf>,
2295        is_susceptible: Option<bool>,
2296    },
2297}
2298
2299impl ItemMetadata {
2300    pub fn is_dir(&self) -> bool {
2301        match self {
2302            Self::Path { metadata, .. } => metadata.is_dir(),
2303            Self::Trash { metadata, .. } => match metadata.size {
2304                trash::TrashItemSize::Entries(_) => true,
2305                trash::TrashItemSize::Bytes(_) => false,
2306            },
2307            Self::SimpleDir { .. } => true,
2308            Self::SimpleFile { .. } => false,
2309            #[cfg(feature = "gvfs")]
2310            Self::GvfsPath { children_opt, .. } => children_opt.is_some(),
2311            #[cfg(feature = "russh")]
2312            Self::RusshPath { children_opt, .. } => children_opt.is_some(),
2313        }
2314    }
2315
2316    pub fn modified(&self) -> Option<SystemTime> {
2317        match self {
2318            Self::Path { metadata, .. } => metadata.modified().ok(),
2319            #[cfg(feature = "gvfs")]
2320            Self::GvfsPath { mtime, .. } => {
2321                Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
2322            }
2323            #[cfg(feature = "russh")]
2324            Self::RusshPath { mtime, .. } => {
2325                Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
2326            }
2327            _ => None,
2328        }
2329    }
2330
2331    pub fn file_size(&self) -> Option<u64> {
2332        match self {
2333            Self::Path { metadata, .. } => (!metadata.is_dir()).then_some(metadata.len()),
2334            Self::Trash { metadata, .. } => match metadata.size {
2335                TrashItemSize::Bytes(size) => Some(size),
2336                TrashItemSize::Entries(_) => None,
2337            },
2338            #[cfg(feature = "gvfs")]
2339            Self::GvfsPath { size_opt, .. } => *size_opt,
2340            #[cfg(feature = "russh")]
2341            Self::RusshPath { size_opt, .. } => *size_opt,
2342            _ => None,
2343        }
2344    }
2345
2346    pub fn children_count(&self) -> Option<&usize> {
2347        match &self {
2348            ItemMetadata::Path { children_opt, .. } => children_opt.as_ref(),
2349            #[cfg(feature = "gvfs")]
2350            ItemMetadata::GvfsPath { children_opt, .. } => children_opt.as_ref(),
2351            #[cfg(feature = "russh")]
2352            ItemMetadata::RusshPath { children_opt, .. } => children_opt.as_ref(),
2353            _ => None,
2354        }
2355    }
2356
2357    pub fn is_tbprofiler_json(&self) -> bool {
2358        match self {
2359            Self::Path {
2360                tbprofilerjson_opt, ..
2361            } => tbprofilerjson_opt.is_some(),
2362            #[cfg(feature = "russh")]
2363            Self::RusshPath {
2364                is_tbprofiler_json, ..
2365            } => *is_tbprofiler_json,
2366            _ => false,
2367        }
2368    }
2369
2370    pub fn is_ab1(&self) -> bool {
2371        match self {
2372            Self::Path { is_ab1, .. } => *is_ab1,
2373            _ => false,
2374        }
2375    }
2376
2377    pub fn erm41position28_call(&self) -> Erm41Position28 {
2378        match self {
2379            Self::Path { sequence_opt, .. } => sequence_opt
2380                .as_ref()
2381                .and_then(|s| s.seq_id_hits.first()?.erm41_position_28_opt)
2382                .unwrap_or(Erm41Position28::Undetermined),
2383            _ => Erm41Position28::Undetermined,
2384        }
2385    }
2386
2387    pub fn is_erm41position28(&self) -> bool {
2388        !matches!(self.erm41position28_call(), Erm41Position28::Undetermined)
2389    }
2390
2391    pub fn ab1_chromatogram(&self) -> Option<&Ab1Channels> {
2392        match self {
2393            Self::Path { sequence_opt, .. } => sequence_opt.as_ref()?.chromatogram_opt.as_ref(),
2394            _ => None,
2395        }
2396    }
2397
2398    pub fn rrl_position_2058_2059_call(&self) -> RrlPosition2058_2059 {
2399        match self {
2400            Self::Path { sequence_opt, .. } => sequence_opt
2401                .as_ref()
2402                .and_then(|s| s.seq_id_hits.first()?.rrl_position_2058_2059_opt)
2403                .unwrap_or(RrlPosition2058_2059::Undetermined),
2404            _ => RrlPosition2058_2059::Undetermined,
2405        }
2406    }
2407
2408    pub fn is_rrl_position_2058_2059(&self) -> bool {
2409        !matches!(
2410            self.rrl_position_2058_2059_call(),
2411            RrlPosition2058_2059::Undetermined
2412        )
2413    }
2414
2415    pub fn seq_id_hits(&self) -> &[SeqIdHit] {
2416        match self {
2417            Self::Path { sequence_opt, .. } => sequence_opt
2418                .as_ref()
2419                .map(|s| s.seq_id_hits.as_slice())
2420                .unwrap_or(&[]),
2421            _ => &[],
2422        }
2423    }
2424
2425    pub fn is_seq_id(&self) -> bool {
2426        self.seq_id_hits()
2427            .first()
2428            .is_some_and(|h| h.identity >= crate::sequencing::MIN_SEQ_ID_IDENTITY)
2429    }
2430
2431    pub fn sequence_length(&self) -> Option<usize> {
2432        match self {
2433            Self::Path { sequence_opt, .. } => sequence_opt.as_ref().map(|s| s.length),
2434            _ => None,
2435        }
2436    }
2437
2438    pub fn sequence_length_trimmed(&self) -> Option<usize> {
2439        match self {
2440            Self::Path { sequence_opt, .. } => sequence_opt.as_ref().map(|s| s.trimmed_length),
2441            _ => None,
2442        }
2443    }
2444
2445    pub fn sequence_avg_quality_trimmed(&self) -> Option<f32> {
2446        match self {
2447            Self::Path { sequence_opt, .. } => sequence_opt.as_ref()?.trimmed_avg_quality_opt,
2448            _ => None,
2449        }
2450    }
2451
2452    pub fn is_abscessus_seq_id(&self) -> bool {
2453        match self {
2454            Self::Path { sequence_opt, .. } => sequence_opt
2455                .as_ref()
2456                .and_then(|s| s.seq_id_hits.first())
2457                .map(|h| h.description == "Mycobacteroides abscessus subsp. abscessus")
2458                .unwrap_or(false),
2459            _ => false,
2460        }
2461    }
2462
2463    pub fn is_bolletii_seq_id(&self) -> bool {
2464        match self {
2465            Self::Path { sequence_opt, .. } => sequence_opt
2466                .as_ref()
2467                .and_then(|s| s.seq_id_hits.first())
2468                .map(|h| h.description == "Mycobacteroides abscessus subsp. bolletii")
2469                .unwrap_or(false),
2470            _ => false,
2471        }
2472    }
2473
2474    pub fn is_massiliense_seq_id(&self) -> bool {
2475        match self {
2476            Self::Path { sequence_opt, .. } => sequence_opt
2477                .as_ref()
2478                .and_then(|s| s.seq_id_hits.first())
2479                .map(|h| h.description == "Mycobacteroides abscessus subsp. massiliense")
2480                .unwrap_or(false),
2481            _ => false,
2482        }
2483    }
2484
2485    pub fn set_json(&mut self, json: Option<TbProfilerJson>) {
2486        match self {
2487            Self::Path {
2488                tbprofilerjson_opt, ..
2489            } => *tbprofilerjson_opt = json,
2490            #[cfg(feature = "russh")]
2491            Self::RusshPath {
2492                tbprofilerjson_opt, ..
2493            } => *tbprofilerjson_opt = json,
2494            _ => {}
2495        }
2496    }
2497
2498    pub fn is_tbprofilerjson_opt(&self) -> bool {
2499        match self {
2500            Self::Path {
2501                tbprofilerjson_opt, ..
2502            } => tbprofilerjson_opt.is_some(),
2503            #[cfg(feature = "russh")]
2504            Self::RusshPath {
2505                tbprofilerjson_opt, ..
2506            } => tbprofilerjson_opt.is_some(),
2507            _ => false,
2508        }
2509    }
2510
2511    pub fn is_tbprofiler_result_as_sample(&self) -> bool {
2512        match self {
2513            Self::Path {
2514                is_tbprofiler_result_as_sample,
2515                ..
2516            } => *is_tbprofiler_result_as_sample,
2517            #[cfg(feature = "russh")]
2518            Self::RusshPath {
2519                is_tbprofiler_result_as_sample,
2520                ..
2521            } => *is_tbprofiler_result_as_sample,
2522            _ => false,
2523        }
2524    }
2525
2526    pub fn is_groupable_as_sample_tbprofiler_result_item(&self) -> bool {
2527        match self {
2528            Self::Path {
2529                is_tbprofiler_groupable_raw_result_file,
2530                ..
2531            } => *is_tbprofiler_groupable_raw_result_file,
2532            #[cfg(feature = "russh")]
2533            Self::RusshPath {
2534                is_tbprofiler_groupable_raw_result_file,
2535                ..
2536            } => *is_tbprofiler_groupable_raw_result_file,
2537            _ => false,
2538        }
2539    }
2540
2541    pub fn json_path(&self) -> Option<&PathBuf> {
2542        match self {
2543            Self::Path {
2544                sample_json_path_opt,
2545                ..
2546            } => sample_json_path_opt.as_ref(),
2547            #[cfg(feature = "russh")]
2548            Self::RusshPath {
2549                sample_json_path_opt,
2550                ..
2551            } => sample_json_path_opt.as_ref(),
2552            _ => None,
2553        }
2554    }
2555
2556    pub fn csv_path(&self) -> Option<&PathBuf> {
2557        match self {
2558            Self::Path {
2559                sample_csv_path_opt,
2560                ..
2561            } => sample_csv_path_opt.as_ref(),
2562            #[cfg(feature = "russh")]
2563            Self::RusshPath {
2564                sample_csv_path_opt,
2565                ..
2566            } => sample_csv_path_opt.as_ref(),
2567            _ => None,
2568        }
2569    }
2570
2571    pub fn docx_path(&self) -> Option<&PathBuf> {
2572        match self {
2573            Self::Path {
2574                sample_docx_path_opt,
2575                ..
2576            } => sample_docx_path_opt.as_ref(),
2577            #[cfg(feature = "russh")]
2578            Self::RusshPath {
2579                sample_docx_path_opt,
2580                ..
2581            } => sample_docx_path_opt.as_ref(),
2582            _ => None,
2583        }
2584    }
2585
2586    pub fn is_susceptible(&self) -> Option<bool> {
2587        match self {
2588            Self::Path { is_susceptible, .. } => *is_susceptible,
2589            #[cfg(feature = "russh")]
2590            Self::RusshPath { is_susceptible, .. } => *is_susceptible,
2591            _ => None,
2592        }
2593    }
2594
2595    pub fn susceptibility_calls(&self) -> SusceptibilityCalls {
2596        match self {
2597            Self::Path {
2598                susceptibility_calls,
2599                ..
2600            } => susceptibility_calls.clone(),
2601            _ => SusceptibilityCalls::default(),
2602        }
2603    }
2604}
2605
2606#[derive(Debug)]
2607pub enum ItemThumbnail {
2608    NotImage,
2609    Image(widget::image::Handle, Option<(u32, u32)>),
2610    Svg(widget::svg::Handle),
2611    Text(widget::text_editor::Content),
2612}
2613
2614impl Clone for ItemThumbnail {
2615    fn clone(&self) -> Self {
2616        match self {
2617            Self::NotImage => Self::NotImage,
2618            Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt),
2619            Self::Svg(handle) => Self::Svg(handle.clone()),
2620            // Content cannot be cloned simply
2621            Self::Text(content) => {
2622                Self::Text(widget::text_editor::Content::with_text(&content.text()))
2623            }
2624        }
2625    }
2626}
2627
2628impl ItemThumbnail {
2629    pub fn new(
2630        path: &Path,
2631        metadata: ItemMetadata,
2632        mime: mime::Mime,
2633        mut thumbnail_size: u32,
2634        max_mem: u64,
2635        jobs: usize,
2636        max_size_mb: u64,
2637    ) -> Self {
2638        let thumbnail_cacher =
2639            ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size));
2640        match thumbnail_cacher.as_ref() {
2641            Ok(cache) => match cache.get_cached_thumbnail() {
2642                CachedThumbnail::Valid((thumbnail_path, size)) => {
2643                    // Check original image dimensions even when loading cached thumbnail
2644                    // This prevents trying to load huge images in preview mode
2645                    let original_dims = match image::image_dimensions(path) {
2646                        Ok((width, height)) => Some((width, height)),
2647                        Err(_) => size.map(|s| (s.pixel_size(), s.pixel_size())),
2648                    };
2649
2650                    return Self::Image(
2651                        widget::image::Handle::from_path(thumbnail_path),
2652                        original_dims,
2653                    );
2654                }
2655                CachedThumbnail::Failed => {
2656                    if mime.type_() != mime::IMAGE {
2657                        return Self::NotImage;
2658                    }
2659                }
2660                CachedThumbnail::RequiresUpdate(size) => {
2661                    thumbnail_size = size.pixel_size();
2662                }
2663            },
2664            Err(err) => {
2665                log::warn!(
2666                    "failed to create ThumbnailCache for {}: {}",
2667                    path.display(),
2668                    err
2669                );
2670            }
2671        }
2672
2673        let size = metadata.file_size().unwrap_or_default();
2674        let check_size = |thumbnailer: &str, max_size| {
2675            if size <= max_size {
2676                true
2677            } else {
2678                log::warn!(
2679                    "skipping internal {} thumbnailer for {}: file size {} is larger than {}",
2680                    thumbnailer,
2681                    path.display(),
2682                    format_size(size),
2683                    format_size(max_size)
2684                );
2685                false
2686            }
2687        };
2688
2689        let mut tried_supported_file = false;
2690        // First try built-in image thumbnailer
2691        if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) {
2692            // Check if image dimensions would exceed available memory budget
2693            // The GPU tiling system can handle large images, but we still need to decode them first
2694            let dimensions_ok = match image::image_dimensions(path) {
2695                Ok((width, height)) => {
2696                    if exceeds_memory_limit(width, height, max_mem) {
2697                        log::warn!(
2698                            "skipping thumbnail generation for {}: {}x{} image would exceed {}MB memory budget",
2699                            path.display(),
2700                            width,
2701                            height,
2702                            max_mem
2703                        );
2704                        false
2705                    } else {
2706                        if should_use_tiling(width, height) {
2707                            log::info!(
2708                                "Large image {}x{} detected, will use GPU tiling for display",
2709                                width,
2710                                height
2711                            );
2712                        }
2713                        true
2714                    }
2715                }
2716                Err(err) => {
2717                    log::debug!(
2718                        "failed to read dimensions for {}: {}, will try decoding",
2719                        path.display(),
2720                        err
2721                    );
2722                    true // If we can't read dimensions, try anyway
2723                }
2724            };
2725
2726            if !dimensions_ok {
2727                // Skip this image entirely since it is too large to safely decode
2728                return Self::NotImage;
2729            }
2730
2731            tried_supported_file = true;
2732            let dyn_img = match image::ImageReader::open(path)
2733                .and_then(image::ImageReader::with_guessed_format)
2734            {
2735                Ok(mut reader) => {
2736                    let mut limits = image::Limits::default();
2737                    let max_ram = max_mem * 1000 * 1000 / jobs as u64;
2738                    limits.max_alloc = Some(max_ram);
2739                    reader.limits(limits);
2740                    match reader.decode() {
2741                        Ok(reader) => Some(reader),
2742                        Err(err) => {
2743                            log::warn!("failed to decode {}: {}", path.display(), err);
2744                            None
2745                        }
2746                    }
2747                }
2748                Err(err) => {
2749                    log::warn!("failed to read {}: {}", path.display(), err);
2750                    None
2751                }
2752            };
2753
2754            if let Some(dyn_img) = dyn_img {
2755                let (img_width, img_height) = (dyn_img.width(), dyn_img.height());
2756
2757                if let Ok(cacher) = thumbnail_cacher.as_ref() {
2758                    match cacher.update_with_image(dyn_img) {
2759                        Ok(thumb_path) => {
2760                            return Self::Image(
2761                                widget::image::Handle::from_path(thumb_path),
2762                                Some((img_width, img_height)),
2763                            );
2764                        }
2765                        Err(err) => {
2766                            log::warn!("cacher failed to decode {}: {}", path.display(), err);
2767                        }
2768                    }
2769                } else {
2770                    // Fallback for when thumbnail cacher isn't available.
2771                    let thumbnail = dyn_img
2772                        .thumbnail(thumbnail_size, thumbnail_size)
2773                        .into_rgba8();
2774                    return Self::Image(
2775                        widget::image::Handle::from_rgba(
2776                            thumbnail.width(),
2777                            thumbnail.height(),
2778                            thumbnail.into_raw(),
2779                        ),
2780                        Some((img_width, img_height)),
2781                    );
2782                }
2783            }
2784        }
2785
2786        // Try external thumbnailers.
2787        let thumbnail_dir = thumbnail_cacher
2788            .as_ref()
2789            .ok()
2790            .map(ThumbnailCacher::thumbnail_dir);
2791        if let Some((item_thumbnail, temp_file)) =
2792            Self::generate_thumbnail_external(path, &mime, thumbnail_size, thumbnail_dir)
2793        {
2794            if let Ok(cache) = thumbnail_cacher
2795                && let Err(err) = cache.update_with_temp_file(temp_file)
2796            {
2797                log::warn!("failed to update cache for {}: {}", path.display(), err);
2798            }
2799            return item_thumbnail;
2800        }
2801
2802        tried_supported_file = tried_supported_file || !thumbnailer(&mime).is_empty();
2803
2804        // Try internal thumbnailers that don't get cached.
2805        //TODO: adjust limits for internal thumbnailers as desired
2806        if mime.type_() == mime::IMAGE
2807            && mime.subtype() == mime::SVG
2808            && check_size("svg", 8 * 1000 * 1000)
2809        {
2810            tried_supported_file = true;
2811            // Try built-in svg thumbnailer
2812            match fs::read(path) {
2813                Ok(data) => {
2814                    //TODO: validate SVG data
2815                    return Self::Svg(widget::svg::Handle::from_memory(data));
2816                }
2817                Err(err) => {
2818                    log::warn!("failed to read {}: {}", path.display(), err);
2819                }
2820            }
2821        } else if mime.type_() == mime::TEXT && check_size("text", TEXT_PREVIEW_MAX_FILE_BYTES) {
2822            tried_supported_file = true;
2823            if size > 0 {
2824                // Reuse size from metadata above; cap allocation and read
2825                let read_cap = (size.min(TEXT_PREVIEW_MAX_BYTES as u64)) as usize;
2826                let mut buf = vec![0u8; read_cap];
2827                match File::open(path).and_then(|f| {
2828                    let n = Read::read(&mut f.take(read_cap as u64), &mut buf)?;
2829                    buf.truncate(n);
2830                    Ok(())
2831                }) {
2832                    Ok(()) => {
2833                        let text = match std::str::from_utf8(&buf) {
2834                            Ok(s) => s.to_string(),
2835                            Err(e) => {
2836                                // Use only the valid UTF-8 prefix (slice is guaranteed valid by valid_up_to())
2837                                std::str::from_utf8(&buf[..e.valid_up_to()])
2838                                    .unwrap_or("")
2839                                    .to_string()
2840                            }
2841                        };
2842                        if !text.is_empty() {
2843                            return Self::Text(widget::text_editor::Content::with_text(&text));
2844                        }
2845                    }
2846                    Err(err) => {
2847                        log::warn!("failed to read {}: {}", path.display(), err);
2848                    }
2849                }
2850            }
2851            // size == 0: empty file or unknown size; skip read and allocation
2852        }
2853
2854        // If we weren't able to create a thumbnail, but we should have
2855        // been able to, create a fail marker so that it isn't tried the
2856        // next time.
2857        if let Ok(cacher) = thumbnail_cacher
2858            && tried_supported_file
2859            && let Err(err) = cacher.create_fail_marker()
2860        {
2861            log::warn!(
2862                "failed to create thumbnail fail marker for {}: {}",
2863                path.display(),
2864                err
2865            );
2866        }
2867
2868        Self::NotImage
2869    }
2870
2871    fn generate_thumbnail_external(
2872        path: &Path,
2873        mime: &mime::Mime,
2874        thumbnail_size: u32,
2875        thumbnail_dir: Option<&Path>,
2876    ) -> Option<(Self, NamedTempFile)> {
2877        // Try external thumbnailers
2878        for thumbnailer in thumbnailer(mime) {
2879            let is_evince = thumbnailer.exec.starts_with("evince-thumbnailer ");
2880            let prefix = if is_evince {
2881                //TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files*
2882                "gnome-desktop-"
2883            } else {
2884                "cosmic-files-"
2885            };
2886
2887            // It's preferable to create the tempfile in the same directory as the final cached
2888            // thumbnail to ensure that no copies across filesytems need to be made. However,
2889            // the apparmor config for evince-thumbnailer does not allow this, so we need to
2890            // fallback to the system tempdir.
2891            let dir = if is_evince { None } else { thumbnail_dir };
2892            let file = match dir {
2893                Some(d) => tempfile::Builder::new().prefix(prefix).tempfile_in(d),
2894                None => tempfile::Builder::new().prefix(prefix).tempfile(),
2895            };
2896            let file = match file {
2897                Ok(ok) => ok,
2898                Err(err) => {
2899                    log::warn!(
2900                        "failed to create temporary file for thumbnail of {}: {}",
2901                        path.display(),
2902                        err
2903                    );
2904                    continue;
2905                }
2906            };
2907
2908            let Some(mut command) = thumbnailer.command(path, file.path(), thumbnail_size) else {
2909                continue;
2910            };
2911            match command.status() {
2912                Ok(status) => {
2913                    if status.success() {
2914                        match image::ImageReader::open(file.path())
2915                            .and_then(ImageReader::with_guessed_format)
2916                        {
2917                            Ok(reader) => match reader.decode().map(DynamicImage::into_rgba8) {
2918                                Ok(image) => {
2919                                    return Some((
2920                                        Self::Image(
2921                                            widget::image::Handle::from_rgba(
2922                                                image.width(),
2923                                                image.height(),
2924                                                image.into_raw(),
2925                                            ),
2926                                            None,
2927                                        ),
2928                                        file,
2929                                    ));
2930                                }
2931                                Err(err) => {
2932                                    log::warn!("failed to decode {}: {}", path.display(), err);
2933                                }
2934                            },
2935                            Err(err) => {
2936                                log::warn!("failed to read {}: {}", path.display(), err);
2937                            }
2938                        }
2939                    } else {
2940                        log::warn!(
2941                            "failed to run {:?} for {}: {}",
2942                            thumbnailer,
2943                            path.display(),
2944                            status
2945                        );
2946                    }
2947                }
2948                Err(err) => {
2949                    log::warn!(
2950                        "failed to run {thumbnailer:?} for {}: {}",
2951                        path.display(),
2952                        err
2953                    );
2954                }
2955            }
2956        }
2957
2958        None
2959    }
2960}
2961
2962#[derive(Clone, Debug)]
2963pub struct Item {
2964    pub name: String,
2965    pub is_mount_point: bool,
2966    pub is_client_point: bool,
2967    pub display_name: String,
2968    pub metadata: ItemMetadata,
2969    pub hidden: bool,
2970    pub location_opt: Option<Location>,
2971    pub mime: Mime,
2972    pub image_dimensions: Option<(u32, u32)>,
2973    pub icon_handle_grid: widget::icon::Handle,
2974    pub icon_handle_list: widget::icon::Handle,
2975    pub icon_handle_list_condensed: widget::icon::Handle,
2976    pub thumbnail_opt: Option<ItemThumbnail>,
2977    pub button_id: widget::Id,
2978    pub pos_opt: Cell<Option<(usize, usize)>>,
2979    pub rect_opt: Cell<Option<Rectangle>>,
2980    pub selected: bool,
2981    pub highlighted: bool,
2982    pub cut: bool,
2983    pub overlaps_drag_rect: bool,
2984    pub dir_size: DirSize,
2985}
2986
2987impl Item {
2988    fn display_name(name: &str) -> String {
2989        // In order to wrap at periods and underscores, add a zero width space after each one
2990        name.replace('.', ".\u{200B}").replace('_', "_\u{200B}")
2991    }
2992
2993    /// Text widget for a filename in grid/icon view: word-or-glyph wrapping, middle-ellipsized to 3 lines.
2994    fn grid_display_name<'a>(
2995        name: impl Into<Cow<'a, str>> + 'a,
2996    ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
2997        widget::text::body(name)
2998            .wrapping(text::Wrapping::WordOrGlyph)
2999            .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3000                3,
3001            )))
3002    }
3003
3004    /// Text widget for a filename in list view: word-or-glyph wrapping, middle-ellipsized to 1 line.
3005    fn list_display_name<'a>(
3006        name: impl Into<Cow<'a, str>> + 'a,
3007    ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
3008        widget::text::body(name)
3009            .wrapping(text::Wrapping::WordOrGlyph)
3010            .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3011                1,
3012            )))
3013    }
3014
3015    pub fn path_opt(&self) -> Option<&PathBuf> {
3016        self.location_opt.as_ref()?.path_opt()
3017    }
3018
3019    /// Returns seq_id_hits for preview, falling back to `AB1_SEQ_CACHE` when the item was
3020    /// created before the background scan ran (empty stored hits).
3021    pub fn seq_id_hits_cached(&self) -> Vec<SeqIdHit> {
3022        let stored = self.metadata.seq_id_hits();
3023        if !stored.is_empty() {
3024            log::debug!("seq_id_hits_cached({}): {} stored hits", self.name, stored.len());
3025            return stored.to_vec();
3026        }
3027        let path = self.path_opt();
3028        let cache_size = crate::sequencing::batch::AB1_SEQ_CACHE.read().map(|g| g.len()).unwrap_or(0);
3029        let result = path
3030            .and_then(|p| {
3031                crate::sequencing::batch::AB1_SEQ_CACHE
3032                    .read()
3033                    .ok()
3034                    .and_then(|guard| guard.get(p).cloned())
3035            })
3036            .unwrap_or_default();
3037        log::debug!("seq_id_hits_cached({}): path={:?}, cache_size={}, found {} hits", self.name, path, cache_size, result.len());
3038        result
3039    }
3040
3041    fn is_fasta(&self) -> bool {
3042        let lower = self.name.to_ascii_lowercase();
3043        lower.ends_with(".fasta") || lower.ends_with(".fa")
3044    }
3045
3046    pub fn is_erm41(&self) -> bool {
3047        let lower = self.name.to_ascii_lowercase();
3048        !self.is_fasta() && (lower.contains("erm41") || lower.contains("erm"))
3049    }
3050
3051    pub fn is_hsp65(&self) -> bool {
3052        let lower = self.name.to_ascii_lowercase();
3053        !self.is_fasta() && (lower.contains("hsp65") || lower.contains("65kda"))
3054    }
3055
3056    pub fn is_rpob(&self) -> bool {
3057        let lower = self.name.to_ascii_lowercase();
3058        !self.is_fasta()
3059            && (lower.contains("rpob") || lower.contains("2573f") || lower.contains("3337r"))
3060    }
3061
3062    pub fn is_16s(&self) -> bool {
3063        let lower = self.name.to_ascii_lowercase();
3064        !self.is_fasta() && lower.contains("mbak14")
3065    }
3066
3067    pub fn is_rrl_ntm(&self) -> bool {
3068        let lower = self.name.to_ascii_lowercase();
3069        !self.is_fasta() && (lower.contains("mclr") || lower.contains("rrl"))
3070    }
3071
3072    pub fn is_pnca(&self) -> bool {
3073        let lower = self.name.to_ascii_lowercase();
3074        !self.is_fasta() && lower.contains("pnca")
3075    }
3076
3077    pub fn can_gallery(&self) -> bool {
3078        self.mime.type_() == mime::IMAGE || self.mime.type_() == mime::TEXT
3079    }
3080
3081    pub fn file_metadata(&self) -> Option<Metadata> {
3082        match &self.metadata {
3083            ItemMetadata::Path { metadata, .. } => Some(metadata.clone()),
3084            #[cfg(feature = "gvfs")]
3085            ItemMetadata::GvfsPath { .. } => self.path_opt().and_then(|p| fs::metadata(p).ok()),
3086            #[cfg(feature = "russh")]
3087            ItemMetadata::RusshPath { .. } => None,
3088            _ => {
3089                //TODO: other metadata types
3090                None
3091            }
3092        }
3093    }
3094
3095    fn preview(&self) -> Element<'_, Message> {
3096        let spacing = cosmic::theme::spacing();
3097        // This loads the image only if thumbnailing worked
3098        let icon = widget::icon::icon(self.icon_handle_grid.clone())
3099            .content_fit(ContentFit::Contain)
3100            .size(IconSizes::default().grid())
3101            .into();
3102        match self
3103            .thumbnail_opt
3104            .as_ref()
3105            .unwrap_or(&ItemThumbnail::NotImage)
3106        {
3107            ItemThumbnail::NotImage => icon,
3108            ItemThumbnail::Image(handle, _original_dims) => {
3109                // Preview pane: ALWAYS show thumbnail for instant, responsive UI
3110                // Full resolution loading happens in gallery mode
3111                widget::image(handle.clone()).into()
3112            }
3113            ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(),
3114            ItemThumbnail::Text(content) => widget::text_editor(content)
3115                .class(cosmic::theme::iced::TextEditor::Custom(Box::new(
3116                    text_editor_class,
3117                )))
3118                .width(THUMBNAIL_SIZE as f32)
3119                .height(Length::Fixed(THUMBNAIL_SIZE as f32))
3120                .padding(spacing.space_xxs)
3121                .into(),
3122        }
3123    }
3124
3125    pub fn preview_actions(&self) -> Element<'_, Message> {
3126        let mut row = widget::row::with_capacity(3)
3127            .align_y(Alignment::Center)
3128            .spacing(theme::spacing().space_xxs)
3129            .push(
3130                widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
3131                    .on_press(Message::ItemLeft),
3132            )
3133            .push(
3134                widget::button::icon(widget::icon::from_name("go-next-symbolic"))
3135                    .on_press(Message::ItemRight),
3136            );
3137        if self.can_gallery()
3138            && let Some(_path) = self.path_opt()
3139        {
3140            row = row.push(
3141                widget::button::icon(widget::icon::from_name("view-fullscreen-symbolic"))
3142                    .on_press(Message::Gallery(true)),
3143            );
3144        }
3145        row.into()
3146    }
3147
3148    pub fn preview_view<'a>(
3149        &'a self,
3150        mime_app_cache_opt: Option<&'a mime_app::MimeAppCache>,
3151        military_time: bool,
3152    ) -> Element<'a, Message> {
3153        let cosmic_theme::Spacing {
3154            space_xxxs,
3155            space_m,
3156            ..
3157        } = theme::spacing();
3158
3159        let mut column = widget::column::with_capacity(4).spacing(space_m);
3160
3161        column = column.push(
3162            widget::container(self.preview())
3163                .center_x(Length::Fill)
3164                .max_height(THUMBNAIL_SIZE as f32),
3165        );
3166
3167        let mut details = widget::column::with_capacity(8).spacing(space_xxxs);
3168        details = details.push(widget::text::heading(self.name.clone()));
3169        details = details.push(widget::text::body(fl!(
3170            "type",
3171            mime = self.mime.to_string()
3172        )));
3173        let mut settings = Vec::new();
3174        if let Some(mime_app_cache) = mime_app_cache_opt {
3175            let mime_apps = mime_app_cache.get_apps_for_mime(&self.mime, false);
3176            if !mime_apps.is_empty() {
3177                let (names, icons) = mime_apps
3178                    .iter()
3179                    .map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
3180                    .collect::<(Vec<_>, Vec<_>)>();
3181                settings.push(
3182                    widget::settings::item::builder(fl!("open-with")).control(
3183                        Element::from(
3184                            widget::dropdown(
3185                                names,
3186                                mime_apps.iter().position(|(x, _)| x.is_default(&self.mime)),
3187                                move |index| index,
3188                            )
3189                            .icons(Cow::Owned(icons)),
3190                        )
3191                        .map(move |index| {
3192                            let mime_app = &mime_apps[index].0;
3193                            Message::SetOpenWith(self.mime.clone(), mime_app.id.clone())
3194                        }),
3195                    ),
3196                );
3197            }
3198        }
3199
3200        if let Some(metadata) = self.file_metadata() {
3201            if metadata.is_dir() {
3202                if let Some(children) = self.metadata.children_count() {
3203                    details = details.push(widget::text::body(fl!("items", items = children)));
3204                }
3205                let size = match &self.dir_size {
3206                    DirSize::Calculating(_) => fl!("calculating"),
3207                    DirSize::Directory(size) => format_size(*size),
3208                    DirSize::NotDirectory => String::new(),
3209                    DirSize::Error(err) => err.clone(),
3210                };
3211                if !size.is_empty() {
3212                    details = details.push(widget::text::body(fl!("item-size", size = size)));
3213                }
3214            } else {
3215                details = details.push(widget::text::body(fl!(
3216                    "item-size",
3217                    size = format_size(metadata.len())
3218                )));
3219            }
3220
3221            let date_time_formatter = date_time_formatter(military_time);
3222            let time_formatter = time_formatter(military_time);
3223
3224            if let Ok(time) = metadata.created() {
3225                details = details.push(widget::text::body(fl!(
3226                    "item-created",
3227                    created = format_time(time, &date_time_formatter, &time_formatter).to_string()
3228                )));
3229            }
3230
3231            if let Ok(time) = metadata.modified() {
3232                details = details.push(widget::text::body(fl!(
3233                    "item-modified",
3234                    modified = format_time(time, &date_time_formatter, &time_formatter).to_string()
3235                )));
3236            }
3237
3238            if let Ok(time) = metadata.accessed() {
3239                details = details.push(widget::text::body(fl!(
3240                    "item-accessed",
3241                    accessed = format_time(time, &date_time_formatter, &time_formatter).to_string()
3242                )));
3243            }
3244
3245            #[cfg(unix)]
3246            if let Some(path) = self.path_opt() {
3247                use std::os::unix::fs::MetadataExt;
3248
3249                let mode = metadata.mode();
3250
3251                let user_name = uzers::get_user_by_uid(metadata.uid())
3252                    .and_then(|user| user.name().to_str().map(ToOwned::to_owned))
3253                    .unwrap_or_default();
3254                let user_path = path.clone();
3255                settings.push(
3256                    widget::settings::item::builder(user_name)
3257                        .description(fl!("owner"))
3258                        .control(widget::dropdown(
3259                            Cow::Borrowed(MODE_NAMES.as_slice()),
3260                            Some(get_mode_part(mode, MODE_SHIFT_USER).try_into().unwrap()),
3261                            move |selected| {
3262                                Message::SetPermissions(
3263                                    user_path.clone(),
3264                                    set_mode_part(
3265                                        mode,
3266                                        MODE_SHIFT_USER,
3267                                        selected.try_into().unwrap(),
3268                                    ),
3269                                )
3270                            },
3271                        )),
3272                );
3273
3274                let group_name = uzers::get_group_by_gid(metadata.gid())
3275                    .and_then(|group| group.name().to_str().map(ToOwned::to_owned))
3276                    .unwrap_or_default();
3277                let group_path = path.clone();
3278                settings.push(
3279                    widget::settings::item::builder(group_name)
3280                        .description(fl!("group"))
3281                        .control(widget::dropdown(
3282                            Cow::Borrowed(MODE_NAMES.as_slice()),
3283                            Some(get_mode_part(mode, MODE_SHIFT_GROUP).try_into().unwrap()),
3284                            move |selected| {
3285                                Message::SetPermissions(
3286                                    group_path.clone(),
3287                                    set_mode_part(
3288                                        mode,
3289                                        MODE_SHIFT_GROUP,
3290                                        selected.try_into().unwrap(),
3291                                    ),
3292                                )
3293                            },
3294                        )),
3295                );
3296
3297                let other_path = path.clone();
3298                settings.push(widget::settings::item::builder(fl!("other")).control(
3299                    widget::dropdown(
3300                        Cow::Borrowed(MODE_NAMES.as_slice()),
3301                        Some(get_mode_part(mode, MODE_SHIFT_OTHER).try_into().unwrap()),
3302                        move |selected| {
3303                            Message::SetPermissions(
3304                                other_path.clone(),
3305                                set_mode_part(mode, MODE_SHIFT_OTHER, selected.try_into().unwrap()),
3306                            )
3307                        },
3308                    ),
3309                ));
3310            }
3311        } else {
3312            match self.metadata {
3313                #[cfg(feature = "russh")]
3314                ItemMetadata::RusshPath { .. } => {
3315                    if self.metadata.is_dir() {
3316                        if let Some(children) = self.metadata.children_count() {
3317                            details =
3318                                details.push(widget::text::body(fl!("items", items = children)));
3319                        }
3320                    } else if let Some(size) = self.metadata.file_size() {
3321                        details = details.push(widget::text::body(fl!(
3322                            "item-size",
3323                            size = format_size(size)
3324                        )));
3325                    }
3326                    let date_time_formatter = date_time_formatter(military_time);
3327                    let time_formatter = time_formatter(military_time);
3328                    if let Some(time) = self.metadata.modified() {
3329                        details = details.push(widget::text::body(fl!(
3330                            "item-modified",
3331                            modified = format_time(time, &date_time_formatter, &time_formatter)
3332                                .to_string()
3333                        )));
3334                    }
3335                }
3336                _ => (),
3337            }
3338        }
3339
3340        match self.metadata {
3341            #[cfg(feature = "russh")]
3342            ItemMetadata::RusshPath { .. } => {
3343                column = column.push(details);
3344
3345                if self.metadata.is_dir() {
3346                    if let Some(_path) = self.path_opt()
3347                        && self.selected
3348                    {
3349                        column = column.push(
3350                            widget::button::standard(fl!("open")).on_press(Message::Open(None)),
3351                        );
3352                    }
3353                } else if let Some(Location::Remote(uri, _user, path_opt)) =
3354                    self.location_opt.clone()
3355                    && self.selected
3356                    && path_opt.is_some()
3357                {
3358                    column = column.push(
3359                        widget::button::standard(fl!("download"))
3360                            .on_press(Message::Download(Some((path_opt.unwrap(), uri.clone())))),
3361                    );
3362                }
3363            }
3364            _ => {
3365                if let Some(path) = self.path_opt()
3366                    && let Ok(img) = image::image_dimensions(path)
3367                {
3368                    let (width, height) = img;
3369                    details = details.push(widget::text::body(format!("{width}x{height}")));
3370                }
3371                column = column.push(details);
3372
3373                if let Some(path) = self.path_opt()
3374                    && self.selected
3375                {
3376                    column = column.push(
3377                        widget::button::standard(fl!("open"))
3378                            .on_press(Message::Open(Some(path.clone()))),
3379                    );
3380                }
3381            }
3382        }
3383
3384        if !settings.is_empty() {
3385            let mut section = widget::settings::section();
3386            section = section.extend(settings);
3387            column = column.push(section);
3388        }
3389
3390        column.into()
3391    }
3392
3393    pub fn preview_tbprofiler_json(&self) -> Element<'_, Message> {
3394        fn tb_variant_widget(v: &DrVariant) -> Element<'static, Message> {
3395            widget::container(
3396                widget::column::with_capacity(3)
3397                    .push({
3398                        let mut drug_col = widget::column::with_capacity(v.drugs.len());
3399                        for drug in &v.drugs {
3400                            drug_col = drug_col.push(widget::text::body(format!(
3401                                "{}: {}",
3402                                drug.drug, drug.confidence
3403                            )));
3404                        }
3405                        drug_col
3406                    })
3407                    .push(widget::text::body(format!(
3408                        "{} ({}): {}",
3409                        v.gene_name, v.gene_id, v.change
3410                    )))
3411                    .push({
3412                        let mut ecoli_col = widget::column::with_capacity(1);
3413                        if let Some(ecoli_value) =
3414                            TB_ECOLI_MAPPING.get(&(v.gene_name.clone(), v.change.clone()))
3415                        {
3416                            ecoli_col = ecoli_col
3417                                .push(widget::text::body(format!("E. coli: {}", ecoli_value)));
3418                        }
3419                        ecoli_col
3420                    }),
3421            )
3422            .into()
3423        }
3424        let cosmic_theme::Spacing {
3425            space_xxxs,
3426            space_m,
3427            ..
3428        } = theme::active().cosmic().spacing;
3429        let mut column = widget::column::with_capacity(10).spacing(space_m);
3430        let mut details = widget::column::with_capacity(1).spacing(space_xxxs);
3431        details = details.push(widget::text::heading(self.name.clone()));
3432
3433        let tbprofilerjson_opt = match &self.metadata {
3434            ItemMetadata::Path {
3435                tbprofilerjson_opt, ..
3436            } => tbprofilerjson_opt.as_ref(),
3437            #[cfg(feature = "russh")]
3438            ItemMetadata::RusshPath {
3439                tbprofilerjson_opt, ..
3440            } => tbprofilerjson_opt.as_ref(),
3441            _ => None,
3442        };
3443
3444        column = column.push(details);
3445
3446        #[cfg(feature = "russh")]
3447        if let ItemMetadata::RusshPath { .. } = &self.metadata
3448            && self.metadata.is_dir()
3449        {
3450            if self.selected {
3451                column = column
3452                    .push(widget::button::standard(fl!("open")).on_press(Message::Open(None)));
3453            }
3454            return column.into();
3455        }
3456
3457        if let Some(json) = tbprofilerjson_opt {
3458            column = column.push(widget::text::heading(format!(
3459                "DB: {} ({})",
3460                json.pipeline.db_version.name, json.pipeline.db_version.commit
3461            )));
3462            let mut dr_variants = json.dr_variants.clone();
3463            dr_variants.sort_by_key(|v| v.highest_confidence_rank());
3464            for v in &dr_variants {
3465                column = column.push(tb_variant_widget(v));
3466            }
3467        }
3468
3469        if let ItemMetadata::Path { .. } = &self.metadata
3470            && self.metadata.is_tbprofiler_result_as_sample()
3471        {
3472            for (label, opt_path) in [
3473                ("Open .results.csv", self.metadata.csv_path()),
3474                ("Open .results.json", self.metadata.json_path()),
3475                ("Open .results.docx", self.metadata.docx_path()),
3476            ] {
3477                if let Some(p) = opt_path {
3478                    column = column.push(
3479                        widget::button::standard(label)
3480                            .on_press(Message::Open(Some(p.to_path_buf()))),
3481                    );
3482                }
3483            }
3484        }
3485
3486        #[cfg(feature = "russh")]
3487        if let ItemMetadata::RusshPath { .. } = &self.metadata {
3488            if let Some(Location::Remote(uri, _, Some(path))) = &self.location_opt {
3489                log::info!(
3490                    "item is remote, showing download button: {}",
3491                    path.display()
3492                );
3493                if !self.metadata.is_tbprofiler_result_as_sample() {
3494                    column = column.push(
3495                        widget::button::standard(fl!("download"))
3496                            .on_press(Message::Download(Some((path.clone(), uri.clone())))),
3497                    );
3498                }
3499            }
3500            if let Some(Location::Remote(uri, _, _)) = &self.location_opt
3501                && self.metadata.is_tbprofiler_result_as_sample()
3502            {
3503                for (label, opt_path) in [
3504                    ("Download .results.csv", self.metadata.csv_path()),
3505                    ("Download .results.json", self.metadata.json_path()),
3506                    ("Download .results.docx", self.metadata.docx_path()),
3507                ] {
3508                    if let Some(p) = opt_path {
3509                        column = column.push(
3510                            widget::button::standard(label)
3511                                .on_press(Message::Download(Some((p.to_path_buf(), uri.clone())))),
3512                        );
3513                    }
3514                }
3515            }
3516        }
3517
3518        column.into()
3519    }
3520
3521    pub fn preview_erm41(&self) -> Element<'_, Message> {
3522        let cosmic_theme::Spacing {
3523            space_xxxs,
3524            space_m,
3525            ..
3526        } = theme::active().cosmic().spacing;
3527
3528        let mut column = widget::column::with_capacity(1).spacing(space_m);
3529        let mut details = widget::column::with_capacity(18).spacing(space_xxxs);
3530        details = details.push(widget::text::heading(self.name.clone()));
3531
3532        let hits = self.seq_id_hits_cached();
3533        if hits.is_empty()
3534            || self
3535                .metadata
3536                .sequence_length_trimmed()
3537                .is_some_and(|n| n < 100)
3538            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3539        {
3540            details = details.push(widget::text::body(
3541                "Could not align sequence to references.",
3542            ));
3543            if let Some(sequence_length) = self.metadata.sequence_length() {
3544                details = details.push(widget::text::body(format!(
3545                    "Sequence length: {}",
3546                    sequence_length
3547                )));
3548            }
3549            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3550                details = details.push(widget::text::body(format!(
3551                    "Trimmed sequence length: {}",
3552                    sequence_length_trimmed
3553                )));
3554            }
3555            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3556                details = details.push(widget::text::body(format!(
3557                    "Average quality score: {:.1}",
3558                    avg_qual
3559                )));
3560            }
3561        } else {
3562            let best = &hits[0];
3563            details = details.push(widget::text::body(format!(
3564                "Sequence identity to {}: {:.1}%",
3565                best.description, best.identity
3566            )));
3567            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3568                details = details.push(widget::text::body(format!(
3569                    "Trimmed sequence length: {}",
3570                    sequence_length_trimmed
3571                )));
3572            }
3573            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3574                details = details.push(widget::text::body(format!(
3575                    "Average quality score: {:.1}",
3576                    avg_qual
3577                )));
3578            }
3579            details = details.push(widget::text::heading(""));
3580            details = details.push(widget::text::heading(
3581                "Percent identity to erm(41) references of abscessus subspecies:",
3582            ));
3583            for hit in &hits[..hits.len().min(3)] {
3584                details = details.push(
3585                    widget::button::link(format!("{}: {:.1}%", hit.description, hit.identity,))
3586                        .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3587                        .padding(0),
3588                );
3589            }
3590            if self.metadata.is_abscessus_seq_id() || self.metadata.is_bolletii_seq_id() {
3591                let call = self.metadata.erm41position28_call();
3592                details = details.push(widget::text::body(""));
3593                details = details.push(widget::text::heading(format!("erm(41) {}", call)));
3594            }
3595            details = details.push(widget::text::body(""));
3596            {
3597                let snp_tags: Vec<String> = best
3598                    .erm41_snp_calls
3599                    .iter()
3600                    .map(|s| s.call_tag())
3601                    .filter(|t| !t.is_empty())
3602                    .collect();
3603                details = details.push(widget::text::body("erm(41) loss-of-function SNPs:"));
3604                if snp_tags.is_empty() {
3605                    details = details.push(widget::text::body("No erm(41) SNPs found."));
3606                } else {
3607                    details = details.push(widget::text::body(format!(
3608                        "(Using commit: {} of ntm-db repository)",
3609                        env!("NTM_DB_COMMIT")
3610                    )));
3611                    for tag in snp_tags {
3612                        details = details.push(widget::text::body(tag));
3613                    }
3614                }
3615            }
3616            if let Some(chrom) = self
3617                .metadata
3618                .ab1_chromatogram()
3619                .filter(|c| c.erm41_view_state_opt.is_some())
3620                && !self.metadata.is_massiliense_seq_id()
3621            {
3622                let view_state = chrom.erm41_view_state_opt.unwrap();
3623                details = details.push(widget::text::body(""));
3624                details = details.push(widget::text::body(
3625                    "Shown are bases 19-39, with position 28 in bold.",
3626                ));
3627                if view_state.is_reverse {
3628                    details = details.push(widget::text::body("reverse complement"));
3629                }
3630                details = details.push(widget::text::body(""));
3631                let canvas = widget::Canvas::new(ChromatogramProgram {
3632                    is_reverse: view_state.is_reverse,
3633                    display_window: Some(view_state.window),
3634                    highlighted_scans: chrom
3635                        .peak_locs
3636                        .get(view_state.pos28_base_idx as usize)
3637                        .copied()
3638                        .map(|v| vec![v])
3639                        .unwrap_or_default(),
3640                    chrom,
3641                })
3642                .width(Length::Fill)
3643                .height(Length::Fixed(200.0));
3644                details = details.push(canvas);
3645                details = details.push(widget::text::body(""));
3646            } else {
3647                details = details.push(widget::text::body(""));
3648                details = details.push(widget::text::body(
3649                    "erm(41) chromatogram view is not available.",
3650                ));
3651                details = details.push(widget::text::body(""));
3652            }
3653            match self
3654                .metadata
3655                .susceptibility_calls()
3656                .erm41
3657                .position_28
3658                .and_then(|p| p.is_susceptible())
3659            {
3660                Some(true) => {
3661                    details = details.push(widget::text::heading(
3662                        "Predicted to be susceptible to macrolides.",
3663                    ))
3664                }
3665                Some(false) => {
3666                    details = details.push(widget::text::heading(
3667                        "Predicted inducible macrolide resistance.",
3668                    ))
3669                }
3670                None => {}
3671            }
3672        }
3673
3674        column = column.push(details);
3675        column.into()
3676    }
3677
3678    pub fn preview_hsp65(&self) -> Element<'_, Message> {
3679        let cosmic_theme::Spacing {
3680            space_xxxs,
3681            space_m,
3682            ..
3683        } = theme::active().cosmic().spacing;
3684
3685        let mut column = widget::column::with_capacity(1).spacing(space_m);
3686        let mut details = widget::column::with_capacity(10).spacing(space_xxxs);
3687        details = details.push(widget::text::heading(self.name.clone()));
3688
3689        let hits = self.seq_id_hits_cached();
3690        if hits.is_empty()
3691            || self
3692                .metadata
3693                .sequence_length_trimmed()
3694                .is_some_and(|n| n < 100)
3695            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3696        {
3697            details = details.push(widget::text::body(
3698                "Could not align sequence to references.",
3699            ));
3700            if let Some(sequence_length) = self.metadata.sequence_length() {
3701                details = details.push(widget::text::body(format!(
3702                    "Sequence length: {}",
3703                    sequence_length
3704                )));
3705            }
3706            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3707                details = details.push(widget::text::body(format!(
3708                    "Trimmed sequence length: {}",
3709                    sequence_length_trimmed
3710                )));
3711            }
3712            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3713                details = details.push(widget::text::body(format!(
3714                    "Average quality score: {:.1}",
3715                    avg_qual
3716                )));
3717            }
3718        } else {
3719            let best = &hits[0];
3720            details = details.push(widget::text::body(format!(
3721                "Sequence identity to {}: {:.1}%",
3722                best.description, best.identity
3723            )));
3724            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3725                details = details.push(widget::text::body(format!(
3726                    "Trimmed sequence length: {}",
3727                    sequence_length_trimmed
3728                )));
3729            }
3730            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3731                details = details.push(widget::text::body(format!(
3732                    "Average quality score: {:.1}",
3733                    avg_qual
3734                )));
3735            }
3736            details = details.push(widget::text::heading(""));
3737            details = details.push(widget::text::heading(
3738                "Species identification (hsp65 database):",
3739            ));
3740            for hit in &hits[..hits.len().min(3)] {
3741                details = details.push(
3742                    widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3743                        .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3744                        .padding(0),
3745                );
3746            }
3747
3748            details = details.push(widget::text::body(""));
3749            if best.is_kansasii() || best.is_gastri() {
3750                let gastri_n = best
3751                    .kansasii_gastri_snp_calls
3752                    .iter()
3753                    .filter(|c| c.is_gastri())
3754                    .count();
3755                let kansasii_n = best
3756                    .kansasii_gastri_snp_calls
3757                    .iter()
3758                    .filter(|c| c.is_kansasii())
3759                    .count();
3760                let total = best.kansasii_gastri_snp_calls.len();
3761                let species_call = best
3762                    .kansasii_gastri_snp_species_call()
3763                    .unwrap_or("Ambiguous");
3764                details = details.push(widget::text::body("hsp65 kansasii/gastri SNP call:"));
3765                details = details.push(widget::text::body(format!(
3766                    "{} (M. gastri {}/{}, M. kansasii {}/{})",
3767                    species_call, gastri_n, total, kansasii_n, total,
3768                )));
3769                for snp in &best.kansasii_gastri_snp_calls {
3770                    details = details.push(widget::text::body(format!(
3771                        "  pos {}: {} = {}",
3772                        snp.ref_pos + 1,
3773                        snp.query_base as char,
3774                        snp.species_tag(),
3775                    )));
3776                }
3777                details = details.push(widget::text::body(""));
3778            }
3779            if best.is_marinum() || best.is_ulcerans() {
3780                let ulcerans_n = best
3781                    .marinum_ulcerans_snp_calls
3782                    .iter()
3783                    .filter(|c| c.is_ulcerans())
3784                    .count();
3785                let marinum_n = best
3786                    .marinum_ulcerans_snp_calls
3787                    .iter()
3788                    .filter(|c| c.is_marinum())
3789                    .count();
3790                let total = best.marinum_ulcerans_snp_calls.len();
3791                let species_call = best
3792                    .marinum_ulcerans_snp_species_call()
3793                    .unwrap_or("Ambiguous");
3794                details = details.push(widget::text::body("hsp65 marinum/ulcerans SNP call:"));
3795                details = details.push(widget::text::body(format!(
3796                    "{} (M. ulcerans {}/{}, M. marinum {}/{})",
3797                    species_call, ulcerans_n, total, marinum_n, total,
3798                )));
3799                for snp in &best.marinum_ulcerans_snp_calls {
3800                    details = details.push(widget::text::body(format!(
3801                        "  pos {}: {} = {}",
3802                        snp.ref_pos + 1,
3803                        snp.query_base as char,
3804                        snp.species_tag(),
3805                    )));
3806                }
3807                details = details.push(widget::text::body(""));
3808            }
3809        }
3810
3811        column = column.push(details);
3812        column.into()
3813    }
3814
3815    pub fn preview_rpob(&self) -> Element<'_, Message> {
3816        let cosmic_theme::Spacing {
3817            space_xxxs,
3818            space_m,
3819            ..
3820        } = theme::active().cosmic().spacing;
3821
3822        let mut column = widget::column::with_capacity(1).spacing(space_m);
3823        let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
3824        details = details.push(widget::text::heading(self.name.clone()));
3825
3826        let hits = self.seq_id_hits_cached();
3827        if hits.is_empty()
3828            || self
3829                .metadata
3830                .sequence_length_trimmed()
3831                .is_some_and(|n| n < 100)
3832            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3833        {
3834            details = details.push(widget::text::body(
3835                "Could not align sequence to references.",
3836            ));
3837            if let Some(sequence_length) = self.metadata.sequence_length() {
3838                details = details.push(widget::text::body(format!(
3839                    "Sequence length: {}",
3840                    sequence_length
3841                )));
3842            }
3843            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3844                details = details.push(widget::text::body(format!(
3845                    "Trimmed sequence length: {}",
3846                    sequence_length_trimmed
3847                )));
3848            }
3849            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3850                details = details.push(widget::text::body(format!(
3851                    "Average quality score: {:.1}",
3852                    avg_qual
3853                )));
3854            }
3855        } else {
3856            let best = &hits[0];
3857            details = details.push(widget::text::body(format!(
3858                "Sequence identity to {}: {:.1}%",
3859                best.description, best.identity
3860            )));
3861            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3862                details = details.push(widget::text::body(format!(
3863                    "Trimmed sequence length: {}",
3864                    sequence_length_trimmed
3865                )));
3866            }
3867            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3868                details = details.push(widget::text::body(format!(
3869                    "Average quality score: {:.1}",
3870                    avg_qual
3871                )));
3872            }
3873            details = details.push(widget::text::heading(""));
3874            details = details.push(widget::text::heading(
3875                "Species identification (rpoB database):",
3876            ));
3877            for hit in &hits[..hits.len().min(3)] {
3878                details = details.push(
3879                    widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3880                        .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3881                        .padding(0),
3882                );
3883            }
3884        }
3885
3886        column = column.push(details);
3887        column.into()
3888    }
3889
3890    pub fn preview_16s(&self) -> Element<'_, Message> {
3891        let cosmic_theme::Spacing {
3892            space_xxxs,
3893            space_m,
3894            ..
3895        } = theme::active().cosmic().spacing;
3896
3897        let mut column = widget::column::with_capacity(1).spacing(space_m);
3898        let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
3899        details = details.push(widget::text::heading(self.name.clone()));
3900
3901        let hits = self.seq_id_hits_cached();
3902        if hits.is_empty()
3903            || self
3904                .metadata
3905                .sequence_length_trimmed()
3906                .is_some_and(|n| n < 100)
3907            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3908        {
3909            details = details.push(widget::text::body(
3910                "Could not align sequence to references.",
3911            ));
3912            if let Some(sequence_length) = self.metadata.sequence_length() {
3913                details = details.push(widget::text::body(format!(
3914                    "Sequence length: {}",
3915                    sequence_length
3916                )));
3917            }
3918            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3919                details = details.push(widget::text::body(format!(
3920                    "Trimmed sequence length: {}",
3921                    sequence_length_trimmed
3922                )));
3923            }
3924            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3925                details = details.push(widget::text::body(format!(
3926                    "Average quality score: {:.1}",
3927                    avg_qual
3928                )));
3929            }
3930        } else {
3931            let best = &hits[0];
3932            let best_snp_hit = hits
3933                .iter()
3934                .find(|h| h.description == best.description && !h.rrs_snp_calls.is_empty());
3935            details = details.push(widget::text::body(format!(
3936                "Sequence identity to {}: {:.1}%",
3937                best.description, best.identity
3938            )));
3939            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3940                details = details.push(widget::text::body(format!(
3941                    "Trimmed sequence length: {}",
3942                    sequence_length_trimmed
3943                )));
3944            }
3945            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3946                details = details.push(widget::text::body(format!(
3947                    "Average quality score: {:.1}",
3948                    avg_qual
3949                )));
3950            }
3951            details = details.push(widget::text::heading(""));
3952            details = details.push(widget::text::heading(
3953                "Species identification (16S database):",
3954            ));
3955            for hit in &hits[..hits.len().min(8)] {
3956                details = details.push(
3957                    widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3958                        .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3959                        .padding(0),
3960                );
3961            }
3962            if let Some(snp_hit) = best_snp_hit {
3963                details = details.push(widget::text::body(""));
3964                details = details.push(widget::text::body(
3965                    "16S rRNA amikacin resistance SNPs (rrs):",
3966                ));
3967                details = details.push(widget::text::body(format!(
3968                    "(Using commit: {} of ntm-db repository)",
3969                    env!("NTM_DB_COMMIT")
3970                )));
3971                for snp in &snp_hit.rrs_snp_calls {
3972                    details = details.push(widget::text::body(format!(
3973                        "  pos {}: {}",
3974                        snp.ref_pos + 1,
3975                        snp.call_tag()
3976                    )));
3977                }
3978            }
3979        }
3980
3981        column = column.push(details);
3982        column.into()
3983    }
3984
3985    pub fn preview_rrl_ntm(&self) -> Element<'_, Message> {
3986        let cosmic_theme::Spacing {
3987            space_xxxs,
3988            space_m,
3989            ..
3990        } = theme::active().cosmic().spacing;
3991
3992        let mut column = widget::column::with_capacity(1).spacing(space_m);
3993        let mut details = widget::column::with_capacity(10).spacing(space_xxxs);
3994        details = details.push(widget::text::heading(self.name.clone()));
3995
3996        let hits = self.seq_id_hits_cached();
3997        if hits.is_empty()
3998            || self
3999                .metadata
4000                .sequence_length_trimmed()
4001                .is_some_and(|n| n < 100)
4002            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
4003        {
4004            details = details.push(widget::text::body(
4005                "Could not align sequence to references.",
4006            ));
4007            if let Some(sequence_length) = self.metadata.sequence_length() {
4008                details = details.push(widget::text::body(format!(
4009                    "Sequence length: {}",
4010                    sequence_length
4011                )));
4012            }
4013            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4014                details = details.push(widget::text::body(format!(
4015                    "Trimmed sequence length: {}",
4016                    sequence_length_trimmed
4017                )));
4018            }
4019            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4020                details = details.push(widget::text::body(format!(
4021                    "Average quality score: {:.1}",
4022                    avg_qual
4023                )));
4024            }
4025        } else {
4026            let best = &hits[0];
4027            // For SNP calls, find the best ntm-db-sourced hit (accession contains ':')
4028            // for the winning species, since SNP positions are anchored to the gene start
4029            // in the ntm-db full-gene sequences, not to partial NCBI sequences.
4030            let best_snp_hit = hits
4031                .iter()
4032                .find(|h| h.description == best.description && !h.rrl_snp_calls.is_empty());
4033            details = details.push(widget::text::body(format!(
4034                "Sequence identity to {}: {:.1}%",
4035                best.description, best.identity
4036            )));
4037            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4038                details = details.push(widget::text::body(format!(
4039                    "Trimmed sequence length: {}",
4040                    sequence_length_trimmed
4041                )));
4042            }
4043            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4044                details = details.push(widget::text::body(format!(
4045                    "Average quality score: {:.1}",
4046                    avg_qual
4047                )));
4048            }
4049            details = details.push(widget::text::heading(""));
4050            details = details.push(widget::text::heading(
4051                "Species identification (rrl database):",
4052            ));
4053            for hit in &hits[..hits.len().min(3)] {
4054                details = details.push(
4055                    widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
4056                        .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
4057                        .padding(0),
4058                );
4059            }
4060            details = details.push(widget::text::body(""));
4061            if let Some(snp_hit) = best_snp_hit {
4062                details = details.push(widget::text::body(
4063                    "23S rRNA macrolide resistance SNPs (rrl):",
4064                ));
4065                details = details.push(widget::text::body(format!(
4066                    "(Using commit: {} of ntm-db repository)",
4067                    env!("NTM_DB_COMMIT")
4068                )));
4069                for snp in &snp_hit.rrl_snp_calls {
4070                    details = details.push(widget::text::body(format!(
4071                        "  pos {}: {}",
4072                        snp.ref_pos + 1,
4073                        snp.call_tag()
4074                    )));
4075                }
4076            }
4077            if let Some(chrom) = self
4078                .metadata
4079                .ab1_chromatogram()
4080                .filter(|c| c.rrl_ntm_view_state_opt.is_some())
4081            {
4082                let view_state = chrom.rrl_ntm_view_state_opt.unwrap();
4083                details = details.push(widget::text::body(""));
4084                details = details.push(widget::text::body(
4085                    "Shown in bold are bases coresponding to E. coli positions 2058/2059",
4086                ));
4087                if view_state.is_reverse {
4088                    details = details.push(widget::text::body("reverse complement"));
4089                }
4090                details = details.push(widget::text::body(""));
4091                let canvas = widget::Canvas::new(ChromatogramProgram {
4092                    is_reverse: view_state.is_reverse,
4093                    display_window: Some(view_state.window),
4094                    highlighted_scans: {
4095                        let idx = view_state.snp_base_idx as usize;
4096                        [idx, idx + 1]
4097                            .iter()
4098                            .filter_map(|&i| chrom.peak_locs.get(i).copied())
4099                            .collect()
4100                    },
4101                    chrom,
4102                })
4103                .width(Length::Fill)
4104                .height(Length::Fixed(200.0));
4105                details = details.push(canvas);
4106                details = details.push(widget::text::body(""));
4107            } else {
4108                details = details.push(widget::text::body(""));
4109                details = details.push(widget::text::body(
4110                    "rrl chromatogram view is not available.",
4111                ));
4112                details = details.push(widget::text::body(""));
4113            }
4114            match self
4115                .metadata
4116                .susceptibility_calls()
4117                .rrl
4118                .position_2058_2059
4119                .and_then(|p| p.is_susceptible())
4120            {
4121                Some(true) => {
4122                    details = details.push(widget::text::heading(
4123                        "E. coli positions A2058 and A2059 are wt (macrolide susceptible).",
4124                    ))
4125                }
4126                Some(false) => {
4127                    details = details.push(widget::text::heading(
4128                        "Mutation in E. coli A2058 and/or A2059. Predicted macrolide resistance.",
4129                    ))
4130                }
4131                None => {}
4132            }
4133        }
4134
4135        column = column.push(details);
4136        column.into()
4137    }
4138
4139    pub fn preview_pnca(&self) -> Element<'_, Message> {
4140        let cosmic_theme::Spacing {
4141            space_xxxs,
4142            space_m,
4143            ..
4144        } = theme::active().cosmic().spacing;
4145
4146        let mut column = widget::column::with_capacity(1).spacing(space_m);
4147        let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
4148        details = details.push(widget::text::heading(self.name.clone()));
4149
4150        let hits = self.seq_id_hits_cached();
4151        if hits.is_empty()
4152            || self
4153                .metadata
4154                .sequence_length_trimmed()
4155                .is_some_and(|n| n < 100)
4156            || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
4157        {
4158            details = details.push(widget::text::body(
4159                "Could not align sequence to the pncA reference.",
4160            ));
4161            if let Some(sequence_length) = self.metadata.sequence_length() {
4162                details = details.push(widget::text::body(format!(
4163                    "Sequence length: {}",
4164                    sequence_length
4165                )));
4166            }
4167            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4168                details = details.push(widget::text::body(format!(
4169                    "Trimmed sequence length: {}",
4170                    sequence_length_trimmed
4171                )));
4172            }
4173            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4174                details = details.push(widget::text::body(format!(
4175                    "Average quality score: {:.1}",
4176                    avg_qual
4177                )));
4178            }
4179        } else {
4180            let best = &hits[0];
4181            details = details.push(widget::text::body(format!(
4182                "Sequence identity to {}: {:.1}%",
4183                best.description, best.identity
4184            )));
4185            if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4186                details = details.push(widget::text::body(format!(
4187                    "Trimmed sequence length: {}",
4188                    sequence_length_trimmed
4189                )));
4190            }
4191            if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4192                details = details.push(widget::text::body(format!(
4193                    "Average quality score: {:.1}",
4194                    avg_qual
4195                )));
4196            }
4197            details = details.push(
4198                widget::button::link(format!("View alignment ({:.1}%)", best.identity))
4199                    .on_press(Message::OpenSeqAlignment(Box::new(best.clone())))
4200                    .padding(0),
4201            );
4202
4203            let pnca = &self.metadata.susceptibility_calls().pnca;
4204            let called: Vec<_> = pnca
4205                .snp_calls
4206                .iter()
4207                .filter(|c| !c.call_tag().is_empty())
4208                .collect();
4209            details = details.push(widget::text::body(""));
4210            details = details.push(widget::text::heading(
4211                "Pyrazinamide resistance calls (pncA, WHO mutation catalogue):",
4212            ));
4213            if called.is_empty() {
4214                details = details.push(widget::text::body("No catalogued sites covered."));
4215            } else {
4216                for snp in &called {
4217                    details = details.push(widget::text::body(format!(
4218                        "  {}: {}",
4219                        snp.site_label(),
4220                        snp.call_tag()
4221                    )));
4222                }
4223            }
4224            match pnca.is_susceptible {
4225                Some(true) => {
4226                    details = details.push(widget::text::heading(
4227                        "No pncA mutation at or above WHO-catalogue resistance confidence. Predicted pyrazinamide susceptible.",
4228                    ))
4229                }
4230                Some(false) => {
4231                    details = details.push(widget::text::heading(
4232                        "pncA mutation meets WHO-catalogue resistance confidence. Predicted pyrazinamide resistance.",
4233                    ))
4234                }
4235                None => {}
4236            }
4237        }
4238
4239        column = column.push(details);
4240        column.into()
4241    }
4242
4243    pub fn replace_view(&self, heading: String, military_time: bool) -> Element<'_, Message> {
4244        let cosmic_theme::Spacing { space_xxxs, .. } = theme::spacing();
4245
4246        let mut row = widget::row::with_capacity(2).spacing(space_xxxs);
4247        row = row.push(self.preview());
4248
4249        let mut column = widget::column::with_capacity(3).spacing(space_xxxs);
4250        column = column.push(widget::text::heading(heading));
4251
4252        //TODO: translate!
4253        //TODO: correct display of folder size?
4254        if let ItemMetadata::Path {
4255            metadata,
4256            children_opt,
4257            ..
4258        } = &self.metadata
4259        {
4260            if metadata.is_dir() {
4261                if let Some(children) = children_opt {
4262                    column = column.push(widget::text::body(format!("Items: {children}")));
4263                }
4264            } else {
4265                column = column.push(widget::text::body(format!(
4266                    "Size: {}",
4267                    format_size(metadata.len())
4268                )));
4269            }
4270            if let Ok(time) = metadata.modified() {
4271                let date_time_formatter = date_time_formatter(military_time);
4272                let time_formatter = time_formatter(military_time);
4273
4274                column = column.push(widget::text::body(format!(
4275                    "Last modified: {}",
4276                    format_time(time, &date_time_formatter, &time_formatter)
4277                )));
4278            }
4279        } else {
4280            //TODO: other metadata
4281        }
4282
4283        row = row.push(column);
4284        row.into()
4285    }
4286}
4287
4288/// Canvas program that renders an AB1 Sanger chromatogram.
4289///
4290/// Draws the four channel intensity curves in standard colors:
4291/// A = green, C = blue, G = black, T = red.
4292/// Base call letters are drawn at their peak scan positions along the top.
4293struct ChromatogramProgram<'a> {
4294    chrom: &'a crate::sequencing::Ab1Channels,
4295    is_reverse: bool,
4296    display_window: Option<(u16, u16)>,
4297    highlighted_scans: Vec<u16>,
4298}
4299
4300impl<'a> widget::canvas::Program<Message, cosmic::Theme, cosmic::Renderer>
4301    for ChromatogramProgram<'a>
4302{
4303    type State = ();
4304
4305    fn draw(
4306        &self,
4307        _state: &Self::State,
4308        renderer: &cosmic::Renderer,
4309        _theme: &cosmic::Theme,
4310        bounds: Rectangle,
4311        _cursor: cosmic::iced::mouse::Cursor,
4312    ) -> Vec<widget::canvas::Geometry<cosmic::Renderer>> {
4313        use cosmic::iced::alignment;
4314        use widget::canvas::{Frame, Path, Stroke};
4315
4316        let chrom = self.chrom;
4317        let is_rev = self.is_reverse;
4318
4319        // Standard Sanger palette: colour by the base the dye represents.
4320        // For a reverse read the channel physically contains the complement base,
4321        // so we complement the channel's base before looking up the colour —
4322        // that way the displayed colour always matches the plus-strand base.
4323        let dna_complement = |b: u8| -> u8 {
4324            match b.to_ascii_uppercase() {
4325                b'A' => b'T',
4326                b'T' => b'A',
4327                b'C' => b'G',
4328                b'G' => b'C',
4329                _ => b'N',
4330            }
4331        };
4332        let base_color = |base: u8| -> Color {
4333            match base.to_ascii_uppercase() {
4334                b'A' => Color::from_rgb(0.0, 0.7, 0.0),
4335                b'C' => Color::from_rgb(0.0, 0.0, 1.0),
4336                b'G' => Color::from_rgb(0.8, 0.55, 0.0),
4337                b'T' => Color::from_rgb(1.0, 0.0, 0.0),
4338                _ => Color::from_rgb(0.5, 0.5, 0.5),
4339            }
4340        };
4341
4342        // Restrict to the display window.
4343        // Fall back to the full scan range if the anchor was not found.
4344        let total_scans: u16 = chrom
4345            .channels
4346            .iter()
4347            .map(|c| c.len() as u16)
4348            .max()
4349            .unwrap_or(1);
4350        if total_scans == 0 {
4351            return vec![];
4352        }
4353        let (scan_start, scan_end) = self
4354            .display_window
4355            .unwrap_or((0, total_scans.saturating_sub(1)));
4356        let scan_end = scan_end.min(total_scans.saturating_sub(1));
4357        let window_len = (scan_end + 1).saturating_sub(scan_start).max(1);
4358
4359        // Y range: only over the visible window for better contrast
4360        let mut y_min = i16::MAX;
4361        let mut y_max = i16::MIN;
4362        for channel in &chrom.channels {
4363            for &v in &channel
4364                [(scan_start as usize).min(channel.len())..(scan_end as usize).min(channel.len())]
4365            {
4366                if v < y_min {
4367                    y_min = v;
4368                }
4369                if v > y_max {
4370                    y_max = v;
4371                }
4372            }
4373        }
4374        if y_min == i16::MAX {
4375            y_min = 0;
4376            y_max = 1;
4377        }
4378        if y_max == y_min {
4379            y_max = y_min + 1;
4380        }
4381        let y_range = (y_max - y_min) as f32;
4382
4383        // Leave a small margin at the top for base letters
4384        let top_margin = 18.0_f32;
4385        let plot_h = (bounds.height - top_margin).max(1.0);
4386        let plot_w = bounds.width;
4387
4388        // Map scan index to canvas x.
4389        // For reverse reads we flip the x-axis so the plus-strand 5′→3′ direction
4390        // always runs left to right, identical to a forward read.
4391        let scan_to_x = |scan: usize| -> f32 {
4392            let t = (scan.saturating_sub(scan_start as usize)) as f32
4393                / ((window_len as usize) - 1).max(1) as f32;
4394            if is_rev {
4395                (1.0 - t) * plot_w
4396            } else {
4397                t * plot_w
4398            }
4399        };
4400        let intensity_to_y =
4401            |v: i16| -> f32 { top_margin + (1.0 - (v - y_min) as f32 / y_range) * plot_h };
4402
4403        let mut frame = Frame::new(renderer, bounds.size());
4404
4405        // Draw each channel, restricted to [scan_start, scan_end].
4406        // For reverse reads the channel base is the complement of the plus-strand base,
4407        // so we complement it before resolving the display colour.
4408        for (ch_idx, channel) in chrom.channels.iter().enumerate() {
4409            if channel.is_empty() {
4410                continue;
4411            }
4412            let raw_base = chrom.base_order[ch_idx];
4413            let display_base = if is_rev {
4414                dna_complement(raw_base)
4415            } else {
4416                raw_base
4417            };
4418            let color = base_color(display_base);
4419
4420            let slice_end = (scan_end + 1).min(channel.len() as u16);
4421            let slice_start = scan_start.min(slice_end);
4422            if slice_start >= slice_end {
4423                continue;
4424            }
4425            let window_slice = &channel[slice_start as usize..slice_end as usize];
4426
4427            let path = Path::new(|builder| {
4428                builder.move_to(Point {
4429                    x: scan_to_x(slice_start as usize),
4430                    y: intensity_to_y(window_slice[0]),
4431                });
4432                for (i, &v) in window_slice.iter().enumerate().skip(1) {
4433                    builder.line_to(Point {
4434                        x: scan_to_x(slice_start as usize + i),
4435                        y: intensity_to_y(v),
4436                    });
4437                }
4438            });
4439
4440            frame.stroke(&path, Stroke::default().with_color(color).with_width(1.2));
4441        }
4442
4443        // Draw base call letters only for bases whose peak falls in the window.
4444        // For reverse reads show the complement letter so the label matches the
4445        // plus-strand base at each position.
4446        // Highlighted scan positions are drawn in bold.
4447        for (base, &peak) in chrom.bases.iter().zip(chrom.peak_locs.iter()) {
4448            let scan = peak;
4449            if scan < scan_start || scan > scan_end {
4450                continue;
4451            }
4452            let display_base = if is_rev { dna_complement(*base) } else { *base };
4453            let x = scan_to_x(scan as usize);
4454            let color = base_color(display_base);
4455            let is_highlighted = self.highlighted_scans.contains(&scan);
4456            frame.fill_text(widget::canvas::Text {
4457                content: String::from(display_base as char),
4458                position: Point { x, y: 2.0 },
4459                max_width: f32::INFINITY,
4460                color,
4461                size: cosmic::iced::Pixels(if is_highlighted { 20.0 } else { 11.0 }),
4462                line_height: cosmic::iced::advanced::text::LineHeight::default(),
4463                font: cosmic::iced::Font::default(),
4464                align_x: cosmic::iced::advanced::text::Alignment::Center,
4465                align_y: alignment::Vertical::Top,
4466                shaping: cosmic::iced::advanced::text::Shaping::Basic,
4467            });
4468        }
4469
4470        vec![frame.into_geometry()]
4471    }
4472}
4473
4474#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
4475pub enum View {
4476    Grid,
4477    List,
4478}
4479#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq, Deserialize, Serialize)]
4480pub enum HeadingOptions {
4481    Name = 0,
4482    Modified,
4483    Size,
4484    TrashedOn,
4485}
4486
4487impl fmt::Display for HeadingOptions {
4488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4489        match self {
4490            Self::Name => write!(f, "{}", fl!("name")),
4491            Self::Modified => write!(f, "{}", fl!("modified")),
4492            Self::Size => write!(f, "{}", fl!("size")),
4493            Self::TrashedOn => write!(f, "{}", fl!("trashed-on")),
4494        }
4495    }
4496}
4497
4498impl HeadingOptions {
4499    pub fn names() -> Vec<String> {
4500        vec![
4501            Self::Name.to_string(),
4502            Self::Modified.to_string(),
4503            Self::Size.to_string(),
4504            Self::TrashedOn.to_string(),
4505        ]
4506    }
4507}
4508
4509#[derive(Clone, Debug)]
4510pub enum Mode {
4511    App,
4512    Desktop,
4513    Dialog(DialogKind),
4514}
4515
4516impl Mode {
4517    /// Whether multiple files can be selected in this mode
4518    pub fn multiple(&self) -> bool {
4519        match self {
4520            Self::App | Self::Desktop => true,
4521            Self::Dialog(dialog) => dialog.multiple(),
4522        }
4523    }
4524}
4525
4526struct SearchContext {
4527    results_rx: mpsc::Receiver<SearchItem>,
4528    ready: Arc<atomic::AtomicBool>,
4529    last_modified_opt: Arc<RwLock<Option<SystemTime>>>,
4530}
4531
4532pub struct SearchContextWrapper(Option<SearchContext>);
4533
4534impl Clone for SearchContextWrapper {
4535    fn clone(&self) -> Self {
4536        Self(None)
4537    }
4538}
4539
4540impl fmt::Debug for SearchContextWrapper {
4541    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4542        f.debug_struct("SearchContextWrapper").finish()
4543    }
4544}
4545
4546// TODO when creating items, pass <Arc<SelectedItems>> to each item
4547// as a drag data, so that when dnd is initiated, they are all included
4548pub struct Tab {
4549    //TODO: make more items private
4550    pub location: Location,
4551    pub location_ancestors: Vec<(Location, String)>,
4552    pub location_title: String,
4553    pub location_context_menu_point: Option<Point>,
4554    pub location_context_menu_index: Option<usize>,
4555    pub context_menu: Option<Point>,
4556    pub mode: Mode,
4557    pub scroll_opt: Option<AbsoluteOffset>,
4558    pub size_opt: Cell<Option<Size>>,
4559    pub content_height_opt: Cell<Option<f32>>,
4560    pub viewport_opt: Option<Rectangle>,
4561    pub item_view_size_opt: Cell<Option<Size>>,
4562    pub edit_location: Option<EditLocation>,
4563    pub edit_location_id: widget::Id,
4564    pub history_i: usize,
4565    pub history: Vec<Location>,
4566    pub config: TabConfig,
4567    pub tb_config: TBConfig,
4568    pub thumb_config: ThumbCfg,
4569    pub sort_name: HeadingOptions,
4570    pub sort_direction: bool,
4571    pub gallery: bool,
4572    pub(crate) parent_item_opt: Option<Box<Item>>,
4573    pub(crate) items_opt: Option<Vec<Item>>,
4574    pub dnd_hovered: Option<(Location, Instant)>,
4575    pub(crate) scrollable_id: widget::Id,
4576    select_focus: Option<usize>,
4577    select_range: Option<(usize, usize)>,
4578    clicked: Option<usize>,
4579    selected_clicked: bool,
4580    last_right_click: Option<usize>,
4581    search_context: Option<SearchContext>,
4582    date_time_formatter: DateTimeFormatter<fieldsets::YMDT>,
4583    time_formatter: DateTimeFormatter<fieldsets::T>,
4584    watch_drag: bool,
4585    window_id: Option<window::Id>,
4586    large_image_manager: LargeImageManager,
4587}
4588
4589async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64, OperationError> {
4590    let mut total = 0;
4591    for entry_res in WalkDir::new(path) {
4592        controller
4593            .check()
4594            .await
4595            .map_err(|s| OperationError::from_state(s, &controller))?;
4596
4597        //TODO: report more errors?
4598        if let Ok(entry) = entry_res
4599            && let Ok(metadata) = entry.metadata()
4600            && metadata.is_file()
4601        {
4602            total += metadata.len();
4603        }
4604
4605        // Yield in case this process takes a while.
4606        tokio::task::yield_now().await;
4607    }
4608    Ok(total)
4609}
4610
4611fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) {
4612    let path = path.as_ref();
4613    let mut found_home = false;
4614    let name = match path.file_name() {
4615        Some(name) => {
4616            if path == crate::home_dir() {
4617                found_home = true;
4618                fl!("home")
4619            } else {
4620                match (get_filename_from_path(path), fs::metadata(path)) {
4621                    (Ok(name), Ok(metadata)) => {
4622                        let is_gvfs = fs_kind(&metadata) == FsKind::Gvfs;
4623                        display_name_for_file(path, &name, is_gvfs, false)
4624                    }
4625                    _ => name.to_string_lossy().into_owned(),
4626                }
4627            }
4628        }
4629        None => {
4630            fl!("filesystem")
4631        }
4632    };
4633    (name, found_home)
4634}
4635
4636// parse .hidden file and return files path
4637pub fn parse_hidden_file(path: &PathBuf) -> Box<[String]> {
4638    let Ok(file) = File::open(path) else {
4639        return Default::default();
4640    };
4641
4642    BufReader::new(file)
4643        .lines()
4644        .map_while(Result::ok)
4645        .filter_map(|line| {
4646            let line = line.trim();
4647            (!line.is_empty()).then_some(line.to_owned())
4648        })
4649        .collect()
4650}
4651
4652impl Tab {
4653    pub fn new(
4654        location: Location,
4655        config: TabConfig,
4656        tb_config: TBConfig,
4657        thumb_config: ThumbCfg,
4658        sorting_options: Option<&FxOrderMap<String, (HeadingOptions, bool)>>,
4659        scrollable_id: widget::Id,
4660        window_id: Option<window::Id>,
4661    ) -> Self {
4662        let location_str = location.to_string();
4663        let (sort_name, sort_direction) = sorting_options
4664            .and_then(|opts| opts.get(&location_str))
4665            .or_else(|| SORT_OPTION_FALLBACK.get(&location_str))
4666            .copied()
4667            .unwrap_or((HeadingOptions::Name, true));
4668        let location = location.normalize();
4669        let location_ancestors = location.ancestors();
4670        let location_title = location.title();
4671        let history = vec![location.clone()];
4672        Self {
4673            location,
4674            location_ancestors,
4675            location_title,
4676            context_menu: None,
4677            location_context_menu_point: None,
4678            location_context_menu_index: None,
4679            mode: Mode::App,
4680            scroll_opt: None,
4681            size_opt: Cell::new(None),
4682            content_height_opt: Cell::new(None),
4683            viewport_opt: None,
4684            item_view_size_opt: Cell::new(None),
4685            edit_location: None,
4686            edit_location_id: widget::Id::unique(),
4687            history_i: 0,
4688            history,
4689            config,
4690            tb_config,
4691            thumb_config,
4692            sort_name,
4693            sort_direction,
4694            gallery: false,
4695            parent_item_opt: None,
4696            items_opt: None,
4697            scrollable_id,
4698            select_focus: None,
4699            select_range: None,
4700            clicked: None,
4701            dnd_hovered: None,
4702            selected_clicked: false,
4703            last_right_click: None,
4704            search_context: None,
4705            date_time_formatter: date_time_formatter(config.military_time),
4706            time_formatter: time_formatter(config.military_time),
4707            watch_drag: true,
4708            window_id,
4709            large_image_manager: LargeImageManager::new(),
4710        }
4711    }
4712
4713    pub fn title(&self) -> String {
4714        //TODO: is it possible to return a &str?
4715        self.location_title.clone()
4716    }
4717
4718    pub const fn items_opt(&self) -> Option<&Vec<Item>> {
4719        self.items_opt.as_ref()
4720    }
4721
4722    pub const fn items_opt_mut(&mut self) -> Option<&mut Vec<Item>> {
4723        self.items_opt.as_mut()
4724    }
4725
4726    pub fn set_items(&mut self, mut items: Vec<Item>) {
4727        let highlighted = self
4728            .items_opt
4729            .as_ref()
4730            .and_then(|items| items.iter().enumerate().find(|i| i.1.highlighted))
4731            .map(|(i, _)| i);
4732        let selected = self.selected_locations();
4733        for item in &mut items {
4734            item.selected = false;
4735            if let Some(location) = &item.location_opt
4736                && selected.contains(location)
4737            {
4738                item.selected = true;
4739            }
4740        }
4741        self.items_opt = Some(items);
4742        if let Some(i) = highlighted
4743            .zip(self.items_opt.as_mut())
4744            .and_then(|(h, items)| items.get_mut(h))
4745        {
4746            i.highlighted = true;
4747        }
4748    }
4749
4750    pub fn cut_selected(&mut self) {
4751        if let Some(ref mut items) = self.items_opt {
4752            for item in items.iter_mut() {
4753                item.cut = item.selected;
4754            }
4755        }
4756    }
4757
4758    pub fn refresh_cut(&mut self, locations: &[PathBuf]) {
4759        if let Some(ref mut items) = self.items_opt {
4760            for item in items.iter_mut() {
4761                item.cut = false;
4762                if let Some(location_path) = item.location_opt.as_ref().and_then(Location::path_opt)
4763                    && locations.contains(location_path)
4764                {
4765                    item.cut = true;
4766                }
4767            }
4768        }
4769    }
4770
4771    pub fn selected_locations(&self) -> Vec<Location> {
4772        if let Some(ref items) = self.items_opt {
4773            items
4774                .iter()
4775                .filter_map(|item| {
4776                    if item.selected {
4777                        item.location_opt.clone()
4778                    } else {
4779                        None
4780                    }
4781                })
4782                .collect()
4783        } else {
4784            Vec::new()
4785        }
4786    }
4787
4788    /// Returns the actual file paths to delete for selected items.
4789    /// TB result items are virtual groupings — expand them to their component files.
4790    pub fn selected_delete_paths(&self) -> Vec<PathBuf> {
4791        let Some(ref items) = self.items_opt else {
4792            return Vec::new();
4793        };
4794        items
4795            .iter()
4796            .filter(|item| item.selected)
4797            .flat_map(|item| {
4798                if item.metadata.is_tbprofiler_result_as_sample() {
4799                    let mut paths = Vec::new();
4800                    if let Some(p) = item.metadata.json_path() {
4801                        paths.push(p.clone());
4802                    }
4803                    if let Some(p) = item.metadata.csv_path() {
4804                        paths.push(p.clone());
4805                    }
4806                    if let Some(p) = item.metadata.docx_path() {
4807                        paths.push(p.clone());
4808                    }
4809                    paths
4810                } else {
4811                    item.location_opt
4812                        .as_ref()
4813                        .and_then(Location::path_opt)
4814                        .map(|p| vec![p.clone()])
4815                        .unwrap_or_default()
4816                }
4817            })
4818            .collect()
4819    }
4820
4821    pub fn select_all(&mut self) {
4822        if let Some(ref mut items) = self.items_opt {
4823            for item in items.iter_mut() {
4824                if !self.config.show_hidden && item.hidden {
4825                    item.selected = false;
4826                    continue;
4827                }
4828                item.selected = true;
4829            }
4830        }
4831    }
4832
4833    pub fn select_none(&mut self) -> bool {
4834        self.select_focus = None;
4835        let mut had_selection = false;
4836        if let Some(ref mut items) = self.items_opt {
4837            for item in items.iter_mut() {
4838                if item.selected {
4839                    item.selected = false;
4840                    had_selection = true;
4841                }
4842            }
4843        }
4844        had_selection
4845    }
4846
4847    pub fn select_name(&mut self, name: &str) {
4848        self.select_focus = None;
4849        if let Some(ref mut items) = self.items_opt {
4850            for (i, item) in items.iter_mut().enumerate() {
4851                item.selected = item.name == name;
4852                if item.selected {
4853                    self.select_focus = Some(i);
4854                }
4855            }
4856        }
4857    }
4858
4859    /// Selects the first item whose name starts with the given prefix (case-insensitive).
4860    /// Returns true if an item was selected.
4861    pub fn select_by_prefix(&mut self, prefix: &str) -> bool {
4862        let prefix_lower = prefix.to_lowercase();
4863        let focus = self.select_focus.take();
4864
4865        if let Some(ref mut items) = self.items_opt {
4866            // First, deselect all items
4867            for item in items.iter_mut() {
4868                item.selected = false;
4869            }
4870
4871            // Determine the start index of the search. When the index is before the currently focused item, it will be
4872            // considered first, otherwise last. Consider the focused item last when only a single character has been
4873            // typed, so we eagerly switch focus on the first character and stay on the same item as long as the prefix
4874            // matches.
4875            let single_char = prefix_lower.chars().count() == 1;
4876            let start = if single_char {
4877                Self::index_after_focus(focus, self.sort_direction)
4878            } else {
4879                Self::index_before_focus(focus, self.sort_direction)
4880            };
4881            self.select_focus = Self::select_first_prefix_from_index(
4882                &prefix_lower,
4883                items,
4884                start,
4885                self.sort_direction,
4886            );
4887
4888            if self.select_focus.is_some() || single_char {
4889                return self.select_focus.is_some();
4890            }
4891
4892            let mut chars = prefix_lower.chars();
4893            let Some(first) = chars.next() else {
4894                log::error!("search term is empty");
4895                return self.select_focus.is_some();
4896            };
4897
4898            // Check if all entered characters are the same
4899            if !chars.all(|c| c == first) {
4900                return self.select_focus.is_some();
4901            }
4902
4903            // Search for a single character when all entered characters are the same.
4904            // This allows cycling through items starting with the same character by repeatedly pressing a key.
4905            let start = Self::index_after_focus(focus, self.sort_direction);
4906            self.select_focus = Self::select_first_prefix_from_index(
4907                &first.to_string(),
4908                items,
4909                start,
4910                self.sort_direction,
4911            );
4912
4913            return self.select_focus.is_some();
4914        }
4915        false
4916    }
4917
4918    fn index_before_focus(current_focus: Option<usize>, forward: bool) -> usize {
4919        current_focus.map_or(0, |i| if forward { i } else { i + 1 })
4920    }
4921
4922    fn index_after_focus(current_focus: Option<usize>, forward: bool) -> usize {
4923        current_focus.map_or(0, |i| if forward { i + 1 } else { i })
4924    }
4925
4926    fn select_first_prefix_from_index(
4927        prefix_lower: &str,
4928        items: &mut [Item],
4929        start: usize,
4930        forward: bool,
4931    ) -> Option<usize> {
4932        // Order the search item so they begin at `start`.
4933        let Some((until, after)) = items.split_at_mut_checked(start) else {
4934            log::error!(
4935                "invalid start index {start} for items of length {}",
4936                items.len()
4937            );
4938            return None;
4939        };
4940        let search_items = after
4941            .iter_mut()
4942            .enumerate()
4943            .map(|(i, item)| (i + start, item))
4944            .chain(until.iter_mut().enumerate());
4945
4946        if forward {
4947            Self::select_first_prefix_match(prefix_lower, search_items)
4948        } else {
4949            Self::select_first_prefix_match(prefix_lower, search_items.rev())
4950        }
4951    }
4952
4953    /// Selects the first item in the given iterator whose name starts with the given prefix.
4954    ///
4955    /// The `prefix` must be lowercase.
4956    fn select_first_prefix_match<'a>(
4957        prefix: &str,
4958        items: impl Iterator<Item = (usize, &'a mut Item)>,
4959    ) -> Option<usize> {
4960        for (i, item) in items {
4961            if item.name.to_lowercase().starts_with(prefix) {
4962                item.selected = true;
4963                return Some(i);
4964            }
4965        }
4966        None
4967    }
4968
4969    pub fn select_paths(&mut self, paths: Vec<PathBuf>) {
4970        self.select_focus = None;
4971        if let Some(ref mut items) = self.items_opt {
4972            for (i, item) in items.iter_mut().enumerate() {
4973                item.selected = false;
4974                if let Some(path) = item.path_opt()
4975                    && paths.contains(path)
4976                {
4977                    item.selected = true;
4978                    self.select_focus = Some(i);
4979                }
4980            }
4981        }
4982    }
4983
4984    fn select_position(&mut self, row: usize, col: usize, mod_shift: bool) -> bool {
4985        let mut start = (row, col);
4986        let mut end = (row, col);
4987        if mod_shift {
4988            if self.select_focus.is_none() || self.select_range.is_none() {
4989                // Set select range to initial state if necessary
4990                self.select_range = self.select_focus.map(|i| (i, i));
4991            }
4992
4993            if let Some(pos) = self.select_range_start_pos_opt() {
4994                if pos.0 < row || (pos.0 == row && pos.1 < col) {
4995                    start = pos;
4996                } else {
4997                    end = pos;
4998                }
4999            }
5000        }
5001
5002        let mut found = false;
5003        if let Some(ref mut items) = self.items_opt {
5004            for (i, item) in items.iter_mut().enumerate() {
5005                item.selected = false;
5006                let pos = match item.pos_opt.get() {
5007                    Some(some) => some,
5008                    None => continue,
5009                };
5010                if pos.0 < start.0 || (pos.0 == start.0 && pos.1 < start.1) {
5011                    // Before start
5012                    continue;
5013                }
5014                if pos.0 > end.0 || (pos.0 == end.0 && pos.1 > end.1) {
5015                    // After end
5016                    continue;
5017                }
5018                if pos == (row, col) {
5019                    // Update focus if this is what we wanted to select
5020                    self.select_focus = Some(i);
5021                    self.select_range = if mod_shift {
5022                        self.select_range.map(|r| (r.0, i))
5023                    } else {
5024                        Some((i, i))
5025                    };
5026                    found = true;
5027                }
5028                item.selected = true;
5029            }
5030        }
5031        found
5032    }
5033
5034    pub fn select_rect(&mut self, rect: Rectangle, mod_ctrl: bool, mod_shift: bool) {
5035        if let Some(ref mut items) = self.items_opt {
5036            for item in items.iter_mut() {
5037                let was_overlapped = item.overlaps_drag_rect;
5038                item.overlaps_drag_rect = item.rect_opt.get().is_some_and(|r| r.intersects(&rect));
5039
5040                item.selected = if mod_ctrl || mod_shift {
5041                    if was_overlapped == item.overlaps_drag_rect {
5042                        item.selected
5043                    } else {
5044                        !item.selected
5045                    }
5046                } else {
5047                    item.overlaps_drag_rect
5048                };
5049            }
5050        }
5051    }
5052
5053    pub fn select_focus_id(&self) -> Option<widget::Id> {
5054        let items = self.items_opt.as_ref()?;
5055        let item = items.get(self.select_focus?)?;
5056        Some(item.button_id.clone())
5057    }
5058
5059    fn select_focus_pos_opt(&self) -> Option<(usize, usize)> {
5060        let items = self.items_opt.as_ref()?;
5061        let item = items.get(self.select_focus?)?;
5062        item.pos_opt.get()
5063    }
5064
5065    fn dehighlight_all(&mut self) {
5066        if let Some(items) = self.items_opt.as_mut() {
5067            for item in items.iter_mut() {
5068                item.highlighted = false;
5069            }
5070        }
5071    }
5072
5073    pub(crate) fn select_focus_scroll(&mut self) -> Option<AbsoluteOffset> {
5074        let items = self.items_opt.as_ref()?;
5075        let item = items.get(self.select_focus?)?;
5076        let rect = item.rect_opt.get()?;
5077
5078        //TODO: move to function
5079        let visible_rect = {
5080            let point = match self.scroll_opt {
5081                Some(offset) => Point::new(0.0, offset.y),
5082                None => Point::new(0.0, 0.0),
5083            };
5084            let size = self
5085                .item_view_size_opt
5086                .get()
5087                .unwrap_or_else(|| Size::new(0.0, 0.0));
5088            Rectangle::new(point, size)
5089        };
5090
5091        if rect.y < visible_rect.y {
5092            // Scroll up to rect
5093            self.scroll_opt = Some(AbsoluteOffset { x: 0.0, y: rect.y });
5094            self.scroll_opt
5095        } else if (rect.y + rect.height) > (visible_rect.y + visible_rect.height) {
5096            // Scroll down to rect
5097            self.scroll_opt = Some(AbsoluteOffset {
5098                x: 0.0,
5099                y: rect.y + rect.height - visible_rect.height,
5100            });
5101            self.scroll_opt
5102        } else {
5103            // Do not scroll
5104            None
5105        }
5106    }
5107
5108    fn rows_per_page(&self) -> usize {
5109        let viewport_height = self
5110            .item_view_size_opt
5111            .get()
5112            .map_or(0.0, |s| s.height);
5113        let row_height = self
5114            .select_focus
5115            .and_then(|i| self.items_opt.as_ref()?.get(i))
5116            .and_then(|item| item.rect_opt.get())
5117            .map_or(0.0, |r| r.height);
5118        if row_height > 0.0 && viewport_height > 0.0 {
5119            (viewport_height / row_height).floor() as usize
5120        } else {
5121            1
5122        }
5123    }
5124
5125    fn select_range_start_pos_opt(&self) -> Option<(usize, usize)> {
5126        let items = self.items_opt.as_ref()?;
5127        let item = items.get(self.select_range.map(|r| r.0)?)?;
5128        item.pos_opt.get()
5129    }
5130
5131    fn select_first_pos_opt(&self) -> Option<(usize, usize)> {
5132        let items = self.items_opt.as_ref()?;
5133        let mut first = None;
5134        for item in items {
5135            if !item.selected {
5136                continue;
5137            }
5138
5139            let (row, col) = match item.pos_opt.get() {
5140                Some(some) => some,
5141                None => continue,
5142            };
5143
5144            first = Some(match first {
5145                Some((first_row, first_col)) => match row.cmp(&first_row) {
5146                    Ordering::Less => (row, col),
5147                    Ordering::Equal => (row, col.min(first_row)),
5148                    Ordering::Greater => (first_row, first_col),
5149                },
5150                None => (row, col),
5151            });
5152        }
5153        first
5154    }
5155
5156    fn select_last_pos_opt(&self) -> Option<(usize, usize)> {
5157        let items = self.items_opt.as_ref()?;
5158        let mut last = None;
5159        for item in items {
5160            if !item.selected {
5161                continue;
5162            }
5163
5164            let (row, col) = match item.pos_opt.get() {
5165                Some(some) => some,
5166                None => continue,
5167            };
5168
5169            last = Some(match last {
5170                Some((last_row, last_col)) => match row.cmp(&last_row) {
5171                    Ordering::Greater => (row, col),
5172                    Ordering::Equal => (row, col.max(last_row)),
5173                    Ordering::Less => (last_row, last_col),
5174                },
5175                None => (row, col),
5176            });
5177        }
5178        last
5179    }
5180
5181    fn trigger_async_decode(&mut self) -> Vec<Command> {
5182        // Only trigger decode in gallery mode for the currently selected image
5183        if !self.gallery {
5184            return Vec::new();
5185        }
5186
5187        let Some(index) = self.select_focus else {
5188            return Vec::new();
5189        };
5190
5191        let Some(items) = &self.items_opt else {
5192            return Vec::new();
5193        };
5194
5195        let Some(item) = items.get(index) else {
5196            return Vec::new();
5197        };
5198
5199        let Some(ItemThumbnail::Image(_, original_dims)) = &item.thumbnail_opt else {
5200            return Vec::new();
5201        };
5202
5203        if let Some((w, h)) = original_dims
5204            && !should_use_tiling(*w, *h)
5205        {
5206            return Vec::new();
5207        }
5208
5209        let Some(path) = item.path_opt() else {
5210            return Vec::new();
5211        };
5212
5213        // Clone path to avoid borrow checker issues
5214        let path = path.to_path_buf();
5215
5216        // Get display size for adaptive resolution
5217        let display_dimensions = self
5218            .size_opt
5219            .get()
5220            .map(|size| (size.width as u32, size.height as u32));
5221
5222        // Try to decode the image using LargeImageManager with adaptive resolution
5223        let (should_decode, target_dimensions, generation) = self
5224            .large_image_manager
5225            .try_decode(&path, display_dimensions);
5226        if should_decode {
5227            vec![Command::Iced(
5228                cosmic::iced::Task::perform(
5229                    decode_large_image(path, target_dimensions),
5230                    move |result| {
5231                        result
5232                            .map(|(path, width, height, pixels)| {
5233                                Message::ImageDecoded(
5234                                    path,
5235                                    width,
5236                                    height,
5237                                    pixels,
5238                                    display_dimensions,
5239                                    generation,
5240                                )
5241                            })
5242                            .unwrap_or_else(|| Message::AutoScroll(None))
5243                    },
5244                )
5245                .into(),
5246            )]
5247        } else {
5248            Vec::new()
5249        }
5250    }
5251
5252    pub fn change_location(&mut self, location: &Location, history_i_opt: Option<usize>) {
5253        self.location = location.normalize();
5254        self.location_ancestors = self.location.ancestors();
5255        self.location_title = self.location.title();
5256        self.context_menu = None;
5257        self.edit_location = None;
5258        self.items_opt = None;
5259        //TODO: remember scroll by location?
5260        self.scroll_opt = None;
5261        self.select_focus = None;
5262        self.search_context = None;
5263        if let Some(history_i) = history_i_opt {
5264            // Navigating in history
5265            self.history_i = history_i;
5266        } else {
5267            // Truncate history to remove next entries
5268            self.history.truncate(self.history_i + 1);
5269
5270            // Compact consecutive matching paths
5271            {
5272                let mut remove = false;
5273                if let Some(last_location) = self.history.last() {
5274                    if let Location::Network(last_uri, ..) = last_location
5275                        && let Location::Network(uri, ..) = location
5276                    {
5277                        remove = last_uri == uri;
5278                    } else if let Location::Remote(last_uri, ..) = last_location
5279                        && let Location::Remote(uri, ..) = location
5280                    {
5281                        remove = last_uri == uri;
5282                    } else if let Some(last_path) = last_location.path_opt()
5283                        && let Some(path) = location.path_opt()
5284                    {
5285                        remove = last_path == path;
5286                    }
5287                }
5288                if remove {
5289                    self.history.pop();
5290                }
5291            }
5292
5293            // Push to the front of history
5294            self.history_i = self.history.len();
5295            self.history.push(location.clone());
5296        }
5297    }
5298
5299    pub fn update(&mut self, message: Message, modifiers: Modifiers) -> Vec<Command> {
5300        let mut commands = Vec::new();
5301        let mut cd = None;
5302        let mut history_i_opt = None;
5303        let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple();
5304        let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple();
5305        let last_context_menu = self.context_menu;
5306        match message {
5307            Message::AddNetworkDrive => {
5308                commands.push(Command::AddNetworkDrive);
5309            }
5310            Message::AddRemoteDrive => {
5311                commands.push(Command::AddRemoteDrive);
5312            }
5313            Message::DeleteTbProfilerResults(uri, tb_config) => {
5314                commands.push(Command::DeleteTbProfilerResults(uri, tb_config));
5315            }
5316            Message::AutoScroll(auto_scroll) => {
5317                commands.push(Command::AutoScroll(auto_scroll));
5318            }
5319            Message::ClickRelease(click_i_opt) => {
5320                // Single click to open.
5321                if !mod_ctrl && self.config.single_click {
5322                    let mut paths_to_open = Vec::new();
5323                    if let Some(ref mut items) = self.items_opt {
5324                        for (i, item) in items.iter_mut().enumerate() {
5325                            if Some(i) == click_i_opt {
5326                                if let Some(location) = &item.location_opt {
5327                                    if item.metadata.is_dir() {
5328                                        cd = Some(location.clone());
5329                                    } else if let Some(path) = location.path_opt() {
5330                                        paths_to_open.push(path.clone());
5331                                    } else {
5332                                        log::warn!("no path for item {item:?}");
5333                                    }
5334                                } else {
5335                                    log::warn!("no location for item {item:?}");
5336                                }
5337                            }
5338                        }
5339                    }
5340                    if !paths_to_open.is_empty() {
5341                        commands.push(Command::OpenFile(paths_to_open));
5342                    }
5343                }
5344
5345                if click_i_opt != self.clicked.take() {
5346                    self.context_menu = None;
5347                    self.location_context_menu_index = None;
5348                    if let Some(ref mut items) = self.items_opt {
5349                        for (i, item) in items.iter_mut().enumerate() {
5350                            if mod_ctrl {
5351                                if Some(i) == click_i_opt && item.selected {
5352                                    item.selected = false;
5353                                    self.select_range = None;
5354                                }
5355                            } else if Some(i) != click_i_opt {
5356                                item.selected = false;
5357                            }
5358                        }
5359                    }
5360                }
5361            }
5362            Message::DragEnd => {
5363                self.clicked = None;
5364                self.watch_drag = true;
5365            }
5366            Message::DoubleClick(click_i_opt) => {
5367                if let Some(clicked_item) = self
5368                    .items_opt
5369                    .as_ref()
5370                    .and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i)))
5371                {
5372                    if let Some(location) = &clicked_item.location_opt {
5373                        if clicked_item.metadata.is_dir() {
5374                            cd = Some(location.clone());
5375                        } else if let Some(path) = location.path_opt() {
5376                            commands.push(Command::OpenFile(vec![path.clone()]));
5377                        } else {
5378                            log::warn!("no path for item {clicked_item:?}");
5379                        }
5380                    } else {
5381                        log::warn!("no location for item {clicked_item:?}");
5382                    }
5383                } else {
5384                    log::warn!("no item for click index {click_i_opt:?}");
5385                }
5386            }
5387            Message::Click(click_i_opt) => {
5388                self.selected_clicked = false;
5389                self.context_menu = None;
5390                self.edit_location = None;
5391                self.location_context_menu_index = None;
5392                if click_i_opt.is_none() {
5393                    self.clicked = click_i_opt;
5394                }
5395
5396                if mod_shift {
5397                    if let Some(click_i) = click_i_opt {
5398                        self.select_range = self
5399                            .select_range
5400                            .map_or(Some((click_i, click_i)), |r| Some((r.0, click_i)));
5401                        if let Some(range) = self.select_range {
5402                            let range_min = range.0.min(range.1);
5403                            let range_max = range.0.max(range.1);
5404                            // A sorted tab's items can't be linearly selected
5405                            // Let's say we have:
5406                            // index | file
5407                            // 0     | file0
5408                            // 1     | file1
5409                            // 2     | file2
5410                            // This is both the default sort and internal ordering
5411                            // When sorted it may be displayed as:
5412                            // 1     | file1
5413                            // 0     | file0
5414                            // 2     | file2
5415                            // However, the internal ordering is still the same thus
5416                            // linearly selecting items doesn't work. Shift selecting
5417                            // file0 and file2 would select indices 0 to 2 when it should
5418                            // select indices 0 AND 2 from items_opt
5419                            let indices: Vec<_> = self
5420                                .column_sort()
5421                                .map(|sorted| sorted.into_iter().map(|(i, _)| i).collect())
5422                                .unwrap_or_else(|| {
5423                                    let len = self
5424                                        .items_opt
5425                                        .as_deref()
5426                                        .map(<[Item]>::len)
5427                                        .unwrap_or_default();
5428                                    (0..len).collect()
5429                                });
5430
5431                            // Find the true indices for the min and max element w.r.t.
5432                            // a sorted tab.
5433                            let min = indices
5434                                .iter()
5435                                .copied()
5436                                .position(|offset| offset == range_min)
5437                                .unwrap_or_default();
5438                            // We can't skip `min_real` elements here because the index of
5439                            // `max` may actually be before `min` in a sorted tab
5440                            let max = indices
5441                                .iter()
5442                                .copied()
5443                                .position(|offset| offset == range_max)
5444                                .unwrap_or(indices.len());
5445                            let min_real = min.min(max);
5446                            let max_real = max.max(min);
5447
5448                            if let Some(ref mut items) = self.items_opt {
5449                                for index in indices
5450                                    .into_iter()
5451                                    .skip(min_real)
5452                                    .take(max_real - min_real + 1)
5453                                {
5454                                    if let Some(item) = items.get_mut(index) {
5455                                        if item.hidden {
5456                                            if self.config.show_hidden {
5457                                                item.selected = true;
5458                                            }
5459                                        } else {
5460                                            item.selected = true;
5461                                        }
5462                                    }
5463                                }
5464                            }
5465                        }
5466                        self.clicked = click_i_opt;
5467                        self.select_focus = click_i_opt;
5468                        self.selected_clicked = true;
5469                    }
5470                } else {
5471                    let dont_unset = mod_ctrl
5472                        || self.column_sort().is_some_and(|l| {
5473                            l.iter()
5474                                .any(|&(e_i, e)| Some(e_i) == click_i_opt && e.selected)
5475                        });
5476                    if let Some(ref mut items) = self.items_opt {
5477                        for (i, item) in items.iter_mut().enumerate() {
5478                            if Some(i) == click_i_opt {
5479                                // Filter out selection if it does not match dialog kind
5480                                if let Mode::Dialog(dialog) = &self.mode {
5481                                    let item_is_dir = item.metadata.is_dir();
5482                                    if item_is_dir != dialog.is_dir() {
5483                                        // Allow selecting folder if dialog is for files to make it
5484                                        // possible to double click
5485                                        //TODO: clear any other selection when selecting a folder
5486                                        if !item_is_dir {
5487                                            continue;
5488                                        }
5489                                    }
5490                                }
5491                                if !item.selected {
5492                                    self.clicked = click_i_opt;
5493                                    item.selected = true;
5494                                }
5495                                self.select_range = Some((i, i));
5496                                self.select_focus = click_i_opt;
5497                                self.selected_clicked = true;
5498                            } else if !dont_unset && item.selected {
5499                                self.clicked = click_i_opt;
5500                                item.selected = false;
5501                            }
5502                        }
5503                    }
5504                }
5505            }
5506            Message::Config(config) => {
5507                // View is preserved for existing tabs
5508                let view = self.config.view;
5509                let military_time_changed = self.config.military_time != config.military_time;
5510                let show_hidden_changed = self.config.show_hidden != config.show_hidden;
5511                self.config = config;
5512                self.config.view = view;
5513                if military_time_changed {
5514                    self.date_time_formatter = date_time_formatter(self.config.military_time);
5515                    self.time_formatter = time_formatter(self.config.military_time);
5516                }
5517                if show_hidden_changed && let Location::Search(path, term, ..) = &self.location {
5518                    cd = Some(Location::Search(
5519                        path.clone(),
5520                        term.clone(),
5521                        self.config.show_hidden,
5522                        Instant::now(),
5523                    ));
5524                }
5525                // Unhighlight all items when config changes
5526                if let Some(ref mut items) = self.items_opt {
5527                    for item in items.iter_mut() {
5528                        item.highlighted = false;
5529                    }
5530                }
5531            }
5532            Message::ContextAction(action) => {
5533                // Close context menu
5534                self.context_menu = None;
5535
5536                commands.push(Command::Action(action));
5537            }
5538            Message::RunContextAction(action) => {
5539                self.context_menu = None;
5540
5541                commands.push(Command::RunContextAction(action));
5542            }
5543            Message::ContextMenu(point_opt, _) => {
5544                self.edit_location = None;
5545                self.context_menu = point_opt;
5546                self.location_context_menu_index = None;
5547
5548                //TODO: hack for clearing selecting when right clicking empty space
5549                if self.context_menu.is_some()
5550                    && self.last_right_click.take().is_none()
5551                    && let Some(ref mut items) = self.items_opt
5552                {
5553                    for item in items.iter_mut() {
5554                        item.selected = false;
5555                    }
5556                }
5557            }
5558            Message::Download(path_uri_opt) => {
5559                if let Some(path_uri) = path_uri_opt {
5560                    commands.push(Command::DownloadFile(vec![path_uri.0], vec![path_uri.1]));
5561                }
5562            }
5563            Message::DownloadMany(paths, uris) => {
5564                commands.push(Command::DownloadFile(paths, uris));
5565            }
5566            Message::LocationContextMenuPoint(point_opt) => {
5567                self.context_menu = None;
5568                self.location_context_menu_point = point_opt;
5569            }
5570            Message::LocationContextMenuIndex(p, index_opt) => {
5571                self.context_menu = None;
5572                self.location_context_menu_point = p;
5573                self.location_context_menu_index = index_opt;
5574            }
5575            Message::LocationMenuAction(action) => {
5576                self.location_context_menu_index = None;
5577                let path_for_index = |ancestor_index| {
5578                    self.location
5579                        .path_opt()
5580                        .and_then(|path| path.ancestors().nth(ancestor_index))
5581                        .map(Path::to_path_buf)
5582                };
5583                match action {
5584                    LocationMenuAction::OpenInNewTab(ancestor_index) => {
5585                        if let Some(path) = path_for_index(ancestor_index) {
5586                            commands.push(Command::OpenInNewTab(path));
5587                        }
5588                    }
5589                    LocationMenuAction::OpenInNewWindow(ancestor_index) => {
5590                        if let Some(path) = path_for_index(ancestor_index) {
5591                            commands.push(Command::OpenInNewWindow(path));
5592                        }
5593                    }
5594                    LocationMenuAction::Preview(ancestor_index) => {
5595                        if let Some(path) = path_for_index(ancestor_index) {
5596                            //TODO: blocking code, run in command
5597                            match item_from_path(&path, IconSizes::default()) {
5598                                Ok(item) => {
5599                                    commands.push(Command::Preview(PreviewKind::Custom(
5600                                        PreviewItem(Box::new(item)),
5601                                    )));
5602                                }
5603                                Err(err) => {
5604                                    log::warn!(
5605                                        "failed to get item from path {}: {}",
5606                                        path.display(),
5607                                        err
5608                                    );
5609                                }
5610                            }
5611                        }
5612                    }
5613                    LocationMenuAction::AddToSidebar(ancestor_index) => {
5614                        if let Some(path) = path_for_index(ancestor_index) {
5615                            commands.push(Command::AddToSidebar(path));
5616                        } else {
5617                            log::warn!(
5618                                "no ancestor {ancestor_index} for location {:?}",
5619                                self.location
5620                            );
5621                        }
5622                    }
5623                }
5624            }
5625            Message::Drag(rect_opt) => {
5626                self.watch_drag = false;
5627                if let Some(rect) = rect_opt {
5628                    self.context_menu = None;
5629                    self.location_context_menu_index = None;
5630                    if self.mode.multiple() {
5631                        self.select_rect(rect, mod_ctrl, mod_shift);
5632                    }
5633                    if self.select_focus.take().is_some() {
5634                        // Unfocus currently focused button
5635                        commands.push(Command::Iced(
5636                            widget::button::focus(widget::Id::unique()).into(),
5637                        ));
5638                    }
5639                }
5640            }
5641            Message::EditLocation(edit_location) => {
5642                self.edit_location = edit_location;
5643                if self.edit_location.is_some() {
5644                    commands.push(Command::Iced(
5645                        widget::text_input::focus(self.edit_location_id.clone()).into(),
5646                    ));
5647                }
5648            }
5649            Message::EditLocationComplete(selected) => {
5650                if let Some(mut edit_location) = self.edit_location.take()
5651                    && !matches!(
5652                        edit_location.location,
5653                        Location::Network(..) | Location::Remote(..)
5654                    )
5655                {
5656                    edit_location.selected = Some(selected);
5657                    cd = edit_location.resolve();
5658                }
5659            }
5660            Message::EditLocationEnable => {
5661                commands.push(Command::Iced(
5662                    widget::text_input::focus(self.edit_location_id.clone()).into(),
5663                ));
5664                self.edit_location = Some(self.location.clone().into());
5665            }
5666            Message::EditLocationSubmit => {
5667                if let Some(mut edit_location) = self.edit_location.take() {
5668                    // Select first completion if current location does not exist
5669                    if edit_location.selected.is_none()
5670                        && edit_location
5671                            .completions
5672                            .as_ref()
5673                            .is_some_and(|completions| !completions.is_empty())
5674                        && edit_location
5675                            .location
5676                            .path_opt()
5677                            .is_some_and(|path| !path.exists())
5678                    {
5679                        edit_location.selected = Some(0);
5680                    }
5681
5682                    cd = edit_location.resolve();
5683                }
5684            }
5685            Message::EditLocationTab => {
5686                if let Some(edit_location) = &mut self.edit_location {
5687                    edit_location.select(!modifiers.contains(Modifiers::SHIFT));
5688                }
5689            }
5690            Message::OpenInNewTab(path) => {
5691                commands.push(Command::OpenInNewTab(path));
5692            }
5693            Message::ClearRecents => {
5694                commands.push(Command::ClearRecents);
5695            }
5696            Message::EmptyTrash => {
5697                commands.push(Command::EmptyTrash);
5698            }
5699            #[cfg(feature = "desktop")]
5700            Message::ExecEntryAction(path, action) => {
5701                let lang_id = crate::localize::LANGUAGE_LOADER.current_language();
5702                let language = lang_id.language.as_str();
5703                match path.map_or_else(
5704                    || {
5705                        let items = self.items_opt.as_deref()?;
5706                        items.iter().find(|&item| item.selected).and_then(|item| {
5707                            let location = item.location_opt.as_ref()?;
5708                            let path = location.path_opt()?;
5709                            cosmic::desktop::load_desktop_file(&[language.into()], path.into())
5710                        })
5711                    },
5712                    |path| cosmic::desktop::load_desktop_file(&[language.into()], path),
5713                ) {
5714                    Some(entry) => commands.push(Command::ExecEntryAction(entry, action)),
5715                    None => log::warn!("Invalid desktop entry path passed to ExecEntryAction"),
5716                }
5717            }
5718            Message::Gallery(gallery) => {
5719                self.gallery = gallery;
5720
5721                if gallery {
5722                    commands.extend(self.trigger_async_decode());
5723                }
5724            }
5725            Message::GalleryPrevious | Message::GalleryNext => {
5726                let mut pos_opt = None;
5727                if let Some(mut indices) = self.column_sort() {
5728                    if matches!(message, Message::GalleryPrevious) {
5729                        indices.reverse();
5730                    }
5731                    let mut found = false;
5732                    for (index, item) in indices {
5733                        if self.select_focus.is_none() {
5734                            found = true;
5735                        }
5736                        if self.select_focus == Some(index) {
5737                            found = true;
5738                            continue;
5739                        }
5740                        if found && item.can_gallery() {
5741                            pos_opt = item.pos_opt.get();
5742                            if pos_opt.is_some() {
5743                                break;
5744                            }
5745                        }
5746                    }
5747                }
5748                if let Some((row, col)) = pos_opt {
5749                    // Should mod_shift be available?
5750                    self.select_position(row, col, mod_shift);
5751
5752                    commands.extend(self.trigger_async_decode());
5753                }
5754                if let Some(offset) = self.select_focus_scroll() {
5755                    commands.push(Command::Iced(
5756                        scrollable::scroll_to(
5757                            self.scrollable_id.clone(),
5758                            AbsoluteOffset {
5759                                x: Some(offset.x),
5760                                y: Some(offset.y),
5761                            },
5762                        )
5763                        .into(),
5764                    ));
5765                }
5766                if let Some(id) = self.select_focus_id() {
5767                    commands.push(Command::Iced(widget::button::focus(id).into()));
5768                }
5769            }
5770            Message::GalleryToggle => {
5771                if let Some(indices) = self.column_sort() {
5772                    for (_, item) in &indices {
5773                        if item.selected && item.can_gallery() {
5774                            self.gallery = !self.gallery;
5775
5776                            if self.gallery {
5777                                commands.extend(self.trigger_async_decode());
5778                            }
5779                            break;
5780                        }
5781                    }
5782                }
5783            }
5784            Message::GoNext => {
5785                if let Some(history_i) = self.history_i.checked_add(1)
5786                    && let Some(location) = self.history.get(history_i)
5787                {
5788                    cd = Some(location.clone());
5789                    history_i_opt = Some(history_i);
5790                }
5791            }
5792            Message::GoPrevious => {
5793                if let Some(history_i) = self.history_i.checked_sub(1)
5794                    && let Some(location) = self.history.get(history_i)
5795                {
5796                    cd = Some(location.clone());
5797                    history_i_opt = Some(history_i);
5798                }
5799            }
5800            Message::ItemDown => {
5801                self.dehighlight_all();
5802                if let Some(edit_location) = &mut self.edit_location {
5803                    edit_location.select(true);
5804                } else if self.gallery {
5805                    commands.append(&mut self.update(Message::GalleryNext, modifiers));
5806                } else {
5807                    if let Some((row, col)) =
5808                        self.select_focus_pos_opt().or(self.select_last_pos_opt())
5809                    {
5810                        if self.select_focus.is_none() {
5811                            // Select last item in current selection to focus it.
5812                            self.select_position(row, col, mod_shift);
5813                        }
5814
5815                        //TODO: Shift modifier should select items in between
5816                        // Try to select item in next row
5817                        if !self.select_position(row + 1, col, mod_shift) {
5818                            // Ensure current item is still selected if there are no other items
5819                            self.select_position(row, col, mod_shift);
5820                        }
5821                    } else {
5822                        // Select first item
5823                        //TODO: select first in scroll
5824                        self.select_position(0, 0, mod_shift);
5825                    }
5826                    if let Some(offset) = self.select_focus_scroll() {
5827                        commands.push(Command::Iced(
5828                            scrollable::scroll_to(
5829                                self.scrollable_id.clone(),
5830                                AbsoluteOffset {
5831                                    x: Some(offset.x),
5832                                    y: Some(offset.y),
5833                                },
5834                            )
5835                            .into(),
5836                        ));
5837                    }
5838                    if let Some(id) = self.select_focus_id() {
5839                        commands.push(Command::Iced(widget::button::focus(id).into()));
5840                    }
5841                }
5842            }
5843            Message::ItemPageDown => {
5844                self.dehighlight_all();
5845                if let Some((row, col)) =
5846                    self.select_focus_pos_opt().or(self.select_last_pos_opt())
5847                {
5848                    if self.select_focus.is_none() {
5849                        self.select_position(row, col, mod_shift);
5850                    }
5851
5852                    let rows_per_page = self.rows_per_page().max(1);
5853                    let target_row = row.saturating_add(rows_per_page);
5854
5855                    if !self.select_position(target_row, col, mod_shift) {
5856                        // Fall back to the last item at or before target_row
5857                        let best = self
5858                            .items_opt
5859                            .as_ref()
5860                            .and_then(|items| {
5861                                items
5862                                    .iter()
5863                                    .filter_map(|item| item.pos_opt.get())
5864                                    .filter(|(r, _)| *r <= target_row)
5865                                    .max()
5866                            });
5867                        if let Some((best_row, best_col)) = best {
5868                            self.select_position(best_row, best_col, mod_shift);
5869                        }
5870                    }
5871                } else {
5872                    self.select_position(0, 0, mod_shift);
5873                }
5874                if let Some(offset) = self.select_focus_scroll() {
5875                    commands.push(Command::Iced(
5876                        scrollable::scroll_to(
5877                            self.scrollable_id.clone(),
5878                            AbsoluteOffset {
5879                                x: Some(offset.x),
5880                                y: Some(offset.y),
5881                            },
5882                        )
5883                        .into(),
5884                    ));
5885                }
5886                if let Some(id) = self.select_focus_id() {
5887                    commands.push(Command::Iced(widget::button::focus(id).into()));
5888                }
5889            }
5890            Message::ItemPageUp => {
5891                self.dehighlight_all();
5892                if let Some((row, col)) =
5893                    self.select_focus_pos_opt().or(self.select_first_pos_opt())
5894                {
5895                    if self.select_focus.is_none() {
5896                        self.select_position(row, col, mod_shift);
5897                    }
5898
5899                    let rows_per_page = self.rows_per_page().max(1);
5900                    let target_row = row.saturating_sub(rows_per_page);
5901
5902                    if !self.select_position(target_row, col, mod_shift) {
5903                        // Fall back to the first item at or after target_row
5904                        let best = self
5905                            .items_opt
5906                            .as_ref()
5907                            .and_then(|items| {
5908                                items
5909                                    .iter()
5910                                    .filter_map(|item| item.pos_opt.get())
5911                                    .filter(|(r, _)| *r >= target_row)
5912                                    .min()
5913                            });
5914                        if let Some((best_row, best_col)) = best {
5915                            self.select_position(best_row, best_col, mod_shift);
5916                        }
5917                    }
5918                } else {
5919                    self.select_position(0, 0, mod_shift);
5920                }
5921                if let Some(offset) = self.select_focus_scroll() {
5922                    commands.push(Command::Iced(
5923                        scrollable::scroll_to(
5924                            self.scrollable_id.clone(),
5925                            AbsoluteOffset {
5926                                x: Some(offset.x),
5927                                y: Some(offset.y),
5928                            },
5929                        )
5930                        .into(),
5931                    ));
5932                }
5933                if let Some(id) = self.select_focus_id() {
5934                    commands.push(Command::Iced(widget::button::focus(id).into()));
5935                }
5936            }
5937            Message::ItemLeft => {
5938                self.dehighlight_all();
5939                if self.gallery {
5940                    commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
5941                } else {
5942                    if let Some((row, col)) =
5943                        self.select_focus_pos_opt().or(self.select_first_pos_opt())
5944                    {
5945                        if self.select_focus.is_none() {
5946                            // Select first item in current selection to focus it.
5947                            self.select_position(row, col, mod_shift);
5948                        }
5949
5950                        // Try to select previous item in current row
5951                        if !col
5952                            .checked_sub(1)
5953                            .is_some_and(|col| self.select_position(row, col, mod_shift))
5954                        {
5955                            // Try to select last item in previous row
5956                            if !row.checked_sub(1).is_some_and(|row| {
5957                                let mut col = 0;
5958                                if let Some(ref items) = self.items_opt {
5959                                    for item in items {
5960                                        match item.pos_opt.get() {
5961                                            Some((item_row, item_col)) if item_row == row => {
5962                                                col = col.max(item_col);
5963                                            }
5964                                            _ => continue,
5965                                        }
5966                                    }
5967                                }
5968                                self.select_position(row, col, mod_shift)
5969                            }) {
5970                                // Ensure current item is still selected if there are no other items
5971                                self.select_position(row, col, mod_shift);
5972                            }
5973                        }
5974                    } else {
5975                        // Select first item
5976                        //TODO: select first in scroll
5977                        self.select_position(0, 0, mod_shift);
5978                    }
5979                    if let Some(offset) = self.select_focus_scroll() {
5980                        commands.push(Command::Iced(
5981                            scrollable::scroll_to(
5982                                self.scrollable_id.clone(),
5983                                AbsoluteOffset {
5984                                    x: Some(offset.x),
5985                                    y: Some(offset.y),
5986                                },
5987                            )
5988                            .into(),
5989                        ));
5990                    }
5991                    if let Some(id) = self.select_focus_id() {
5992                        commands.push(Command::Iced(widget::button::focus(id).into()));
5993                    }
5994                }
5995            }
5996            Message::ItemRight => {
5997                self.dehighlight_all();
5998                if self.gallery {
5999                    commands.append(&mut self.update(Message::GalleryNext, modifiers));
6000                } else {
6001                    if let Some((row, col)) =
6002                        self.select_focus_pos_opt().or(self.select_last_pos_opt())
6003                    {
6004                        if self.select_focus.is_none() {
6005                            // Select last item in current selection to focus it.
6006                            self.select_position(row, col, mod_shift);
6007                        }
6008                        // Try to select next item in current row
6009                        if !self.select_position(row, col + 1, mod_shift) {
6010                            // Try to select first item in next row
6011                            if !self.select_position(row + 1, 0, mod_shift) {
6012                                // Ensure current item is still selected if there are no other items
6013                                self.select_position(row, col, mod_shift);
6014                            }
6015                        }
6016                    } else {
6017                        // Select first item
6018                        //TODO: select first in scroll
6019                        self.select_position(0, 0, mod_shift);
6020                    }
6021                    if let Some(offset) = self.select_focus_scroll() {
6022                        commands.push(Command::Iced(
6023                            scrollable::scroll_to(
6024                                self.scrollable_id.clone(),
6025                                AbsoluteOffset {
6026                                    x: Some(offset.x),
6027                                    y: Some(offset.y),
6028                                },
6029                            )
6030                            .into(),
6031                        ));
6032                    }
6033                    if let Some(id) = self.select_focus_id() {
6034                        commands.push(Command::Iced(widget::button::focus(id).into()));
6035                    }
6036                }
6037            }
6038            Message::ItemUp => {
6039                self.dehighlight_all();
6040                if let Some(edit_location) = &mut self.edit_location {
6041                    edit_location.select(false);
6042                } else if self.gallery {
6043                    commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
6044                } else {
6045                    if let Some((row, col)) =
6046                        self.select_focus_pos_opt().or(self.select_first_pos_opt())
6047                    {
6048                        if self.select_focus.is_none() {
6049                            // Select first item in current selection to focus it.
6050                            self.select_position(row, col, mod_shift);
6051                        }
6052
6053                        //TODO: Shift modifier should select items in between
6054                        // Try to select item in last row
6055                        if !row
6056                            .checked_sub(1)
6057                            .is_some_and(|row| self.select_position(row, col, mod_shift))
6058                        {
6059                            // Ensure current item is still selected if there are no other items
6060                            self.select_position(row, col, mod_shift);
6061                        }
6062                    } else {
6063                        // Select first item
6064                        //TODO: select first in scroll
6065                        self.select_position(0, 0, mod_shift);
6066                    }
6067                    if let Some(offset) = self.select_focus_scroll() {
6068                        commands.push(Command::Iced(
6069                            scrollable::scroll_to(
6070                                self.scrollable_id.clone(),
6071                                AbsoluteOffset {
6072                                    x: Some(offset.x),
6073                                    y: Some(offset.y),
6074                                },
6075                            )
6076                            .into(),
6077                        ));
6078                    }
6079                    if let Some(id) = self.select_focus_id() {
6080                        commands.push(Command::Iced(widget::button::focus(id).into()));
6081                    }
6082                }
6083            }
6084            Message::Location(location) => {
6085                // Workaround to support favorited files
6086                match &location {
6087                    Location::Path(path) => {
6088                        if path.is_dir() {
6089                            cd = Some(location);
6090                        } else {
6091                            commands.push(Command::OpenFile(vec![path.clone()]));
6092                        }
6093                    }
6094                    #[cfg(feature = "russh")]
6095                    Location::Remote(..) => {
6096                        cd = Some(location);
6097                    }
6098                    _ => {
6099                        cd = Some(location);
6100                    }
6101                }
6102            }
6103            Message::LocationUp => {
6104                // Sets location to the path's parent
6105                // Does nothing if path is root or location is Trash
6106                if let Location::Path(ref path) = self.location
6107                    && let Some(parent) = path.parent()
6108                {
6109                    cd = Some(Location::Path(parent.to_owned()));
6110                }
6111            }
6112            Message::Open(path_opt) => {
6113                match path_opt {
6114                    Some(path) => {
6115                        if path.is_dir() {
6116                            cd = Some(Location::Path(path));
6117                        } else {
6118                            commands.push(Command::OpenFile(vec![path]));
6119                        }
6120                    }
6121                    // Open selected items
6122                    None => {
6123                        enum ResolveResult {
6124                            Open(Option<PathBuf>),
6125                            OpenInTab(Option<PathBuf>),
6126                            OpenUriInTab(String, String, Option<PathBuf>),
6127                            OpenTrash,
6128                            OpenProperties,
6129                            Cd(Location),
6130                            Skip,
6131                        }
6132                        fn resolve_item(
6133                            item: &Item,
6134                            mode: &Mode,
6135                            is_only_one_selected: bool,
6136                        ) -> ResolveResult {
6137                            if !item.selected {
6138                                return ResolveResult::Skip;
6139                            }
6140
6141                            let location = match &item.location_opt {
6142                                Some(l) => l,
6143                                None => return ResolveResult::OpenProperties,
6144                            };
6145
6146                            let path_opt = location.path_opt();
6147
6148                            if item.metadata.is_dir() {
6149                                match mode {
6150                                    Mode::App => match &item.location_opt {
6151                                        Some(Location::Remote(uri, name, path)) => {
6152                                            if is_only_one_selected {
6153                                                ResolveResult::Cd(location.clone())
6154                                            } else {
6155                                                ResolveResult::OpenUriInTab(
6156                                                    uri.clone(),
6157                                                    name.clone(),
6158                                                    path.clone(),
6159                                                )
6160                                            }
6161                                        }
6162                                        _ => {
6163                                            if is_only_one_selected {
6164                                                ResolveResult::Cd(location.clone())
6165                                            } else {
6166                                                ResolveResult::OpenInTab(path_opt.cloned())
6167                                            }
6168                                        }
6169                                    },
6170                                    Mode::Desktop => match location {
6171                                        Location::Trash => ResolveResult::OpenTrash,
6172                                        _ => ResolveResult::Open(path_opt.cloned()),
6173                                    },
6174                                    Mode::Dialog(_) => {
6175                                        if is_only_one_selected {
6176                                            ResolveResult::Cd(location.clone())
6177                                        } else {
6178                                            ResolveResult::Skip
6179                                        }
6180                                    }
6181                                }
6182                            } else {
6183                                ResolveResult::Open(path_opt.cloned())
6184                            }
6185                        }
6186                        let mut open_files = Vec::new();
6187                        if let Some(items) = self.items_opt.as_ref() {
6188                            let selected_count = items.iter().filter(|i| i.selected).count();
6189
6190                            for item in items.iter() {
6191                                match resolve_item(item, &self.mode, selected_count == 1) {
6192                                    ResolveResult::Open(Some(p)) => open_files.push(p),
6193                                    ResolveResult::OpenInTab(Some(p)) => {
6194                                        commands.push(Command::OpenInNewTab(p))
6195                                    }
6196                                    ResolveResult::OpenUriInTab(uri, name, path) => {
6197                                        commands.push(Command::OpenUriInNewTab(uri, name, path))
6198                                    }
6199                                    ResolveResult::Cd(loc) => cd = Some(loc),
6200                                    ResolveResult::OpenTrash => commands.push(Command::OpenTrash),
6201                                    ResolveResult::OpenProperties => {} //TODO: open properties?
6202                                    _ => {}
6203                                }
6204                            }
6205                        }
6206                        if !open_files.is_empty() {
6207                            commands.push(Command::OpenFile(open_files));
6208                        }
6209                    }
6210                }
6211            }
6212            Message::Reload => {
6213                //TODO: support keeping selected locations without paths
6214                let selected_paths = self
6215                    .selected_locations()
6216                    .into_iter()
6217                    .filter_map(Location::into_path_opt)
6218                    .collect();
6219                let location = self.location.clone();
6220                self.change_location(&location, None);
6221                commands.push(Command::ChangeLocation(
6222                    self.title(),
6223                    location,
6224                    Some(selected_paths),
6225                ));
6226            }
6227            Message::RightClick(_point_opt, click_i_opt) => {
6228                if mod_ctrl || mod_shift {
6229                    self.update(Message::Click(click_i_opt), modifiers);
6230                }
6231                if let Some(ref mut items) = self.items_opt
6232                    && !click_i_opt
6233                        .is_some_and(|click_i| items.get(click_i).is_some_and(|x| x.selected))
6234                {
6235                    // If item not selected, clear selection on other items
6236                    for (i, item) in items.iter_mut().enumerate() {
6237                        item.selected = Some(i) == click_i_opt;
6238                    }
6239                }
6240                //TODO: hack for clearing selecting when right clicking empty space
6241                self.last_right_click = click_i_opt;
6242            }
6243            Message::MiddleClick(click_i) => {
6244                if mod_ctrl || mod_shift {
6245                    self.update(Message::Click(Some(click_i)), modifiers);
6246                } else {
6247                    if let Some(ref mut items) = self.items_opt {
6248                        for (i, item) in items.iter_mut().enumerate() {
6249                            item.selected = i == click_i;
6250                        }
6251                        self.select_range = Some((click_i, click_i));
6252                    }
6253                    if let Some(clicked_item) =
6254                        self.items_opt.as_ref().and_then(|items| items.get(click_i))
6255                    {
6256                        if let Some(path) = clicked_item.path_opt() {
6257                            if clicked_item.metadata.is_dir() {
6258                                //cd = Some(Location::Path(path.clone()));
6259                                commands.push(Command::OpenInNewTab(path.clone()));
6260                            } else {
6261                                commands.push(Command::OpenFile(vec![path.clone()]));
6262                            }
6263                        } else {
6264                            log::warn!("no path for item {clicked_item:?}");
6265                        }
6266                    } else {
6267                        log::warn!("no item for click index {click_i:?}");
6268                    }
6269                }
6270            }
6271            Message::HighlightDeactivate(i) => {
6272                self.watch_drag = true;
6273                if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
6274                    item.highlighted = false;
6275                }
6276            }
6277            Message::HighlightActivate(i) => {
6278                self.watch_drag = true;
6279                if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
6280                    item.highlighted = true;
6281                }
6282            }
6283            Message::Resize(viewport) => {
6284                // Scroll to ensure focused item still in view
6285                if self.viewport_opt.map(|v| v.size()) != Some(viewport.size())
6286                    && let Some(offset) = self.select_focus_scroll()
6287                {
6288                    commands.push(Command::Iced(
6289                        scrollable::scroll_to(
6290                            self.scrollable_id.clone(),
6291                            AbsoluteOffset {
6292                                x: Some(offset.x),
6293                                y: Some(offset.y),
6294                            },
6295                        )
6296                        .into(),
6297                    ));
6298                }
6299
6300                self.viewport_opt = Some(viewport);
6301            }
6302            Message::Scroll(viewport) => {
6303                self.scroll_opt = Some(viewport.absolute_offset());
6304                self.watch_drag = true;
6305            }
6306            Message::ScrollTab(scroll_speed) => {
6307                commands.push(Command::Iced(
6308                    scrollable::scroll_by(
6309                        self.scrollable_id.clone(),
6310                        AbsoluteOffset {
6311                            x: 0.0,
6312                            y: scroll_speed,
6313                        },
6314                    )
6315                    .into(),
6316                ));
6317            }
6318            Message::ScrollToFocused => {
6319                if let Some(offset) = self.select_focus_scroll() {
6320                    commands.push(Command::Iced(
6321                        scrollable::scroll_to(
6322                            self.scrollable_id.clone(),
6323                            AbsoluteOffset {
6324                                x: Some(offset.x),
6325                                y: Some(offset.y),
6326                            },
6327                        )
6328                        .into(),
6329                    ));
6330                }
6331            }
6332            Message::SearchContext(location, context) => {
6333                if location == self.location {
6334                    self.search_context = context.0;
6335                } else {
6336                    log::warn!(
6337                        "search context provided for {:?} instead of {:?}",
6338                        location,
6339                        self.location
6340                    );
6341                }
6342            }
6343            Message::SearchReady(finished) => {
6344                if let Some(context) = &mut self.search_context {
6345                    if let Some(items) = &mut self.items_opt {
6346                        if finished || context.ready.swap(false, atomic::Ordering::SeqCst) {
6347                            let duration = Instant::now();
6348                            while let Ok(search_item) = context.results_rx.try_recv() {
6349                                //TODO: combine this with column_sort logic, they must match!
6350                                let index =
6351                                    if let SearchItem::Path(_, _, ref metadata) = search_item {
6352                                        let item_modified = metadata.modified().ok();
6353                                        match items.binary_search_by(|other| {
6354                                            item_modified.cmp(&other.metadata.modified())
6355                                        }) {
6356                                            Ok(index) => index,
6357                                            Err(index) => index,
6358                                        }
6359                                    } else {
6360                                        items.len()
6361                                    };
6362
6363                                if index < MAX_SEARCH_RESULTS {
6364                                    //TODO: use correct IconSizes
6365                                    let item =
6366                                        item_from_search_item(search_item, IconSizes::default());
6367                                    items.insert(index, item);
6368                                }
6369                                // Ensure that updates make it to the GUI in a timely manner
6370                                if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY {
6371                                    break;
6372                                }
6373                            }
6374                        }
6375                        if items.len() >= MAX_SEARCH_RESULTS {
6376                            items.truncate(MAX_SEARCH_RESULTS);
6377                            if let Some(last_modified) =
6378                                items.last().and_then(|item| item.metadata.modified())
6379                            {
6380                                *context.last_modified_opt.write().unwrap() = Some(last_modified);
6381                            }
6382                        }
6383                    } else {
6384                        log::warn!("search ready but items array is empty");
6385                    }
6386                }
6387                if finished {
6388                    self.search_context = None;
6389                }
6390            }
6391            Message::SelectAll => {
6392                self.select_all();
6393                if self.select_focus.take().is_some() {
6394                    // Unfocus currently focused button
6395                    commands.push(Command::Iced(
6396                        widget::button::focus(widget::Id::unique()).into(),
6397                    ));
6398                }
6399            }
6400            Message::SelectFirst => {
6401                if self.select_position(0, 0, mod_shift) {
6402                    if let Some(offset) = self.select_focus_scroll() {
6403                        commands.push(Command::Iced(
6404                            scrollable::scroll_to(
6405                                self.scrollable_id.clone(),
6406                                AbsoluteOffset {
6407                                    x: Some(offset.x),
6408                                    y: Some(offset.y),
6409                                },
6410                            )
6411                            .into(),
6412                        ));
6413                    }
6414                    if let Some(id) = self.select_focus_id() {
6415                        commands.push(Command::Iced(widget::button::focus(id).into()));
6416                    }
6417                }
6418            }
6419            Message::SelectLast => {
6420                if let Some(ref items) = self.items_opt
6421                    && let Some(last_pos) = items.iter().filter_map(|item| item.pos_opt.get()).max()
6422                    && self.select_position(last_pos.0, last_pos.1, mod_shift)
6423                {
6424                    if let Some(offset) = self.select_focus_scroll() {
6425                        commands.push(Command::Iced(
6426                            scrollable::scroll_to(
6427                                self.scrollable_id.clone(),
6428                                AbsoluteOffset {
6429                                    x: Some(offset.x),
6430                                    y: Some(offset.y),
6431                                },
6432                            )
6433                            .into(),
6434                        ));
6435                    }
6436                    if let Some(id) = self.select_focus_id() {
6437                        commands.push(Command::Iced(widget::button::focus(id).into()));
6438                    }
6439                }
6440            }
6441            Message::SetOpenWith(mime, id) => {
6442                commands.push(Command::SetOpenWith(mime, id));
6443            }
6444            Message::SetPermissions(path, mode) => {
6445                commands.push(Command::SetPermissions(path, mode));
6446            }
6447            Message::ShiftPermissions(path_mode_opt, shift, bits) => match path_mode_opt {
6448                Some((path, mode)) => commands.push(Command::SetPermissions(
6449                    path,
6450                    set_mode_part(mode, shift, bits),
6451                )),
6452                // Shift permissions on all selected items
6453                None => {
6454                    let permissions = Vec::new();
6455                    for _item in self.items_opt().map_or(Vec::new(), |items| {
6456                        items.iter().filter(|item| item.selected).collect()
6457                    }) {
6458                        #[cfg(unix)]
6459                        if let (Some(path), Some(mode)) = (
6460                            item.path_opt(),
6461                            item.file_metadata().map(|metadata| metadata.mode()),
6462                        ) {
6463                            permissions.push((path.clone(), set_mode_part(mode, shift, bits)));
6464                        }
6465                    }
6466                    commands.push(Command::SetMultiplePermissions(permissions));
6467                }
6468            },
6469            Message::SetSort(heading_option, dir) => {
6470                if !matches!(self.location, Location::Search(..)) {
6471                    self.sort_name = heading_option;
6472                    self.sort_direction = dir;
6473                    if !matches!(self.location, Location::Desktop(..)) {
6474                        commands.push(Command::SetSort(
6475                            self.location.normalize().to_string(),
6476                            heading_option,
6477                            self.sort_direction,
6478                        ));
6479                    }
6480                }
6481            }
6482            Message::TabComplete(path, completions) => {
6483                if let Some(edit_location) = &mut self.edit_location
6484                    && edit_location.location.path_opt() == Some(&path)
6485                {
6486                    edit_location.completions = Some(completions);
6487                    commands.push(Command::Iced(
6488                        widget::text_input::focus(self.edit_location_id.clone()).into(),
6489                    ));
6490                }
6491            }
6492            Message::Thumbnail(path, thumbnail) => {
6493                if let Some(ref mut items) = self.items_opt {
6494                    let location = Location::Path(path);
6495                    for item in items.iter_mut() {
6496                        if item.location_opt.as_ref() == Some(&location) {
6497                            let handle_opt = match &thumbnail {
6498                                ItemThumbnail::NotImage => None,
6499                                ItemThumbnail::Image(handle, _) => Some(widget::icon::Handle {
6500                                    symbolic: false,
6501                                    data: widget::icon::Data::Image(handle.clone()),
6502                                }),
6503                                ItemThumbnail::Svg(handle) => Some(widget::icon::Handle {
6504                                    symbolic: false,
6505                                    data: widget::icon::Data::Svg(handle.clone()),
6506                                }),
6507                                //TODO: text thumbnails?
6508                                ItemThumbnail::Text(_text) => None,
6509                            };
6510                            if let Some(handle) = handle_opt {
6511                                item.icon_handle_grid.clone_from(&handle);
6512                                item.icon_handle_list.clone_from(&handle);
6513                                item.icon_handle_list_condensed = handle;
6514                            }
6515                            item.thumbnail_opt = Some(thumbnail);
6516                            break;
6517                        }
6518                    }
6519                }
6520            }
6521            Message::ImageDecoded(path, width, height, pixels, display_size, generation) => {
6522                // Create handle from pre-decoded RGBA data (fast!)
6523                let handle = widget::image::Handle::from_rgba(width, height, pixels);
6524
6525                // Store decoded image handle if generation still matches (not superseded)
6526                self.large_image_manager.store_decoded_with_generation(
6527                    path,
6528                    handle,
6529                    display_size,
6530                    generation,
6531                );
6532            }
6533            Message::ToggleSort(heading_option) => {
6534                if !matches!(self.location, Location::Search(..)) {
6535                    let heading_sort = if self.sort_name == heading_option {
6536                        !self.sort_direction
6537                    } else {
6538                        // Default modified to descending, and others to ascending.
6539                        heading_option != HeadingOptions::Modified
6540                    };
6541
6542                    if !matches!(self.location, Location::Desktop(..)) {
6543                        commands.push(Command::SetSort(
6544                            self.location.normalize().to_string(),
6545                            heading_option,
6546                            heading_sort,
6547                        ));
6548                    }
6549
6550                    self.sort_direction = heading_sort;
6551                    self.sort_name = heading_option;
6552                }
6553            }
6554            Message::Drop(Some((to, mut from))) => {
6555                self.dnd_hovered = None;
6556                match to {
6557                    Location::Desktop(to, ..)
6558                    | Location::Path(to)
6559                    | Location::Network(_, _, Some(to)) => {
6560                        if let Ok(entries) = fs::read_dir(&to) {
6561                            for i in entries.into_iter().filter_map(Result::ok) {
6562                                let i = i.path();
6563                                from.paths.retain(|p| &i != p);
6564                                if from.paths.is_empty() {
6565                                    log::info!("All dropped files already in target directory.");
6566                                    return commands;
6567                                }
6568                            }
6569                        }
6570                        commands.push(Command::DropFiles(to, from));
6571                    }
6572                    Location::Trash if matches!(from.kind, ClipboardKind::Cut { .. }) => {
6573                        commands.push(Command::Delete(from.paths));
6574                    }
6575                    _ => {
6576                        log::warn!("{:?} to {:?} is not supported.", from.kind, to);
6577                    }
6578                }
6579            }
6580            Message::Drop(None) => {
6581                self.dnd_hovered = None;
6582            }
6583            Message::DndHover(loc) => {
6584                if self
6585                    .dnd_hovered
6586                    .as_ref()
6587                    .is_some_and(|(l, i)| *l == loc && i.elapsed() > HOVER_DURATION)
6588                {
6589                    cd = Some(loc);
6590                }
6591            }
6592            Message::DndEnter(loc) => {
6593                self.dnd_hovered = Some((loc.clone(), Instant::now()));
6594                if loc != self.location {
6595                    commands.push(Command::Iced(
6596                        cosmic::Task::future(async move {
6597                            tokio::time::sleep(HOVER_DURATION).await;
6598                            Message::DndHover(loc)
6599                        })
6600                        .into(),
6601                    ));
6602                }
6603            }
6604            Message::DndLeave(loc) => {
6605                if Some(&loc) == self.dnd_hovered.as_ref().map(|(l, _)| l) {
6606                    self.dnd_hovered = None;
6607                }
6608            }
6609            Message::WindowDrag => {
6610                commands.push(Command::WindowDrag);
6611            }
6612            Message::WindowToggleMaximize => {
6613                commands.push(Command::WindowToggleMaximize);
6614            }
6615            Message::ZoomIn => {
6616                commands.push(Command::Action(Action::ZoomIn));
6617            }
6618            Message::ZoomOut => {
6619                commands.push(Command::Action(Action::ZoomOut));
6620            }
6621            Message::DirectorySize(path, dir_size) => {
6622                let location = Location::Path(path);
6623                if let Some(ref mut item) = self.parent_item_opt
6624                    && item.location_opt.as_ref() == Some(&location)
6625                {
6626                    item.dir_size.clone_from(&dir_size);
6627                }
6628                if let Some(ref mut items) = self.items_opt {
6629                    for item in items.iter_mut() {
6630                        if item.location_opt.as_ref() == Some(&location) {
6631                            item.dir_size = dir_size;
6632                            break;
6633                        }
6634                    }
6635                }
6636            }
6637            Message::OpenSeqAlignment(hit) => {
6638                commands.push(Command::OpenSeqAlignment(hit));
6639            }
6640            Message::OpenSpeciesAlignment(hit) => {
6641                commands.push(Command::OpenSpeciesAlignment(hit));
6642            }
6643        }
6644
6645        // Scroll to top if needed
6646        if self.scroll_opt.is_none() {
6647            let offset = AbsoluteOffset { x: 0.0, y: 0.0 };
6648            self.scroll_opt = Some(offset);
6649            commands.push(Command::Iced(
6650                scrollable::scroll_to(
6651                    self.scrollable_id.clone(),
6652                    AbsoluteOffset {
6653                        x: Some(0.0),
6654                        y: Some(0.0),
6655                    },
6656                )
6657                .into(),
6658            ));
6659        }
6660
6661        // Change directory if requested
6662        if let Some(mut location) = cd {
6663            location = location.normalize();
6664            if matches!(self.mode, Mode::Desktop) {
6665                match location {
6666                    Location::Path(path) => {
6667                        commands.push(Command::OpenFile(vec![path]));
6668                    }
6669                    Location::Trash => {
6670                        commands.push(Command::OpenTrash);
6671                    }
6672                    _ => {}
6673                }
6674            } else {
6675                // Select parent if location is not directory
6676                let mut selected_paths = None;
6677                if let Some(path) = location.path_opt()
6678                    && !matches!(location, Location::Remote(..))
6679                    && !path.is_dir()
6680                    && let Some(parent) = path.parent()
6681                {
6682                    selected_paths = Some(vec![path.clone()]);
6683                    location = location.with_path(parent.to_path_buf());
6684                }
6685                if location != self.location || selected_paths.is_some() {
6686                    if matches!(location, Location::Remote(..))
6687                        || location.path_opt().is_none_or(|path| path.is_dir())
6688                    {
6689                        if selected_paths.is_none() {
6690                            selected_paths =
6691                                self.location.path_opt().map(|path| vec![path.clone()]);
6692                        }
6693                        self.change_location(&location, history_i_opt);
6694                        commands.push(Command::ChangeLocation(
6695                            self.title(),
6696                            location,
6697                            selected_paths,
6698                        ));
6699                    } else {
6700                        log::warn!("tried to cd to {location:?} which is not a directory");
6701                    }
6702                }
6703            }
6704        }
6705
6706        // Update context menu popup
6707        if self.context_menu != last_context_menu {
6708            if last_context_menu.is_some() {
6709                commands.push(Command::ContextMenu(None, self.window_id));
6710            }
6711            if let Some(point) = self.context_menu {
6712                commands.push(Command::ContextMenu(Some(point), self.window_id));
6713            }
6714        }
6715
6716        commands
6717    }
6718
6719    pub(crate) const fn sort_options(&self) -> (HeadingOptions, bool, bool) {
6720        match self.location {
6721            Location::Search(..) => (HeadingOptions::Modified, false, false),
6722            _ => (
6723                self.sort_name,
6724                self.sort_direction,
6725                self.config.folders_first,
6726            ),
6727        }
6728    }
6729
6730    fn column_sort(&self) -> Option<Vec<(usize, &Item)>> {
6731        let check_reverse = |ord: Ordering, sort: bool| {
6732            if sort { ord } else { ord.reverse() }
6733        };
6734        let mut items: Vec<_> = self.items_opt.as_ref()?.iter().enumerate().collect();
6735        let (sort_name, sort_direction, folders_first) = self.sort_options();
6736        match sort_name {
6737            HeadingOptions::Size => {
6738                items.sort_by(|a, b| {
6739                    // entries take precedence over size
6740                    let get_size = |x: &Item| match &x.metadata {
6741                        ItemMetadata::Path {
6742                            metadata,
6743                            children_opt,
6744                            ..
6745                        } => {
6746                            if metadata.is_dir() {
6747                                (true, children_opt.unwrap_or_default() as u64)
6748                            } else {
6749                                (false, metadata.len())
6750                            }
6751                        }
6752                        ItemMetadata::Trash { metadata, .. } => match metadata.size {
6753                            trash::TrashItemSize::Entries(entries) => (true, entries as u64),
6754                            trash::TrashItemSize::Bytes(bytes) => (false, bytes),
6755                        },
6756                        ItemMetadata::SimpleDir { entries } => (true, *entries),
6757                        ItemMetadata::SimpleFile { size } => (false, *size),
6758                        #[cfg(feature = "gvfs")]
6759                        ItemMetadata::GvfsPath {
6760                            size_opt,
6761                            children_opt,
6762                            ..
6763                        } => match children_opt {
6764                            Some(child_count) => (true, *child_count as u64),
6765                            None => (false, size_opt.unwrap_or_default()),
6766                        },
6767                        #[cfg(feature = "russh")]
6768                        ItemMetadata::RusshPath {
6769                            size_opt,
6770                            children_opt,
6771                            ..
6772                        } => match children_opt {
6773                            Some(child_count) => (true, *child_count as u64),
6774                            None => (false, size_opt.unwrap_or_default()),
6775                        },
6776                    };
6777                    let (a_is_entry, a_size) = get_size(a.1);
6778                    let (b_is_entry, b_size) = get_size(b.1);
6779
6780                    //TODO: use folders_first?
6781                    match (a_is_entry, b_is_entry) {
6782                        (true, false) => Ordering::Less,
6783                        (false, true) => Ordering::Greater,
6784                        _ => check_reverse(a_size.cmp(&b_size), sort_direction),
6785                    }
6786                });
6787            }
6788            HeadingOptions::Name => items.sort_by(|a, b| {
6789                if folders_first {
6790                    match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6791                        (true, false) => Ordering::Less,
6792                        (false, true) => Ordering::Greater,
6793                        _ => check_reverse(
6794                            LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
6795                            sort_direction,
6796                        ),
6797                    }
6798                } else {
6799                    check_reverse(
6800                        LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
6801                        sort_direction,
6802                    )
6803                }
6804            }),
6805            HeadingOptions::Modified => {
6806                items.sort_by(|a, b| {
6807                    let a_modified = a.1.metadata.modified();
6808                    let b_modified = b.1.metadata.modified();
6809                    if folders_first {
6810                        match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6811                            (true, false) => Ordering::Less,
6812                            (false, true) => Ordering::Greater,
6813                            _ => check_reverse(a_modified.cmp(&b_modified), sort_direction),
6814                        }
6815                    } else {
6816                        check_reverse(a_modified.cmp(&b_modified), sort_direction)
6817                    }
6818                });
6819            }
6820            HeadingOptions::TrashedOn => {
6821                let time_deleted = |x: &Item| match &x.metadata {
6822                    ItemMetadata::Trash { entry, .. } => Some(entry.time_deleted),
6823                    _ => None,
6824                };
6825
6826                items.sort_by(|a, b| {
6827                    let a_time_deleted = time_deleted(a.1);
6828                    let b_time_deleted = time_deleted(b.1);
6829                    if folders_first {
6830                        match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6831                            (true, false) => Ordering::Less,
6832                            (false, true) => Ordering::Greater,
6833                            _ => check_reverse(a_time_deleted.cmp(&b_time_deleted), sort_direction),
6834                        }
6835                    } else {
6836                        check_reverse(b_time_deleted.cmp(&a_time_deleted), sort_direction)
6837                    }
6838                });
6839            }
6840        }
6841        Some(items)
6842    }
6843
6844    fn dnd_dest<'a>(
6845        &self,
6846        location: &Location,
6847        element: impl Into<Element<'a, Message>>,
6848    ) -> Element<'a, Message> {
6849        let location1 = location.clone();
6850        let location2 = location.clone();
6851        let location3 = location.clone();
6852        let is_dnd_hovered = self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(location);
6853        let mut container = widget::container(
6854            DndDestination::for_data::<ClipboardPaste>(element, move |data, action| {
6855                if let Some(mut data) = data {
6856                    if action == DndAction::Copy {
6857                        Message::Drop(Some((location1.clone(), data)))
6858                    } else if action == DndAction::Move {
6859                        data.kind = ClipboardKind::Cut { is_dnd: true };
6860                        Message::Drop(Some((location1.clone(), data)))
6861                    } else {
6862                        log::warn!("unsupported action: {action:?}");
6863                        Message::Drop(None)
6864                    }
6865                } else {
6866                    Message::Drop(None)
6867                }
6868            })
6869            .on_enter(move |_, _, _| Message::DndEnter(location2.clone()))
6870            .on_leave(move || Message::DndLeave(location3.clone())),
6871        );
6872        // Desktop will not show DnD indicator
6873        if is_dnd_hovered && !matches!(self.mode, Mode::Desktop) {
6874            container = container.style(|t| {
6875                let mut a = widget::container::Style::default();
6876                let t = t.cosmic();
6877                // todo use theme drop target color
6878                let mut bg = t.accent_color();
6879                bg.alpha = 0.2;
6880                a.background = Some(Color::from(bg).into());
6881                a.border = Border {
6882                    color: t.accent_color().into(),
6883                    width: 1.0,
6884                    radius: t.radius_s().into(),
6885                };
6886                a
6887            });
6888        }
6889        container.into()
6890    }
6891
6892    pub fn gallery_view(&self) -> Element<'_, Message> {
6893        let cosmic_theme::Spacing {
6894            space_xxs,
6895            space_xs,
6896            space_m,
6897            ..
6898        } = theme::spacing();
6899
6900        //TODO: display error messages when image not found?
6901        let mut name_opt = None;
6902        let mut element_opt: Option<Element<Message>> = None;
6903        if let Some(index) = self.select_focus
6904            && let Some(items) = &self.items_opt
6905            && let Some(item) = items.get(index)
6906        {
6907            name_opt = Some(widget::text::heading(&item.display_name));
6908            match item
6909                .thumbnail_opt
6910                .as_ref()
6911                .unwrap_or(&ItemThumbnail::NotImage)
6912            {
6913                ItemThumbnail::NotImage => {}
6914                ItemThumbnail::Image(handle, original_dims) => {
6915                    // Determine which image to show based on async decode state
6916                    let mut is_loading = false;
6917                    let mut error_msg_opt = None;
6918                    let image_handle = if let Some(path) = item.path_opt() {
6919                        if let Some(error_msg) = self.large_image_manager.get_error(path) {
6920                            error_msg_opt = Some(error_msg.clone());
6921                            handle.clone()
6922                        } else if self.large_image_manager.is_decoding(path) {
6923                            // Currently decoding (initial or re-decode) --> show cached/thumbnail with loading indicator
6924                            is_loading = true;
6925                            // Use decoded handle if available (re-decode), otherwise thumbnail (initial decode)
6926                            self.large_image_manager
6927                                .get_decoded(path)
6928                                .cloned()
6929                                .unwrap_or_else(|| handle.clone())
6930                        } else if let Some(decoded_handle) =
6931                            self.large_image_manager.get_decoded(path)
6932                        {
6933                            // Decoded and not currently decoding --> use it
6934                            decoded_handle.clone()
6935                        } else if let Some((w, h)) = original_dims {
6936                            // Check if image needs tiling
6937                            if should_use_tiling(*w, *h) {
6938                                // Large image --> show thumbnail only
6939                                handle.clone()
6940                            } else {
6941                                // Normal-sized image --> load full resolution directly
6942                                widget::image::Handle::from_path(path)
6943                            }
6944                        } else {
6945                            // No dimensions available --> show thumbnail
6946                            handle.clone()
6947                        }
6948                    } else {
6949                        handle.clone()
6950                    };
6951
6952                    let content: cosmic::Element<'_, Message> =
6953                        if let Some(error_msg) = error_msg_opt {
6954                            widget::column::with_capacity(2)
6955                                .push(widget::image(image_handle))
6956                                .push(widget::text(format!("âš  {}", error_msg)).size(13))
6957                                .padding(space_xs)
6958                                .align_x(cosmic::iced::Alignment::Center)
6959                                .into()
6960                        } else if is_loading {
6961                            widget::column::with_capacity(2)
6962                                .push(widget::image(image_handle))
6963                                .push(widget::text("Loading higher resolution...").size(14))
6964                                .padding(space_xs)
6965                                .align_x(cosmic::iced::Alignment::Center)
6966                                .into()
6967                        } else {
6968                            //TODO: use widget::image::viewer, when its zoom can be reset
6969                            crate::load_image::loaded_image(image_handle).into()
6970                        };
6971
6972                    element_opt = Some(widget::container(content).center(Length::Fill).into());
6973                }
6974                ItemThumbnail::Svg(handle) => {
6975                    element_opt = Some(
6976                        widget::svg(handle.clone())
6977                            .width(Length::Fill)
6978                            .height(Length::Fill)
6979                            .into(),
6980                    );
6981                }
6982                ItemThumbnail::Text(text) => {
6983                    element_opt = Some(
6984                        widget::container(widget::text_editor(text).padding(space_xxs).class(
6985                            cosmic::theme::iced::TextEditor::Custom(Box::new(text_editor_class)),
6986                        ))
6987                        .center(Length::Fill)
6988                        .into(),
6989                    );
6990                }
6991            }
6992        }
6993
6994        let mut column = widget::column::with_capacity(2);
6995        column = column.push(widget::space::vertical().height(Length::Fixed(space_m.into())));
6996        {
6997            let mut row = widget::row::with_capacity(5).align_y(Alignment::Center);
6998            row = row.push(widget::space::horizontal());
6999            if let Some(name) = name_opt {
7000                row = row.push(name);
7001            }
7002            row = row.push(widget::space::horizontal());
7003            row = row.push(
7004                widget::button::icon(widget::icon::from_name("window-close-symbolic"))
7005                    .class(theme::Button::Standard)
7006                    .on_press(Message::Gallery(false)),
7007            );
7008            row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7009            // This mouse area provides window drag while the header bar is hidden
7010            let mouse_area = mouse_area::MouseArea::new(row)
7011                .on_press(|_| Message::WindowDrag)
7012                .on_double_click(|_| Message::WindowToggleMaximize);
7013            column = column.push(mouse_area);
7014        }
7015        {
7016            let mut row = widget::row::with_capacity(7).align_y(Alignment::Center);
7017            row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7018            row = row.push(
7019                widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
7020                    .padding(space_xs)
7021                    .class(theme::Button::Standard)
7022                    .on_press(Message::GalleryPrevious),
7023            );
7024            row = row.push(widget::space::horizontal().width(Length::Fixed(space_xxs.into())));
7025            if let Some(element) = element_opt {
7026                row = row.push(element);
7027            } else {
7028                //TODO: what to do when no image?
7029                row = row.push(space::horizontal().width(Length::Fill));
7030                row = row.push(space::vertical().height(Length::Fill));
7031            }
7032            row = row.push(widget::space::horizontal().width(Length::Fixed(space_xxs.into())));
7033            row = row.push(
7034                widget::button::icon(widget::icon::from_name("go-next-symbolic"))
7035                    .padding(space_xs)
7036                    .class(theme::Button::Standard)
7037                    .on_press(Message::GalleryNext),
7038            );
7039            row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7040            column = column.push(row);
7041        }
7042
7043        widget::container(column)
7044            .width(Length::Fill)
7045            .height(Length::Fill)
7046            .style(|theme| {
7047                let cosmic = theme.cosmic();
7048                let mut bg = cosmic.bg_color();
7049                bg.alpha = 0.75;
7050                widget::container::Style {
7051                    background: Some(Color::from(bg).into()),
7052                    ..Default::default()
7053                }
7054            })
7055            .into()
7056    }
7057
7058    pub fn location_view(&self) -> Element<'_, Message> {
7059        //TODO: responsiveness is done in a hacky way, potentially move this to a custom widget?
7060        fn text_width<'a>(
7061            content: &'a str,
7062            font: font::Font,
7063            font_size: f32,
7064            line_height: f32,
7065        ) -> f32 {
7066            let text: text::Text<&'a str, font::Font> = text::Text {
7067                content,
7068                bounds: Size::INFINITE,
7069                size: font_size.into(),
7070                line_height: text::LineHeight::Absolute(line_height.into()),
7071                font,
7072                align_x: text::Alignment::Left,
7073                align_y: Vertical::Top,
7074                shaping: text::Shaping::default(),
7075                wrapping: text::Wrapping::None,
7076                ellipsize: text::Ellipsize::End(text::EllipsizeHeightLimit::Lines(1)),
7077            };
7078            graphics::text::Paragraph::with_text(text)
7079                .min_bounds()
7080                .width
7081        }
7082        fn text_width_body(content: &str) -> f32 {
7083            //TODO: should libcosmic set the font when using widget::text::body?
7084            text_width(content, font::default(), 14.0, 20.0)
7085        }
7086        fn text_width_heading(content: &str) -> f32 {
7087            text_width(content, font::semibold(), 14.0, 20.0)
7088        }
7089
7090        let cosmic_theme::Spacing {
7091            space_xxxs,
7092            space_xxs,
7093            space_s,
7094            space_m,
7095            ..
7096        } = theme::spacing();
7097
7098        let size = self.size_opt.get().unwrap_or(Size::new(0.0, 0.0));
7099
7100        let mut row = widget::row::with_capacity(5)
7101            .align_y(Alignment::Center)
7102            .padding([space_xxxs, 0]);
7103        let mut w = 0.0;
7104
7105        let mut prev_button =
7106            widget::button::custom(widget::icon::from_name("go-previous-symbolic").size(16))
7107                .padding(space_xxs)
7108                .class(theme::Button::Icon);
7109        if self.history_i > 0 && !self.history.is_empty() {
7110            prev_button = prev_button.on_press(Message::GoPrevious);
7111        }
7112        row = row.push(prev_button);
7113        w += f32::from(space_xxs).mul_add(2.0, 16.0);
7114
7115        let mut next_button =
7116            widget::button::custom(widget::icon::from_name("go-next-symbolic").size(16))
7117                .padding(space_xxs)
7118                .class(theme::Button::Icon);
7119        if self.history_i + 1 < self.history.len() {
7120            next_button = next_button.on_press(Message::GoNext);
7121        }
7122        row = row.push(next_button);
7123        w += f32::from(space_xxs).mul_add(2.0, 16.0);
7124
7125        row = row.push(widget::space::horizontal().width(Length::Fixed(space_s.into())));
7126        w += f32::from(space_s);
7127
7128        //TODO: allow resizing?
7129        let name_width = 300.0;
7130        let modified_width = 200.0;
7131        let size_width = 100.0;
7132        let condensed = size.width < (name_width + modified_width + size_width);
7133
7134        let (sort_name, sort_direction, _) = self.sort_options();
7135        let heading_item = |name, width, msg| {
7136            let mut row = widget::row::with_capacity(2)
7137                .align_y(Alignment::Center)
7138                .spacing(space_xxxs)
7139                .width(width);
7140            row = row.push(widget::text::heading(name));
7141            match (sort_name == msg, sort_direction) {
7142                (true, true) => {
7143                    row = row.push(widget::icon::from_name("pan-down-symbolic").size(16));
7144                }
7145                (true, false) => {
7146                    row = row.push(widget::icon::from_name("pan-up-symbolic").size(16));
7147                }
7148                _ => {}
7149            }
7150            //TODO: make it possible to resize with the mouse
7151            mouse_area::MouseArea::new(row)
7152                .on_press(move |_point_opt| Message::ToggleSort(msg))
7153                .into()
7154        };
7155
7156        let heading_row = widget::row::with_children([
7157            heading_item(fl!("name"), Length::Fill, HeadingOptions::Name),
7158            if self.location.is_trash() {
7159                heading_item(
7160                    fl!("trashed-on"),
7161                    Length::Fixed(modified_width),
7162                    HeadingOptions::TrashedOn,
7163                )
7164            } else {
7165                heading_item(
7166                    fl!("modified"),
7167                    Length::Fixed(modified_width),
7168                    HeadingOptions::Modified,
7169                )
7170            },
7171            heading_item(fl!("size"), Length::Fixed(size_width), HeadingOptions::Size),
7172        ])
7173        .align_y(Alignment::Center)
7174        .height(Length::Fixed((space_m + 4).into()))
7175        .padding([0, space_xxs]);
7176
7177        let accent_rule =
7178            rule::horizontal(1).class(theme::Rule::Custom(Box::new(|theme| rule::Style {
7179                color: theme.cosmic().accent_color().into(),
7180                radius: 0.0.into(),
7181                fill_mode: rule::FillMode::Full,
7182                snap: true,
7183            })));
7184        let heading_rule = widget::container(rule::horizontal(1))
7185            .padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]);
7186
7187        if let Some(edit_location) = &self.edit_location {
7188            let mut text_input = None;
7189
7190            //TODO: allow editing other locations
7191            if let Location::Network(ref uri, ..) = edit_location.location {
7192                let location = edit_location.location.clone();
7193                text_input = Some(
7194                    widget::text_input("", uri.clone())
7195                        .id(self.edit_location_id.clone())
7196                        .on_input(move |input| {
7197                            Message::EditLocation(Some(location.with_uri(input).into()))
7198                        })
7199                        .on_submit(|_| Message::EditLocationSubmit)
7200                        .line_height(1.0),
7201                );
7202            } else if let Location::Remote(ref uri, ..) = edit_location.location {
7203                let location = edit_location.location.clone();
7204                text_input = Some(
7205                    widget::text_input("", uri.clone())
7206                        .id(self.edit_location_id.clone())
7207                        .on_input(move |input| {
7208                            Message::EditLocation(Some(location.with_uri(input).into()))
7209                        })
7210                        .on_submit(|_| Message::EditLocationSubmit)
7211                        .line_height(1.0),
7212                );
7213            } else if let Some(resolved_location) = edit_location.resolve()
7214                && let Some(path) = resolved_location.path_opt().cloned()
7215            {
7216                text_input = Some(
7217                    widget::text_input("", path.to_string_lossy().into_owned())
7218                        .id(self.edit_location_id.clone())
7219                        .on_input(move |input| {
7220                            Message::EditLocation(Some(
7221                                resolved_location.with_path(PathBuf::from(input)).into(),
7222                            ))
7223                        })
7224                        .on_submit(|_| Message::EditLocationSubmit)
7225                        .on_tab(Message::EditLocationTab)
7226                        .on_unfocus(Message::EditLocation(None))
7227                        .line_height(1.0),
7228                );
7229            }
7230            if let Some(text_input) = text_input {
7231                row = row.push(
7232                    widget::button::custom(
7233                        widget::icon::from_name("window-close-symbolic").size(16),
7234                    )
7235                    .on_press(Message::EditLocation(None))
7236                    .padding(space_xxs)
7237                    .class(theme::Button::Icon),
7238                );
7239                let mut popover =
7240                    widget::popover(text_input).position(widget::popover::Position::Bottom);
7241                if let Some(completions) = &edit_location.completions
7242                    && !completions.is_empty()
7243                {
7244                    let mut column =
7245                        widget::column::with_capacity(completions.len()).padding(space_xxs);
7246                    for (i, (name, _path)) in completions.iter().enumerate() {
7247                        let selected = edit_location.selected == Some(i);
7248                        column = column.push(
7249                            widget::button::custom(widget::text::body(name))
7250                                //TODO: match to design
7251                                .class(if selected {
7252                                    theme::Button::Standard
7253                                } else {
7254                                    theme::Button::HeaderBar
7255                                })
7256                                .on_press(Message::EditLocationComplete(i))
7257                                .padding(space_xxs)
7258                                .width(Length::Fill),
7259                        );
7260                    }
7261                    popover = popover.popup(
7262                        widget::container(column)
7263                            .class(theme::Container::Dropdown)
7264                            //TODO: This is a hack to get the popover to be the right width
7265                            .max_width(size.width - 140.0),
7266                    );
7267                }
7268                row = row.push(popover);
7269                let mut column = widget::column::with_capacity(4).padding([0, space_s]);
7270                column = column.push(row);
7271                column = column.push(accent_rule);
7272                if self.config.view == View::List && !condensed {
7273                    column = column.push(heading_row);
7274                    column = column.push(heading_rule);
7275                }
7276                return column.into();
7277            }
7278        } else if let Some(path) = self.location.path_opt() {
7279            row = row.push(
7280                crate::mouse_area::MouseArea::new(
7281                    widget::button::custom(widget::icon::from_name("edit-symbolic").size(16))
7282                        .padding(space_xxs)
7283                        .class(theme::Button::Icon)
7284                        .on_press(Message::EditLocation(Some(self.location.clone().into()))),
7285                )
7286                .on_middle_press(move |_| Message::OpenInNewTab(path.clone())),
7287            );
7288            w += f32::from(space_xxs).mul_add(2.0, 16.0);
7289        }
7290
7291        let mut children: Vec<Element<_>> = Vec::new();
7292        match &self.location {
7293            Location::Desktop(path, ..)
7294            | Location::Path(path)
7295            | Location::Search(SearchLocation::Path(path), ..) => {
7296                let excess_str = "...";
7297                let excess_width = text_width_body(excess_str);
7298                for (index, ancestor) in path.ancestors().enumerate() {
7299                    let (name, found_home) = folder_name(ancestor);
7300                    let (name_width, name_text) = if children.is_empty() {
7301                        (
7302                            text_width_heading(&name),
7303                            widget::text::heading(name)
7304                                .wrapping(text::Wrapping::None)
7305                                .ellipsize(text::Ellipsize::End(
7306                                    text::EllipsizeHeightLimit::Lines(1),
7307                                )),
7308                        )
7309                    } else {
7310                        children.push(
7311                            widget::icon::from_name("go-next-symbolic")
7312                                .size(16)
7313                                .icon()
7314                                .into(),
7315                        );
7316                        w += 16.0;
7317                        (
7318                            text_width_body(&name),
7319                            widget::text::body(name).wrapping(text::Wrapping::None),
7320                        )
7321                    };
7322
7323                    // Add padding for mouse area
7324                    w += 2.0 * f32::from(space_xxxs);
7325
7326                    let mut row = widget::row::with_capacity(2)
7327                        .align_y(Alignment::Center)
7328                        .spacing(space_xxxs);
7329                    //TODO: figure out why this hardcoded offset is needed after the first item is ellipsed
7330                    let overflow_offset = 64.0;
7331                    let overflow = w + name_width + overflow_offset > size.width && index > 0;
7332                    if overflow {
7333                        row = row.push(widget::text::body(excess_str));
7334                        w += excess_width;
7335                    } else {
7336                        row = row.push(name_text);
7337                        w += name_width;
7338                    }
7339
7340                    let location = self.location.with_path(ancestor.to_path_buf());
7341                    let mut mouse_area = crate::mouse_area::MouseArea::new(
7342                        widget::button::custom(row)
7343                            .padding(space_xxxs)
7344                            .class(theme::Button::Link)
7345                            .on_press(if ancestor == path {
7346                                Message::EditLocation(Some(self.location.clone().into()))
7347                            } else {
7348                                Message::Location(location.clone())
7349                            }),
7350                    );
7351
7352                    if self.location_context_menu_index.is_some() {
7353                        mouse_area = mouse_area
7354                            .on_right_press(move |point_opt| {
7355                                Message::LocationContextMenuIndex(point_opt, None)
7356                            })
7357                            .wayland_on_right_press_window_position();
7358                    } else {
7359                        mouse_area = mouse_area
7360                            .on_right_press_no_capture()
7361                            .on_right_press(move |point_opt| {
7362                                Message::LocationContextMenuIndex(point_opt, Some(index))
7363                            })
7364                            .wayland_on_right_press_window_position();
7365                    }
7366
7367                    let mouse_area = if let Location::Path(_) = &self.location {
7368                        mouse_area
7369                            .on_middle_press(move |_| Message::OpenInNewTab(ancestor.to_path_buf()))
7370                    } else {
7371                        mouse_area
7372                    };
7373
7374                    children.push(self.dnd_dest(&location, mouse_area));
7375
7376                    if found_home || overflow {
7377                        break;
7378                    }
7379                }
7380                children.reverse();
7381            }
7382            Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
7383                children.push(
7384                    widget::button::custom(widget::text::heading(fl!("trash")))
7385                        .padding(space_xxxs)
7386                        .on_press(Message::Location(Location::Trash))
7387                        .class(theme::Button::Text)
7388                        .into(),
7389                );
7390            }
7391            Location::Recents | Location::Search(SearchLocation::Recents, ..) => {
7392                children.push(
7393                    widget::button::custom(widget::text::heading(fl!("recents")))
7394                        .padding(space_xxxs)
7395                        .on_press(Message::Location(Location::Recents))
7396                        .class(theme::Button::Text)
7397                        .into(),
7398                );
7399            }
7400            Location::Network(uri, display_name, path) => {
7401                children.push(
7402                    widget::button::custom(widget::text::heading(display_name))
7403                        .padding(space_xxxs)
7404                        .on_press(Message::Location(Location::Network(
7405                            uri.clone(),
7406                            display_name.clone(),
7407                            path.clone(),
7408                        )))
7409                        .class(theme::Button::Text)
7410                        .into(),
7411                );
7412            }
7413            Location::Remote(uri, display_name, Some(path)) => {
7414                let excess_str = "...";
7415                let excess_width = text_width_body(excess_str);
7416
7417                // Extract "user@host" label from URI for the root `/` entry.
7418                let host_label = if let Ok(url) = url::Url::parse(uri) {
7419                    let host = url.host_str().unwrap_or("");
7420                    let username = url.username();
7421                    if username.is_empty() {
7422                        host.to_string()
7423                    } else {
7424                        format!("{}@{}", username, host)
7425                    }
7426                } else {
7427                    display_name.clone()
7428                };
7429
7430                for (index, ancestor) in path.ancestors().enumerate() {
7431                    let name = if ancestor == Path::new("/") {
7432                        host_label.clone()
7433                    } else {
7434                        ancestor
7435                            .file_name()
7436                            .and_then(|n| n.to_str())
7437                            .unwrap_or_default()
7438                            .to_string()
7439                    };
7440
7441                    let (name_width, name_text) = if children.is_empty() {
7442                        (
7443                            text_width_heading(&name),
7444                            widget::text::heading(name)
7445                                .wrapping(text::Wrapping::None)
7446                                .ellipsize(text::Ellipsize::End(
7447                                    text::EllipsizeHeightLimit::Lines(1),
7448                                )),
7449                        )
7450                    } else {
7451                        children.push(
7452                            widget::icon::from_name("go-next-symbolic")
7453                                .size(16)
7454                                .icon()
7455                                .into(),
7456                        );
7457                        w += 16.0;
7458                        (
7459                            text_width_body(&name),
7460                            widget::text::body(name).wrapping(text::Wrapping::None),
7461                        )
7462                    };
7463
7464                    w += 2.0 * f32::from(space_xxxs);
7465
7466                    let mut button_row = widget::row::with_capacity(2)
7467                        .align_y(Alignment::Center)
7468                        .spacing(space_xxxs);
7469                    let overflow_offset = 64.0;
7470                    let overflow = w + name_width + overflow_offset > size.width && index > 0;
7471                    if overflow {
7472                        button_row = button_row.push(widget::text::body(excess_str));
7473                        w += excess_width;
7474                    } else {
7475                        button_row = button_row.push(name_text);
7476                        w += name_width;
7477                    }
7478
7479                    let location = self.location.with_path(ancestor.to_path_buf());
7480                    children.push(
7481                        widget::button::custom(button_row)
7482                            .padding(space_xxxs)
7483                            .class(theme::Button::Link)
7484                            .on_press(if ancestor == path.as_path() {
7485                                Message::EditLocation(Some(self.location.clone().into()))
7486                            } else {
7487                                Message::Location(location)
7488                            })
7489                            .into(),
7490                    );
7491
7492                    if ancestor == Path::new("/") || overflow {
7493                        break;
7494                    }
7495                }
7496                children.reverse();
7497            }
7498            Location::Remote(uri, display_name, None) => {
7499                children.push(
7500                    widget::button::custom(widget::text::heading(display_name))
7501                        .padding(space_xxxs)
7502                        .on_press(Message::Location(Location::Remote(
7503                            uri.clone(),
7504                            display_name.clone(),
7505                            None,
7506                        )))
7507                        .class(theme::Button::Text)
7508                        .into(),
7509                );
7510            }
7511        }
7512
7513        row = row.extend(children);
7514        let mut column = widget::column::with_capacity(4).padding([0, space_s]);
7515        column = column.push(row);
7516        column = column.push(accent_rule);
7517
7518        if self.config.view == View::List && !condensed {
7519            column = column.push(heading_row);
7520            column = column.push(heading_rule);
7521        }
7522
7523        let mouse_area = crate::mouse_area::MouseArea::new(column)
7524            .on_right_press(Message::LocationContextMenuPoint);
7525
7526        let mut popover = widget::popover(mouse_area);
7527        if let (Some(point), Some(index)) = (
7528            self.location_context_menu_point,
7529            self.location_context_menu_index,
7530        ) {
7531            popover = popover
7532                .popup(menu::location_context_menu(index))
7533                .position(widget::popover::Position::Point(point));
7534        }
7535
7536        popover.into()
7537    }
7538
7539    pub fn empty_view(&self, has_hidden: bool, susceptible_hidden: bool) -> Element<'_, Message> {
7540        let cosmic_theme::Spacing { space_xxs, .. } = theme::spacing();
7541
7542        mouse_area::MouseArea::new(widget::column::with_children([widget::container(
7543            match self.mode {
7544                Mode::App | Mode::Dialog(_) => widget::column::with_children([
7545                    widget::icon::from_name("folder-symbolic")
7546                        .size(64)
7547                        .icon()
7548                        .into(),
7549                    widget::text::body(if has_hidden {
7550                        fl!("empty-folder-hidden")
7551                    } else if susceptible_hidden {
7552                        fl!("empty-folder-susceptible-hidden")
7553                    } else if matches!(self.location, Location::Search(..)) {
7554                        fl!("no-results")
7555                    } else {
7556                        fl!("empty-folder")
7557                    })
7558                    .into(),
7559                ]),
7560                Mode::Desktop => widget::column::with_capacity(0),
7561            }
7562            .align_x(Alignment::Center)
7563            .spacing(space_xxs),
7564        )
7565        .center(Length::Fill)
7566        .into()]))
7567        .on_press(|_| Message::Click(None))
7568        .into()
7569    }
7570
7571    pub fn grid_view(
7572        &self,
7573    ) -> (
7574        Option<Element<'static, Message>>,
7575        Element<'_, Message>,
7576        bool,
7577    ) {
7578        let cosmic_theme::Spacing {
7579            space_xxs,
7580            space_xxxs,
7581            ..
7582        } = theme::spacing();
7583
7584        let TabConfig {
7585            show_hidden,
7586            show_susceptible,
7587            show_as_samples,
7588            mut icon_sizes,
7589            ..
7590        } = self.config;
7591
7592        let mut grid_spacing = space_xxs;
7593        if let Location::Desktop(_path, _output, desktop_config) = &self.location {
7594            icon_sizes.grid = desktop_config.icon_size;
7595            grid_spacing = desktop_config.grid_spacing_for(space_xxs);
7596        }
7597
7598        let text_height = 3 * 20; // 3 lines of text
7599        let item_width = (3 * space_xxs + icon_sizes.grid() + 3 * space_xxs) as usize;
7600        let item_height =
7601            (space_xxxs + icon_sizes.grid() + space_xxxs + text_height + space_xxxs) as usize;
7602
7603        let (width, height) = match self.size_opt.get() {
7604            Some(size) => (
7605                (size.width.floor() as usize)
7606                    .saturating_sub(2 * (space_xxs as usize))
7607                    .max(item_width),
7608                (size.height.floor() as usize).max(item_height),
7609            ),
7610            None => (item_width, item_height),
7611        };
7612
7613        let (cols, column_spacing) = {
7614            let width_m1 = width.saturating_sub(item_width);
7615            let cols_m1 = width_m1 / (item_width + grid_spacing as usize);
7616            let cols = cols_m1 + 1;
7617            let spacing = width_m1
7618                .checked_div(cols_m1)
7619                .unwrap_or(0)
7620                .saturating_sub(item_width);
7621            (cols, spacing as u16)
7622        };
7623
7624        let rows = {
7625            let height_m1 = height.saturating_sub(item_height);
7626            let rows_m1 = height_m1 / (item_height + grid_spacing as usize);
7627            rows_m1 + 1
7628        };
7629
7630        //TODO: move to function
7631        let visible_rect = {
7632            // Use cached content height to clamp scroll offset after resize
7633            let max_scroll_y = self
7634                .content_height_opt
7635                .get()
7636                .map(|ch| (ch - height as f32).max(0.0))
7637                .unwrap_or(f32::MAX);
7638            let scroll_y = self
7639                .scroll_opt
7640                .map(|o| o.y.min(max_scroll_y).max(0.0))
7641                .unwrap_or(0.0);
7642            let point = Point::new(0.0, scroll_y);
7643            let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
7644            Rectangle::new(point, size)
7645        };
7646
7647        let mut grid = widget::grid()
7648            .column_spacing(column_spacing)
7649            .row_spacing(grid_spacing)
7650            .padding(space_xxs.into());
7651        let mut dnd_items: Vec<(usize, (usize, usize), &Item)> = Vec::new();
7652        let mut drag_w_i = usize::MAX;
7653        let mut drag_n_i = usize::MAX;
7654        let mut drag_e_i = 0;
7655        let mut drag_s_i = 0;
7656
7657        let mut column = widget::column::with_capacity(2);
7658        if let Some(items) = self.column_sort() {
7659            let mut count = 0;
7660            let mut col = 0;
7661            let mut row = 0;
7662            let mut page_row = 0;
7663            let mut hidden = 0;
7664            let mut susceptible_hidden = 0;
7665            let mut grid_elements = Vec::new();
7666            for &(i, item) in &items {
7667                if !show_hidden && item.hidden {
7668                    item.pos_opt.set(None);
7669                    item.rect_opt.set(None);
7670                    hidden += 1;
7671                    continue;
7672                }
7673                if matches!(item.metadata.is_susceptible(), Some(true)) && !show_susceptible {
7674                    item.pos_opt.set(None);
7675                    item.rect_opt.set(None);
7676                    susceptible_hidden += 1;
7677                    continue;
7678                }
7679                if item
7680                    .metadata
7681                    .is_groupable_as_sample_tbprofiler_result_item()
7682                    && show_as_samples
7683                {
7684                    item.pos_opt.set(None);
7685                    item.rect_opt.set(None);
7686                    continue;
7687                }
7688                if item.metadata.is_tbprofiler_result_as_sample()
7689                    && !item
7690                        .metadata
7691                        .is_groupable_as_sample_tbprofiler_result_item()
7692                    && !show_as_samples
7693                {
7694                    item.pos_opt.set(None);
7695                    item.rect_opt.set(None);
7696                    continue;
7697                }
7698                item.pos_opt.set(Some((row, col)));
7699                let item_rect = Rectangle::new(
7700                    Point::new(
7701                        (col * (item_width + column_spacing as usize) + space_xxs as usize) as f32,
7702                        (row * (item_height + grid_spacing as usize) + space_xxs as usize) as f32,
7703                    ),
7704                    Size::new(item_width as f32, item_height as f32),
7705                );
7706                item.rect_opt.set(Some(item_rect));
7707
7708                //TODO: error if the row or col is already set?
7709                while grid_elements.len() <= row {
7710                    grid_elements.push(Vec::new());
7711                }
7712
7713                // Only build elements if visible (for performance)
7714                if item_rect.intersects(&visible_rect) {
7715                    //TODO: one focus group per grid item (needs custom widget)
7716                    let buttons: Vec<Element<Message>> = vec![
7717                        widget::button::custom(
7718                            widget::icon::icon(item.icon_handle_grid.clone())
7719                                .content_fit(ContentFit::Contain)
7720                                .size(icon_sizes.grid()),
7721                        )
7722                        .padding(space_xxxs)
7723                        .class(button_style(
7724                            item.selected,
7725                            item.highlighted,
7726                            item.cut,
7727                            false,
7728                            false,
7729                            false,
7730                        ))
7731                        .into(),
7732                        widget::tooltip(
7733                            widget::button::custom(Item::grid_display_name(&item.display_name))
7734                                .id(item.button_id.clone())
7735                                .padding([0, space_xxxs])
7736                                .class(button_style(
7737                                    item.selected,
7738                                    item.highlighted,
7739                                    item.cut,
7740                                    true,
7741                                    true,
7742                                    matches!(self.mode, Mode::Desktop),
7743                                )),
7744                            widget::text::body(&item.name),
7745                            widget::tooltip::Position::Bottom,
7746                        )
7747                        .into(),
7748                    ];
7749
7750                    let mut column = widget::column::with_capacity(buttons.len())
7751                        .align_x(Alignment::Center)
7752                        .height(Length::Fixed(item_height as f32))
7753                        .width(Length::Fixed(item_width as f32));
7754                    for button in buttons {
7755                        if self.context_menu.is_some() {
7756                            column = column.push(button);
7757                        } else {
7758                            column = column.push(
7759                                mouse_area::MouseArea::new(button)
7760                                    .on_right_press_no_capture()
7761                                    .wayland_on_right_press_window_position()
7762                                    .on_right_press(move |point_opt| {
7763                                        Message::RightClick(point_opt, Some(i))
7764                                    }),
7765                            );
7766                        }
7767                    }
7768
7769                    let column: Element<Message> =
7770                        if item.metadata.is_dir() && item.location_opt.is_some() {
7771                            self.dnd_dest(&item.location_opt.clone().unwrap(), column)
7772                        } else {
7773                            column.into()
7774                        };
7775
7776                    if item.selected {
7777                        dnd_items.push((i, (row, col), item));
7778                        drag_w_i = drag_w_i.min(col);
7779                        drag_n_i = drag_n_i.min(row);
7780                        drag_e_i = drag_e_i.max(col);
7781                        drag_s_i = drag_s_i.max(row);
7782                    }
7783                    let mouse_area = crate::mouse_area::MouseArea::new(column)
7784                        .on_press(move |_| Message::Click(Some(i)))
7785                        .on_double_click(move |_| Message::DoubleClick(Some(i)))
7786                        .on_release(move |_| Message::ClickRelease(Some(i)))
7787                        .on_middle_press(move |_| Message::MiddleClick(i))
7788                        .on_enter(move || Message::HighlightActivate(i))
7789                        .on_exit(move || Message::HighlightDeactivate(i));
7790                    grid_elements[row].push(Element::from(mouse_area));
7791                } else {
7792                    // Add a spacer if the row is empty, so scroll works
7793                    if grid_elements[row].is_empty() {
7794                        grid_elements[row].push(Element::from(
7795                            widget::column::with_capacity(0)
7796                                .width(Length::Fill)
7797                                .height(Length::Fixed(item_height as f32)),
7798                        ));
7799                    }
7800                }
7801
7802                count += 1;
7803                if matches!(self.mode, Mode::Desktop) {
7804                    row += 1;
7805                    if row >= page_row + rows {
7806                        row = 0;
7807                        col += 1;
7808                    }
7809                    if col >= cols {
7810                        col = 0;
7811                        page_row += rows;
7812                        row = page_row;
7813                    }
7814                } else {
7815                    col += 1;
7816                    if col >= cols {
7817                        col = 0;
7818                        row += 1;
7819                    }
7820                }
7821            }
7822
7823            for row_elements in grid_elements {
7824                for element in row_elements {
7825                    grid = grid.push(element);
7826                }
7827                grid = grid.insert_row();
7828            }
7829
7830            if count == 0 {
7831                return (
7832                    None,
7833                    self.empty_view(hidden > 0, susceptible_hidden > 0),
7834                    false,
7835                );
7836            }
7837
7838            column = column.push(grid);
7839
7840            //TODO: HACK If we don't reach the bottom of the view, go ahead and add a spacer to do that
7841            {
7842                let mut max_bottom = 0;
7843                for (_, item) in items {
7844                    if let Some(rect) = item.rect_opt.get() {
7845                        let bottom = (rect.y + rect.height).ceil() as usize;
7846                        if bottom > max_bottom {
7847                            max_bottom = bottom;
7848                        }
7849                    }
7850                }
7851
7852                // Cache content height for scroll clamping on next frame
7853                self.content_height_opt.set(Some(max_bottom as f32));
7854
7855                let top_deduct = 7 * (space_xxs as usize);
7856
7857                self.item_view_size_opt
7858                    .set(self.size_opt.get().map(|s| Size {
7859                        width: s.width,
7860                        height: s.height - top_deduct as f32,
7861                    }));
7862
7863                let spacer_height = height.saturating_sub(max_bottom + top_deduct);
7864                if spacer_height > 0 {
7865                    column = column.push(widget::container(
7866                        space::vertical().height(Length::Fixed(spacer_height as f32)),
7867                    ));
7868                }
7869            }
7870        }
7871
7872        let drag_list = (!dnd_items.is_empty()).then(|| {
7873            let mut dnd_grid = widget::grid()
7874                .column_spacing(column_spacing)
7875                .row_spacing(grid_spacing)
7876                .padding(space_xxs.into());
7877
7878            let mut dnd_item_i = 0;
7879            for r in drag_n_i..=drag_s_i {
7880                dnd_grid = dnd_grid.insert_row();
7881                for c in drag_w_i..=drag_e_i {
7882                    let Some((i, (row, col), item)) = dnd_items.get(dnd_item_i) else {
7883                        break;
7884                    };
7885                    if *row == r && *col == c {
7886                        let buttons = vec![
7887                            widget::button::custom(
7888                                widget::icon::icon(item.icon_handle_grid.clone())
7889                                    .content_fit(ContentFit::Contain)
7890                                    .size(icon_sizes.grid()),
7891                            )
7892                            .on_press(Message::Click(Some(*i)))
7893                            .padding(space_xxxs)
7894                            .class(button_style(
7895                                item.selected,
7896                                item.highlighted,
7897                                item.cut,
7898                                false,
7899                                false,
7900                                false,
7901                            )),
7902                            widget::button::custom(Item::grid_display_name(
7903                                item.display_name.clone(),
7904                            ))
7905                            .id(item.button_id.clone())
7906                            .on_press(Message::Click(Some(*i)))
7907                            .padding([0, space_xxxs])
7908                            .class(button_style(
7909                                item.selected,
7910                                item.highlighted,
7911                                item.cut,
7912                                true,
7913                                true,
7914                                false,
7915                            )),
7916                        ];
7917
7918                        let column =
7919                            widget::column::with_children(buttons.into_iter().map(Element::from))
7920                                .align_x(Alignment::Center)
7921                                .height(Length::Fixed(item_height as f32))
7922                                .width(Length::Fixed(item_width as f32));
7923
7924                        dnd_grid = dnd_grid.push(column);
7925                        dnd_item_i += 1;
7926                    } else {
7927                        dnd_grid = dnd_grid.push(
7928                            widget::container(space::vertical().height(item_width as f32))
7929                                .height(Length::Fixed(item_height as f32)),
7930                        );
7931                    }
7932                }
7933            }
7934            Element::from(dnd_grid)
7935        });
7936
7937        let mut mouse_area = mouse_area::MouseArea::new(column.width(Length::Fill))
7938            .on_press(|_| Message::Click(None))
7939            .on_auto_scroll(Message::AutoScroll)
7940            .on_drag_end(|_| Message::DragEnd)
7941            .show_drag_rect(self.mode.multiple())
7942            .on_release(|_| Message::ClickRelease(None));
7943        if self.watch_drag {
7944            mouse_area = mouse_area.on_drag(Message::Drag);
7945        }
7946
7947        (drag_list, mouse_area.into(), true)
7948    }
7949
7950    pub fn list_view(
7951        &self,
7952    ) -> (
7953        Option<Element<'static, Message>>,
7954        Element<'_, Message>,
7955        bool,
7956    ) {
7957        let cosmic_theme::Spacing {
7958            space_s, space_xxs, ..
7959        } = theme::spacing();
7960
7961        let TabConfig {
7962            show_hidden,
7963            show_susceptible,
7964            show_as_samples,
7965            icon_sizes,
7966            ..
7967        } = self.config;
7968
7969        let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
7970        //TODO: allow resizing?
7971        let name_width = 300.0;
7972        let modified_width = 200.0;
7973        let size_width = 100.0;
7974        let condensed = size.width < (name_width + modified_width + size_width);
7975        let is_search = matches!(self.location, Location::Search(..));
7976        let icon_size = if condensed || is_search {
7977            icon_sizes.list_condensed()
7978        } else {
7979            icon_sizes.list()
7980        };
7981        let row_height = icon_size + 2 * space_xxs;
7982
7983        let mut column = widget::column::with_capacity(3);
7984        let mut y: f32 = 0.0;
7985
7986        let rule_padding = theme::active().cosmic().corner_radii.radius_xs[0] as u16;
7987
7988        //TODO: move to function
7989        let visible_rect = {
7990            // Use cached content height to clamp scroll offset after resize
7991            let max_scroll_y = self
7992                .content_height_opt
7993                .get()
7994                .map(|ch| (ch - size.height).max(0.0))
7995                .unwrap_or(f32::MAX);
7996            let scroll_y = self
7997                .scroll_opt
7998                .map(|o| o.y.min(max_scroll_y).max(0.0))
7999                .unwrap_or(0.0);
8000            let point = Point::new(0.0, scroll_y);
8001            let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
8002            Rectangle::new(point, size)
8003        };
8004
8005        let mut drag_items = Vec::new();
8006        if let Some(items) = self.column_sort() {
8007            let mut count = 0;
8008            let mut hidden = 0;
8009            let mut susceptible_hidden = 0;
8010            for (i, item) in items {
8011                if item.hidden && !show_hidden {
8012                    item.pos_opt.set(None);
8013                    item.rect_opt.set(None);
8014                    hidden += 1;
8015                    continue;
8016                }
8017                if matches!(item.metadata.is_susceptible(), Some(true)) && !show_susceptible {
8018                    item.pos_opt.set(None);
8019                    item.rect_opt.set(None);
8020                    susceptible_hidden += 1;
8021                    continue;
8022                }
8023                // When show_as_samples is on, hide raw .results.* files (shown as grouped items).
8024                // When show_as_samples is off, hide grouped sample items.
8025                if item
8026                    .metadata
8027                    .is_groupable_as_sample_tbprofiler_result_item()
8028                    && show_as_samples
8029                {
8030                    item.pos_opt.set(None);
8031                    item.rect_opt.set(None);
8032                    continue;
8033                }
8034                if item.metadata.is_tbprofiler_result_as_sample()
8035                    && !item
8036                        .metadata
8037                        .is_groupable_as_sample_tbprofiler_result_item()
8038                    && !show_as_samples
8039                {
8040                    item.pos_opt.set(None);
8041                    item.rect_opt.set(None);
8042                    continue;
8043                }
8044
8045                if count > 0 {
8046                    column = column
8047                        .push(widget::container(rule::horizontal(1)).padding([0, rule_padding]));
8048                    y += 1.0;
8049                }
8050
8051                item.pos_opt.set(Some((count, 0)));
8052                let item_rect = Rectangle::new(
8053                    Point::new(f32::from(space_s), y),
8054                    Size::new(size.width - f32::from(2 * space_s), f32::from(row_height)),
8055                );
8056                item.rect_opt.set(Some(item_rect));
8057
8058                // Only build elements if visible (for performance)
8059                let button_row = if item_rect.intersects(&visible_rect) {
8060                    let modified_text = match &item.metadata {
8061                        ItemMetadata::Path { metadata, .. } => match metadata.modified() {
8062                            Ok(time) => self.format_time(time).to_string(),
8063                            Err(_) => String::new(),
8064                        },
8065                        ItemMetadata::Trash { entry, .. } => FormatTime::from_secs(
8066                            entry.time_deleted,
8067                            &self.date_time_formatter,
8068                            &self.time_formatter,
8069                        )
8070                        .map(|t| t.to_string())
8071                        .unwrap_or_default(),
8072                        #[cfg(feature = "gvfs")]
8073                        ItemMetadata::GvfsPath { .. } => match item.metadata.modified() {
8074                            Some(mtime) => self.format_time(mtime).to_string(),
8075                            None => String::new(),
8076                        },
8077                        #[cfg(feature = "russh")]
8078                        ItemMetadata::RusshPath { .. } => match item.metadata.modified() {
8079                            Some(mtime) => self.format_time(mtime).to_string(),
8080                            None => String::new(),
8081                        },
8082                        _ => String::new(),
8083                    };
8084
8085                    let size_text = match &item.metadata {
8086                        ItemMetadata::Path {
8087                            metadata,
8088                            children_opt,
8089                            ..
8090                        } => {
8091                            if metadata.is_dir() {
8092                                //TODO: translate
8093                                if let Some(children) = children_opt {
8094                                    if *children == 1 {
8095                                        format!("{children} item")
8096                                    } else {
8097                                        format!("{children} items")
8098                                    }
8099                                } else {
8100                                    String::new()
8101                                }
8102                            } else {
8103                                format_size(metadata.len())
8104                            }
8105                        }
8106                        ItemMetadata::Trash { metadata, .. } => match metadata.size {
8107                            trash::TrashItemSize::Entries(entries) => {
8108                                //TODO: translate
8109                                if entries == 1 {
8110                                    format!("{entries} item")
8111                                } else {
8112                                    format!("{entries} items")
8113                                }
8114                            }
8115                            trash::TrashItemSize::Bytes(bytes) => format_size(bytes),
8116                        },
8117                        ItemMetadata::SimpleDir { entries } => {
8118                            //TODO: translate
8119                            if *entries == 1 {
8120                                format!("{entries} item")
8121                            } else {
8122                                format!("{entries} items")
8123                            }
8124                        }
8125                        ItemMetadata::SimpleFile { size } => format_size(*size),
8126                        #[cfg(feature = "gvfs")]
8127                        ItemMetadata::GvfsPath {
8128                            size_opt,
8129                            children_opt,
8130                            ..
8131                        } => match children_opt {
8132                            Some(child_count) => {
8133                                if *child_count == 1 {
8134                                    format!("{child_count} item")
8135                                } else {
8136                                    format!("{child_count} items")
8137                                }
8138                            }
8139                            None => format_size(size_opt.unwrap_or_default()),
8140                        },
8141                        #[cfg(feature = "russh")]
8142                        ItemMetadata::RusshPath {
8143                            size_opt,
8144                            children_opt,
8145                            ..
8146                        } => match children_opt {
8147                            Some(child_count) => {
8148                                if *child_count == 1 {
8149                                    format!("{child_count} item")
8150                                } else {
8151                                    format!("{child_count} items")
8152                                }
8153                            }
8154                            None => format_size(size_opt.unwrap_or_default()),
8155                        },
8156                    };
8157
8158                    let row = if condensed {
8159                        widget::row::with_children([
8160                            widget::icon::icon(item.icon_handle_list_condensed.clone())
8161                                .content_fit(ContentFit::Contain)
8162                                .size(icon_size)
8163                                .into(),
8164                            widget::column::with_children([
8165                                Item::list_display_name(item.display_name.clone()).into(),
8166                                //TODO: translate?
8167                                widget::text::caption(format!("{modified_text} - {size_text}"))
8168                                    .into(),
8169                            ])
8170                            .into(),
8171                        ])
8172                        .height(Length::Fixed(f32::from(row_height)))
8173                        .align_y(Alignment::Center)
8174                        .spacing(space_xxs)
8175                    } else if is_search {
8176                        widget::row::with_children([
8177                            widget::icon::icon(item.icon_handle_list_condensed.clone())
8178                                .content_fit(ContentFit::Contain)
8179                                .size(icon_size)
8180                                .into(),
8181                            widget::column::with_children([
8182                                Item::list_display_name(item.display_name.clone()).into(),
8183                                widget::text::caption(match item.path_opt() {
8184                                    Some(path) => path.display().to_string(),
8185                                    None => String::new(),
8186                                })
8187                                .into(),
8188                            ])
8189                            .width(Length::Fill)
8190                            .into(),
8191                            widget::text::body(modified_text.clone())
8192                                .width(Length::Fixed(modified_width))
8193                                .into(),
8194                            widget::text::body(size_text.clone())
8195                                .width(Length::Fixed(size_width))
8196                                .into(),
8197                        ])
8198                        .height(Length::Fixed(f32::from(row_height)))
8199                        .align_y(Alignment::Center)
8200                        .spacing(space_xxs)
8201                    } else {
8202                        widget::row::with_children([
8203                            widget::icon::icon(item.icon_handle_list.clone())
8204                                .content_fit(ContentFit::Contain)
8205                                .size(icon_size)
8206                                .into(),
8207                            Item::list_display_name(item.display_name.clone())
8208                                .width(Length::Fill)
8209                                .into(),
8210                            widget::text::body(modified_text.clone())
8211                                .width(Length::Fixed(modified_width))
8212                                .into(),
8213                            widget::text::body(size_text.clone())
8214                                .width(Length::Fixed(size_width))
8215                                .into(),
8216                        ])
8217                        .height(Length::Fixed(f32::from(row_height)))
8218                        .align_y(Alignment::Center)
8219                        .spacing(space_xxs)
8220                    };
8221
8222                    let button = |row| {
8223                        let mouse_area = crate::mouse_area::MouseArea::new(
8224                            widget::button::custom(row)
8225                                .width(Length::Fill)
8226                                .id(item.button_id.clone())
8227                                .padding([0, space_xxs])
8228                                .class(button_style(
8229                                    item.selected,
8230                                    item.highlighted,
8231                                    item.cut,
8232                                    true,
8233                                    true,
8234                                    false,
8235                                )),
8236                        )
8237                        .on_press(move |_| Message::Click(Some(i)))
8238                        .on_double_click(move |_| Message::DoubleClick(Some(i)))
8239                        .on_release(move |_| Message::ClickRelease(Some(i)))
8240                        .on_middle_press(move |_| Message::MiddleClick(i))
8241                        .on_enter(move || Message::HighlightActivate(i))
8242                        .on_exit(move || Message::HighlightDeactivate(i));
8243
8244                        if self.context_menu.is_some() {
8245                            mouse_area
8246                        } else {
8247                            mouse_area
8248                                .on_right_press_no_capture()
8249                                .wayland_on_right_press_window_position()
8250                                .on_right_press(move |point_opt| {
8251                                    Message::RightClick(point_opt, Some(i))
8252                                })
8253                        }
8254                    };
8255
8256                    let button_row = button(row.into());
8257                    let button_row: Element<_> = if item.metadata.is_dir()
8258                        && let Some(location) = item.location_opt.as_ref()
8259                    {
8260                        self.dnd_dest(location, button_row)
8261                    } else {
8262                        button_row.into()
8263                    };
8264
8265                    if item.selected || !drag_items.is_empty() {
8266                        let dnd_row = if !item.selected {
8267                            Element::from(
8268                                space::vertical().height(Length::Fixed(f32::from(row_height))),
8269                            )
8270                        } else if condensed {
8271                            widget::row::with_children([
8272                                widget::icon::icon(item.icon_handle_list_condensed.clone())
8273                                    .content_fit(ContentFit::Contain)
8274                                    .size(icon_size)
8275                                    .into(),
8276                                widget::column::with_children([
8277                                    Item::list_display_name(item.display_name.clone()).into(),
8278                                    //TODO: translate?
8279                                    widget::text::body(format!("{modified_text} - {size_text}"))
8280                                        .into(),
8281                                ])
8282                                .into(),
8283                            ])
8284                            .align_y(Alignment::Center)
8285                            .spacing(space_xxs)
8286                            .into()
8287                        } else if is_search {
8288                            widget::row::with_children([
8289                                widget::icon::icon(item.icon_handle_list_condensed.clone())
8290                                    .content_fit(ContentFit::Contain)
8291                                    .size(icon_size)
8292                                    .into(),
8293                                widget::column::with_children([
8294                                    Item::list_display_name(item.display_name.clone()).into(),
8295                                    widget::text::caption(match item.path_opt() {
8296                                        Some(path) => path.display().to_string(),
8297                                        None => String::new(),
8298                                    })
8299                                    .into(),
8300                                ])
8301                                .width(Length::Fill)
8302                                .into(),
8303                                widget::text::body(modified_text.clone())
8304                                    .width(Length::Fixed(modified_width))
8305                                    .into(),
8306                                widget::text::body(size_text.clone())
8307                                    .width(Length::Fixed(size_width))
8308                                    .into(),
8309                            ])
8310                            .align_y(Alignment::Center)
8311                            .spacing(space_xxs)
8312                            .into()
8313                        } else {
8314                            widget::row::with_children([
8315                                widget::icon::icon(item.icon_handle_list.clone())
8316                                    .content_fit(ContentFit::Contain)
8317                                    .size(icon_size)
8318                                    .into(),
8319                                Item::list_display_name(item.display_name.clone())
8320                                    .width(Length::Fill)
8321                                    .into(),
8322                                widget::text(modified_text)
8323                                    .width(Length::Fixed(modified_width))
8324                                    .into(),
8325                                widget::text::body(size_text)
8326                                    .width(Length::Fixed(size_width))
8327                                    .into(),
8328                            ])
8329                            .align_y(Alignment::Center)
8330                            .spacing(space_xxs)
8331                            .into()
8332                        };
8333                        if item.selected {
8334                            drag_items.push(
8335                                widget::container(button(dnd_row))
8336                                    .width(Length::Shrink)
8337                                    .into(),
8338                            );
8339                        } else {
8340                            drag_items.push(dnd_row);
8341                        }
8342                    }
8343
8344                    button_row
8345                } else {
8346                    widget::column::with_capacity(0)
8347                        .width(Length::Fill)
8348                        .height(Length::Fixed(f32::from(row_height)))
8349                        .into()
8350                };
8351
8352                count += 1;
8353                y += f32::from(row_height);
8354                column = column.push(button_row);
8355            }
8356
8357            if count == 0 {
8358                return (
8359                    None,
8360                    self.empty_view(hidden > 0, susceptible_hidden > 0),
8361                    false,
8362                );
8363            }
8364
8365            // Cache content height for scroll clamping on next frame
8366            self.content_height_opt.set(Some(y));
8367        }
8368        //TODO: HACK If we don't reach the bottom of the view, go ahead and add a spacer to do that
8369        {
8370            let top_deduct = (if condensed || is_search { 6 } else { 9 }) * space_xxs;
8371
8372            self.item_view_size_opt
8373                .set(self.size_opt.get().map(|s| Size {
8374                    width: s.width,
8375                    height: s.height - f32::from(top_deduct),
8376                }));
8377
8378            let spacer_height = size.height - y - f32::from(top_deduct);
8379            if spacer_height > 0. {
8380                column = column.push(widget::container(space::vertical().height(spacer_height)));
8381            }
8382        }
8383        let drag_col = (!drag_items.is_empty())
8384            .then(|| Element::from(widget::column::with_children(drag_items)));
8385
8386        let mut mouse_area = mouse_area::MouseArea::new(column.padding([0, space_s]))
8387            .with_id(Id::new("list-view"))
8388            .on_press(|_| Message::Click(None))
8389            .on_auto_scroll(Message::AutoScroll)
8390            .on_drag_end(|_| Message::DragEnd)
8391            .show_drag_rect(self.mode.multiple())
8392            .on_release(|_| Message::ClickRelease(None));
8393        if self.watch_drag {
8394            mouse_area = mouse_area.on_drag(Message::Drag);
8395        }
8396
8397        (drag_col, mouse_area.into(), true)
8398    }
8399
8400    pub fn view_responsive<'a>(
8401        &'a self,
8402        key_binds: &'a HashMap<KeyBind, Action>,
8403        modifiers: &'a Modifiers,
8404        size: Size,
8405        clipboard_paste_available: bool,
8406        context_actions: &'a [ContextActionPreset],
8407    ) -> Element<'a, Message> {
8408        // Update cached size
8409        self.size_opt.set(Some(size));
8410
8411        let cosmic_theme::Spacing {
8412            space_xxxs,
8413            space_xxs,
8414            space_xs,
8415            ..
8416        } = theme::spacing();
8417
8418        let location_view_opt = if matches!(self.mode, Mode::Desktop) {
8419            None
8420        } else {
8421            Some(self.location_view())
8422        };
8423        let (drag_list, mut item_view, can_scroll) = match self.config.view {
8424            View::Grid => self.grid_view(),
8425            View::List => self.list_view(),
8426        };
8427        item_view = widget::container(item_view).width(Length::Fill).into();
8428        let files = self
8429            .items_opt
8430            .as_ref()
8431            .map(|items| {
8432                items
8433                    .iter()
8434                    .filter_map(|item| {
8435                        if item.selected {
8436                            item.path_opt().cloned()
8437                        } else {
8438                            None
8439                        }
8440                    })
8441                    .collect::<Box<[PathBuf]>>()
8442            })
8443            .unwrap_or_default();
8444        let item_view =
8445            DndSource::<Message, ClipboardCopy>::with_id(item_view, Id::new("tab-view"));
8446
8447        let view = self.config.view;
8448        let item_view = match drag_list {
8449            Some(drag_list) if self.selected_clicked => {
8450                let drag_list = RcElementWrapper::new(drag_list);
8451                item_view
8452                    .drag_content(move || {
8453                        ClipboardCopy::new(crate::clipboard::ClipboardKind::Copy, &files)
8454                    })
8455                    .drag_icon(move |_| {
8456                        let state: tree::State = Widget::<Message, _, _>::state(&drag_list);
8457                        (
8458                            Element::from(drag_list.clone()).map(|_m| ()),
8459                            state,
8460                            match view {
8461                                // offset by grid padding so that we grab the top left corner of the item in the drag grid.
8462                                View::Grid => Vector::new(
8463                                    f32::from(space_xxs).mul_add(-3.0, -f32::from(space_xxxs)),
8464                                    -4. * f32::from(space_xxxs),
8465                                ),
8466                                View::List => Vector::ZERO,
8467                            },
8468                        )
8469                    })
8470            }
8471            _ => item_view,
8472        };
8473
8474        let tab_location = self.location.clone();
8475        let mouse_area = mouse_area::MouseArea::new(item_view)
8476            .on_press(move |_point_opt| Message::Click(None))
8477            .on_release(|_| Message::ClickRelease(None))
8478            .on_resize(Message::Resize)
8479            .on_back_press(move |_point_opt| Message::GoPrevious)
8480            .on_forward_press(move |_point_opt| Message::GoNext)
8481            .on_scroll(|delta| respond_to_scroll_direction(delta, modifiers))
8482            .on_right_press(move |p| {
8483                Message::ContextMenu(
8484                    if self.context_menu.is_some() { None } else { p },
8485                    self.window_id,
8486                )
8487            })
8488            .wayland_on_right_press_window_position();
8489
8490        let mut popover = widget::popover(mouse_area);
8491        if let Some(point) = self.context_menu
8492            && (!cfg!(feature = "wayland") || !crate::is_wayland())
8493        {
8494            let context_menu = menu::context_menu(
8495                self,
8496                key_binds,
8497                modifiers,
8498                clipboard_paste_available,
8499                &self.tb_config,
8500                context_actions,
8501            );
8502            popover = popover
8503                .popup(context_menu)
8504                .position(widget::popover::Position::Point(point));
8505        }
8506
8507        let mut tab_column = widget::column::with_capacity(3);
8508        if let Some(location_view) = location_view_opt {
8509            tab_column = tab_column.push(location_view);
8510        }
8511        if can_scroll {
8512            tab_column = tab_column.push(
8513                // FIXME: new responsive widget will remove the state from the scrollable
8514                // id_container with custom id forces the state to be extracted in a diff
8515                // pre-processing step
8516                widget::id_container(
8517                    widget::scrollable(popover)
8518                        .id(self.scrollable_id.clone())
8519                        .on_scroll(Message::Scroll)
8520                        .width(Length::Fill)
8521                        .height(Length::Fill),
8522                    widget::Id::new(format!("{}-scrollable", self.scrollable_id)),
8523                ),
8524            );
8525        } else {
8526            tab_column = tab_column.push(popover);
8527        }
8528        match &self.location {
8529            Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
8530                if let Some(items) = self.items_opt()
8531                    && !items.is_empty()
8532                {
8533                    tab_column = tab_column.push(
8534                        widget::layer_container(widget::row::with_children([
8535                            widget::space::horizontal().into(),
8536                            widget::button::standard(fl!("empty-trash"))
8537                                .on_press(Message::EmptyTrash)
8538                                .into(),
8539                        ]))
8540                        .padding([space_xxs, space_xs])
8541                        .layer(cosmic_theme::Layer::Primary)
8542                        .apply(widget::container)
8543                        .padding([0, 0, 7, 0]),
8544                    );
8545                }
8546            }
8547            Location::Recents | Location::Search(SearchLocation::Recents, ..) => {
8548                if let Some(items) = self.items_opt()
8549                    && !items.is_empty()
8550                {
8551                    tab_column = tab_column.push(
8552                        widget::layer_container(widget::row::with_children([
8553                            widget::space::horizontal().into(),
8554                            widget::button::standard(fl!("clear-recents-history"))
8555                                .on_press(Message::ClearRecents)
8556                                .into(),
8557                        ]))
8558                        .padding([space_xxs, space_xs])
8559                        .layer(cosmic_theme::Layer::Primary)
8560                        .apply(widget::container)
8561                        .padding([0, 0, 7, 0]),
8562                    );
8563                }
8564            }
8565            Location::Network(uri, _display_name, _path) if uri == "network:///" => {
8566                tab_column = tab_column.push(
8567                    widget::layer_container(widget::row::with_children([
8568                        widget::space::horizontal().into(),
8569                        widget::button::standard(fl!("add-network-drive"))
8570                            .on_press(Message::AddNetworkDrive)
8571                            .into(),
8572                    ]))
8573                    .padding([space_xxs, space_xs])
8574                    .layer(cosmic_theme::Layer::Primary)
8575                    .apply(widget::container)
8576                    .padding([0, 0, 7, 0]),
8577                );
8578            }
8579            Location::Remote(uri, _display_name, _path) if uri == "ssh:///" => {
8580                tab_column = tab_column.push(
8581                    widget::layer_container(widget::row::with_children([
8582                        widget::space::horizontal().into(),
8583                        widget::button::standard(fl!("add-remote-drive"))
8584                            .on_press(Message::AddRemoteDrive)
8585                            .into(),
8586                    ]))
8587                    .padding([space_xxs, space_xs])
8588                    .layer(cosmic_theme::Layer::Primary)
8589                    .apply(widget::container)
8590                    .padding([0, 0, 7, 0]),
8591                );
8592            }
8593            Location::Remote(uri, _display_name, path)
8594                if path.as_ref().is_some_and(|p| {
8595                    let expected = Path::new(&self.tb_config.out_dir).join("results");
8596                    p == &expected
8597                }) =>
8598            {
8599                tab_column = tab_column.push(
8600                    widget::layer_container(widget::row::with_children([
8601                        widget::space::horizontal().into(),
8602                        widget::button::standard(fl!("delete-tb-profiler-results"))
8603                            .on_press(Message::DeleteTbProfilerResults(
8604                                uri.to_string(),
8605                                self.tb_config.clone(),
8606                            ))
8607                            .into(),
8608                    ]))
8609                    .padding([space_xxs, space_xs])
8610                    .layer(cosmic_theme::Layer::Primary)
8611                    .apply(widget::container)
8612                    .padding([0, 0, 7, 0]),
8613                );
8614            }
8615            _ => {}
8616        }
8617        let mut tab_view = widget::container(tab_column)
8618            .height(Length::Fill)
8619            .width(Length::Fill);
8620
8621        // Desktop will not show DnD indicator
8622        if self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location)
8623            && !matches!(self.mode, Mode::Desktop)
8624        {
8625            tab_view = tab_view.style(|t| {
8626                let mut a = widget::container::Style::default();
8627                let c = t.cosmic();
8628                a.border = cosmic::iced::core::Border {
8629                    color: (c.accent_color()).into(),
8630                    width: 1.,
8631                    radius: c.radius_0().into(),
8632                };
8633                a
8634            });
8635        }
8636
8637        let tab_location_2 = self.location.clone();
8638        let tab_location_3 = self.location.clone();
8639        let dnd_dest = DndDestination::for_data(tab_view, move |data, action| {
8640            if let Some(mut data) = data {
8641                if action == DndAction::Copy {
8642                    Message::Drop(Some((tab_location.clone(), data)))
8643                } else if action == DndAction::Move {
8644                    data.kind = ClipboardKind::Cut { is_dnd: true };
8645                    Message::Drop(Some((tab_location.clone(), data)))
8646                } else {
8647                    log::warn!("unsupported action: {action:?}");
8648                    Message::Drop(None)
8649                }
8650            } else {
8651                Message::Drop(None)
8652            }
8653        })
8654        .on_enter(move |_, _, _| Message::DndEnter(tab_location_2.clone()))
8655        .on_leave(move || Message::DndLeave(tab_location_3.clone()));
8656
8657        dnd_dest.into()
8658    }
8659    pub fn multi_preview_view<'a>(
8660        &'a self,
8661        mime_app_cache_opt: Option<&'a mime_app::MimeAppCache>,
8662    ) -> Element<'a, Message> {
8663        let cosmic_theme::Spacing {
8664            space_xxxs,
8665            space_m,
8666            ..
8667        } = theme::spacing();
8668
8669        let mut column = widget::column::with_capacity(4).spacing(space_m);
8670
8671        let handle = widget::icon::from_name("text-x-generic")
8672            .size(IconSizes::default().grid())
8673            .handle();
8674
8675        let icon = widget::icon::icon(handle.clone())
8676            .content_fit(ContentFit::Contain)
8677            .size(IconSizes::default().grid());
8678
8679        let icon_container1 = widget::container(icon.clone()).padding(padding::bottom(10).left(10));
8680        let icon_container2 =
8681            widget::container(icon.clone()).padding(padding::top(5).bottom(5).left(5).right(5));
8682        let icon_container3 = widget::container(icon).padding(padding::top(10).right(10));
8683        let stack = stack![icon_container1, icon_container2, icon_container3];
8684
8685        column = column.push(
8686            widget::container(stack)
8687                .center_x(Length::Fill)
8688                .max_height(THUMBNAIL_SIZE as f32),
8689        );
8690
8691        let selected_items: Vec<&Item> = self.items_opt().map_or(Vec::new(), |items| {
8692            items
8693                .iter()
8694                .filter(|item| {
8695                    if !item.selected {
8696                        return false;
8697                    }
8698                    // Include remote TB result items even though their location path is None
8699                    // (they carry their downloadable paths in sample_*_path_opt fields)
8700                    #[cfg(feature = "russh")]
8701                    if item.metadata.is_tbprofiler_result_as_sample()
8702                        && let Some(Location::Remote(..)) = &item.location_opt
8703                    {
8704                        return true;
8705                    }
8706                    item.location_opt
8707                        .as_ref()
8708                        .and_then(Location::path_opt)
8709                        .is_some()
8710                })
8711                .collect()
8712        });
8713
8714        let mut details = widget::column::with_capacity(3).spacing(space_xxxs);
8715        details = details.push(widget::text::body(fl!(
8716            "items",
8717            items = selected_items.len()
8718        )));
8719
8720        let mut total_size: u64 = 0;
8721        let mut mime_type_counts: BTreeMap<String, u64> = BTreeMap::new();
8722        let _user_name: BTreeSet<String> = BTreeSet::new();
8723        let _mode_user: BTreeSet<u32> = BTreeSet::new();
8724        let _group_name: BTreeSet<String> = BTreeSet::new();
8725        let _mode_group: BTreeSet<u32> = BTreeSet::new();
8726        let _mode_other: BTreeSet<u32> = BTreeSet::new();
8727        let mut calculating_dir_size = false;
8728        let mut dir_size_error: Option<String> = None;
8729        let mut show_size = true;
8730
8731        for item in selected_items.iter() {
8732            *mime_type_counts.entry(item.mime.to_string()).or_insert(0) += 1;
8733
8734            if let Some(metadata) = item.file_metadata() {
8735                if metadata.is_dir() {
8736                    match &item.dir_size {
8737                        DirSize::Calculating(_) => {
8738                            calculating_dir_size = true;
8739                        }
8740                        DirSize::Directory(size) => {
8741                            total_size = total_size.saturating_add(*size);
8742                        }
8743                        DirSize::NotDirectory => (),
8744                        DirSize::Error(err) => {
8745                            dir_size_error = Some(err.clone());
8746                        }
8747                    };
8748                } else {
8749                    total_size = total_size.saturating_add(metadata.len());
8750                }
8751                #[cfg(unix)]
8752                {
8753                    let mode = metadata.mode();
8754                    user_name.insert(
8755                        uzers::get_user_by_uid(metadata.uid())
8756                            .and_then(|user| user.name().to_str().map(ToOwned::to_owned))
8757                            .unwrap_or_default(),
8758                    );
8759                    mode_user.insert(get_mode_part(mode, MODE_SHIFT_USER));
8760                    group_name.insert(
8761                        uzers::get_group_by_gid(metadata.gid())
8762                            .and_then(|group| group.name().to_str().map(ToOwned::to_owned))
8763                            .unwrap_or_default(),
8764                    );
8765                    mode_group.insert(get_mode_part(mode, MODE_SHIFT_GROUP));
8766                    mode_other.insert(get_mode_part(mode, MODE_SHIFT_OTHER));
8767                }
8768            } else {
8769                match item.metadata {
8770                    #[cfg(feature = "russh")]
8771                    ItemMetadata::RusshPath { .. } => {
8772                        if item.metadata.is_dir() {
8773                            show_size = false;
8774                        } else if let Some(size) = item.metadata.file_size() {
8775                            total_size = total_size.saturating_add(size);
8776                        }
8777                    }
8778                    _ => (),
8779                }
8780            }
8781        }
8782        let mut mime_types: Vec<(String, u64)> = mime_type_counts.into_iter().collect();
8783        mime_types.sort_by(|(_, v1), (_, v2)| v2.cmp(v1));
8784
8785        // Limit the number of displayed mime types
8786        let limit = usize::min(10, mime_types.len());
8787
8788        let mut mime_type_strings: Vec<String> = mime_types[..limit]
8789            .iter()
8790            .map(|(mime, count)| format!("{} ({})", mime, count))
8791            .collect();
8792
8793        if mime_types.len() > limit {
8794            mime_type_strings.push("...".to_string());
8795        }
8796
8797        details = details.push(widget::text::body(fl!(
8798            "type",
8799            mime = mime_type_strings.join(", ")
8800        )));
8801
8802        let size = {
8803            if calculating_dir_size {
8804                fl!("calculating")
8805            } else if let Some(error) = dir_size_error {
8806                error
8807            } else {
8808                format_size(total_size)
8809            }
8810        };
8811
8812        if show_size {
8813            details = details.push(widget::text::body(fl!("item-size", size = size)));
8814        }
8815
8816        column = column.push(details);
8817
8818        let action_button = {
8819            #[cfg(feature = "russh")]
8820            {
8821                let remote_paths_uris: Vec<(PathBuf, String)> = {
8822                    let mut seen = std::collections::HashSet::new();
8823                    selected_items
8824                        .iter()
8825                        .flat_map(|item| {
8826                            if let ItemMetadata::RusshPath { .. } = &item.metadata
8827                                && let Some(Location::Remote(uri, _, path_opt)) = &item.location_opt
8828                            {
8829                                if item.metadata.is_tbprofiler_result_as_sample() {
8830                                    // Expand sample items into their individual file paths
8831                                    let mut result = Vec::new();
8832                                    if let Some(p) = item.metadata.json_path() {
8833                                        result.push((p.clone(), uri.clone()));
8834                                    }
8835                                    if let Some(p) = item.metadata.csv_path() {
8836                                        result.push((p.clone(), uri.clone()));
8837                                    }
8838                                    if let Some(p) = item.metadata.docx_path() {
8839                                        result.push((p.clone(), uri.clone()));
8840                                    }
8841                                    return result;
8842                                } else if let Some(path) = path_opt {
8843                                    return vec![(path.clone(), uri.clone())];
8844                                }
8845                            }
8846                            vec![]
8847                        })
8848                        .filter(|(path, _)| seen.insert(path.clone()))
8849                        .collect()
8850                };
8851
8852                if !remote_paths_uris.is_empty() {
8853                    let (paths, uris) = remote_paths_uris.into_iter().unzip();
8854                    Some(
8855                        widget::button::standard(fl!("download"))
8856                            .on_press(Message::DownloadMany(paths, uris)),
8857                    )
8858                } else if matches!(self.location, Location::Remote(..)) {
8859                    None
8860                } else {
8861                    Some(widget::button::standard(fl!("open")).on_press(Message::Open(None)))
8862                }
8863            }
8864            #[cfg(not(feature = "russh"))]
8865            Some(widget::button::standard(fl!("open")).on_press(Message::Open(None)))
8866        };
8867
8868        if let Some(btn) = action_button {
8869            column = column.push(btn);
8870        }
8871
8872        let mut settings = Vec::new();
8873        // Only allow modifying open-with if all mime types are the same
8874        if mime_types.len() == 1
8875            && let Some(mime) = mime_types
8876                .first()
8877                .and_then(|(mime, _)| mime.parse::<Mime>().ok())
8878            && let Some(mime_app_cache) = mime_app_cache_opt
8879        {
8880            let mime_apps = mime_app_cache.get_apps_for_mime(&mime, false);
8881            if !mime_apps.is_empty() {
8882                let mime_closure = mime.clone();
8883                let (names, icons) = mime_apps
8884                    .iter()
8885                    .map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
8886                    .collect::<(Vec<_>, Vec<_>)>();
8887                settings.push(
8888                    widget::settings::item::builder(fl!("open-with")).control(
8889                        Element::from(
8890                            widget::dropdown(
8891                                names,
8892                                mime_apps.iter().position(|(x, _)| x.is_default(&mime)),
8893                                move |index| (index, mime_closure.clone()),
8894                            )
8895                            .icons(Cow::Owned(icons)),
8896                        )
8897                        .map(move |(index, mime)| {
8898                            let mime_app = &mime_apps[index].0;
8899                            Message::SetOpenWith(mime, mime_app.id.clone())
8900                        }),
8901                    ),
8902                );
8903            }
8904        }
8905
8906        #[cfg(unix)]
8907        {
8908            // Only return mode part if it's the only one
8909            fn selected_mode_part(mut modes: BTreeSet<u32>) -> Option<usize> {
8910                match (modes.pop_first(), modes.pop_first()) {
8911                    (Some(mode), None) => Some(mode.try_into().unwrap()),
8912                    _ => None,
8913                }
8914            }
8915
8916            // Convert a limited number of values from a set into a comma separated list
8917            fn join_set(set: BTreeSet<String>) -> String {
8918                let limit = 5;
8919                let mut title = set.into_iter().collect::<Vec<String>>();
8920                if title.len() > limit {
8921                    title.truncate(limit);
8922                    title.push("...".to_string());
8923                }
8924                title.join(", ")
8925            }
8926
8927            let mode_part_user = selected_mode_part(mode_user);
8928            settings.push(
8929                widget::settings::item::builder(join_set(user_name))
8930                    .description(fl!("owner"))
8931                    .control(
8932                        widget::dropdown(
8933                            Cow::Borrowed(MODE_NAMES.as_slice()),
8934                            mode_part_user,
8935                            move |selected| {
8936                                Message::ShiftPermissions(
8937                                    None,
8938                                    MODE_SHIFT_USER,
8939                                    selected.try_into().unwrap(),
8940                                )
8941                            },
8942                        )
8943                        .placeholder(fl!("mixed")),
8944                    ),
8945            );
8946
8947            let mode_part_group = selected_mode_part(mode_group);
8948            settings.push(
8949                widget::settings::item::builder(join_set(group_name))
8950                    .description(fl!("group"))
8951                    .control(
8952                        widget::dropdown(
8953                            Cow::Borrowed(MODE_NAMES.as_slice()),
8954                            mode_part_group,
8955                            move |selected| {
8956                                Message::ShiftPermissions(
8957                                    None,
8958                                    MODE_SHIFT_GROUP,
8959                                    selected.try_into().unwrap(),
8960                                )
8961                            },
8962                        )
8963                        .placeholder(fl!("mixed")),
8964                    ),
8965            );
8966
8967            let mode_part_other = selected_mode_part(mode_other);
8968            settings.push(
8969                widget::settings::item::builder(fl!("other")).control(
8970                    widget::dropdown(
8971                        Cow::Borrowed(MODE_NAMES.as_slice()),
8972                        mode_part_other,
8973                        move |selected| {
8974                            Message::ShiftPermissions(
8975                                None,
8976                                MODE_SHIFT_OTHER,
8977                                selected.try_into().unwrap(),
8978                            )
8979                        },
8980                    )
8981                    .placeholder(fl!("mixed")),
8982                ),
8983            );
8984        }
8985
8986        if !settings.is_empty() {
8987            let mut section = widget::settings::section();
8988            section = section.extend(settings);
8989            column = column.push(section);
8990        }
8991
8992        column.into()
8993    }
8994    pub fn view<'a>(
8995        &'a self,
8996        key_binds: &'a HashMap<KeyBind, Action>,
8997        modifiers: &'a Modifiers,
8998        clipboard_paste_available: bool,
8999        context_actions: &'a [ContextActionPreset],
9000    ) -> Element<'a, Message> {
9001        widget::responsive(move |size| {
9002            widget::id_container(
9003                self.view_responsive(
9004                    key_binds,
9005                    modifiers,
9006                    size,
9007                    clipboard_paste_available,
9008                    context_actions,
9009                ),
9010                Id::new(format!(
9011                    "tab-{}-{}",
9012                    self.scrollable_id, self.location_title
9013                )),
9014            )
9015            .into()
9016        })
9017        .into()
9018    }
9019
9020    pub fn subscription(&self, preview: bool) -> Subscription<Message> {
9021        //TODO: how many thumbnail loads should be in flight at once?
9022        let jobs = self.thumb_config.jobs.get() as usize;
9023        let mut subscriptions = Vec::with_capacity(jobs + 3);
9024
9025        if let Some(items) = &self.items_opt {
9026            //TODO: move to function
9027            let visible_rect = {
9028                let point = match self.scroll_opt {
9029                    Some(offset) => Point::new(0.0, offset.y),
9030                    None => Point::new(0.0, 0.0),
9031                };
9032                let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
9033                Rectangle::new(point, size)
9034            };
9035
9036            for item in items {
9037                if item.thumbnail_opt.is_some() {
9038                    // Skip items that already have a mime type and thumbnail
9039                    continue;
9040                }
9041
9042                match item.rect_opt.get() {
9043                    Some(rect) => {
9044                        if !rect.intersects(&visible_rect) {
9045                            // Skip items that are not visible
9046                            continue;
9047                        }
9048                    }
9049                    None => {
9050                        // Skip items with no determined rect (this should include hidden items)
9051                        continue;
9052                    }
9053                }
9054
9055                let Some(path) = item.path_opt().cloned() else {
9056                    continue;
9057                };
9058
9059                let metadata = item.metadata.clone();
9060                let can_thumbnail = match metadata {
9061                    ItemMetadata::Path { .. } => true,
9062                    #[cfg(feature = "gvfs")]
9063                    ItemMetadata::GvfsPath { .. } => true,
9064                    _ => false,
9065                };
9066                if can_thumbnail {
9067                    let mime = item.mime.clone();
9068                    let max_jobs = jobs;
9069                    let max_mb = u64::from(self.thumb_config.max_mem_mb.get());
9070                    let max_size = u64::from(self.thumb_config.max_size_mb.get());
9071
9072                    // Determine effective memory budget based on image size
9073                    let (effective_max_mb, effective_jobs) = if mime.type_() == mime::IMAGE {
9074                        match item.image_dimensions {
9075                            Some((width, height)) => {
9076                                let (_use_dedicated, eff_mb, eff_jobs) =
9077                                    should_use_dedicated_worker(width, height, max_mb, max_jobs);
9078                                (eff_mb, eff_jobs)
9079                            }
9080                            None => (max_mb, max_jobs),
9081                        }
9082                    } else {
9083                        (max_mb, max_jobs)
9084                    };
9085
9086                    #[derive(Clone)]
9087                    struct Wrapper {
9088                        path: PathBuf,
9089                        metadata: ItemMetadata,
9090                        mime: mime::Mime,
9091                        effective_max_mb: u64,
9092                        effective_jobs: usize,
9093                        max_size: u64,
9094                    }
9095
9096                    impl Hash for Wrapper {
9097                        fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
9098                            self.path.hash(state);
9099                        }
9100                    }
9101
9102                    subscriptions.push(Subscription::run_with(
9103                        Wrapper {
9104                            path: path.clone(),
9105                            metadata,
9106                            mime,
9107                            effective_max_mb,
9108                            effective_jobs,
9109                            max_size,
9110                        },
9111                        |wrapper| {
9112                            let Wrapper {
9113                                path,
9114                                metadata,
9115                                mime,
9116                                effective_max_mb,
9117                                effective_jobs,
9118                                max_size,
9119                            } = wrapper.clone();
9120                            stream::channel(
9121                                1,
9122                                move |mut output: futures::channel::mpsc::Sender<_>| async move {
9123                                    while crate::operation::is_actively_writing_to(&path) {
9124                                        crate::operation::actively_writing_tick().await;
9125                                    }
9126
9127                                    let message = {
9128                                        let path = path.clone();
9129
9130                                        // Acquire semaphore permit
9131                                        _ = THUMB_SEMAPHORE.acquire().await;
9132
9133                                        tokio::task::spawn_blocking(move || {
9134                                            let start = Instant::now();
9135                                            let thumbnail = ItemThumbnail::new(
9136                                                &path,
9137                                                metadata,
9138                                                mime,
9139                                                THUMBNAIL_SIZE,
9140                                                effective_max_mb,
9141                                                effective_jobs,
9142                                                max_size,
9143                                            );
9144                                            log::debug!(
9145                                                "thumbnailed {} in {:?}",
9146                                                path.display(),
9147                                                start.elapsed()
9148                                            );
9149                                            Message::Thumbnail(path, thumbnail)
9150                                        })
9151                                        .await
9152                                        .unwrap()
9153                                    };
9154
9155                                    match output.send(message).await {
9156                                        Ok(()) => {}
9157                                        Err(err) => {
9158                                            log::warn!(
9159                                                "failed to send thumbnail for {}: {}",
9160                                                path.display(),
9161                                                err
9162                                            );
9163                                        }
9164                                    }
9165
9166                                    std::future::pending().await
9167                                },
9168                            )
9169                        },
9170                    ));
9171                }
9172
9173                if subscriptions.len() >= jobs {
9174                    break;
9175                }
9176            }
9177
9178            if preview {
9179                // Load directory size for selected items
9180
9181                let mut selected_items: Vec<&Item> =
9182                    items.iter().filter(|item| item.selected).collect();
9183
9184                if selected_items.is_empty()
9185                    && let Some(p) = self.parent_item_opt.as_ref()
9186                {
9187                    selected_items.push(p)
9188                }
9189                for item in selected_items {
9190                    // Item must have a path
9191                    if let Some(path) = item.path_opt().cloned() {
9192                        // Item must be calculating directory size
9193                        if let DirSize::Calculating(controller) = &item.dir_size {
9194                            struct Wrapper {
9195                                path: PathBuf,
9196                                controller: Controller,
9197                            }
9198                            impl Hash for Wrapper {
9199                                fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
9200                                    self.path.hash(state);
9201                                }
9202                            }
9203                            subscriptions.push(Subscription::run_with(
9204                                Wrapper { path: path.clone(), controller: controller.clone() },
9205                                |Wrapper { path, controller }| {
9206                                    let path = path.clone();
9207                                    let controller = controller.clone();
9208                                    stream::channel(1, |mut output: futures::channel::mpsc::Sender<_>| async move {
9209                                        let message = {
9210                                            let start = Instant::now();
9211                                            match calculate_dir_size(&path, controller).await {
9212                                                Ok(size) => {
9213                                                    log::debug!(
9214                                                        "calculated directory size of {} in {:?}",
9215                                                        path.display(),
9216                                                        start.elapsed()
9217                                                    );
9218                                                    Message::DirectorySize(
9219                                                        path.clone(),
9220                                                        DirSize::Directory(size),
9221                                                    )
9222                                                }
9223                                                Err(err) => {
9224                                                    log::warn!(
9225                                                        "failed to calculate directory size of {}: {}",
9226                                                        path.display(),
9227                                                        err
9228                                                    );
9229                                                    Message::DirectorySize(
9230                                                        path.clone(),
9231                                                        DirSize::Error(err.to_string()),
9232                                                    )
9233                                                }
9234                                            }
9235                                        };
9236
9237                                        match output.send(message).await {
9238                                            Ok(()) => {}
9239                                            Err(err) => {
9240                                                log::warn!(
9241                                                    "failed to send directory size for {}: {}",
9242                                                    path.display(),
9243                                                    err
9244                                                );
9245                                            }
9246                                        }
9247
9248                                        std::future::pending().await
9249                                    })
9250                                }
9251                            ));
9252                        }
9253                    }
9254                }
9255            }
9256        }
9257
9258        // Load search items incrementally
9259        if let Location::Search(search_location, term, show_hidden, start) = &self.location {
9260            let location = self.location.clone();
9261            let search_location = search_location.clone();
9262            let term = term.clone();
9263            let show_hidden = *show_hidden;
9264            let start = *start;
9265            #[derive(Debug, Hash, Clone)]
9266            struct Wrapper {
9267                location: Location,
9268                search_location: SearchLocation,
9269                term: String,
9270                show_hidden: bool,
9271                start: Instant,
9272            }
9273
9274            subscriptions.push(Subscription::run_with(
9275                Wrapper {
9276                    location: location.clone(),
9277                    search_location: search_location.clone(),
9278                    term: term.clone(),
9279                    show_hidden,
9280                    start,
9281                },
9282                |wrapper| {
9283                    let wrapper = wrapper.clone();
9284                    stream::channel(
9285                        2,
9286                        move |mut output: futures::channel::mpsc::Sender<Message>| async move {
9287                            let Wrapper {
9288                                location,
9289                                search_location,
9290                                term,
9291                                show_hidden,
9292                                start,
9293                            } = wrapper;
9294                            //TODO: optimal size?
9295                            let (results_tx, results_rx) = mpsc::channel(65536);
9296
9297                            let ready = Arc::new(atomic::AtomicBool::new(false));
9298                            let last_modified_opt = Arc::new(RwLock::new(None));
9299                            output
9300                                .send(Message::SearchContext(
9301                                    location.clone(),
9302                                    SearchContextWrapper(Some(SearchContext {
9303                                        results_rx,
9304                                        ready: ready.clone(),
9305                                        last_modified_opt: last_modified_opt.clone(),
9306                                    })),
9307                                ))
9308                                .await
9309                                .unwrap();
9310
9311                            let (watch_tx, mut watch_rx) = tokio::sync::watch::channel(true);
9312                            {
9313                                tokio::task::spawn_blocking(move || {
9314                                    scan_search(
9315                                        &search_location,
9316                                        &term,
9317                                        show_hidden,
9318                                        move |search_item| -> bool {
9319                                            // Don't send if the result is too old
9320                                            if let Some(last_modified) =
9321                                                *last_modified_opt.read().unwrap()
9322                                                && let SearchItem::Path(_, _, ref metadata) =
9323                                                    search_item
9324                                            {
9325                                                if let Ok(modified) = metadata.modified() {
9326                                                    if modified < last_modified {
9327                                                        return true;
9328                                                    }
9329                                                } else {
9330                                                    return true;
9331                                                }
9332                                            }
9333
9334                                            match results_tx.blocking_send(search_item) {
9335                                                Ok(()) => {
9336                                                    if ready.swap(true, atomic::Ordering::SeqCst) {
9337                                                        true
9338                                                    } else {
9339                                                        // Wake up update method
9340                                                        watch_tx.send(false).is_ok()
9341                                                    }
9342                                                }
9343                                                Err(_) => false,
9344                                            }
9345                                        },
9346                                    );
9347                                    log::info!(
9348                                        "searched for {:?} in {} in {:?}",
9349                                        term,
9350                                        search_location,
9351                                        start.elapsed(),
9352                                    );
9353                                });
9354                            }
9355
9356                            while watch_rx.changed().await.is_ok() {
9357                                let is_ready = *watch_rx.borrow_and_update();
9358                                let _ = output.send(Message::SearchReady(is_ready)).await;
9359                            }
9360
9361                            // Send final ready
9362                            let _ = output.send(Message::SearchReady(true)).await;
9363
9364                            std::future::pending().await
9365                        },
9366                    )
9367                },
9368            ));
9369        }
9370
9371        if let Some(path) = self
9372            .edit_location
9373            .as_ref()
9374            .and_then(|x| x.location.path_opt())
9375            .cloned()
9376        {
9377            subscriptions.push(Subscription::run_with(
9378                ("tab_complete", path.clone()),
9379                |(_, path)| {
9380                    let path = path.clone();
9381                    stream::channel(
9382                        1,
9383                        |mut output: futures::channel::mpsc::Sender<_>| async move {
9384                            let message = {
9385                                let path = path.clone();
9386                                tokio::task::spawn_blocking(move || {
9387                                    let start = Instant::now();
9388                                    match tab_complete(&path) {
9389                                        Ok(completions) => {
9390                                            log::info!(
9391                                                "tab completed {} in {:?}",
9392                                                path.display(),
9393                                                start.elapsed()
9394                                            );
9395                                            Message::TabComplete(path.clone(), completions)
9396                                        }
9397                                        Err(err) => {
9398                                            log::warn!(
9399                                                "failed to tab complete {}: {}",
9400                                                path.display(),
9401                                                err
9402                                            );
9403                                            Message::TabComplete(path.clone(), Vec::new())
9404                                        }
9405                                    }
9406                                })
9407                                .await
9408                                .unwrap()
9409                            };
9410
9411                            match output.send(message).await {
9412                                Ok(()) => {}
9413                                Err(err) => {
9414                                    log::warn!(
9415                                        "failed to send tab completion for {}: {}",
9416                                        path.display(),
9417                                        err
9418                                    );
9419                                }
9420                            }
9421
9422                            std::future::pending().await
9423                        },
9424                    )
9425                },
9426            ));
9427        }
9428
9429        Subscription::batch(subscriptions)
9430    }
9431
9432    const fn format_time(&self, time: SystemTime) -> FormatTime<'_> {
9433        format_time(time, &self.date_time_formatter, &self.time_formatter)
9434    }
9435}
9436
9437pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: &Modifiers) -> Option<Message> {
9438    if !modifiers.control() {
9439        return None;
9440    }
9441
9442    let delta_y = match delta {
9443        ScrollDelta::Lines { y, .. } => y,
9444        ScrollDelta::Pixels { y, .. } => y,
9445    };
9446
9447    if delta_y > 0.0 {
9448        return Some(Message::ZoomIn);
9449    }
9450
9451    if delta_y < 0.0 {
9452        return Some(Message::ZoomOut);
9453    }
9454
9455    None
9456}
9457
9458fn text_editor_class(
9459    theme: &cosmic::Theme,
9460    status: cosmic::widget::text_editor::Status,
9461) -> cosmic::iced::widget::text_editor::Style {
9462    let cosmic = theme.cosmic();
9463    let container = theme.current_container();
9464
9465    let mut background: cosmic::iced::Color = container.component.base.into();
9466    background.a = 0.25;
9467    let selection = cosmic.accent.base.into();
9468    let value = cosmic.palette.neutral_9.into();
9469    let mut placeholder = cosmic.palette.neutral_9;
9470    placeholder.alpha = 0.7;
9471    let placeholder = placeholder.into();
9472
9473    match status {
9474        cosmic::iced::widget::text_editor::Status::Active
9475        | cosmic::iced::widget::text_editor::Status::Disabled => {
9476            cosmic::iced::widget::text_editor::Style {
9477                background: background.into(),
9478                border: cosmic::iced::Border {
9479                    radius: cosmic.corner_radii.radius_m.into(),
9480                    width: 2.0,
9481                    color: container.component.divider.into(),
9482                },
9483                placeholder,
9484                value,
9485                selection,
9486            }
9487        }
9488        cosmic::iced::widget::text_editor::Status::Hovered
9489        | cosmic::iced::widget::text_editor::Status::Focused { .. } => {
9490            cosmic::iced::widget::text_editor::Style {
9491                background: background.into(),
9492                border: cosmic::iced::Border {
9493                    radius: cosmic.corner_radii.radius_m.into(),
9494                    width: 2.0,
9495                    color: cosmic::iced::Color::from(cosmic.accent.base),
9496                },
9497                placeholder,
9498                value,
9499                selection,
9500            }
9501        }
9502    }
9503}
9504
9505#[cfg(test)]
9506mod tests {
9507    use std::path::PathBuf;
9508    use std::{fs, io};
9509
9510    use cosmic::iced::mouse::ScrollDelta;
9511    use cosmic::iced::runtime::keyboard::Modifiers;
9512    use cosmic::widget;
9513    use log::{debug, trace};
9514    use mime_guess::mime;
9515    use tempfile::TempDir;
9516    use test_log::test;
9517
9518    use super::{
9519        ItemMetadata, ItemThumbnail, Location, Message, Tab, respond_to_scroll_direction, scan_path,
9520    };
9521    use crate::app::test_utils::{
9522        NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, assert_eq_tab_path, empty_fs,
9523        eq_path_item, filter_dirs, read_dir_sorted, simple_fs, tab_click_new,
9524    };
9525    use crate::config::{IconSizes, TBConfig, TabConfig, ThumbCfg};
9526    use crate::sequencing::SusceptibilityCalls;
9527
9528    // Boilerplate for tab tests. Checks if simulated clicks selected items.
9529    fn tab_selects_item(
9530        clicks: &[usize],
9531        modifiers: Modifiers,
9532        expected_selected: &[bool],
9533    ) -> io::Result<()> {
9534        let (_fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9535
9536        // Simulate clicks by triggering Message::Click
9537        for &click in clicks {
9538            debug!("Emitting Message::Click(Some({click})) with modifiers: {modifiers:?}");
9539            tab.update(Message::Click(Some(click)), modifiers);
9540        }
9541
9542        let items = tab
9543            .items_opt
9544            .as_deref()
9545            .expect("tab should be populated with items");
9546
9547        for (i, (&expected, actual)) in expected_selected.iter().zip(items).enumerate() {
9548            assert_eq!(
9549                expected,
9550                actual.selected,
9551                "expected index {i} to be {}",
9552                if expected {
9553                    "selected but it was deselected"
9554                } else {
9555                    "deselected but it was selected"
9556                }
9557            );
9558        }
9559
9560        Ok(())
9561    }
9562
9563    fn tab_history() -> io::Result<(TempDir, Tab, Vec<PathBuf>)> {
9564        let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9565        let path = fs.path();
9566        let mut tab = Tab::new(
9567            Location::Path(path.into()),
9568            TabConfig::default(),
9569            TBConfig::default(),
9570            ThumbCfg::default(),
9571            None,
9572            widget::Id::unique(),
9573            None,
9574        );
9575
9576        // All directories (simple_fs only produces one nested layer)
9577        let dirs: Vec<PathBuf> = {
9578            let top_level = filter_dirs(path)?;
9579            let mut result = Vec::new();
9580            for dir in top_level {
9581                let nested_dirs = filter_dirs(&dir)?;
9582                result.push(dir);
9583                result.extend(nested_dirs);
9584            }
9585            result
9586        };
9587        assert!(
9588            dirs.len() == NUM_DIRS + NUM_DIRS * NUM_NESTED,
9589            "Sanity check: Have {} dirs instead of {}",
9590            dirs.len(),
9591            NUM_DIRS + NUM_DIRS * NUM_NESTED
9592        );
9593
9594        debug!("Building history by emitting Message::Location");
9595        for dir in &dirs {
9596            debug!(
9597                "Emitting Message::Location(Location::Path(\"{}\"))",
9598                dir.display()
9599            );
9600            tab.update(
9601                Message::Location(Location::Path(dir.clone())),
9602                Modifiers::empty(),
9603            );
9604        }
9605        trace!("Tab history: {:?}", tab.history);
9606
9607        Ok((fs, tab, dirs))
9608    }
9609
9610    #[test]
9611    fn scan_path_succeeds_on_valid_path() -> io::Result<()> {
9612        let fs = simple_fs(NUM_FILES, NUM_HIDDEN, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9613        let path = fs.path();
9614
9615        // Read directory entries and sort as cosmic-files does
9616        let entries = read_dir_sorted(path)?;
9617
9618        debug!("Calling scan_path(\"{}\")", path.display());
9619        let actual = scan_path(&path.to_owned(), IconSizes::default());
9620
9621        // scan_path shouldn't skip any entries
9622        assert_eq!(entries.len(), actual.len());
9623
9624        // Correct files should be scanned
9625        assert!(
9626            entries
9627                .into_iter()
9628                .zip(actual.into_iter())
9629                .all(|(path, item)| eq_path_item(&path, &item))
9630        );
9631
9632        Ok(())
9633    }
9634
9635    #[test]
9636    fn scan_path_returns_empty_vec_for_invalid_path() -> io::Result<()> {
9637        let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9638        let path = fs.path();
9639
9640        // A nonexisting path within the temp dir
9641        let invalid_path = path.join("ferris");
9642        assert!(!invalid_path.exists());
9643
9644        debug!("Calling scan_path(\"{}\")", invalid_path.display());
9645        let actual = scan_path(&invalid_path, IconSizes::default());
9646
9647        assert!(actual.is_empty());
9648
9649        Ok(())
9650    }
9651
9652    #[test]
9653    fn scan_path_empty_dir_returns_empty_vec() -> io::Result<()> {
9654        let fs = empty_fs()?;
9655        let path = fs.path();
9656
9657        debug!("Calling scan_path(\"{}\")", path.display());
9658        let actual = scan_path(&path.to_owned(), IconSizes::default());
9659
9660        assert_eq!(0, path.read_dir()?.count());
9661        assert!(actual.is_empty());
9662
9663        Ok(())
9664    }
9665
9666    #[test]
9667    fn tab_location_changes_location() -> io::Result<()> {
9668        let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9669        let path = fs.path();
9670
9671        // Next directory in temp directory
9672        // This does not have to be sorted
9673        let next_dir = filter_dirs(path)?
9674            .next()
9675            .expect("temp directory should have at least one directory");
9676
9677        let mut tab = Tab::new(
9678            Location::Path(path.to_owned()),
9679            TabConfig::default(),
9680            TBConfig::default(),
9681            ThumbCfg::default(),
9682            None,
9683            widget::Id::unique(),
9684            None,
9685        );
9686        debug!(
9687            "Emitting Message::Location(Location::Path(\"{}\"))",
9688            next_dir.display()
9689        );
9690        tab.update(
9691            Message::Location(Location::Path(next_dir.clone())),
9692            Modifiers::empty(),
9693        );
9694
9695        // Validate that the tab's path updated
9696        // NOTE: `items_opt` is set to None with Message::Location so this ONLY checks for equal paths
9697        // If item contents are NOT None then this needs to be reevaluated for correctness
9698        assert_eq_tab_path(&tab, &next_dir);
9699        assert!(
9700            tab.items_opt.is_none(),
9701            "Tab's `items` is not None which means this test needs to be updated"
9702        );
9703
9704        Ok(())
9705    }
9706
9707    #[test]
9708    fn tab_click_single_selects_item() -> io::Result<()> {
9709        // Select the second directory with no keys held down
9710        tab_selects_item(&[1], Modifiers::empty(), &[false, true])
9711    }
9712
9713    #[test]
9714    fn tab_click_double_opens_folder() -> io::Result<()> {
9715        let (fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9716        let path = fs.path();
9717
9718        // Simulate double clicking second directory
9719        debug!("Emitting double click Message::DoubleClick(Some(1))");
9720        tab.update(Message::DoubleClick(Some(1)), Modifiers::empty());
9721
9722        // Path to second directory
9723        let second_dir = read_dir_sorted(path)?
9724            .into_iter()
9725            .filter(|p| p.is_dir())
9726            .nth(1)
9727            .expect("should be at least two directories");
9728
9729        // Location should have changed to second_dir
9730        assert_eq_tab_path(&tab, &second_dir);
9731
9732        Ok(())
9733    }
9734
9735    #[test]
9736    fn tab_click_ctrl_selects_multiple() -> io::Result<()> {
9737        // Select the first and second directory by holding down ctrl
9738        tab_selects_item(&[0, 1], Modifiers::CTRL, &[true, true])
9739    }
9740
9741    #[test]
9742    fn tab_gonext_moves_forward_in_history() -> io::Result<()> {
9743        let (fs, mut tab, dirs) = tab_history()?;
9744        let path = fs.path();
9745
9746        // Rewind to the start
9747        for _ in 0..dirs.len() {
9748            debug!("Emitting Message::GoPrevious to rewind to the start",);
9749            tab.update(Message::GoPrevious, Modifiers::empty());
9750        }
9751        assert_eq_tab_path(&tab, path);
9752
9753        // Back to the future. Directories should be in the order they were opened.
9754        for dir in dirs {
9755            debug!("Emitting Message::GoNext",);
9756            tab.update(Message::GoNext, Modifiers::empty());
9757            assert_eq_tab_path(&tab, &dir);
9758        }
9759
9760        Ok(())
9761    }
9762
9763    #[test]
9764    fn tab_goprev_moves_backward_in_history() -> io::Result<()> {
9765        let (fs, mut tab, dirs) = tab_history()?;
9766        let path = fs.path();
9767
9768        for dir in dirs.into_iter().rev() {
9769            assert_eq_tab_path(&tab, &dir);
9770            debug!("Emitting Message::GoPrevious",);
9771            tab.update(Message::GoPrevious, Modifiers::empty());
9772        }
9773        assert_eq_tab_path(&tab, path);
9774
9775        Ok(())
9776    }
9777
9778    #[test]
9779    fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> {
9780        let message_maybe =
9781            respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, &Modifiers::CTRL);
9782        assert!(message_maybe.is_some());
9783        assert!(matches!(message_maybe.unwrap(), Message::ZoomIn));
9784        Ok(())
9785    }
9786
9787    #[test]
9788    fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
9789        let message_maybe = respond_to_scroll_direction(
9790            ScrollDelta::Pixels { x: 0.0, y: 1.0 },
9791            &Modifiers::empty(),
9792        );
9793        assert!(message_maybe.is_none());
9794        Ok(())
9795    }
9796
9797    #[test]
9798    fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> {
9799        let message_maybe =
9800            respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, &Modifiers::CTRL);
9801        assert!(message_maybe.is_some());
9802        assert!(matches!(message_maybe.unwrap(), Message::ZoomOut));
9803        Ok(())
9804    }
9805
9806    #[test]
9807    fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
9808        let message_maybe = respond_to_scroll_direction(
9809            ScrollDelta::Pixels { x: 0.0, y: -1.0 },
9810            &Modifiers::empty(),
9811        );
9812        assert!(message_maybe.is_none());
9813        Ok(())
9814    }
9815    #[test]
9816    fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> {
9817        let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
9818        let path = fs.path();
9819        let mut tab = Tab::new(
9820            Location::Path(path.into()),
9821            TabConfig::default(),
9822            TBConfig::default(),
9823            ThumbCfg::default(),
9824            None,
9825            widget::Id::unique(),
9826            None,
9827        );
9828
9829        // Tab's location shouldn't change if GoPrev or GoNext is triggered
9830        debug!("Emitting Message::GoPrevious",);
9831        tab.update(Message::GoPrevious, Modifiers::empty());
9832        assert_eq_tab_path(&tab, path);
9833
9834        debug!("Emitting Message::GoNext",);
9835        tab.update(Message::GoNext, Modifiers::empty());
9836        assert_eq_tab_path(&tab, path);
9837
9838        Ok(())
9839    }
9840
9841    #[test]
9842    fn tab_locationup_moves_up_hierarchy() -> io::Result<()> {
9843        let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
9844        let path = fs.path();
9845        let mut next_dir = filter_dirs(path)?
9846            .next()
9847            .expect("should be at least one directory");
9848
9849        let mut tab = Tab::new(
9850            Location::Path(next_dir.clone()),
9851            TabConfig::default(),
9852            TBConfig::default(),
9853            ThumbCfg::default(),
9854            None,
9855            widget::Id::unique(),
9856            None,
9857        );
9858        // This will eventually yield false once root is hit
9859        while next_dir.pop() {
9860            debug!("Emitting Message::LocationUp",);
9861            tab.update(Message::LocationUp, Modifiers::empty());
9862            assert_eq_tab_path(&tab, &next_dir);
9863        }
9864
9865        Ok(())
9866    }
9867
9868    #[test]
9869    fn sort_long_number_file_names() -> io::Result<()> {
9870        let fs = empty_fs()?;
9871        let path = fs.path();
9872
9873        // Create files with names 255 characters long that only contain a single number
9874        // Example: 0000...0 for 255 characters
9875        // https://en.wikipedia.org/wiki/Filename#Comparison_of_filename_limitations
9876        let mut base_nums: Vec<_> = ('0'..='9').collect();
9877        fastrand::shuffle(&mut base_nums);
9878        debug!("Shuffled numbers for paths: {base_nums:?}");
9879        let paths: Vec<_> = base_nums
9880            .iter()
9881            .copied()
9882            .map(|base| path.join(std::iter::repeat_n(base, 255).collect::<String>()))
9883            .collect();
9884
9885        for (file, base) in paths.iter().zip(base_nums.into_iter()) {
9886            trace!("Creating long file name for {base}");
9887            fs::File::create(file)?;
9888        }
9889
9890        debug!("Creating tab for directory of long file names");
9891        Tab::new(
9892            Location::Path(path.into()),
9893            TabConfig::default(),
9894            TBConfig::default(),
9895            ThumbCfg::default(),
9896            None,
9897            widget::Id::unique(),
9898            None,
9899        );
9900
9901        Ok(())
9902    }
9903
9904    #[test]
9905    fn mode_calculations() {
9906        use super::{
9907            MODE_SHIFT_GROUP, MODE_SHIFT_OTHER, MODE_SHIFT_USER, get_mode_part, set_mode_part,
9908        };
9909        for user in 0..=7 {
9910            for group in 0..=7 {
9911                for other in 0..=7 {
9912                    let mode = (user << MODE_SHIFT_USER)
9913                        | (group << MODE_SHIFT_GROUP)
9914                        | (other << MODE_SHIFT_OTHER);
9915                    assert_eq!(format!("{mode:03o}"), format!("{user:o}{group:o}{other:o}"),);
9916                    assert_eq!(get_mode_part(mode, MODE_SHIFT_USER), user);
9917                    assert_eq!(get_mode_part(mode, MODE_SHIFT_GROUP), group);
9918                    assert_eq!(get_mode_part(mode, MODE_SHIFT_OTHER), other);
9919
9920                    let mode_no_user = (group << MODE_SHIFT_GROUP) | (other << MODE_SHIFT_OTHER);
9921                    assert_eq!(
9922                        format!("{mode_no_user:03o}"),
9923                        format!("0{group:o}{other:o}")
9924                    );
9925                    assert_eq!(set_mode_part(mode_no_user, MODE_SHIFT_USER, user), mode);
9926
9927                    let mode_no_group = (user << MODE_SHIFT_USER) | (other << MODE_SHIFT_OTHER);
9928                    assert_eq!(
9929                        format!("{mode_no_group:03o}"),
9930                        format!("{user:o}0{other:o}")
9931                    );
9932                    assert_eq!(set_mode_part(mode_no_group, MODE_SHIFT_GROUP, group), mode);
9933
9934                    let mode_no_other = (user << MODE_SHIFT_USER) | (group << MODE_SHIFT_GROUP);
9935                    assert_eq!(
9936                        format!("{mode_no_other:03o}"),
9937                        format!("{user:o}{group:o}0")
9938                    );
9939                    assert_eq!(set_mode_part(mode_no_other, MODE_SHIFT_OTHER, other), mode);
9940                }
9941            }
9942        }
9943    }
9944
9945    #[test]
9946    fn item_thumbnail_text_preview_small_utf8_returns_text() -> io::Result<()> {
9947        let dir = TempDir::new()?;
9948        let path = dir.path().join("preview.txt");
9949        fs::write(&path, "Hello, world!")?;
9950        let metadata = fs::metadata(&path)?;
9951        let item_metadata = ItemMetadata::Path {
9952            metadata,
9953            children_opt: None,
9954            tbprofilerjson_opt: None,
9955            is_ab1: false,
9956            sequence_opt: None,
9957            is_tbprofiler_result_as_sample: false,
9958            is_tbprofiler_groupable_raw_result_file: false,
9959            sample_json_path_opt: None,
9960            sample_csv_path_opt: None,
9961            sample_docx_path_opt: None,
9962            is_susceptible: None,
9963            susceptibility_calls: SusceptibilityCalls::default(),
9964        };
9965        let thumb = ItemThumbnail::new(
9966            &path,
9967            item_metadata,
9968            mime::TEXT_PLAIN,
9969            128,
9970            100 * 1024 * 1024,
9971            1,
9972            8,
9973        );
9974        assert!(
9975            matches!(thumb, ItemThumbnail::Text(_)),
9976            "small text file should produce Text thumbnail"
9977        );
9978        Ok(())
9979    }
9980
9981    #[test]
9982    fn item_thumbnail_text_preview_empty_file_returns_not_image() -> io::Result<()> {
9983        let dir = TempDir::new()?;
9984        let path = dir.path().join("empty.txt");
9985        fs::File::create(&path)?;
9986        let metadata = fs::metadata(&path)?;
9987        let item_metadata = ItemMetadata::Path {
9988            metadata,
9989            children_opt: None,
9990            tbprofilerjson_opt: None,
9991            is_ab1: false,
9992            sequence_opt: None,
9993            is_tbprofiler_result_as_sample: false,
9994            is_tbprofiler_groupable_raw_result_file: false,
9995            sample_json_path_opt: None,
9996            sample_csv_path_opt: None,
9997            sample_docx_path_opt: None,
9998            is_susceptible: None,
9999            susceptibility_calls: SusceptibilityCalls::default(),
10000        };
10001        let thumb = ItemThumbnail::new(
10002            &path,
10003            item_metadata,
10004            mime::TEXT_PLAIN,
10005            128,
10006            100 * 1024 * 1024,
10007            1,
10008            8,
10009        );
10010        assert!(
10011            matches!(thumb, ItemThumbnail::NotImage),
10012            "empty text file should produce NotImage (no read)"
10013        );
10014        Ok(())
10015    }
10016
10017    #[test]
10018    fn item_thumbnail_text_preview_invalid_utf8_uses_valid_prefix() -> io::Result<()> {
10019        let dir = TempDir::new()?;
10020        let path = dir.path().join("invalid_utf8.txt");
10021        // Valid UTF-8 "ab" then invalid byte sequence then "c"
10022        fs::write(&path, b"ab\xff\xfe\xfdc")?;
10023        let metadata = fs::metadata(&path)?;
10024        let item_metadata = ItemMetadata::Path {
10025            metadata,
10026            children_opt: None,
10027            tbprofilerjson_opt: None,
10028            is_ab1: false,
10029            sequence_opt: None,
10030            is_tbprofiler_result_as_sample: false,
10031            is_tbprofiler_groupable_raw_result_file: false,
10032            sample_json_path_opt: None,
10033            sample_csv_path_opt: None,
10034            sample_docx_path_opt: None,
10035            is_susceptible: None,
10036            susceptibility_calls: SusceptibilityCalls::default(),
10037        };
10038        let thumb = ItemThumbnail::new(
10039            &path,
10040            item_metadata,
10041            mime::TEXT_PLAIN,
10042            128,
10043            100 * 1024 * 1024,
10044            1,
10045            8,
10046        );
10047        match &thumb {
10048            ItemThumbnail::Text(content) => {
10049                // Text editor content may add a trailing newline
10050                assert_eq!(content.text().trim_end(), "ab");
10051            }
10052            _ => panic!(
10053                "expected Text thumbnail with valid prefix only, got {:?}",
10054                thumb
10055            ),
10056        }
10057        Ok(())
10058    }
10059}