Compare commits

...

46 commits

Author SHA1 Message Date
20842026d7 chore: fige Cargo.lock (epoch-1.9.0, [patch] locaux)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:03:54 +02:00
4b5fcfe5d7 merge: epoch-1.9.0 d'upstream
# Conflicts:
#	Cargo.lock
#	src/menu.rs
2026-09-24 20:51:53 +02:00
Hojjat
d1f0af78e6 chore: update libcosmic to fix esc on context-menu 2026-09-23 10:44:56 -06:00
Hojjat
87f407e3ed chore: update libcosmic to fix shift+right-click 2026-09-22 10:08:24 -06:00
Huy Nguyen
e2cad979ce
feat(dialog): persist global sort option (#2039)
Add dialog_sort_name field to State to track the dialog sort option.
Add state_handler and state fields to Dialog to load saved state.
Implement tab::Command::SetSort for Dialog to apply and persist sort
options.
2026-09-22 10:06:06 -06:00
Jeremy Soller
b326e48cf6
Make Cosmic Files show .exe icons (#2070)
* [Feature Request] Make Cosmic Files show .exe icons like Dolphin

* Sped Up Icon Extraction

* Compacted Code

* Replace wrestool with pelite crate for .exe icon extraction

* Run PE icon extraction as external thumbnailer

* Use dedicated external EXE thumbnailer

* Remove embedded EXE thumbnailer

* Move cosmic-files-thumbnailer to new crate

---------

Co-authored-by: ZlordHUN <137647604+ZlordHUN@users.noreply.github.com>
2026-09-21 13:25:53 -06:00
Jason Rodney Hansen
c0b8a8c60a fix: drag selection
Fixes bug where drag selection only worked if the pointer passed through an
item.
2026-09-21 13:09:39 -06:00
Michael Murphy
fe71aca0e5
i18n: translation update from Hosted Weblate (#2040)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [Pop OS/COSMIC
Files](https://hosted.weblate.org/projects/pop-os/cosmic-files/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/pop-os/cosmic-files/matrix-auto.svg)
2026-09-15 21:51:43 +02:00
Michael Aaron Murphy
d6e7618b76 chore: update pinned cosmic-protocols 2026-09-15 18:33:17 +02:00
Michael Aaron Murphy
c576bbcd60 Epoch 1.9.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-09-15 18:33:17 +02:00
Hojjat
762fc8fb48 chore: update libcosmic & cargo +nightly fmt 2026-09-14 20:25:48 +02:00
Hojjat
7ba55f66cc chore: adopt typed surface actions from libcosmic 2026-09-14 20:25:48 +02:00
Hojjat
b7005660f1 feat: use libcosmic's context_menu widget for the breadcrumb menu 2026-09-14 20:25:48 +02:00
Hojjat
77f062647c feat: use libcosmic's context_menu widget for the file context menu 2026-09-14 20:25:48 +02:00
Hosted Weblate
00233428b3
i18n: translation updates from weblate
Co-authored-by: Amadɣas <massiin@proton.me>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Quentin PAGÈS <quentinantonin@free.fr>
Co-authored-by: Satakun Utama <galaxytomostore@gmail.com>
Co-authored-by: Thomas Worofsky <thomas.worofsky@gmail.com>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: taijuin Lee <taijuin@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/cs/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/de/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kab/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/oc/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/th/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/zh_Hant/
Translation: Pop OS/COSMIC Files
2026-09-13 01:56:28 +02:00
98159d317c chore: fige Cargo.lock 2026-09-10 18:12:37 +02:00
e8eb42b507 Merge tag 'epoch-1.8.0' into local/libcosmic-path-patch
Epoch 1.8.0

# Conflicts:
#	Cargo.lock
2026-09-10 17:57:28 +02:00
dd57647ced chore: fige Cargo.lock (reordonnancement patch.unused par cargo) 2026-09-04 10:48:53 +02:00
Levi Portenier
089ad2b417
Epoch 1.8.0 version update (#2036)
Generated by cosmic-epoch scripts/version-update.sh
2026-09-02 13:02:27 -06:00
Jeremy Soller
1761b9b84b Epoch 1.8.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-09-02 08:14:58 -06:00
Josh Megnauth
06278ee7f1 Change focus only if selected tab is closed
Closes: #1921

Closing a tab other than the focused tab shouldn't change the focus.
2026-09-01 14:29:34 -06:00
Michael Murphy
a412787d69
i18n: translation update from Hosted Weblate (#2016)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [Pop OS/COSMIC
Files](https://hosted.weblate.org/projects/pop-os/cosmic-files/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/pop-os/cosmic-files/matrix-auto.svg)
2026-09-01 19:45:10 +02:00
Hosted Weblate
f3b8f6f585
i18n: translation updates from weblate
Co-authored-by: Alex <aleha@users.noreply.hosted.weblate.org>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: David Carvalho <david.snt.carvalho@gmail.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jim Spentzos <jimspentzos2000@gmail.com>
Co-authored-by: Julien Brouillard <julienbrouillard1@gmail.com>
Co-authored-by: Kurt Harren Catindig <kurtharrencatindig03@gmail.com>
Co-authored-by: Mattias Eriksson <snaggen@gmail.com>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/cs/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/el/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/fr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/id/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ru/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/sv/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/uk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/zh_Hant/
Translation: Pop OS/COSMIC Files
2026-09-01 17:00:54 +00:00
Hojjat
de739425cb fix: rubber band selection not redrawing on mouse movement 2026-08-31 17:21:58 +02:00
35334ba93b chore: fige Cargo.lock tel que resolu apres le merge 1.7.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:58:51 +02:00
deb6000c10 Merge tag 'epoch-1.7.0' into local/libcosmic-path-patch
Epoch 1.7.0

# Conflicts:
#	Cargo.lock
#	src/app.rs
2026-08-27 07:52:59 +02:00
Jeremy Soller
67af455898 Epoch 1.7.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-08-25 11:28:07 -06:00
Konstantinos
b46b77f3a7 Align center for text on items in grid view. fixes #754 2026-08-24 17:08:53 -06:00
Jeremy Soller
9642fef9cd
detect remote filesystems so SFTP browsing is not slow (#1996)
- [x] I have disclosed use of any AI generated code in my commit
messages. (didnt use any)
- If you are using an LLM, and do not fully understand the changes it is
making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex
code that lacks enough context for a proper fix or feature inclusion.
This results in considerably longer code reviews. Due to this, AI
authored or partially authored PRs may be closed without comment.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.

Fixes #582 

filesystem::remote only comes from query_filesystem_info(), but both
scan
paths read it from enumerate_children(), where it is always missing.
Every
remote file was treated as local, so each guard that skips expensive
work on
remote filesystems did nothing.

Also request only the attributes actually used instead of "*", add
is_dir to
ItemMetadata::GvfsPath, and count children in the background for visible
items.
2026-08-24 15:33:05 -06:00
Paul K
0afac77e83
Merge branch 'master' into fix-remote-browsing-speed 2026-08-24 22:44:41 +02:00
Michael Aaron Murphy
e84d3be4fd
i18n: update translations from weblate 2026-08-24 20:17:19 +02:00
David Carvalho
bb8b61f48b
i18n(pt-BR): update translations from Weblate
Currently translated at 100.0% (244 of 244 strings)

Translation: Pop OS/COSMIC Files
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt_BR/
2026-08-23 11:38:21 +02:00
Jeremy Soller
7cebbef82f
feat: allow renaming sidebar favorites to custom names (#1911)
Closes #1474 



https://github.com/user-attachments/assets/69fb51ca-225c-43c9-91c9-8f48375e39c4

- [x] I have disclosed use of any AI generated code in my commit
messages.
- If you are using an LLM, and do not fully understand the changes it is
making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex
code that lacks enough context for a proper fix or feature inclusion.
This results in considerably longer code reviews. Due to this, AI
authored or partially authored PRs may be closed without comment.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.
2026-08-21 07:20:21 -06:00
César Mendonça
08a637767d refactor: rename favorite renaming action to "Change sidebar label"
Address review feedback on the sidebar favorite renaming feature:

- Context menu item and dialog title now share the same
  "change-sidebar-label" translation key
- Rename NavMenuAction::RenameFavorite and DialogPage::RenameFavorite
  to ChangeSidebarLabel, with the dialog field renamed to "label"
- Rename Favorite::with_name to Favorite::with_label
- Dialog confirm button now uses the existing "save" key instead of
  "rename-confirm"
- Replace the "rename-favorite"/"favorite-name" keys with
  "sidebar-label" in en and pt-BR translations

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJDQDBKhCqipbcZm7U1v9
2026-08-20 23:16:06 -03:00
César Mendonça
3d520858f3 feat: allow renaming sidebar favorites to custom names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:16:06 -03:00
Jeremy Soller
28546795b0
fix: handle file mtime overflow (#1959)
Modification time is doing an unchecked add. Files with bad mtime will
overflow and crash the app.

Use a checked add instead, returning None and displaying an empty mtime
instead.

This only affects mtimes via GVFS. Viewing local files works because it
interprets the mtime directly instead of relying on addition.

Fixes #1886 

- [x] I have disclosed use of any AI generated code in my commit
messages.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.
2026-08-20 17:21:59 -06:00
Paul K
cca10d2fd3
detect remote filesystems so SFTP browsing is not slow 2026-08-20 17:11:10 +02:00
Will Sheehan
eebf5a9d4c do not panic in truly invalid timestamps 2026-08-19 21:46:45 -07:00
norepro
a5422167cf Return None on mtime overflow 2026-08-19 20:30:31 -07:00
Jeremy Soller
3aa55b07b7
add search field in 'open with' dialog (#1943)
The "Open With" context menu item, which allows you to open a file, now
includes a search option by application name.

Demo:


https://github.com/user-attachments/assets/b4ff5da3-4bdf-4a0f-a20b-b6783f293fef



- [x] I have disclosed use of any AI generated code in my commit
messages.
- If you are using an LLM, and do not fully understand the changes it is
making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex
code that lacks enough context for a proper fix or feature inclusion.
This results in considerably longer code reviews. Due to this, AI
authored or partially authored PRs may be closed without comment.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.
2026-08-19 16:01:52 -06:00
sanua356
fd6d4523cd add submit form on focus search input, change import search_input 2026-08-19 09:35:15 +03:00
Hosted Weblate
9bdba47514
i18n: translation updates from weblate
Co-authored-by: Alex <aleha@users.noreply.hosted.weblate.org>
Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jens Peter Nielsen <mail@jp-nielsen.dk>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Kartik Nayak <65574961+kodebarista@users.noreply.github.com>
Co-authored-by: Martin Meizoso <martinmeizoso@gmail.com>
Co-authored-by: Michael Millet <mikevlet@protonmail.com>
Co-authored-by: Prasanta-Hembram <Prasantahembram720@gmail.com>
Co-authored-by: Quentin PAGÈS <quentinantonin@free.fr>
Co-authored-by: Sproston <ojspr2007@gmail.com>
Co-authored-by: Steen Rabol <steen@rabol.dev>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: Wilbur Lindqvist <william@willmantrade.com>
Co-authored-by: sicKat <giuseppecatillo2003@gmail.com>
Co-authored-by: the-forest-watches <lucastronks@protonmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: 김유빈 <k.sein1016@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/da/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/en_GB/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/gl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/it/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ko/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/nl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/oc/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ru/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/sat/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/zh_Hans/
Translation: Pop OS/COSMIC Files
2026-08-19 04:51:30 +00:00
sanua356
e3aa2a0ec5 fix incorrect search 2026-08-16 11:14:10 +03:00
sanua356
6273fe6505 add autofocus, move label to placeholder, add search and clear icons 2026-08-16 10:30:57 +03:00
sanua356
9a4f78aa75 remove extra translations 2026-07-26 17:36:56 +03:00
sanua356
a1c1aff60c add search field in 'open with' dialog 2026-07-26 14:46:03 +03:00
39 changed files with 1645 additions and 1227 deletions

732
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "cosmic-files"
version = "1.6.0"
version = "1.9.0"
authors = ["Jeremy Soller <jeremy@system76.com>"]
edition = "2024"
license = "GPL-3.0-only"
@ -11,16 +11,16 @@ anyhow = "1"
jiff = "0.2"
jiff-icu = "0.2"
icu = { version = "2.2.0", features = ["compiled_data"] }
cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "32283d7", optional = true }
cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "c0cff4d", optional = true }
cosmic-mime-apps = { git = "https://github.com/pop-os/cosmic-mime-apps.git", optional = true }
dirs = "6.0.0"
gio = { version = "0.21", optional = true }
glib = { version = "0.21", optional = true }
glob = "0.3"
ignore = "0.4"
image = "0.25"
image.workspace = true
libc = "0.2"
log = "0.4"
log.workspace = true
mime_guess = "2"
notify-debouncer-full = "0.7"
notify-rust = { version = "4", optional = true }
@ -166,4 +166,8 @@ mime = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch =
cosmic-text = { path = "../cosmic-text" }
[workspace]
members = ["cosmic-files-applet"]
members = ["cosmic-files-applet", "cosmic-files-thumbnailer"]
[workspace.dependencies]
image = "0.25"
log = "0.4"

View file

@ -1,10 +1,10 @@
[package]
name = "cosmic-files-applet"
version = "1.6.0"
version = "1.9.0"
edition = "2024"
[dependencies]
log = "0.4"
log.workspace = true
zbus = "4" # Blocking connection in zbus 5 hangs
[dependencies.cosmic-files]

View file

@ -0,0 +1,8 @@
[package]
name = "cosmic-files-thumbnailer"
version = "1.9.0"
edition = "2024"
[dependencies]
image.workspace = true
pelite = "0.10"

View file

@ -0,0 +1,254 @@
// SPDX-License-Identifier: GPL-3.0-only
use image::{DynamicImage, ImageFormat, RgbaImage};
use std::error::Error;
use std::ffi::{OsStr, OsString};
use std::io;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
thumbnail_from_args(std::env::args_os().skip(1))
}
#[allow(dead_code)]
pub fn thumbnail_from_args(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> {
let usage = || {
io::Error::new(
io::ErrorKind::InvalidInput,
"usage: cosmic-files-thumbnailer OUTPUT --size SIZE INPUT",
)
};
let output = args.next().ok_or_else(usage)?;
if args.next().as_deref() != Some(OsStr::new("--size")) {
return Err(usage().into());
}
let size = args
.next()
.and_then(|arg| arg.into_string().ok())
.and_then(|arg| arg.parse::<u32>().ok())
.filter(|size| *size > 0)
.ok_or_else(usage)?;
let input = args.next().ok_or_else(usage)?;
if args.next().is_some() {
return Err(usage().into());
}
thumbnail(Path::new(&input), Path::new(&output), size)
}
#[allow(dead_code)]
pub fn thumbnail(input: &Path, output: &Path, size: u32) -> Result<(), Box<dyn Error>> {
let icon = thumbnail_image(input, size)?;
DynamicImage::ImageRgba8(icon).save_with_format(output, ImageFormat::Png)?;
Ok(())
}
pub fn thumbnail_image(input: &Path, size: u32) -> Result<RgbaImage, Box<dyn Error>> {
if size == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"thumbnail size must be positive",
)
.into());
}
// Map the PE so only the headers and resource pages touched by pelite are read.
let data = pelite::FileMap::open(input)?;
let bytes = data.as_ref();
if bytes.len() < 64 || !bytes.starts_with(b"MZ") {
return Err(io::Error::new(io::ErrorKind::InvalidData, "input is not a PE file").into());
}
let file = pelite::PeFile::from_bytes(bytes).map_err(invalid_data)?;
let resources = file.resources().map_err(invalid_data)?;
let (_name, group) = resources
.icons()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "PE file has no icon group"))?
.map_err(invalid_data)?;
let mut ico = Vec::new();
group.write(&mut ico).map_err(invalid_data)?;
Ok(
DynamicImage::ImageRgba8(largest_icon(&ico).map_err(invalid_data)?)
.thumbnail(size, size)
.into_rgba8(),
)
}
fn invalid_data(error: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}
fn largest_icon(ico: &[u8]) -> Result<RgbaImage, String> {
if ico.len() < 6 {
return Err("ICO header is truncated".to_string());
}
let count = usize::from(u16::from_le_bytes([ico[4], ico[5]]));
let mut best: Option<(u64, RgbaImage)> = None;
let mut errors = Vec::new();
for i in 0..count.min(32) {
let entry = 6 + i * 16;
if ico.len() < entry + 16 {
break;
}
let size = u32::from_le_bytes([
ico[entry + 8],
ico[entry + 9],
ico[entry + 10],
ico[entry + 11],
]) as usize;
let offset = u32::from_le_bytes([
ico[entry + 12],
ico[entry + 13],
ico[entry + 14],
ico[entry + 15],
]) as usize;
let Some(end) = offset.checked_add(size) else {
continue;
};
let Some(data) = ico.get(offset..end) else {
continue;
};
match decode_icon(&ico[entry..entry + 16], data) {
Ok(image) => {
let pixels = u64::from(image.width()) * u64::from(image.height());
if best
.as_ref()
.is_none_or(|(best_pixels, _)| pixels > *best_pixels)
{
best = Some((pixels, image));
}
}
Err(err) => errors.push(format!("frame {i}: {err}")),
}
}
best.map(|(_, image)| image).ok_or_else(|| {
if errors.is_empty() {
"PE icon contains no complete frames".to_string()
} else {
format!("PE icon could not be decoded ({})", errors.join("; "))
}
})
}
fn decode_icon(entry: &[u8], data: &[u8]) -> Result<RgbaImage, String> {
if data.starts_with(b"\x89PNG\r\n\x1a\n") {
return image::ImageReader::with_format(io::Cursor::new(data), ImageFormat::Png)
.decode()
.map(DynamicImage::into_rgba8)
.map_err(|err| err.to_string());
}
let capacity = 22usize
.checked_add(data.len())
.ok_or_else(|| "frame is too large".to_string())?;
let data_len = u32::try_from(data.len()).map_err(|_| "frame is too large".to_string())?;
let mut ico = Vec::with_capacity(capacity);
ico.extend_from_slice(&[0, 0, 1, 0, 1, 0]);
ico.extend_from_slice(entry);
ico[14..18].copy_from_slice(&data_len.to_le_bytes());
ico[18..22].copy_from_slice(&22u32.to_le_bytes());
ico.extend_from_slice(data);
image::ImageReader::with_format(io::Cursor::new(ico), ImageFormat::Ico)
.decode()
.map(DynamicImage::into_rgba8)
.map_err(|err| err.to_string())
}
#[cfg(test)]
mod tests {
use super::{largest_icon, thumbnail_from_args};
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
use std::ffi::OsString;
use std::io::Cursor;
#[test]
fn malformed_thumbnail_request_returns_an_error() {
let args = [OsString::from("output.png")];
assert!(thumbnail_from_args(args.into_iter()).is_err());
}
#[test]
fn largest_icon_skips_invalid_entries() {
let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255]));
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
.write_to(&mut png, ImageFormat::Png)
.unwrap();
let png = png.into_inner();
let mut ico = vec![0, 0, 1, 0, 2, 0];
let mut invalid_entry = [0; 16];
invalid_entry[8..12].copy_from_slice(&4u32.to_le_bytes());
invalid_entry[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
ico.extend_from_slice(&invalid_entry);
let mut valid_entry = [0; 16];
valid_entry[..8].copy_from_slice(&[2, 2, 0, 0, 1, 0, 32, 0]);
valid_entry[8..12].copy_from_slice(&u32::try_from(png.len()).unwrap().to_le_bytes());
valid_entry[12..16].copy_from_slice(&38u32.to_le_bytes());
ico.extend_from_slice(&valid_entry);
ico.extend_from_slice(&png);
let decoded = largest_icon(&ico).expect("valid icon after malformed entry");
assert_eq!(decoded.dimensions(), (2, 2));
assert_eq!(decoded.get_pixel(0, 0), &Rgba([1, 2, 3, 255]));
}
#[test]
fn dib_and_mask_is_applied() {
let mut dib = vec![0; 40];
dib[0..4].copy_from_slice(&40u32.to_le_bytes());
dib[4..8].copy_from_slice(&2i32.to_le_bytes());
dib[8..12].copy_from_slice(&4i32.to_le_bytes());
dib[12..14].copy_from_slice(&1u16.to_le_bytes());
dib[14..16].copy_from_slice(&24u16.to_le_bytes());
// Two bottom-up BGR rows, padded to four-byte boundaries.
dib.extend_from_slice(&[0, 0, 255, 0, 0, 255, 0, 0]);
dib.extend_from_slice(&[0, 0, 255, 0, 0, 255, 0, 0]);
// The first mask row is the bottom image row; its first pixel is transparent.
dib.extend_from_slice(&[0b1000_0000, 0, 0, 0]);
dib.extend_from_slice(&[0, 0, 0, 0]);
let mut ico = vec![0, 0, 1, 0, 1, 0];
let mut entry = [0; 16];
entry[..8].copy_from_slice(&[2, 2, 0, 0, 1, 0, 24, 0]);
entry[8..12].copy_from_slice(&u32::try_from(dib.len()).unwrap().to_le_bytes());
entry[12..16].copy_from_slice(&22u32.to_le_bytes());
ico.extend_from_slice(&entry);
ico.extend_from_slice(&dib);
let decoded = largest_icon(&ico).expect("valid DIB icon");
assert_eq!(decoded.get_pixel(0, 0), &Rgba([255, 0, 0, 255]));
assert_eq!(decoded.get_pixel(0, 1), &Rgba([255, 0, 0, 0]));
}
#[test]
fn rgb_png_icon_is_decoded() {
let image = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgb8(image)
.write_to(&mut png, ImageFormat::Png)
.unwrap();
let png = png.into_inner();
let mut ico = vec![0, 0, 1, 0, 1, 0];
let mut entry = [0; 16];
entry[..8].copy_from_slice(&[2, 2, 0, 0, 1, 0, 24, 0]);
entry[8..12].copy_from_slice(&u32::try_from(png.len()).unwrap().to_le_bytes());
entry[12..16].copy_from_slice(&22u32.to_le_bytes());
ico.extend_from_slice(&entry);
ico.extend_from_slice(&png);
let decoded = largest_icon(&ico).expect("valid RGB PNG icon");
assert_eq!(decoded.get_pixel(0, 0), &Rgba([1, 2, 3, 255]));
}
}

18
debian/changelog vendored
View file

@ -1,3 +1,21 @@
cosmic-files (1.9.0) noble; urgency=medium
* Epoch 1.9.0 version update
-- Michael Murphy <michael@mmurphy.dev> Mon, 14 Sep 2026 20:35:19 +0200
cosmic-files (1.8.0) noble; urgency=medium
* Epoch 1.8.0 version update
-- Jeremy Soller <jeremy@system76.com> Wed, 02 Sep 2026 08:14:53 -0600
cosmic-files (1.7.0) noble; urgency=medium
* Epoch 1.7.0 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 25 Aug 2026 09:20:22 -0600
cosmic-files (1.6.0) noble; urgency=medium
* Epoch 1.6.0 version update

View file

@ -1,4 +1,6 @@
tar-ignore=.github
tar-ignore=.vscode
tar-ignore=target/.rustc_info.json
tar-ignore=target/CACHEDIR.TAG
tar-ignore=target/debug
tar-ignore=target/release
tar-ignore=vendor
tar-ignore=target

View file

@ -431,3 +431,6 @@ rename-confirm = Přejmenovat
checksum = Kontrolní součet { $kind }
calculate = Vypočítat
error = Chyba
search-application = Hledat podle názvu aplikace
sidebar-label = Popisek v postranním panelu
change-sidebar-label = Změnit popisek

View file

@ -5,7 +5,7 @@ no-results = Fandt ingen resultater
filesystem = Filsystem
home = Hjem
networks = Netværk
notification-in-progress = Fil-operationer er igang.
notification-in-progress = Fil-operationer er igang
trash = Papirkurv
recents = Seneste
undo = Fortryd
@ -69,7 +69,7 @@ name-no-slashes = Navn må ikke indeholde skråstreger
## Open/Save Dialog
cancel = Annuller
cancel = Afbryd
create = Opret
open = Åbn
open-file = Åbn fil
@ -95,7 +95,7 @@ rename-folder = Omdøb mappe
## Replace Dialog
replace = Erstat
replace-title = "{ $filename }" eksisterer allerede i denne placering.
replace-title = "{ $filename }" eksisterer allerede i denne placering
replace-warning = Ønsker du at erstatte den med den, du er ved at gemme? Erstatning af den vil overskrive dens indhold.
replace-warning-operation = Ønsker du at erstatte den? Erstatning af den vil overskrive dens indhold.
original-file = Original fil
@ -253,7 +253,7 @@ light = Lyst
# Context menu
add-to-sidebar = Tilføj til sidebjælke
compress = Komprimer…
extract-here = Extract
extract-here = Uddrag
new-file = Ny fil...
new-folder = Ny mappe...
open-in-terminal = Åbn i terminal
@ -332,7 +332,7 @@ deleted =
Slettede { $items } { $items ->
[one] element
*[other] elementer
} fra papirkurven
} fra { trash }
removing-from-recents =
Fjerner { $items } { $items ->
[one] element
@ -360,7 +360,7 @@ remove = Fjern
context-action = Konteksthandling
context-action-confirm-title = Kør “{ $name }”?
context-action-confirm-warning =
This will run on { $items } { $items ->
Dette vil køre på { $items } { $items ->
[one] item
*[other] items
}.

View file

@ -341,7 +341,7 @@ new-folder = Neuer Ordner ...
open-in-terminal = Im Terminal öffnen
move-to-trash = In den Papierkorb verschieben
restore-from-trash = Aus dem Papierkorb wiederherstellen
remove-from-sidebar = Von der Seitenleiste entfernen
remove-from-sidebar = Aus der Seitenleiste entfernen
sort-by-name = Nach Name sortieren
sort-by-modified = Nach Änderung sortieren
sort-by-size = Nach Größe sortieren
@ -441,3 +441,5 @@ rename-confirm = Umbenennen
checksum = { $kind } Prüfsumme
calculate = Berechnen
error = Fehler
sidebar-label = Bezeichnung in der Seitenleiste
search-application = Nach App-Name suchen

View file

@ -331,3 +331,6 @@ rename-confirm = Μετονομασία
checksum = Άθροισμα ελέγχου { $kind }
calculate = Υπολογισμός
error = Σφάλμα
sidebar-label = Ετικέτα πλαϊνής στήλης
search-application = Αναζήτηση με όνομα εφαρμογής
change-sidebar-label = Αλλαγή ετικέτας πλαϊνής στήλης

View file

@ -119,6 +119,9 @@ rename-file = Rename file
rename-folder = Rename folder
rename-confirm = Rename
## Change Sidebar Label Dialog
sidebar-label = Sidebar label
## Replace Dialog
replace = Replace
replace-title = "{$filename}" already exists in this location
@ -299,6 +302,7 @@ unknown-folder = unknown folder
## Open with
menu-open-with = Open with...
default-app = {$name} (default)
search-application = Search by app name
## Show details
show-details = Show details
@ -347,6 +351,7 @@ move-to = Move to...
move-to-trash = Move to trash
restore-from-trash = Restore from trash
remove-from-sidebar = Remove from sidebar
change-sidebar-label = Change sidebar label
sort-by-name = Sort by name
sort-by-modified = Sort by modified
sort-by-size = Sort by size

View file

@ -418,3 +418,6 @@ rename-confirm = Nimeä uudelleen
checksum = { $kind }-tarkistussumma
calculate = Laske
error = Virhe
search-application = Hae sovelluksen nimellä
sidebar-label = Sivupalkin nimike
change-sidebar-label = Muuta sivupalkin nimikettä

View file

View file

@ -456,3 +456,6 @@ rename-confirm = Renommer
calculate = Calculer
error = Erreur
checksum = Somme de contrôle { $kind }
sidebar-label = Libellé de la barre latérale
search-application = Chercher par nom d'appli
change-sidebar-label = Changer le libellé de la barre latérale

View file

@ -446,6 +446,9 @@ context-action-confirm-warning =
} fog lefutni.
run = Futtatás
rename-confirm = Átnevezés
checksum = { $kind } ellenőrzőösszeg
checksum = { $kind }-ellenőrzőösszeg
calculate = Számítás
error = Hiba
sidebar-label = Oldalsávcímke
search-application = Keresés alkalmazásnév alapján
change-sidebar-label = Oldalsávcímke módosítása

View file

@ -331,3 +331,6 @@ rename-confirm = Ganti nama
checksum = { $kind } checksum
calculate = Hitung
error = Galat
sidebar-label = Label bilah sisi
search-application = Cari berdasarkan nama aplikasi
change-sidebar-label = Ubah label bilah sisi

View file

@ -20,7 +20,7 @@ name = Isem
modified = Ittusnifel
trashed-on = Yettwakkes ɣer tqecwalt n yiḍumman
size = Tiddi
details = Talqayt
details = Ifatusen
pause = Serǧu
resume = Kemmel
create-archive = Snulfu-d aɣbaṛ
@ -40,7 +40,7 @@ file-already-exists = Afaylu s yisem-agi yella yakan
folder-already-exists = Akaram s yisem-agi yella yakan
name-hidden = Ismawen ibeddun s "." ad ttwaffren
name-invalid = Isem ur yezmir ara ad yili "{ $filename }"
cancel = Sefsex
cancel = Semmet
create = Snulfu-d
open = Ldi
open-file = Ldi afaylu
@ -72,7 +72,7 @@ comment = Amsefrak n yifuyla i tnarit COSMIC
keywords = Akaram;Amsefrak;
delete = kkes
replace = Semselsi
support = Tallalt
support = Tallelt
settings = Iɣewwaṛen
appearance = Timeẓri
theme = Asentel
@ -86,10 +86,10 @@ zoom-out = Asemẓi
view = Wali
menu-settings = Iɣewwaṛen…
save = Sekles
match-desktop = Amṣada d tnarit
match-desktop = Semṣada akked tnarit
file = Afaylu
new-window = Asfaylu amaynut
quit = Tuffɣa
quit = Ffeɣ
edit = Ẓreg
cut = Gzem
copy = Nɣel

View file

@ -117,7 +117,7 @@ list-directories-first = Pêşî rêgehan lîste bike
folder-name = Navê peldankê
browse-store = { $store } bigere
remove-from-recents = Ji dawiyê rake
paste = Pêve bike
paste = Pê ve bike
empty-trash-title = Jêbirdankê vala bike?
menu-settings = Sazkarî...
moving =
@ -325,9 +325,12 @@ replace = Biguhêrîne
replace-title = "{ $filename }" jixwe di vê cihê de heye
unknown-folder = Peldanka nenas
write-only = Tenê nivîsandin
trashed-on = Li Jêbirdankê hatiye avêtin
trashed-on = Jêbirî
mixed = Tevlihev
type-to-search-select = Pel an peldanka yekem a lihevhatî hildibijêre
calculate = Bijmêre
error = Şaşetî
checksum = Tevahiya { $kind } kontrol bike
sidebar-label = Nîşanka benda kêlekê
search-application = Li gorî navê sepanê bigere
change-sidebar-label = Nîşanka benda kêlekê biguhêrîne

View file

@ -1,6 +1,33 @@
appearance = Aparéncia
theme = Tèma
match-desktop = Assortir al burèu
dark = Fosc
dark = Escur
light = Clar
file = Fichièr
details = Detalhs
name = Nom
cancel = Anullar
repository = Depaus
settings = Configuracion
menu-settings = Configuracion...
quit = Quitar
cosmic-files = Fichièrs de COSMIC
comment = Gestionari de fichièrs pel burèu COSMIC
save-file = Enregistrar lo fichièr
open-with-title = Cossí volètz dobrir « { $name } » ?
other-apps = Autras aplicacions
context-action-confirm-title = Executar « { $name } » ?
run = Executar
delete = Suprimir
replace = Remplaçar
owner = Propietari
group = Grop
write-execute = Escritura e execucion
read-only = Lectura sola
read-execute = Lectura e execucion
read-write = Lectura e execucion
read-write-execute = Lectura, escritura e execucion
remove = Suprimir
connecting = Connexion...
domain = Domeni
password = Senhal

View file

@ -1,5 +1,5 @@
cosmic-files = Pliki COSMIC
comment = Menedżer plików pulpitu COSMIC
comment = Menedżer plików dla środowiska COSMIC
keywords = Katalogi;Pliki;Menedżer;
empty-folder = Pusty katalog
empty-folder-hidden = Pusty katalog (z ukrytymi plikami)
@ -450,3 +450,6 @@ rename-confirm = Zmień nazwę
checksum = { $kind } suma kontrolna
calculate = Oblicz
error = Błąd
sidebar-label = Etykieta panelu bocznego
search-application = Wyszukaj po nazwie aplikacji
change-sidebar-label = Zmień etykietę panelu bocznego

View file

@ -109,6 +109,10 @@ permanently-delete-warning = Deseja realmente excluir permanentemente { $target
rename-file = Renomear arquivo
rename-folder = Renomear pasta
## Change Sidebar Label Dialog
sidebar-label = Legenda da barra lateral
## Replace Dialog
replace = Substituir
@ -350,7 +354,7 @@ light = Estilo claro
### Type to Search
type-to-search = Digite para pesquisar
type-to-search = Digitar para pesquisar
type-to-search-recursive = Pesquisa na pasta atual e em todas as subpastas
type-to-search-enter-path = Insere o caminho do diretório ou arquivo
# Context menu
@ -365,6 +369,7 @@ open-in-terminal = Abrir no terminal
move-to-trash = Mover para a lixeira
restore-from-trash = Restaurar da lixeira
remove-from-sidebar = Remover da barra lateral
change-sidebar-label = Alterar legenda da barra lateral
sort-by-name = Ordenar por nome
sort-by-modified = Ordenar por data de modificação
sort-by-size = Ordenar por tamanho
@ -449,3 +454,4 @@ rename-confirm = Renomear
checksum = Soma de verificação { $kind }
calculate = Calcular
error = Erro
search-application = Pesquisar por nome de aplicativo

View file

@ -174,6 +174,7 @@ unknown-folder = неизвестная папка
menu-open-with = Открыть с помощью…
default-app = { $name } (по умолчанию)
search-application = Поиск по названию приложения
## Show details
@ -382,14 +383,16 @@ clear-recents-history = Очистить историю недавних
copy-path = Копировать путь
mixed = Смешанные
context-action = Контекстная команда
context-action-confirm-title = Выполнить «{ $name }»?
context-action-confirm-title = Запустить «{ $name }»?
context-action-confirm-warning =
Команда затронет { $items } { $items ->
[one] элемент
*[other] элем.
}.
run = Выполнить
run = Запустить
rename-confirm = Переименовать
checksum = Хеш-сумма { $kind }
calculate = Вычислить
error = Ошибка
sidebar-label = Подпись в боковой панели
change-sidebar-label = Изменить подпись

View file

@ -422,3 +422,6 @@ rename-confirm = Byt namn
checksum = { $kind }-kontrollsumma
calculate = Beräkna
error = Fel
sidebar-label = Sidofältsetikett
search-application = Sök på programnamn
change-sidebar-label = Ändra sidofältsetikett

View file

@ -202,17 +202,9 @@ moved =
*[other] ไฟล์
} จาก "{ $from }" สู่ "{ $to }"
renaming = กำลังเปลี่ยนชื่อจาก "{ $from }" เป็น "{ $to }"
renamed = Renamed "{ $from }" to "{ $to }"
restoring =
Restoring { $items } { $items ->
[one] item
*[other] items
} from { trash } ({ $progress })...
restored =
Restored { $items } { $items ->
[one] item
*[other] items
} from { trash }
renamed = เปลี่ยนชื่อจาก "{ $from }" เป็น "{ $to }" แล้ว
restoring = กำลังกู้คืน { $items } รายการจาก { trash } ({ $progress })...
restored = กู้คืน { $items } รายการจาก { trash } แล้ว
unknown-folder = แฟ้มที่ไม่รู้จัก
## Open with
@ -240,8 +232,8 @@ settings = การตั้งค่า
appearance = ลักษณะ
theme = ธีม
match-desktop = ใช้ตามธีมหน้าจอหลัก
dark = ธีมมืด
light = ธีมสว่าง
dark = มืด
light = สว่าง
# Context menu
add-to-sidebar = เพิ่มเข้าแถบด้านข้าง
compress = บีบอัด
@ -306,3 +298,4 @@ sort-newest-first = ไฟล์ใหม่ก่อน
sort-oldest-first = ไฟล์เก่าก่อน
sort-smallest-to-largest = ขนาดเล็กก่อน
sort-largest-to-smallest = ขนาดใหญ่ก่อน
remove = ลบ

View file

@ -392,3 +392,6 @@ mixed = Змішано
checksum = контрольна сума { $kind }
calculate = Вирахувати
error = Помилка
sidebar-label = Назва бічної панелі
search-application = Пошук за назвою застосунку
change-sidebar-label = Змінити назву бічної панелі

View file

@ -449,3 +449,6 @@ rename-confirm = 重命名
calculate = 计算
error = 错误
checksum = { $kind } 校验和
sidebar-label = 侧边栏标签
search-application = 使用应用名称搜索
change-sidebar-label = 更改侧边栏标签

View file

@ -42,7 +42,7 @@ name-no-slashes = 名稱不能包含斜線
## Open/Save Dialog
cancel = 取消
create = 建立
create = 創作
open = 開啟
open-file = 開啟檔案
open-folder = 開啟資料夾
@ -396,3 +396,6 @@ rename-confirm = 重新命名
checksum = { $kind } 核對和
calculate = 計算
error = 錯誤
sidebar-label = 側邊欄標籤
search-application = 依應用程式名稱搜尋
change-sidebar-label = 變更側邊欄標籤

View file

@ -12,6 +12,10 @@ cargo-target-dir := env('CARGO_TARGET_DIR', 'target')
bin-src := cargo-target-dir / 'release' / name
bin-dst := base-dir / 'bin' / name
thumbnailer-bin-name := name + '-thumbnailer'
thumbnailer-bin-src := cargo-target-dir / 'release' / thumbnailer-bin-name
thumbnailer-bin-dst := base-dir / 'bin' / thumbnailer-bin-name
applet-name := name + '-applet'
applet-src := cargo-target-dir / 'release' / applet-name
applet-dst := base-dir / 'bin' / applet-name
@ -24,6 +28,10 @@ metainfo := APPID + '.metainfo.xml'
metainfo-src := 'target/xdgen' / metainfo
metainfo-dst := clean(rootdir / prefix) / 'share' / 'metainfo' / metainfo
thumbnailer := APPID + '.thumbnailer'
thumbnailer-src := 'res' / thumbnailer
thumbnailer-dst := clean(rootdir / prefix) / 'share' / 'thumbnailers' / thumbnailer
icons-src := 'res' / 'icons' / 'hicolor'
icons-dst := clean(rootdir / prefix) / 'share' / 'icons' / 'hicolor'
@ -45,14 +53,11 @@ clean-dist: clean clean-vendor
build-debug *args:
cargo build {{args}}
cargo build --package {{applet-name}} {{args}}
cargo build --package {{thumbnailer-bin-name}} {{args}}
# Compiles with release profile
build-release *args: (build-debug '--release' args)
# Compiles applet with release profile
build-release-applet *args:
cargo build --package {{applet-name}} --release {{args}}
# Compiles release profile with vendored dependencies
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
@ -92,9 +97,11 @@ heaptrack *args:
# Installs files
install:
install -Dm0755 {{bin-src}} {{bin-dst}}
install -Dm0755 {{thumbnailer-bin-src}} {{thumbnailer-bin-dst}}
install -Dm0755 {{applet-src}} {{applet-dst}}
install -Dm0644 {{desktop-src}} {{desktop-dst}}
install -Dm0644 {{metainfo-src}} {{metainfo-dst}}
install -Dm0644 {{thumbnailer-src}} {{thumbnailer-dst}}
for size in `ls {{icons-src}}`; do \
install -Dm0644 "{{icons-src}}/$size/apps/{{APPID}}.svg" "{{icons-dst}}/$size/apps/{{APPID}}.svg"; \
done
@ -105,7 +112,7 @@ install-applet:
# Uninstalls installed files
uninstall:
rm -f {{bin-dst}} {{applet-dst}}
rm -f {{bin-dst}} {{thumbnailer-bin-dst}} {{applet-dst}} {{thumbnailer-dst}}
# Vendor dependencies locally
vendor:

View file

@ -0,0 +1,4 @@
[Thumbnailer Entry]
TryExec=cosmic-files-thumbnailer
Exec=cosmic-files-thumbnailer %o --size %s %i
MimeType=application/vnd.microsoft.portable-executable;application/x-msdownload;

View file

@ -17,12 +17,10 @@ use cosmic::iced::widget::button::focus;
use cosmic::iced::widget::scrollable;
use cosmic::iced::widget::scrollable::AbsoluteOffset;
use cosmic::iced::window::{self, Event as WindowEvent, Id as WindowId};
use cosmic::iced::{
self, Alignment, Event, Length, Rectangle, Size, Subscription, event, mouse, stream,
};
use cosmic::iced::{self, Alignment, Event, Length, Size, Subscription, event, mouse, stream};
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
use cosmic::iced::{
Limits, Point,
Limits, Point, Rectangle,
event::wayland::{Event as WaylandEvent, OutputEvent, OverlapNotifyEvent},
platform_specific::runtime::wayland::layer_surface::{
IcedMargin, IcedOutput, SctkLayerSurfaceSettings,
@ -37,7 +35,7 @@ use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::segmented_button::{self, Entity, ReorderEvent};
use cosmic::widget::{self, icon, settings, space};
use cosmic::{Application, ApplicationExt, Element, cosmic_theme, executor, style, surface, theme};
use cosmic::{Application, ApplicationExt, Element, cosmic_theme, executor, surface, theme};
use mime_guess::Mime;
use notify_debouncer_full::notify::{self, RecommendedWatcher};
use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
@ -428,6 +426,7 @@ pub enum NavMenuAction {
Preview(segmented_button::Entity),
RunContextAction(segmented_button::Entity, usize),
RemoveFromSidebar(segmented_button::Entity),
ChangeSidebarLabel(segmented_button::Entity),
}
impl MenuAction for NavMenuAction {
@ -503,6 +502,7 @@ pub enum Message {
OpenWithBrowse,
OpenWithDialog(Option<Entity>),
OpenWithSelection(usize),
OpenWithSearchClear,
OpenWithToggleDefault(bool),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Overlap(window::Id, OverlapNotifyEvent),
@ -606,7 +606,7 @@ pub enum Message {
OutputEvent(OutputEvent, WlOutput),
Cosmic(app::Action),
None,
Surface(surface::Action),
Surface(surface::Action<Message>),
CutPaths(Vec<PathBuf>),
}
@ -691,6 +691,7 @@ pub enum DialogPage {
mime: mime_guess::Mime,
selected: usize,
store_opt: Option<Arc<MimeApp>>,
search_app_name: String,
/// When true, the chosen app is written to mimeapps.list as the
/// default handler for `mime` after it spawns.
set_default: bool,
@ -701,6 +702,10 @@ pub enum DialogPage {
DeleteTrash {
items: Vec<TrashItem>,
},
ChangeSidebarLabel {
entity: Entity,
label: String,
},
RenameItem {
from: PathBuf,
parent: PathBuf,
@ -793,7 +798,6 @@ pub struct MounterData(MounterKey, MounterItem);
#[derive(Clone, Debug)]
pub enum WindowKind {
ContextMenu(Entity, widget::Id),
Desktop(Entity),
DesktopViewOptions,
Dialogs(widget::Id),
@ -1586,12 +1590,6 @@ impl App {
fn remove_window(&mut self, id: &window::Id) {
if let Some(window) = self.windows.remove(id) {
match window.kind {
WindowKind::ContextMenu(entity, _) => {
// Close context menu
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
tab.context_menu = None;
}
}
WindowKind::Desktop(entity) => {
// Remove the tab from the tab model
self.tab_model.remove(entity);
@ -1928,21 +1926,6 @@ impl App {
}
}
fn close_context_menus(&mut self) -> Task<Message> {
let active = self.tab_model.active();
if let Some(tab) = self.tab_model.data_mut::<Tab>(active) {
tab.location_context_menu_index = None;
if tab.context_menu.is_some() {
return self.update(Message::TabMessage(
Some(active),
tab::Message::ContextMenu(None, None),
));
}
}
Task::none()
}
fn update_nav_model(&mut self) {
let mut nav_model = segmented_button::ModelBuilder::default();
@ -1956,15 +1939,7 @@ impl App {
for (favorite_i, favorite) in self.config.favorites.iter().enumerate() {
if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) {
fl!("home")
} else if let Favorite::Network { name, .. } = favorite {
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
fl!("filesystem")
};
let name = favorite.display_name().unwrap_or_else(|| fl!("filesystem"));
nav_model = nav_model.insert(move |b| {
b.text(name.clone())
.icon(
@ -2601,15 +2576,33 @@ impl App {
.favorites
.iter()
.map(|favorite| {
if let Favorite::Path(path) = favorite {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref())) {
if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from)
match favorite {
Favorite::Path(path) => {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
{
favorites_changed = true;
return Favorite::from_path(to.join(relative));
if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from)
{
favorites_changed = true;
return Favorite::from_path(to.join(relative));
}
}
}
Favorite::Named { path, name } => {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
{
if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from)
{
favorites_changed = true;
return Favorite::Named {
path: to.join(relative),
name: name.clone(),
};
}
}
}
_ => {}
}
favorite.clone()
})
@ -2846,7 +2839,7 @@ impl Application for App {
{
nav = nav
.window_id_maybe(self.core().main_window_id())
.on_surface_action(|m| cosmic::Action::Cosmic(cosmic::app::Action::Surface(m)))
.on_surface_action(|action| cosmic::Action::Surface(action.flatten()))
}
let mut nav = nav.into_container();
@ -2925,6 +2918,11 @@ impl Application for App {
}
items.push(cosmic::widget::menu::Item::Divider);
if favorite_index_opt.is_some() {
items.push(cosmic::widget::menu::Item::Button(
fl!("change-sidebar-label"),
None,
NavMenuAction::ChangeSidebarLabel(entity),
));
items.push(cosmic::widget::menu::Item::Button(
fl!("remove-from-sidebar"),
None,
@ -3109,18 +3107,6 @@ impl Application for App {
return cosmic::task::message(cosmic::action::app(Message::SetShowDetails(false)));
}
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
if tab.location_context_menu_index.is_some() {
tab.location_context_menu_index = None;
return Task::none();
}
if tab.context_menu.is_some() {
return self.update(Message::TabMessage(
Some(entity),
tab::Message::ContextMenu(None, None),
));
}
if tab.edit_location.is_some() {
tab.edit_location = None;
return Task::none();
@ -3216,7 +3202,8 @@ impl Application for App {
} else {
Favorite::from_path(path)
};
if !favorites.contains(&favorite) {
let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite);
}
}
@ -3523,10 +3510,18 @@ impl Application for App {
path,
mime,
selected,
search_app_name,
set_default,
..
} => {
let available_apps = self.mime_app_cache.get_apps_for_mime(&mime, true);
let mut available_apps =
self.mime_app_cache.get_apps_for_mime(&mime, true);
available_apps.retain(|(app, _)| {
app.name
.to_lowercase()
.trim()
.contains(search_app_name.to_lowercase().as_str().trim())
});
if let Some((app, _)) = available_apps.get(selected) {
if let Some(mut command) =
@ -3572,6 +3567,18 @@ impl Application for App {
DialogPage::DeleteTrash { items } => {
tasks.push(self.operation(Operation::DeleteTrash { items }));
}
DialogPage::ChangeSidebarLabel { entity, label } => {
if let Some(FavoriteIndex(favorite_i)) =
self.nav_model.data::<FavoriteIndex>(entity)
{
let mut favorites = self.config.favorites.clone();
if let Some(favorite) = favorites.get_mut(*favorite_i) {
*favorite = favorite.with_label(label.trim());
config_set!(favorites, favorites);
tasks.push(self.update_config());
}
}
}
DialogPage::RenameItem {
from, parent, name, ..
} => {
@ -3857,7 +3864,7 @@ impl Application for App {
Message::Mouse(window_id, _button) => {
// Close context menu when clicking outside.
if self.core.main_window_id() == Some(window_id) {
return self.close_context_menus();
return Task::none();
}
}
Message::MoveTo(entity_opt) => {
@ -4173,21 +4180,25 @@ impl Application for App {
let Some(path) = item.path_opt() else {
continue;
};
return self.push_dialog(
DialogPage::OpenWith {
path: path.clone(),
mime: item.mime.clone(),
selected: 0,
store_opt: "x-scheme-handler/mime"
.parse::<mime_guess::Mime>()
.ok()
.and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned()
}),
set_default: false,
},
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
);
return Task::batch([
self.push_dialog(
DialogPage::OpenWith {
path: path.clone(),
mime: item.mime.clone(),
selected: 0,
store_opt: "x-scheme-handler/mime"
.parse::<mime_guess::Mime>()
.ok()
.and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned()
}),
search_app_name: String::new(),
set_default: false,
},
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
),
widget::text_input::focus(self.dialog_text_input.clone()),
]);
}
}
}
@ -4203,6 +4214,14 @@ impl Application for App {
*set_default = enabled;
}
}
Message::OpenWithSearchClear => {
if let Some(DialogPage::OpenWith {
search_app_name, ..
}) = self.dialog_pages.front_mut()
{
*search_app_name = String::new();
}
}
Message::Paste(entity_opt) => {
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
@ -4658,7 +4677,7 @@ impl Application for App {
));
}
Message::SearchActivate => {
let mut tasks = vec![self.close_context_menus()];
let mut tasks = vec![];
if self.search_get().is_none() {
tasks.push(self.search_set_active(Some(String::new())));
@ -4669,7 +4688,7 @@ impl Application for App {
return Task::batch(tasks);
}
Message::SearchClear => {
return Task::batch([self.close_context_menus(), self.search_set_active(None)]);
return self.search_set_active(None);
}
Message::SearchInput(input) => {
return self.search_set_active(Some(input));
@ -4770,7 +4789,7 @@ impl Application for App {
return self.update_config();
}
Message::TabActivate(entity) => {
let mut tasks = vec![self.close_context_menus()];
let mut tasks = vec![];
// Activate new tab
self.tab_model.activate(entity);
@ -4832,7 +4851,9 @@ impl Application for App {
tasks.push(Task::future(async move {
cosmic::action::app(Message::WindowClose)
}));
} else if let Some(position) = self.tab_model.position(entity) {
} else if entity == self.tab_model.active()
&& let Some(position) = self.tab_model.position(entity)
{
let new_position = if position > 0 {
position - 1
} else {
@ -4869,6 +4890,8 @@ impl Application for App {
}
Message::TabMessage(entity_opt, tab_message) => {
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
// The context menu opens on right-button release, so refresh paste availability now
let right_click = matches!(tab_message, tab::Message::RightClick(..));
let tab_commands = match self.tab_model.data_mut::<Tab>(entity) {
Some(tab) => tab.update(tab_message, self.modifiers),
@ -4876,6 +4899,9 @@ impl Application for App {
};
let mut commands = Vec::new();
if right_click {
commands.push(self.update(Message::CheckClipboard));
}
for tab_command in tab_commands {
match tab_command {
tab::Command::Action(action) => {
@ -4888,7 +4914,8 @@ impl Application for App {
tab::Command::AddToSidebar(path) => {
let mut favorites = self.config.favorites.clone();
let favorite = Favorite::from_path(path);
if !favorites.contains(&favorite) {
let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite);
}
config_set!(favorites, favorites);
@ -4915,93 +4942,11 @@ impl Application for App {
self.update_tab(entity, tab_path, selection_paths),
]));
}
tab::Command::ContextMenu(point_opt, parent_id) => {
#[cfg(feature = "wayland")]
if let Some(point) = point_opt {
if crate::is_wayland() {
// Open context menu
use cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{
Anchor, Gravity,
};
use cosmic::{iced::runtime::platform_specific::wayland::popup::{
SctkPopupSettings, SctkPositioner,
}, widget::menu::StyleSheet as _};
let window_id = WindowId::unique();
self.windows.insert(
window_id,
Window::new(WindowKind::ContextMenu(
entity,
widget::Id::unique(),
)),
);
commands.push(self.update(Message::CheckClipboard));
let t = self.core.system_theme();
let styling = t.appearance(
&cosmic::theme::menu_bar::MenuBarStyle::Default,
false,
);
let rad = styling.menu_border_radius;
commands.push(self.update(Message::Surface(
cosmic::surface::action::app_popup(
move |_| cosmic::surface::action::LiveSettings {
corners: Some(iced::runtime::platform_specific::wayland::CornerRadius {
top_left: rad[0] as u32,
top_right: rad[1] as u32,
bottom_left: rad[2] as u32,
bottom_right: rad[3] as u32,
}),
..Default::default()
}, move |app: &mut Self| -> SctkPopupSettings {
let anchor_rect = Rectangle {
x: point.x as i32,
y: point.y as i32,
width: 1,
height: 1,
};
let positioner = SctkPositioner {
size: None,
anchor_rect,
anchor: Anchor::None,
gravity: Gravity::BottomRight,
reactive: true,
..Default::default()
};
SctkPopupSettings {
parent: parent_id.unwrap_or(
app.core
.main_window_id()
.unwrap_or(WindowId::NONE),
),
id: window_id,
positioner,
parent_size: None,
grab: true,
close_with_children: false,
input_zone: None,
}
},
None,
),
)));
}
} else {
// Destroy previous popup
let mut window_ids = Vec::new();
for (window_id, window) in &self.windows {
if let WindowKind::ContextMenu(e, _) = &window.kind
&& *e == entity
{
window_ids.push(*window_id);
}
}
for window_id in window_ids {
commands.push(self.update(Message::Surface(
cosmic::surface::action::destroy_popup(window_id),
)));
}
}
tab::Command::Surface(action) => {
// re-type the tab's surface action the way its messages are re-typed
let action = action
.map(move |message| Message::TabMessage(Some(entity), message));
commands.push(self.update(Message::Surface(action)));
}
tab::Command::Delete(paths) => commands.push(self.delete(paths)),
tab::Command::DropFiles(to, from) => {
@ -5051,7 +4996,6 @@ impl Application for App {
}
tab::Command::OpenFile(paths) => commands.push(self.open_file(&paths)),
tab::Command::OpenInNewTab(path) => {
commands.push(self.close_context_menus());
commands.push(self.open_tab(Location::Path(path), false, None));
}
tab::Command::OpenInNewWindow(path) => match env::current_exe() {
@ -5488,12 +5432,6 @@ impl Application for App {
if let Some(tab) = self.tab_model.data_mut::<Tab>(tab_entity) {
// Close location editing if enabled
tab.edit_location = None;
// Close other context menus.
tab.location_context_menu_index = None;
return Task::done(cosmic::Action::App(Message::TabMessage(
Some(tab_entity),
tab::Message::ContextMenu(None, None),
)));
}
}
Message::NavMenuAction(action) => match action {
@ -5537,6 +5475,7 @@ impl Application for App {
.and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned()
}),
search_app_name: String::new(),
set_default: false,
},
None,
@ -5591,7 +5530,7 @@ impl Application for App {
_ => Task::none(),
};
return Task::batch([self.close_context_menus(), open_task]);
return open_task;
}
// Open the selected path in a new cosmic-files window.
@ -5672,6 +5611,20 @@ impl Application for App {
return self.update_config();
}
}
NavMenuAction::ChangeSidebarLabel(entity) => {
if let Some(favorite) = self.nav_model.data::<FavoriteIndex>(entity).and_then(
|FavoriteIndex(favorite_i)| self.config.favorites.get(*favorite_i),
) {
let label = favorite.display_name().unwrap_or_else(|| fl!("filesystem"));
return Task::batch([
self.dialog_pages
.push_back(DialogPage::ChangeSidebarLabel { entity, label }),
widget::text_input::focus(self.dialog_text_input.clone()),
widget::text_input::select_all(self.dialog_text_input.clone()),
]);
}
}
},
Message::Recents => {
if self.config.show_recents {
@ -5723,35 +5676,31 @@ impl Application for App {
.insert(surface_id, Window::new(WindowKind::Desktop(entity)));
return Task::batch([
command,
cosmic::task::message(cosmic::action::cosmic(
cosmic::app::Action::Surface(
cosmic::surface::action::app_layer_shell(
|_| Default::default(),
move |_: &mut App| SctkLayerSurfaceSettings {
id: surface_id,
layer: Layer::Bottom,
keyboard_interactivity: KeyboardInteractivity::OnDemand,
input_zone: None,
anchor: Anchor::TOP
| Anchor::BOTTOM
| Anchor::LEFT
| Anchor::RIGHT,
output: IcedOutput::Output(output.clone()),
namespace: "cosmic-files-applet".into(),
size: Some((None, None)),
margin: IcedMargin {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
exclusive_zone: 0,
size_limits: Limits::NONE
.min_width(1.0)
.min_height(1.0),
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::app_layer_shell(
|_| Default::default(),
move |_: &mut App| SctkLayerSurfaceSettings {
id: surface_id,
layer: Layer::Bottom,
keyboard_interactivity: KeyboardInteractivity::OnDemand,
input_zone: None,
anchor: Anchor::TOP
| Anchor::BOTTOM
| Anchor::LEFT
| Anchor::RIGHT,
output: IcedOutput::Output(output.clone()),
namespace: "cosmic-files-applet".into(),
size: Some((None, None)),
margin: IcedMargin {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
None,
),
exclusive_zone: 0,
size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
},
None,
),
)),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
@ -5846,9 +5795,7 @@ impl Application for App {
});
}
Message::Surface(action) => {
return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(action),
));
return cosmic::task::message(cosmic::Action::Surface(action));
}
Message::SaveSortNames => {
self.must_save_sort_names = false;
@ -6394,7 +6341,9 @@ impl Application for App {
mime,
selected,
store_opt,
search_app_name,
set_default,
..
} => {
let name = match path.file_name() {
Some(file_name) => file_name.to_str(),
@ -6402,7 +6351,13 @@ impl Application for App {
};
let mut column = widget::list_column();
let available_apps = self.mime_app_cache.get_apps_for_mime(mime, true);
let mut available_apps = self.mime_app_cache.get_apps_for_mime(mime, true);
available_apps.retain(|(app, _)| {
app.name
.to_lowercase()
.trim()
.contains(search_app_name.to_lowercase().as_str().trim())
});
let item_height = 32.0;
let mut displayed_default = false;
let mut last_kind = MimeAppMatch::Exact;
@ -6466,6 +6421,25 @@ impl Application for App {
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
.control(
widget::text_input::search_input(
fl!("search-application"),
search_app_name,
)
.id(self.dialog_text_input.clone())
.on_clear(Message::OpenWithSearchClear)
.on_input(move |search_app_name| {
Message::DialogUpdate(DialogPage::OpenWith {
path: path.clone(),
mime: mime.clone(),
selected: *selected,
store_opt: store_opt.clone(),
search_app_name,
set_default: *set_default,
})
})
.on_submit(|_| Message::DialogComplete),
)
.control(widget::scrollable(column).height({
let max_size = self
.size
@ -6554,6 +6528,40 @@ impl Application for App {
target = target
)))
}
DialogPage::ChangeSidebarLabel { entity, label } => {
let entity = *entity;
let complete_maybe = if label.trim().is_empty() {
None
} else {
Some(Message::DialogComplete)
};
widget::dialog()
.title(fl!("change-sidebar-label"))
.primary_action(
widget::button::suggested(fl!("save"))
.on_press_maybe(complete_maybe.clone()),
)
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
.control(
widget::column::with_children([
widget::text::body(fl!("sidebar-label")).into(),
widget::text_input("", label.as_str())
.id(self.dialog_text_input.clone())
.on_input(move |label| {
Message::DialogUpdate(DialogPage::ChangeSidebarLabel {
entity,
label,
})
})
.on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
.into(),
])
.spacing(space_xxs),
)
}
DialogPage::RenameItem {
from,
parent,
@ -7003,23 +7011,6 @@ impl Application for App {
fn view_window(&self, id: WindowId) -> Element<'_, Self::Message> {
let content = match self.windows.get(&id) {
Some(window) => match &window.kind {
WindowKind::ContextMenu(entity, id) => match self.tab_model.data::<Tab>(*entity) {
Some(tab) => {
return widget::autosize::autosize(
menu::context_menu(
tab,
&self.key_binds,
&window.modifiers,
self.clipboard_has_content(),
&self.config.context_actions,
)
.map(|x| Message::TabMessage(Some(*entity), x)),
id.clone(),
)
.into();
}
None => widget::text("Unknown tab ID").into(),
},
WindowKind::Desktop(entity) => {
let mut tab_column = widget::column::with_capacity(3);
@ -7079,6 +7070,7 @@ impl Application for App {
return self.view_main().map(|message| match message {
cosmic::Action::App(app) => app,
cosmic::Action::Cosmic(cosmic) => Message::Cosmic(cosmic),
cosmic::Action::Surface(action) => Message::Surface(action),
cosmic::Action::None => Message::None,
});
}

View file

@ -70,6 +70,11 @@ pub enum Favorite {
name: String,
path: PathBuf,
},
/// A path with a custom name chosen by the user
Named {
path: PathBuf,
name: String,
},
}
impl Favorite {
@ -98,6 +103,38 @@ impl Favorite {
Self::Videos => dirs::video_dir(),
Self::Path(path) => Some(path.clone()),
Self::Network { path, .. } => Some(path.clone()),
Self::Named { path, .. } => Some(path.clone()),
}
}
/// Name shown in the sidebar, or `None` if the path has no usable file name
pub fn display_name(&self) -> Option<String> {
match self {
Self::Home => Some(crate::fl!("home")),
Self::Named { name, .. } | Self::Network { name, .. } => Some(name.clone()),
_ => self
.path_opt()?
.file_name()
.and_then(|x| x.to_str())
.map(ToString::to_string),
}
}
/// Return this favorite with a custom sidebar label chosen by the user
pub fn with_label(&self, name: &str) -> Self {
match self {
Self::Network { uri, path, .. } => Self::Network {
uri: uri.clone(),
name: name.to_string(),
path: path.clone(),
},
other => match other.path_opt() {
Some(path) => Self::Named {
path,
name: name.to_string(),
},
None => other.clone(),
},
}
}
}
@ -113,6 +150,7 @@ pub enum TypeToSearch {
#[serde(default)]
pub struct State {
pub sort_names: FxOrderMap<String, (HeadingOptions, bool)>,
pub dialog_sort_name: (HeadingOptions, bool),
}
impl Default for State {
@ -124,6 +162,7 @@ impl Default for State {
(HeadingOptions::Modified, false),
)
})),
dialog_sort_name: (HeadingOptions::Modified, false),
}
}
}
@ -476,3 +515,66 @@ pub const TIME_CONFIG_ID: &str = "com.system76.CosmicAppletTime";
pub struct TimeConfig {
pub military_time: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn favorite_with_label_converts_path_to_named() {
let favorite = Favorite::Path(PathBuf::from("/some/dir"));
assert_eq!(
favorite.with_label("Custom"),
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_with_label_updates_network_in_place() {
let favorite = Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "example.com".to_string(),
path: PathBuf::from("/run/mount/example"),
};
assert_eq!(
favorite.with_label("Custom"),
Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "Custom".to_string(),
path: PathBuf::from("/run/mount/example"),
}
);
}
#[test]
fn favorite_with_label_converts_special_folder_to_named() {
let home = dirs::home_dir().unwrap();
assert_eq!(
Favorite::Home.with_label("Custom"),
Favorite::Named {
path: home,
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_display_name() {
assert_eq!(
Favorite::Path(PathBuf::from("/some/dir")).display_name(),
Some("dir".to_string())
);
assert_eq!(Favorite::Path(PathBuf::from("/")).display_name(), None);
assert_eq!(
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
.display_name(),
Some("Custom".to_string())
);
}
}

View file

@ -3,6 +3,7 @@
use cosmic::app::cosmic::Cosmic;
use cosmic::app::{Core, Task, context_drawer};
use cosmic::cosmic_config::ConfigSet;
use cosmic::iced::core::SmolStr;
use cosmic::iced::core::widget::operation;
use cosmic::iced::futures::{self, SinkExt};
@ -33,12 +34,12 @@ use crate::app::{
Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID,
};
use crate::config::{
Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch,
Config, DialogConfig, State, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch,
};
use crate::key_bind::key_binds;
use crate::localize::LANGUAGE_SORTER;
use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage};
use crate::tab::{self, ItemMetadata, Location, SearchLocation, Tab};
use crate::tab::{self, HeadingOptions, ItemMetadata, Location, SearchLocation, Tab};
use crate::zoom::{zoom_in_view, zoom_out_view, zoom_to_default};
use crate::{fl, home_dir, menu, mime_icon};
@ -253,6 +254,7 @@ impl<M: Send + 'static> Dialog<M> {
crate::localize::localize();
let (config_handler, config) = Config::load();
let (state_handler, state) = State::load();
let mut settings = window::Settings {
decorations: false,
@ -287,6 +289,8 @@ impl<M: Send + 'static> Dialog<M> {
window_id,
config_handler,
config,
state_handler,
state,
};
let (cosmic, cosmic_command) = Cosmic::<App>::init((core, flags));
@ -441,6 +445,8 @@ struct Flags {
#[allow(dead_code)]
config_handler: Option<cosmic_config::Config>,
config: Config,
state_handler: Option<cosmic_config::Config>,
state: State,
}
/// Messages that are used specifically by our [`App`].
@ -470,7 +476,7 @@ enum Message {
SearchActivate,
SearchClear,
SearchInput(String),
Surface(cosmic::surface::Action),
Surface(cosmic::surface::Action<Message>),
#[allow(clippy::enum_variant_names)]
TabMessage(tab::Message),
TabRescan(
@ -503,7 +509,7 @@ impl From<AppMessage> for Message {
AppMessage::ZoomIn(_entity_opt) => Self::ZoomIn,
AppMessage::ZoomOut(_entity_opt) => Self::ZoomOut,
AppMessage::NewItem(_entity_opt, true) => Self::NewFolder,
AppMessage::Surface(action) => Self::Surface(action),
AppMessage::Surface(action) => Self::Surface(action.map(Self::from)),
unsupported => {
log::warn!("{unsupported:?} not supported in dialog mode");
Self::None
@ -543,7 +549,6 @@ struct App {
title: String,
accept_label: DialogLabel,
choices: Vec<DialogChoice>,
context_menu_window: Option<window::Id>,
context_page: ContextPage,
dialog_pages: VecDeque<DialogPage>,
dialog_text_input: widget::Id,
@ -873,15 +878,6 @@ impl App {
}
}
fn close_context_menus(&mut self) -> Task<Message> {
self.tab.location_context_menu_index = None;
if self.tab.context_menu.is_some() {
return self.update(Message::TabMessage(tab::Message::ContextMenu(None, None)));
}
Task::none()
}
fn update_nav_model(&mut self) {
let mut nav_model = segmented_button::ModelBuilder::default();
@ -895,13 +891,7 @@ impl App {
for favorite in &self.flags.config.favorites {
if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) {
fl!("home")
} else if let Favorite::Network { name, .. } = favorite {
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
let Some(name) = favorite.display_name() else {
continue;
};
nav_model = nav_model.insert(move |b| {
@ -1051,8 +1041,7 @@ impl Application for App {
None,
);
tab.mode = tab::Mode::Dialog(flags.kind.clone());
tab.sort_name = tab::HeadingOptions::Modified;
tab.sort_direction = false;
(tab.sort_name, tab.sort_direction) = flags.state.dialog_sort_name;
let key_binds = key_binds(&tab.mode);
@ -1062,7 +1051,6 @@ impl Application for App {
title,
accept_label: DialogLabel::from(accept_label),
choices: Vec::new(),
context_menu_window: None,
context_page: ContextPage::Preview(None, PreviewKind::Selected),
dialog_pages: VecDeque::new(),
dialog_text_input: widget::Id::new("Dialog Text Input"),
@ -1333,15 +1321,6 @@ impl Application for App {
return Task::none();
}
if self.tab.location_context_menu_index.is_some() {
self.tab.location_context_menu_index = None;
return Task::none();
}
if self.tab.context_menu.is_some() {
return self.update(Message::TabMessage(tab::Message::ContextMenu(None, None)));
}
if self.tab.edit_location.is_some() {
// Close location editing if enabled
self.tab.edit_location = None;
@ -1576,7 +1555,7 @@ impl Application for App {
Message::Mouse(window_id, _button) => {
// Close context menu when clicking outside.
if self.core.main_window_id() == Some(window_id) {
return self.close_context_menus();
return Task::none();
}
}
Message::NewFolder => {
@ -1736,7 +1715,7 @@ impl Application for App {
)));
}
Message::SearchActivate => {
let mut tasks = vec![self.close_context_menus()];
let mut tasks = vec![];
if self.search_get().is_none() {
tasks.push(self.search_set(Some(String::new())));
@ -1747,7 +1726,7 @@ impl Application for App {
return Task::batch(tasks);
}
Message::SearchClear => {
return Task::batch([self.close_context_menus(), self.search_set(None)]);
return self.search_set(None);
}
Message::SearchInput(input) => {
return self.search_set(Some(input));
@ -1783,94 +1762,9 @@ impl Application for App {
self.rescan_tab(selection_paths),
]));
}
tab::Command::ContextMenu(point_opt, parent_id) => {
#[cfg(feature = "wayland")]
match point_opt {
Some(point) => {
if crate::is_wayland() {
// Open context menu
use cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{
Anchor, Gravity,
};
use cosmic::iced::runtime::platform_specific::wayland::popup::{
SctkPopupSettings, SctkPositioner,
};
use cosmic::iced::Rectangle;
use cosmic::widget::menu::StyleSheet as _;
let window_id = window::Id::unique();
self.context_menu_window = Some(window_id);
let autosize_id = widget::Id::unique();
let t = self.core.system_theme();
let styling = t.appearance(
&cosmic::theme::menu_bar::MenuBarStyle::Default,
false,
);
let rad = styling.menu_border_radius;
commands.push(self.update(Message::Surface(
cosmic::surface::action::app_popup(
move |_| cosmic::surface::action::LiveSettings {
corners: Some(iced::runtime::platform_specific::wayland::CornerRadius {
top_left: rad[0] as u32,
top_right: rad[1] as u32,
bottom_left: rad[2] as u32,
bottom_right: rad[3] as u32,
}),
..Default::default()
},
move |app: &mut Self| -> SctkPopupSettings {
let anchor_rect = Rectangle {
x: point.x as i32,
y: point.y as i32,
width: 1,
height: 1,
};
let positioner = SctkPositioner {
size: None,
anchor_rect,
anchor: Anchor::None,
gravity: Gravity::BottomRight,
reactive: true,
..Default::default()
};
SctkPopupSettings {
parent: parent_id
.unwrap_or(app.flags.window_id),
id: window_id,
positioner,
parent_size: None,
grab: true,
close_with_children: false,
input_zone: None,
}
},
Some(Box::new(move |app: &Self| {
widget::autosize::autosize(
menu::context_menu(
&app.tab,
&app.key_binds,
&app.modifiers,
false, // Paste not used in dialogs
&app.flags.config.context_actions,
)
.map(Message::TabMessage)
.map(cosmic::Action::App),
autosize_id.clone(),
)
.into()
})),
),
)));
}
}
None => {
if let Some(window_id) = self.context_menu_window.take() {
commands.push(self.update(Message::Surface(
cosmic::surface::action::destroy_popup(window_id),
)));
}
}
}
tab::Command::Surface(action) => {
let action = action.map(Message::TabMessage);
commands.push(self.update(Message::Surface(action)));
}
tab::Command::Iced(iced_command) => {
commands.push(iced_command.0.map(|tab_message| {
@ -1905,6 +1799,17 @@ impl Application for App {
self.auto_scroll_speed = None;
}
}
tab::Command::SetSort(_, heading_options, direction) => {
self.flags.state.dialog_sort_name = (heading_options, direction);
if let Some(state_handler) = self.flags.state_handler.as_ref()
&& let Err(err) = state_handler.set::<&(HeadingOptions, bool)>(
"dialog_sort_name",
&self.flags.state.dialog_sort_name,
)
{
log::warn!("Failed to save dialog sort name: {err:?}");
}
}
unsupported => {
log::warn!("{unsupported:?} not supported in dialog mode");
}
@ -2025,9 +1930,7 @@ impl Application for App {
});
}
Message::Surface(action) => {
return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(action),
));
return cosmic::task::message(cosmic::Action::Surface(action));
}
}

View file

@ -1,16 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{Element, theme};
use cosmic::app::Core;
use cosmic::iced::advanced::widget::text::Style as TextStyle;
use cosmic::iced::keyboard::Modifiers;
use cosmic::iced::{Alignment, Background, Border, Length};
use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::menu::{self, ItemHeight, ItemWidth, MenuBar};
use cosmic::widget::{
self, Row, button, column, container, divider, responsive_menu_bar, space, text,
};
use cosmic::{Element, theme};
#[cfg(feature = "desktop")]
use cosmic::widget::{self, responsive_menu_bar};
use i18n_embed::LanguageLoader;
use mime_guess::Mime;
use std::collections::HashMap;
@ -27,21 +23,6 @@ use crate::trash::{Trash, TrashExt};
static MENU_ID: LazyLock<cosmic::widget::Id> =
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
macro_rules! menu_button {
($($x:expr),+ $(,)?) => (
button::custom(
Row::with_children(
[$(Element::from($x)),+]
)
.height(Length::Fixed(24.0))
.align_y(Alignment::Center)
)
.padding([theme::spacing().space_xxs, 16])
.width(Length::Fill)
.class(theme::Button::MenuItem)
);
}
const fn menu_button_optional(
label: String,
action: Action,
@ -54,82 +35,46 @@ const fn menu_button_optional(
}
}
/// A menu action dispatched to the tab the menu was opened on.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TabAction(pub Action);
impl MenuAction for TabAction {
type Message = tab::Message;
fn message(&self) -> tab::Message {
tab::Message::ContextAction(self.0)
}
}
pub fn context_menu<'a>(
tab: &Tab,
key_binds: &HashMap<KeyBind, Action>,
modifiers: &Modifiers,
clipboard_paste_available: bool,
context_actions: &[ContextActionPreset],
) -> Element<'a, tab::Message> {
let find_key = |action: &Action| -> String {
for (key_bind, key_action) in key_binds {
if action == key_action {
return key_bind.to_string();
}
}
String::new()
};
fn key_style(theme: &cosmic::Theme) -> TextStyle {
let mut color = theme.cosmic().background(theme.transparent).component.on;
color.alpha *= 0.75;
TextStyle {
color: Some(color.into()),
..Default::default()
}
}
fn disabled_style(theme: &cosmic::Theme) -> TextStyle {
let mut color = theme.cosmic().background(theme.transparent).component.on;
color.alpha *= 0.5;
TextStyle {
color: Some(color.into()),
..Default::default()
}
}
let menu_item = |label, action| {
let key = find_key(&action);
menu_button!(
text::body(label),
space::horizontal(),
text::body(key).class(theme::Text::Custom(key_style))
)
.on_press(tab::Message::ContextAction(action))
};
let menu_item_disabled = |label, action: Action| {
let key = find_key(&action);
menu_button!(
text::body(label).class(theme::Text::Custom(disabled_style)),
space::horizontal(),
text::body(key).class(theme::Text::Custom(disabled_style))
)
};
) -> Vec<menu::Tree<tab::Message>> {
let menu_item =
|label: String, action: Action| menu::Item::Button(label, None, TabAction(action));
let menu_item_disabled =
|label: String, action: Action| menu::Item::ButtonDisabled(label, None, TabAction(action));
// Allow paste when clipboard has data and we're in a location that supports it
let can_paste = clipboard_paste_available && tab.location.supports_paste();
let (sort_name, sort_direction, _) = tab.sort_options();
let sort_item = |label, variant| {
let key = find_key(&Action::ToggleSort(variant));
let leading: Element<'a, tab::Message> = if sort_name == variant {
let sort_item = |label: String, variant| {
let entry = menu::Entry::new(label, TabAction(Action::ToggleSort(variant)));
menu::Item::Entry(if sort_name == variant {
let icon_name = if sort_direction {
"view-sort-ascending-symbolic"
} else {
"view-sort-descending-symbolic"
};
widget::icon::from_name(icon_name).size(14).into()
entry.icon(widget::icon::from_name(icon_name).size(14).handle())
} else {
space::horizontal().width(Length::Fixed(14.0)).into()
};
menu_button!(
leading,
space::horizontal().width(Length::Fixed(theme::spacing().space_xxs.into())),
text::body(label),
space::horizontal(),
text::body(key).class(theme::Text::Custom(key_style))
)
.on_press(tab::Message::ContextAction(Action::ToggleSort(variant)))
.into()
entry.reserve_icon()
})
};
let mut selected_dir = 0;
@ -174,8 +119,8 @@ pub fn context_menu<'a>(
.iter()
.enumerate()
.filter(|(_, action)| action.matches_selection(selected, selected_dir))
.map(|(i, action)| menu_item(action.name.clone(), Action::RunContextAction(i)).into())
.collect::<Vec<Element<'a, tab::Message>>>()
.map(|(i, action)| menu_item(action.name.clone(), Action::RunContextAction(i)))
.collect::<Vec<_>>()
};
// Parse the desktop entry if it is the only selection
#[cfg(feature = "desktop")]
@ -190,7 +135,7 @@ pub fn context_menu<'a>(
}
});
let mut children: Vec<Element<_>> = Vec::new();
let mut children: Vec<menu::Item<TabAction, String>> = Vec::new();
match (&tab.mode, &tab.location) {
(
tab::Mode::App | tab::Mode::Desktop,
@ -202,108 +147,114 @@ pub fn context_menu<'a>(
| Location::Network(_, _, Some(_)),
) => {
if selected_trash_only {
children.push(menu_item(fl!("open"), Action::Open).into());
children.push(menu_item(fl!("open"), Action::Open));
if !Trash::is_empty() {
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into());
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash));
}
} else if let Some(_entry) = selected_desktop_entry {
children.push(menu_item(fl!("open"), Action::Open).into());
} else if let Some(entry) = selected_desktop_entry {
children.push(menu_item(fl!("open"), Action::Open));
#[cfg(feature = "desktop")]
{
children.extend(_entry.desktop_actions.into_iter().enumerate().map(
|(i, action)| menu_item(action.name, Action::ExecEntryAction(i)).into(),
));
children.extend(
entry
.desktop_actions
.into_iter()
.enumerate()
.map(|(i, action)| menu_item(action.name, Action::ExecEntryAction(i))),
);
}
children
.push(menu_item(fl!("edit-desktop-entry"), Action::EditDesktopEntry).into());
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("rename"), Action::Rename).into());
children.push(menu_item(fl!("cut"), Action::Cut).into());
children.push(menu_item(fl!("edit-desktop-entry"), Action::EditDesktopEntry));
children.push(menu::Item::Divider);
children.push(menu_item(fl!("rename"), Action::Rename));
children.push(menu_item(fl!("cut"), Action::Cut));
if modifiers.shift() && !modifiers.control() {
children.push(menu_item(fl!("copy-path"), Action::CopyPath).into());
children.push(menu_item(fl!("copy-path"), Action::CopyPath));
} else {
children.push(menu_item(fl!("copy"), Action::Copy).into());
children.push(menu_item(fl!("copy"), Action::Copy));
}
// Should this simply bypass trash and remove the shortcut?
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
children.push(menu_item(fl!("move-to-trash"), Action::Delete));
let action_items = context_action_items(selected, selected_dir);
if !action_items.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
children.extend(action_items);
}
} else if selected > 0 {
if selected_dir == 1 && selected == 1 || selected_dir == 0 {
children.push(menu_item(fl!("open"), Action::Open).into());
children.push(menu_item(fl!("open"), Action::Open));
}
if selected == 1 {
children.push(menu_item(fl!("menu-open-with"), Action::OpenWith).into());
children.push(menu_item(fl!("menu-open-with"), Action::OpenWith));
if selected_dir == 1 {
children
.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into());
children.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal));
}
}
if tab.location.is_recents() || matches!(tab.location, Location::Search(..)) {
children.push(
menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(),
);
children.push(menu_item(
fl!("open-item-location"),
Action::OpenItemLocation,
));
}
// All selected items are directories
if selected == selected_dir && matches!(tab.mode, tab::Mode::App) {
children.push(menu_item(fl!("open-in-new-tab"), Action::OpenInNewTab).into());
children
.push(menu_item(fl!("open-in-new-window"), Action::OpenInNewWindow).into());
children.push(menu_item(fl!("open-in-new-tab"), Action::OpenInNewTab));
children.push(menu_item(
fl!("open-in-new-window"),
Action::OpenInNewWindow,
));
}
let action_items = context_action_items(selected, selected_dir);
if !action_items.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
children.extend(action_items);
}
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
if selected_mount_point == 0 {
children.push(menu_item(fl!("rename"), Action::Rename).into());
children.push(menu_item(fl!("cut"), Action::Cut).into());
children.push(menu_item(fl!("rename"), Action::Rename));
children.push(menu_item(fl!("cut"), Action::Cut));
}
if modifiers.shift() && !modifiers.control() {
children.push(menu_item(fl!("copy-path"), Action::CopyPath).into());
children.push(menu_item(fl!("copy-path"), Action::CopyPath));
} else {
children.push(menu_item(fl!("copy"), Action::Copy).into());
children.push(menu_item(fl!("copy"), Action::Copy));
}
if selected_mount_point == 0 {
children.push(menu_item(fl!("move-to"), Action::MoveTo).into());
children.push(menu_item(fl!("move-to"), Action::MoveTo));
}
children.push(menu_item(fl!("copy-to"), Action::CopyTo).into());
children.push(menu_item(fl!("copy-to"), Action::CopyTo));
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
let supported_archive_types = crate::archive::SUPPORTED_ARCHIVE_TYPES;
selected_types.retain(|t| supported_archive_types.iter().copied().all(|m| *t != m));
if selected_types.is_empty() {
children.push(menu_item(fl!("extract-here"), Action::ExtractHere).into());
children.push(menu_item(fl!("extract-to"), Action::ExtractTo).into());
children.push(menu_item(fl!("extract-here"), Action::ExtractHere));
children.push(menu_item(fl!("extract-to"), Action::ExtractTo));
}
children.push(menu_item(fl!("compress"), Action::Compress).into());
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("compress"), Action::Compress));
children.push(menu::Item::Divider);
//TODO: Print?
children.push(menu_item(fl!("show-details"), Action::Preview).into());
children.push(menu_item(fl!("show-details"), Action::Preview));
if any_trash_item {
children.push(divider::horizontal::light().into());
children.push(
menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into(),
);
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("delete-permanently"), Action::Delete).into());
children.push(menu::Item::Divider);
children.push(menu_item(
fl!("restore-from-trash"),
Action::RestoreFromTrash,
));
children.push(menu::Item::Divider);
children.push(menu_item(fl!("delete-permanently"), Action::Delete));
} else {
if matches!(tab.mode, tab::Mode::App) {
children.push(divider::horizontal::light().into());
children
.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
children.push(menu::Item::Divider);
children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar));
}
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
if tab.location.is_recents() {
children.push(
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
);
children.push(divider::horizontal::light().into());
children.push(menu_item(
fl!("remove-from-recents"),
Action::RemoveFromRecents,
));
children.push(menu::Item::Divider);
}
if selected_mount_point == 0 {
if modifiers.shift() && !modifiers.control() {
@ -312,55 +263,59 @@ pub fn context_menu<'a>(
.into(),
);
} else {
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
children.push(menu_item(fl!("move-to-trash"), Action::Delete));
}
} else if selected == 1 {
children.push(menu_item(fl!("eject"), Action::Eject).into());
children.push(menu_item(fl!("eject"), Action::Eject));
}
}
} else {
//TODO: need better designs for menu with no selection
//TODO: have things like properties but they apply to the folder?
if tab.location != Location::Recents {
children.push(menu_item(fl!("new-folder"), Action::NewFolder).into());
children.push(menu_item(fl!("new-file"), Action::NewFile).into());
children.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into());
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("new-folder"), Action::NewFolder));
children.push(menu_item(fl!("new-file"), Action::NewFile));
children.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal));
children.push(menu::Item::Divider);
}
if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
children.push(menu_item(fl!("select-all"), Action::SelectAll));
}
if can_paste {
children.push(menu_item(fl!("paste"), Action::Paste).into());
children.push(menu_item(fl!("paste"), Action::Paste));
} else {
children.push(menu_item_disabled(fl!("paste"), Action::Paste).into());
children.push(menu_item_disabled(fl!("paste"), Action::Paste));
}
//TODO: only show if cosmic-settings is found?
if matches!(tab.mode, tab::Mode::Desktop) {
children.push(divider::horizontal::light().into());
children.push(
menu_item(fl!("change-wallpaper"), Action::CosmicSettingsWallpaper).into(),
);
children.push(
menu_item(fl!("desktop-appearance"), Action::CosmicSettingsDesktop).into(),
);
children.push(
menu_item(fl!("display-settings"), Action::CosmicSettingsDisplays).into(),
);
children.push(menu::Item::Divider);
children.push(menu_item(
fl!("change-wallpaper"),
Action::CosmicSettingsWallpaper,
));
children.push(menu_item(
fl!("desktop-appearance"),
Action::CosmicSettingsDesktop,
));
children.push(menu_item(
fl!("display-settings"),
Action::CosmicSettingsDisplays,
));
}
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
// TODO: Nested menu
children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
if matches!(tab.location, Location::Desktop(..)) {
children.push(divider::horizontal::light().into());
children.push(
menu_item(fl!("desktop-view-options"), Action::DesktopViewOptions).into(),
);
children.push(menu::Item::Divider);
children.push(menu_item(
fl!("desktop-view-options"),
Action::DesktopViewOptions,
));
}
}
}
@ -375,24 +330,25 @@ pub fn context_menu<'a>(
) => {
if selected > 0 {
if selected_dir == 1 && selected == 1 || selected_dir == 0 {
children.push(menu_item(fl!("open"), Action::Open).into());
children.push(menu_item(fl!("open"), Action::Open));
}
if matches!(tab.location, Location::Search(..)) || tab.location.is_recents() {
children.push(
menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(),
);
children.push(menu_item(
fl!("open-item-location"),
Action::OpenItemLocation,
));
}
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("show-details"), Action::Preview).into());
children.push(menu::Item::Divider);
children.push(menu_item(fl!("show-details"), Action::Preview));
} else {
if dialog_kind.save() {
children.push(menu_item(fl!("new-folder"), Action::NewFolder).into());
children.push(menu_item(fl!("new-folder"), Action::NewFolder));
}
if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
children.push(menu_item(fl!("select-all"), Action::SelectAll));
}
if !children.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
}
children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
@ -402,14 +358,14 @@ pub fn context_menu<'a>(
(_, Location::Network(..)) => {
if selected > 0 {
if selected_dir == 1 && selected == 1 || selected_dir == 0 {
children.push(menu_item(fl!("open"), Action::Open).into());
children.push(menu_item(fl!("open"), Action::Open));
}
} else {
if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
children.push(menu_item(fl!("select-all"), Action::SelectAll));
}
if !children.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
}
children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
@ -418,24 +374,26 @@ pub fn context_menu<'a>(
}
(_, Location::Trash | Location::Search(SearchLocation::Trash, ..)) => {
if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
children.push(menu_item(fl!("select-all"), Action::SelectAll));
}
if !Trash::is_empty() {
if !children.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
}
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into());
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash));
}
if !children.is_empty() {
children.push(divider::horizontal::light().into());
children.push(menu::Item::Divider);
}
if selected > 0 {
children.push(menu_item(fl!("show-details"), Action::Preview).into());
children.push(divider::horizontal::light().into());
children
.push(menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into());
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("delete-permanently"), Action::Delete).into());
children.push(menu_item(fl!("show-details"), Action::Preview));
children.push(menu::Item::Divider);
children.push(menu_item(
fl!("restore-from-trash"),
Action::RestoreFromTrash,
));
children.push(menu::Item::Divider);
children.push(menu_item(fl!("delete-permanently"), Action::Delete));
} else {
// TODO: Nested menu
children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
@ -445,26 +403,11 @@ pub fn context_menu<'a>(
}
}
container(cosmic::widget::menu::menu_column::MenuColumn::with_children(children))
.padding(1)
//TODO: move style to libcosmic
.style(|theme| {
let cosmic = theme.cosmic();
let component = &cosmic.background(theme.transparent);
container::Style {
icon_color: Some(component.on.into()),
text_color: Some(component.on.into()),
background: Some(Background::Color(component.base.into())),
border: Border {
radius: cosmic.radius_s().map(|x| x + 1.0).into(),
width: 1.0,
color: component.divider.into(),
},
..Default::default()
}
})
.width(Length::Fixed(360.0))
.into()
let key_binds: HashMap<KeyBind, TabAction> = key_binds
.iter()
.map(|(key_bind, action)| (key_bind.clone(), TabAction(*action)))
.collect();
menu::items(&key_binds, children)
}
pub fn dialog_menu(
@ -813,50 +756,33 @@ pub fn menu_bar<'a>(
)
}
pub fn location_context_menu<'a>(ancestor_index: usize) -> Element<'a, tab::Message> {
pub fn location_context_menu(ancestor_index: usize) -> Vec<menu::Tree<tab::Message>> {
//TODO: only add some of these when in App mode
let children = [
menu_button!(text::body(fl!("open-in-new-tab")))
.on_press(tab::Message::LocationMenuAction(
menu::items(
&HashMap::new(),
vec![
menu::Item::Button(
fl!("open-in-new-tab"),
None,
LocationMenuAction::OpenInNewTab(ancestor_index),
))
.into(),
menu_button!(text::body(fl!("open-in-new-window")))
.on_press(tab::Message::LocationMenuAction(
),
menu::Item::Button(
fl!("open-in-new-window"),
None,
LocationMenuAction::OpenInNewWindow(ancestor_index),
))
.into(),
divider::horizontal::light().into(),
menu_button!(text::body(fl!("show-details")))
.on_press(tab::Message::LocationMenuAction(
),
menu::Item::Divider,
menu::Item::Button(
fl!("show-details"),
None,
LocationMenuAction::Preview(ancestor_index),
))
.into(),
divider::horizontal::light().into(),
menu_button!(text::body(fl!("add-to-sidebar")))
.on_press(tab::Message::LocationMenuAction(
),
menu::Item::Divider,
menu::Item::Button(
fl!("add-to-sidebar"),
None,
LocationMenuAction::AddToSidebar(ancestor_index),
))
.into(),
];
container(column::with_children(children))
.padding(1)
.style(|theme| {
let cosmic = theme.cosmic();
let component = &cosmic.background(theme.transparent).component;
container::Style {
icon_color: Some(component.on.into()),
text_color: Some(component.on.into()),
background: Some(Background::Color(component.base.into())),
border: Border {
radius: cosmic.radius_s().map(|x| x + 1.0).into(),
width: 1.0,
color: component.divider.into(),
},
..Default::default()
}
})
.width(Length::Fixed(360.0))
.into()
),
],
)
}

View file

@ -18,6 +18,15 @@ use crate::tab::{self, ChecksumState, DirSize, ItemMetadata, ItemThumbnail, Loca
const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri";
// Attributes requested when listing a network directory.
const SCAN_ATTRIBUTES: &str = "standard::name,\
standard::display-name,\
standard::type,\
standard::size,\
standard::icon,\
standard::is-hidden,\
time::modified";
fn resolve_uri(uri: &str) -> (String, gio::File) {
let file = gio::File::for_uri(uri);
// Resolve the target-uri if it exists
@ -119,9 +128,27 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
Box::from([])
};
// `filesystem::remote` belongs to the filesystem namespace, which `enumerate_children`
// never fills in, so it has to be queried once for the directory being listed.
let remote = file
.query_filesystem_info(
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
)
.map(|info| info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE))
.unwrap_or_else(|err| {
log::warn!("failed to get GIO filesystem info for {uri}: {err}");
// Assume remote, so per-entry work is skipped rather than retried
true
});
let mut items = Vec::new();
for info_res in file
.enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE)
.enumerate_children(
SCAN_ATTRIBUTES,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
)
.map_err(err_str)?
{
let info = info_res.map_err(err_str)?;
@ -133,13 +160,17 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
//TODO: what is the best way to resolve shortcuts?
let location = Location::Network(uri, display_name.clone(), file.child(&name).path());
let metadata = if !force_dir && !info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE) {
let metadata = if force_dir {
ItemMetadata::SimpleDir { entries: 0 }
} else {
let mtime = info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
let is_dir = matches!(info.file_type(), gio::FileType::Directory);
let size_opt = (!is_dir).then_some(info.size() as u64);
let mut children_opt = None;
if is_dir {
// Counting children costs a directory listing per entry, which is far too
// expensive on a remote filesystem
if is_dir && !remote {
if let Some(path) = file.child(&name).path() {
//TODO: calculate children in the background (and make it cancellable?)
match std::fs::read_dir(&path) {
@ -159,9 +190,8 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
mtime,
size_opt,
children_opt,
is_dir,
}
} else {
ItemMetadata::SimpleDir { entries: 0 }
};
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {

View file

@ -214,8 +214,9 @@ impl<'a, Message, F> OnAutoScroll<'a, Message> for F where F: Fn(Option<f32>) ->
pub trait OnMouseButton<'a, Message>: Fn(Option<Point>) -> Message + 'a {}
impl<'a, Message, F> OnMouseButton<'a, Message> for F where F: Fn(Option<Point>) -> Message + 'a {}
pub trait OnDrag<'a, Message>: Fn(Option<Rectangle>) -> Message + 'a {}
impl<'a, Message, F> OnDrag<'a, Message> for F where F: Fn(Option<Rectangle>) -> Message + 'a {}
pub trait OnDrag<'a, Message>: Fn(Option<Rectangle>) -> Option<Message> + 'a {}
impl<'a, Message, F> OnDrag<'a, Message> for F where F: Fn(Option<Rectangle>) -> Option<Message> + 'a
{}
pub trait OnResize<'a, Message>: Fn(Rectangle) -> Message + 'a {}
impl<'a, Message, F> OnResize<'a, Message> for F where F: Fn(Rectangle) -> Message + 'a {}
@ -563,6 +564,16 @@ fn update<Message: Clone>(
state.last_position = position_in;
}
if state.drag_initiated.is_some()
&& matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. })
| Event::Touch(touch::Event::FingerMoved { .. })
)
{
shell.request_redraw();
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
let virtual_position = Point::new(
viewport.x - layout_bounds.x + position.x,
@ -639,6 +650,7 @@ fn update<Message: Clone>(
{
state.drag_initiated = None;
state.prev_click = None;
shell.request_redraw();
if let Some(message) = widget.on_drag_end.as_ref() {
shell.publish(message(cursor.position_in(layout_bounds)));
}
@ -658,7 +670,9 @@ fn update<Message: Clone>(
state.prev_click = None;
return;
}
state.drag_initiated = None;
if state.drag_initiated.take().is_some() {
shell.request_redraw();
}
if let Some(message) = widget.on_release.as_ref() {
shell.publish(message(cursor.position_in(layout_bounds)));
@ -785,12 +799,13 @@ fn update<Message: Clone>(
}
if let Some((message, drag_rect)) = widget.on_drag.as_ref().zip(state.drag_rect(cursor)) {
shell.publish(message(drag_rect.intersection(&layout_bounds).map(
|mut rect| {
rect.x -= layout_bounds.x;
rect.y -= layout_bounds.y;
rect
},
)));
let rect_opt = drag_rect.intersection(&layout_bounds).map(|mut rect| {
rect.x -= layout_bounds.x;
rect.y -= layout_bounds.y;
rect
});
if let Some(message) = message(rect_opt) {
shell.publish(message);
}
}
}

View file

@ -480,7 +480,9 @@ impl Op {
#[cfg(not(feature = "gvfs"))]
Err(why) => {
_ = from_file.close().await;
return Err(why).with_context(|| format!("failed to open {} for writing", self.to.display())).map_err(Into::into);
return Err(why)
.with_context(|| format!("failed to open {} for writing", self.to.display()))
.map_err(Into::into);
}
#[cfg(feature = "gvfs")]
Err(_why) => {

View file

@ -458,7 +458,9 @@ impl<'a> FormatTime<'a> {
impl Display for FormatTime<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let zoned = jiff::Zoned::try_from(self.time).unwrap();
let Ok(zoned) = jiff::Zoned::try_from(self.time) else {
return Ok(());
};
let now = jiff::Zoned::now();
let icu_datetime = DateTime::convert_from(zoned.datetime());
if zoned.date() == now.date() {
@ -661,6 +663,41 @@ fn display_name_for_file(path: &Path, name: &str, get_from_gvfs: bool, is_deskto
Item::display_name(name)
}
// Whether a dir lives on a remote filesystem, according to GIO.
#[cfg(feature = "gvfs")]
fn gvfs_dir_is_remote(dir: &Path) -> bool {
static REMOTE_CACHE: LazyLock<RwLock<FxHashMap<PathBuf, bool>>> =
LazyLock::new(|| RwLock::new(FxHashMap::default()));
if let Some(remote) = REMOTE_CACHE.read().unwrap().get(dir) {
return *remote;
}
let remote = match gio::prelude::FileExt::query_filesystem_info(
&gio::File::for_path(dir),
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
) {
Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
Err(err) => {
log::warn!(
"failed to get GIO filesystem info for {}: {}",
dir.display(),
err
);
//Assume remote so that the "expensive tasks" are rather skipped then actually executed.
true
}
};
REMOTE_CACHE
.write()
.unwrap()
.insert(dir.to_path_buf(), remote);
remote
}
#[cfg(feature = "gvfs")]
pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
let file_name = file_info
@ -668,7 +705,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
.unwrap_or_default();
let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
let mut is_desktop = false;
let remote = file_info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE);
let remote = path.parent().is_none_or(gvfs_dir_is_remote);
let is_dir = matches!(file_info.file_type(), gio::FileType::Directory);
let size_opt = (!is_dir).then_some(file_info.size() as u64);
@ -729,6 +766,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
mtime,
size_opt,
children_opt,
is_dir,
},
hidden,
image_dimensions: (!remote && mime.type_() == mime::IMAGE)
@ -783,23 +821,7 @@ pub fn item_from_entry(
#[cfg(feature = "gvfs")]
FsKind::Gvfs => {
is_gvfs = true;
let file = gio::File::for_path(&path);
match gio::prelude::FileExt::query_filesystem_info(
&file,
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
) {
Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
Err(err) => {
log::warn!(
"failed to get GIO filesystem info for {}: {}",
path.display(),
err
);
true
}
}
path.parent().is_none_or(gvfs_dir_is_remote)
}
#[cfg(not(feature = "gvfs"))]
FsKind::Gvfs => {
@ -1024,7 +1046,6 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
// gio crate expects a comma delimited string
let attr_string = [
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(),
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE.as_str(),
gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
@ -1736,11 +1757,11 @@ impl fmt::Debug for TaskWrapper {
#[derive(Debug)]
pub enum Command {
Action(Action),
Surface(cosmic::surface::Action<Message>),
AddNetworkDrive,
AddToSidebar(PathBuf),
AutoScroll(Option<f32>),
ChangeLocation(String, Location, Option<Vec<PathBuf>>),
ContextMenu(Option<Point>, Option<window::Id>),
Delete(Vec<PathBuf>),
DropFiles(PathBuf, ClipboardPaste),
ClearRecents,
@ -1771,9 +1792,9 @@ pub enum Message {
ClickRelease(Option<usize>),
Config(TabConfig),
ContextAction(Action),
ContextMenu(Option<Point>, Option<window::Id>),
LocationContextMenuPoint(Option<Point>),
LocationContextMenuIndex(Option<Point>, Option<usize>),
RightClickBackground,
Surface(cosmic::surface::Action<Message>),
LocationContextMenuIndex(Option<usize>),
LocationMenuAction(LocationMenuAction),
Drag(Option<Rectangle>),
DragEnd,
@ -1833,6 +1854,8 @@ pub enum Message {
HighlightDeactivate(usize),
HighlightActivate(usize),
DirectorySize(PathBuf, DirSize),
#[cfg(feature = "gvfs")]
DirectoryChildren(PathBuf, usize),
Checksums(PathBuf, ChecksumState),
CalculateChecksums(PathBuf),
CopyChecksum(String),
@ -1901,6 +1924,7 @@ pub enum ItemMetadata {
mtime: u64,
size_opt: Option<u64>,
children_opt: Option<usize>,
is_dir: bool,
},
}
@ -1915,7 +1939,7 @@ impl ItemMetadata {
Self::SimpleDir { .. } => true,
Self::SimpleFile { .. } => false,
#[cfg(feature = "gvfs")]
Self::GvfsPath { children_opt, .. } => children_opt.is_some(),
Self::GvfsPath { is_dir, .. } => *is_dir,
}
}
@ -1924,7 +1948,7 @@ impl ItemMetadata {
Self::Path { metadata, .. } => metadata.modified().ok(),
#[cfg(feature = "gvfs")]
Self::GvfsPath { mtime, .. } => {
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(*mtime))
}
_ => None,
}
@ -2346,6 +2370,7 @@ impl Item {
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
widget::text::body(name)
.wrapping(text::Wrapping::WordOrGlyph)
.align_x(text::Alignment::Center)
.ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3,
)))
@ -2795,9 +2820,8 @@ pub struct Tab {
pub location: Location,
pub location_ancestors: Vec<(Location, String)>,
pub location_title: String,
pub location_context_menu_point: Option<Point>,
/// Breadcrumb whose context menu is open, drawn as active while it shows
pub location_context_menu_index: Option<usize>,
pub context_menu: Option<Point>,
pub mode: Mode,
pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>,
@ -2825,7 +2849,6 @@ pub struct Tab {
search_context: Option<SearchContext>,
date_time_formatter: DateTimeFormatter<fieldsets::YMDT>,
time_formatter: DateTimeFormatter<fieldsets::T>,
watch_drag: bool,
window_id: Option<window::Id>,
large_image_manager: LargeImageManager,
}
@ -2942,8 +2965,6 @@ impl Tab {
location,
location_ancestors,
location_title,
context_menu: None,
location_context_menu_point: None,
location_context_menu_index: None,
mode: Mode::App,
scroll_opt: None,
@ -2972,7 +2993,6 @@ impl Tab {
search_context: None,
date_time_formatter: date_time_formatter(config.military_time),
time_formatter: time_formatter(config.military_time),
watch_drag: true,
window_id,
large_image_manager: LargeImageManager::new(),
}
@ -3485,7 +3505,6 @@ impl Tab {
self.location = location.normalize();
self.location_ancestors = self.location.ancestors();
self.location_title = self.location.title();
self.context_menu = None;
self.edit_location = None;
self.items_opt = None;
//TODO: remember scroll by location?
@ -3530,7 +3549,6 @@ impl Tab {
let mut history_i_opt = None;
let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple();
let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple();
let last_context_menu = self.context_menu;
match message {
Message::AddNetworkDrive => {
commands.push(Command::AddNetworkDrive);
@ -3565,8 +3583,6 @@ impl Tab {
}
if click_i_opt != self.clicked.take() {
self.context_menu = None;
self.location_context_menu_index = None;
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
if mod_ctrl {
@ -3583,7 +3599,6 @@ impl Tab {
}
Message::DragEnd => {
self.clicked = None;
self.watch_drag = true;
}
Message::DoubleClick(click_i_opt) => {
if let Some(clicked_item) = self
@ -3608,9 +3623,7 @@ impl Tab {
}
Message::Click(click_i_opt) => {
self.selected_clicked = false;
self.context_menu = None;
self.edit_location = None;
self.location_context_menu_index = None;
if click_i_opt.is_none() {
self.clicked = click_i_opt;
}
@ -3752,24 +3765,16 @@ impl Tab {
}
}
Message::ContextAction(action) => {
// Close context menu
self.context_menu = None;
commands.push(Command::Action(action));
}
Message::RunContextAction(action) => {
self.context_menu = None;
commands.push(Command::RunContextAction(action));
}
Message::ContextMenu(point_opt, _) => {
Message::RightClickBackground => {
self.edit_location = None;
self.context_menu = point_opt;
self.location_context_menu_index = None;
//TODO: hack for clearing selecting when right clicking empty space
if self.context_menu.is_some()
&& self.last_right_click.take().is_none()
if self.last_right_click.take().is_none()
&& let Some(ref mut items) = self.items_opt
{
for item in items.iter_mut() {
@ -3777,17 +3782,13 @@ impl Tab {
}
}
}
Message::LocationContextMenuPoint(point_opt) => {
self.context_menu = None;
self.location_context_menu_point = point_opt;
Message::Surface(action) => {
commands.push(Command::Surface(action));
}
Message::LocationContextMenuIndex(p, index_opt) => {
self.context_menu = None;
self.location_context_menu_point = p;
self.location_context_menu_index = index_opt;
Message::LocationContextMenuIndex(index) => {
self.location_context_menu_index = index;
}
Message::LocationMenuAction(action) => {
self.location_context_menu_index = None;
let path_for_index = |ancestor_index| {
self.location
.path_opt()
@ -3837,10 +3838,7 @@ impl Tab {
}
}
Message::Drag(rect_opt) => {
self.watch_drag = false;
if let Some(rect) = rect_opt {
self.context_menu = None;
self.location_context_menu_index = None;
if self.mode.multiple() {
self.select_rect(rect, mod_ctrl, mod_shift);
}
@ -4477,13 +4475,11 @@ impl Tab {
}
}
Message::HighlightDeactivate(i) => {
self.watch_drag = true;
if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
item.highlighted = false;
}
}
Message::HighlightActivate(i) => {
self.watch_drag = true;
if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
item.highlighted = true;
}
@ -4509,7 +4505,6 @@ impl Tab {
}
Message::Scroll(viewport) => {
self.scroll_opt = Some(viewport.absolute_offset());
self.watch_drag = true;
}
Message::ScrollTab(scroll_speed) => {
commands.push(Command::Iced(
@ -4842,6 +4837,20 @@ impl Tab {
}
}
}
#[cfg(feature = "gvfs")]
Message::DirectoryChildren(path, children) => {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.path_opt() == Some(&path) {
if let ItemMetadata::GvfsPath { children_opt, .. } = &mut item.metadata
{
*children_opt = Some(children);
}
break;
}
}
}
}
Message::Checksums(path, checksum_state) => {
let location = Location::Path(path);
if let Some(ref mut item) = self.parent_item_opt
@ -4948,16 +4957,6 @@ impl Tab {
}
}
// Update context menu popup
if self.context_menu != last_context_menu {
if last_context_menu.is_some() {
commands.push(Command::ContextMenu(None, self.window_id));
}
if let Some(point) = self.context_menu {
commands.push(Command::ContextMenu(Some(point), self.window_id));
}
}
commands
}
@ -5003,11 +5002,15 @@ impl Tab {
ItemMetadata::GvfsPath {
size_opt,
children_opt,
is_dir,
..
} => match children_opt {
Some(child_count) => (true, *child_count as u64),
None => (false, size_opt.unwrap_or_default()),
},
} => {
if *is_dir {
(true, children_opt.unwrap_or_default() as u64)
} else {
(false, size_opt.unwrap_or_default())
}
}
};
let (a_is_entry, a_size) = get_size(a.1);
let (b_is_entry, b_size) = get_size(b.1);
@ -5564,10 +5567,14 @@ impl Tab {
}
let location = self.location.with_path(ancestor.to_path_buf());
let mut mouse_area = crate::mouse_area::MouseArea::new(
let mouse_area = crate::mouse_area::MouseArea::new(
widget::button::custom(row)
.padding(space_xxxs)
.class(theme::Button::Link)
.class(if self.location_context_menu_index == Some(index) {
theme::Button::LinkActive
} else {
theme::Button::Link
})
.on_press(if ancestor == path {
Message::EditLocation(Some(self.location.clone().into()))
} else {
@ -5575,21 +5582,6 @@ impl Tab {
}),
);
if self.location_context_menu_index.is_some() {
mouse_area = mouse_area
.on_right_press(move |point_opt| {
Message::LocationContextMenuIndex(point_opt, None)
})
.wayland_on_right_press_window_position();
} else {
mouse_area = mouse_area
.on_right_press_no_capture()
.on_right_press(move |point_opt| {
Message::LocationContextMenuIndex(point_opt, Some(index))
})
.wayland_on_right_press_window_position();
}
let mouse_area = if let Location::Path(_) = &self.location {
mouse_area
.on_middle_press(move |_| Message::OpenInNewTab(ancestor.to_path_buf()))
@ -5597,7 +5589,16 @@ impl Tab {
mouse_area
};
children.push(self.dnd_dest(&location, mouse_area));
// Each breadcrumb carries the menu for its own ancestor index
let mut context_menu =
widget::context_menu(mouse_area, Some(menu::location_context_menu(index)))
.on_open(Message::LocationContextMenuIndex(Some(index)))
.on_close(Message::LocationContextMenuIndex(None))
.on_surface_action(Message::Surface);
if let Some(window_id) = self.window_id {
context_menu = context_menu.window_id(window_id);
}
children.push(self.dnd_dest(&location, context_menu));
if found_home || overflow {
break;
@ -5648,20 +5649,7 @@ impl Tab {
column = column.push(heading_rule);
}
let mouse_area = crate::mouse_area::MouseArea::new(column)
.on_right_press(Message::LocationContextMenuPoint);
let mut popover = widget::popover(mouse_area);
if let (Some(point), Some(index)) = (
self.location_context_menu_point,
self.location_context_menu_index,
) {
popover = popover
.popup(menu::location_context_menu(index))
.position(widget::popover::Position::Point(point));
}
popover.into()
column.into()
}
pub fn empty_view(&self, has_hidden: bool) -> Element<'_, Message> {
@ -5850,18 +5838,13 @@ impl Tab {
.height(Length::Fixed(item_height as f32))
.width(Length::Fixed(item_width as f32));
for button in buttons {
if self.context_menu.is_some() {
column = column.push(button);
} else {
column = column.push(
mouse_area::MouseArea::new(button)
.on_right_press_no_capture()
.wayland_on_right_press_window_position()
.on_right_press(move |point_opt| {
Message::RightClick(point_opt, Some(i))
}),
);
}
column = column.push(
mouse_area::MouseArea::new(button)
.on_right_press_no_capture()
.on_right_press(move |point_opt| {
Message::RightClick(point_opt, Some(i))
}),
);
}
let column: Element<Message> =
@ -6028,15 +6011,13 @@ impl Tab {
Element::from(dnd_grid)
});
let mut mouse_area = mouse_area::MouseArea::new(column.width(Length::Fill))
let mouse_area = mouse_area::MouseArea::new(column.width(Length::Fill))
.on_press(|_| Message::Click(None))
.on_auto_scroll(Message::AutoScroll)
.on_drag(move |rect_opt| self.on_drag(rect_opt))
.on_drag_end(|_| Message::DragEnd)
.show_drag_rect(self.mode.multiple())
.on_release(|_| Message::ClickRelease(None));
if self.watch_drag {
mouse_area = mouse_area.on_drag(Message::Drag);
}
(drag_list, mouse_area.into(), true)
}
@ -6185,17 +6166,21 @@ impl Tab {
ItemMetadata::GvfsPath {
size_opt,
children_opt,
is_dir,
..
} => match children_opt {
Some(child_count) => {
if *child_count == 1 {
format!("{child_count} item")
} else {
format!("{child_count} items")
} => {
if *is_dir {
// Children are not counted on remote filesystems
match children_opt {
//TODO: translate
Some(1) => "1 item".to_string(),
Some(child_count) => format!("{child_count} items"),
None => String::new(),
}
} else {
format_size(size_opt.unwrap_or_default())
}
None => format_size(size_opt.unwrap_or_default()),
},
}
};
let row = if condensed {
@ -6262,39 +6247,33 @@ impl Tab {
.spacing(space_xxs)
};
let button = |row| {
let mouse_area = crate::mouse_area::MouseArea::new(
widget::button::custom(row)
.width(Length::Fill)
.id(item.button_id.clone())
.padding([0, space_xxs])
.class(button_style(
item.selected,
item.highlighted,
item.cut,
true,
true,
false,
)),
)
.on_press(move |_| Message::Click(Some(i)))
.on_double_click(move |_| Message::DoubleClick(Some(i)))
.on_release(move |_| Message::ClickRelease(Some(i)))
.on_middle_press(move |_| Message::MiddleClick(i))
.on_enter(move || Message::HighlightActivate(i))
.on_exit(move || Message::HighlightDeactivate(i));
let button =
|row| {
let mouse_area = crate::mouse_area::MouseArea::new(
widget::button::custom(row)
.width(Length::Fill)
.id(item.button_id.clone())
.padding([0, space_xxs])
.class(button_style(
item.selected,
item.highlighted,
item.cut,
true,
true,
false,
)),
)
.on_press(move |_| Message::Click(Some(i)))
.on_double_click(move |_| Message::DoubleClick(Some(i)))
.on_release(move |_| Message::ClickRelease(Some(i)))
.on_middle_press(move |_| Message::MiddleClick(i))
.on_enter(move || Message::HighlightActivate(i))
.on_exit(move || Message::HighlightDeactivate(i));
if self.context_menu.is_some() {
mouse_area
} else {
mouse_area
.on_right_press_no_capture()
.wayland_on_right_press_window_position()
.on_right_press(move |point_opt| {
Message::RightClick(point_opt, Some(i))
})
}
};
mouse_area.on_right_press_no_capture().on_right_press(
move |point_opt| Message::RightClick(point_opt, Some(i)),
)
};
let button_row = button(row.into());
let button_row: Element<_> = if item.metadata.is_dir()
@ -6422,16 +6401,14 @@ impl Tab {
let drag_col = (!drag_items.is_empty())
.then(|| Element::from(widget::column::with_children(drag_items)));
let mut mouse_area = mouse_area::MouseArea::new(column.padding([0, space_s]))
let mouse_area = mouse_area::MouseArea::new(column.padding([0, space_s]))
.with_id(Id::new("list-view"))
.on_press(|_| Message::Click(None))
.on_auto_scroll(Message::AutoScroll)
.on_drag(move |rect_opt| self.on_drag(rect_opt))
.on_drag_end(|_| Message::DragEnd)
.show_drag_rect(self.mode.multiple())
.on_release(|_| Message::ClickRelease(None));
if self.watch_drag {
mouse_area = mouse_area.on_drag(Message::Drag);
}
(drag_col, mouse_area.into(), true)
}
@ -6518,51 +6495,47 @@ impl Tab {
.on_back_press(move |_point_opt| Message::GoPrevious)
.on_forward_press(move |_point_opt| Message::GoNext)
.on_scroll(|delta| respond_to_scroll_direction(delta, modifiers))
.on_right_press(move |p| {
Message::ContextMenu(
if self.context_menu.is_some() { None } else { p },
self.window_id,
)
})
.wayland_on_right_press_window_position();
.on_right_press(|_| Message::RightClickBackground);
let mut popover = widget::popover(mouse_area);
if let Some(point) = self.context_menu
&& (!cfg!(feature = "wayland") || !crate::is_wayland())
{
let context_menu = menu::context_menu(
let items_area: Element<'_, Message> = if can_scroll {
// FIXME: new responsive widget will remove the state from the scrollable
// id_container with custom id forces the state to be extracted in a diff
// pre-processing step
widget::id_container(
widget::scrollable(mouse_area)
.id(self.scrollable_id.clone())
.on_scroll(Message::Scroll)
.width(Length::Fill)
.height(Length::Fill),
widget::Id::new(format!("{}-scrollable", self.scrollable_id)),
)
.into()
} else {
mouse_area.into()
};
// Wrap the scrollable, not its content, so the popup anchors in window coordinates
let mut context_menu = widget::context_menu(
items_area,
Some(menu::context_menu(
self,
key_binds,
modifiers,
clipboard_paste_available,
context_actions,
);
popover = popover
.popup(context_menu)
.position(widget::popover::Position::Point(point));
)),
)
.item_width(cosmic::widget::menu::ItemWidth::Uniform(360))
.on_surface_action(Message::Surface);
if let Some(window_id) = self.window_id {
context_menu = context_menu.window_id(window_id);
}
let mut tab_column = widget::column::with_capacity(3);
if let Some(location_view) = location_view_opt {
tab_column = tab_column.push(location_view);
}
if can_scroll {
tab_column = tab_column.push(
// FIXME: new responsive widget will remove the state from the scrollable
// id_container with custom id forces the state to be extracted in a diff
// pre-processing step
widget::id_container(
widget::scrollable(popover)
.id(self.scrollable_id.clone())
.on_scroll(Message::Scroll)
.width(Length::Fill)
.height(Length::Fill),
widget::Id::new(format!("{}-scrollable", self.scrollable_id)),
),
);
} else {
tab_column = tab_column.push(popover);
}
tab_column = tab_column.push(context_menu);
match &self.location {
Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
if let Some(items) = self.items_opt()
@ -6961,6 +6934,74 @@ impl Tab {
Rectangle::new(point, size)
};
// Count the children of visible directories in the background. Doing it while
// scanning costs one directory listing per entry, which stalls remote filesystems.
#[cfg(feature = "gvfs")]
for item in items {
let ItemMetadata::GvfsPath {
children_opt: None,
is_dir: true,
..
} = &item.metadata
else {
continue;
};
// Skip items that are not visible, or have no determined rect
match item.rect_opt.get() {
Some(rect) if rect.intersects(&visible_rect) => {}
_ => continue,
}
let Some(path) = item.path_opt().cloned() else {
continue;
};
struct ChildrenWrapper(PathBuf);
impl Hash for ChildrenWrapper {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
subscriptions.push(Subscription::run_with(
ChildrenWrapper(path),
|ChildrenWrapper(path)| {
let path = path.clone();
stream::channel(
1,
move |mut output: futures::channel::mpsc::Sender<_>| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let children = match fs::read_dir(&path) {
Ok(entries) => entries.count(),
Err(err) => {
log::warn!(
"failed to read directory {}: {}",
path.display(),
err
);
0
}
};
Message::DirectoryChildren(path, children)
})
.await
.unwrap()
};
if let Err(err) = output.send(message).await {
log::warn!("failed to send directory children: {err}");
}
std::future::pending().await
},
)
},
));
}
for item in items {
if item.thumbnail_opt.is_some() {
// Skip items that already have a mime type and thumbnail
@ -7056,8 +7097,7 @@ impl Tab {
let path = path.clone();
// Acquire semaphore permit
let _permit =
THUMB_SEMAPHORE.acquire().await.unwrap();
let _permit = THUMB_SEMAPHORE.acquire().await.unwrap();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
@ -7361,6 +7401,19 @@ impl Tab {
const fn format_time(&self, time: SystemTime) -> FormatTime<'_> {
format_time(time, &self.date_time_formatter, &self.time_formatter)
}
fn on_drag<'a>(&self, rect_opt: Option<Rectangle>) -> Option<Message> {
let rect = rect_opt?;
// We only want to publish a drag message if the overlapped items of the drag rect change,
// otherwise a view rebuild is triggered on every drag event.
let changed = self.items_opt.as_ref().is_some_and(|items| {
items.iter().any(|item| {
let overlaps = item.rect_opt.get().is_some_and(|r| r.intersects(&rect));
overlaps != item.overlaps_drag_rect
})
});
changed.then_some(Message::Drag(Some(rect)))
}
}
pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: &Modifiers) -> Option<Message> {

View file

@ -175,3 +175,25 @@ pub fn thumbnailer(mime: &Mime) -> Vec<Thumbnailer> {
let thumbnailer_cache = THUMBNAILER_CACHE.lock().unwrap();
thumbnailer_cache.get(mime)
}
#[cfg(all(test, feature = "desktop"))]
mod tests {
use super::Thumbnailer;
use std::path::Path;
#[test]
fn command_expands_thumbnailer_arguments() {
let thumbnailer = Thumbnailer {
exec: "cosmic-files-thumbnailer %o --size %s %i".to_string(),
};
let command = thumbnailer
.command(Path::new("input.exe"), Path::new("output.png"), 128)
.expect("valid thumbnailer command");
assert_eq!(command.get_program(), "cosmic-files-thumbnailer");
assert_eq!(
command.get_args().collect::<Vec<_>>(),
["output.png", "--size", "128", "input.exe"]
);
}
}