Compare commits

...

45 commits

Author SHA1 Message Date
d6e1572611 chore: refresh lockfile post-1.2.0 rebase
Leyoda 2026 – GPLv3
2026-07-03 15:28:40 +02:00
fd4a2ba610 fix: absorb libcosmic theme-v2 API (nav_context_menu, app_popup LiveSettings)
Leyoda 2026 – GPLv3
2026-07-03 15:26:15 +02:00
2a74748083 chore: use local cosmic-text checkout 2026-07-03 15:23:28 +02:00
Leyoda
17fc1cabbb yoda: cosmic-files customizations (squashed 21 commits)
This commit squashes the 21 local commits that customize cosmic-files for
the yoda stack, to allow a clean rebase on upstream/master.

Original commits (chronological):

- 9bcfe7a Cargo.toml: patch libcosmic via local path for dev builds
- 04abd13 yoda: depend on libcosmic-yoda (path) instead of upstream libcosmic
- 02adcc3 lockfile: libcosmic-yoda 0.1.0-yoda -> 0.1.0-yoda.2
- a025fd6 yoda: prefer cosmic-yoterm over upstream cosmic-term in terminal fallback
- e8d62ae yoda: add "Always use this app" toggle to OpenWith dialog
- 8fb2b15 yoda wayland-v5: redirect window_clipboard + cosmic-text to local forks
- 0595296 yoda: Dolphin-style quick actions toolbar under the headerbar
- 4b6d345 yoda: fix missing rename icon in toolbar
- 8b51af1 yoda: use pencil-symbolic for the Rename toolbar button
- 33a5c8f yoda: phase 2 - customizable toolbar (settings toggles per button)
- 1cf17dc yoda: phase 3 - drag-drop toolbar editor in Settings
- 11d4357 yoda: add up/down buttons next to drag handle in toolbar editor
- af843d2 yoda: direct drag-drop reorder on the toolbar itself
- 94c3e6c yoda: toolbar as segmented_button for working drag reorder
- f053819 yoda: toolbar icon-only + clean visual (Control style, 32px squares)
- 338354c Improve initial directory listing latency
- d080bc8 Resolve cosmic-files warnings without masking
- 69c35ab yoda: switch window_clipboard patch to public Forgejo fork
- 35e115f yoda: switch cosmic-text patch to public Forgejo fork
- 6f3adcd chore: clean feature-gated warnings
- 57ab1ec fix: clean files warnings for terminal build

Original tip preserved as tag backup/pre-rebase-upstream-20260524.
2026-07-03 15:21:21 +02:00
norepro
43a4228ca4
feat: support MIME subclasses in file chooser dialog 2026-07-02 17:52:14 +02:00
Jeremy Soller
3ef7913abd Epoch 1.2.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-06-30 13:13:13 -06:00
ZlordHUN
49107187a4
feat: view folder's content in trash
- [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-06-29 18:39:12 +02:00
Michael Murphy
50deb28a11
i18n: translation updates from weblate (#1822)
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ca/
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/kk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/sr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/uk/
Translation: Pop OS/COSMIC Files

Co-authored-by: Baurzhan Muftakhidinov <baurthefirst@gmail.com>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Hugo Carvalho <hugokarvalho@hotmail.com>
Co-authored-by: Isaac Subirana <isaacsubiranac@gmail.com>
Co-authored-by: Nara Díaz Viñolas <nara.diaz.vinolas@gmail.com>
Co-authored-by: Sami BABAT <samibabat@gmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Co-authored-by: Марко М. Костић <marko.m.kostic@gmail.com>
2026-06-26 18:31:02 +02:00
Hosted Weblate
8883d7cd64
i18n: translation updates from weblate
Co-authored-by: Baurzhan Muftakhidinov <baurthefirst@gmail.com>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hugo Carvalho <hugokarvalho@hotmail.com>
Co-authored-by: Isaac Subirana <isaacsubiranac@gmail.com>
Co-authored-by: Nara Díaz Viñolas <nara.diaz.vinolas@gmail.com>
Co-authored-by: Sami BABAT <samibabat@gmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Co-authored-by: Марко М. Костић <marko.m.kostic@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ca/
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/kk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/sr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/uk/
Translation: Pop OS/COSMIC Files
2026-06-26 18:01:57 +02:00
Fred
4d6788c9fd
feat: add SHA256 checksums to file properties 2026-06-26 16:57:13 +02:00
JADella94
cd427c746c
fix: panic when a second zip extract dialog is opened
* Syncs extracted zip files on the compio executor's thread
* Close the first zip extract dialog before opening a new one to fix a panic
---------

Co-authored-by: James A DellaMorte <dellamorte.james@comcast.net>
2026-06-24 20:12:10 +02:00
Jeremy Soller
c653b69c75 Epoch 1.1.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-06-23 10:15:44 -06:00
Gene Wood
65f5f454be
feat: add PageUp and PageDown bindings 2026-06-22 23:37:41 +02:00
Gene Wood
53a33dbe74 Clear hover highlights when navigating with keyboard
Keyboard navigation (arrow keys) did not clear item hover state, causing
items to retain their grey hover background after the mouse left the
window, resulting in multiple items appearing highlighted simultaneously.

Fixes #1865
2026-06-18 14:41:13 -04:00
Michael Aaron Murphy
7d3487da7b fix(mime_app): unalias mime types when associating apps
Fixes associations for `.iso`, where many apps define `application/x-cd-image` instead of `application/vnd.efi.iso`.
Popsicle and GNOME Disks will now appear as exact matches for opening `.iso` files with this change.
2026-06-16 23:05:07 -04:00
Jeremy Soller
9a3eb1cdea
Mime app handling improvements (#1850)
- Fix empty files getting `application/x-zerosize` as their mime types
    - May be tested by creating an empty `test.cpp` file
- Fix the wrong app being selected as a default if that app was set as
default for another mime type
- Include video players (such as cosmic-player) as suggested apps for
audio files
- Do the same for text-based mime types so that cosmic-edit will be
displayed as a suggestion for `.cpp` files
- Allow related apps to be selected in the Open With dropdown in the
properties context drawer.

---
- [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-06-11 19:09:08 -06:00
Michael Aaron Murphy
e8fdb28a24
feat(mime_app): show related apps in open with dropdown in preview 2026-06-11 18:56:23 +02:00
Michael Aaron Murphy
eda8291678
fix(mime_app): track mime type app default by mime 2026-06-11 17:54:12 +02:00
Michael Aaron Murphy
6d4af69318
feat(mime_app): include text editors for text-based mime types 2026-06-11 17:29:42 +02:00
Michael Aaron Murphy
256a6cba19
feat(mime_app): include video players for audio mime types 2026-06-11 17:29:19 +02:00
Michael Aaron Murphy
3cd0d0c00a
refactor: move mime app logic to mime_app module 2026-06-11 17:29:17 +02:00
Michael Aaron Murphy
64998b37a1
fix: do not use application/x-zerosize as mime type for empty files 2026-06-11 15:52:30 +02:00
Jeremy Soller
48eef3906d
improv: use sort-icons instead of unicode arrow glyphs (#1846)
This replaces the up/down arrow glyphs in the context menu for sort
direction with actual sort icons.
This way the icon is not dependent on the user's system font. For
example "Adwaita Sans" contains the up arrow but not the down arrow,
which means the arrow changes shape (depending on the chosen fallback
font) when you switch direction.

<img width="508" height="498" alt="Screenshot_2026-06-09_14-29-58"
src="https://github.com/user-attachments/assets/265eada7-d7c2-4f76-b63d-38c1879e6e85"
/>
<img width="515" height="453" alt="Screenshot_2026-06-09_14-30-09"
src="https://github.com/user-attachments/assets/d2634e96-5245-422a-b236-c49d7c65e79c"
/>

I took the liberty to move the icon from the end of the word to the left
side and also preserve an empty space when it's not drawn. It may need
UX approval.

Relevant mattermost thread:
https://chat.pop-os.org/pop-os/pl/i3nphjm85fbpdfmnd9pt9abs6y

Fixes https://github.com/pop-os/cosmic-files/issues/1844

___
- [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-06-10 16:56:30 -06:00
Levi Portenier
54f77f95a6
Speed up app icons and take added association into account (#1847)
- instead of loading app icons, creates `icon()` and lazy load when
needed
- takes user Added Association into account instead of just relying on
the desktop files "no display".


I also changed `is_default`'s ordering from `SecCst` to `Relaxed`. Let
me know if that's wrong, but from what I see it's not necessary in this
case, `Relaxed` is fine.

Before:
<img width="3755" height="1742" alt="image"
src="https://github.com/user-attachments/assets/bd9666fe-4e6b-4506-9081-905e31bb6c93"
/>



After:
<img width="3743" height="1717" alt="image"
src="https://github.com/user-attachments/assets/da439ea7-7f90-433b-80d7-1420f1aebde0"
/>

___
- [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-06-10 11:17:32 -06:00
Hojjat
42577aa467 fix: take user's Added Association into account when hiding apps 2026-06-09 16:51:21 -06:00
Hojjat
4ac289f40a improv: lazy load app icons for mime apps 2026-06-09 16:51:21 -06:00
Michael Aaron Murphy
c6c9eac97e fix: hide apps without declared mimetype support 2026-06-09 18:10:24 -04:00
Hojjat
af65a6b1c0 improv: use sort-icons instead of unicode arrow glyphs 2026-06-09 14:36:14 -06:00
Jeremy Soller
1d88fd16d6 Epoch 1.0.16 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-06-09 11:14:26 -06:00
Jeremy Soller
d04c92229c
Sort "other applications" in open with dialog (#1840)
Quick fix that sorts the other apps section.

---
- [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-06-09 10:30:34 -06:00
Michael Aaron Murphy
814b49114b
fix: sort other apps section 2026-06-08 18:24:52 +02:00
Jeremy Soller
245dfaa9b0
fix: hide no-display apps in open-with select (#1835)
Prevents desktop entries with `NoDisplay` set from appearing in the
"Open With" section.

Closes #1833 

---
- [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-06-08 09:46:11 -06:00
Michael Aaron Murphy
7c7970d0c0
fix: hide no-display apps only in other category 2026-06-06 20:26:45 +02:00
Michael Aaron Murphy
fc8f2b4000
fix: hide no-display apps in open-with select 2026-06-06 20:09:36 +02:00
Jeremy Soller
c605dbecaf Epoch 1.0.15 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-06-02 11:35:01 -06:00
Michael Aaron Murphy
fca0753781 fix: gate desktop features for no-default-features build 2026-06-02 09:52:14 -04:00
Michael Aaron Murphy
bb1ae9d458 feat: reload mime app cache when mime app config is updated 2026-06-02 09:52:14 -04:00
Michael Aaron Murphy
0b3d8a9f3f build: use cargo vendor --locked 2026-06-02 09:52:14 -04:00
Michael Aaron Murphy
53cfeebe57 feat: improve mime app detection & do it faster 2026-06-02 09:52:14 -04:00
Hojjat
bf01bb31a3 chore: update libcosmic to fix item highlight in wayland context menu 2026-05-28 21:09:44 +02:00
Ashley Wulber
18eb41930a fix: cosmic-protocols 2026-05-26 13:36:10 -06:00
Hojjat
355423a027 chore: fixes after libcosmic update 2026-05-26 14:14:02 -04:00
Jeremy Soller
cdec335ac7 Epoch 1.0.14 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-05-26 14:14:02 -04:00
Jeremy Soller
2a3c9f12d9
i18n: translation update from Hosted Weblate (#1806)
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/horizontal-auto.svg)
2026-05-26 09:06:59 -06:00
Hosted Weblate
5d1c056bed
i18n: translation updates from weblate
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Fedorov Alexei <aleksejfedorov963@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hugo Carvalho <hugokarvalho@hotmail.com>
Co-authored-by: Jim Spentzos <jimspentzos2000@gmail.com>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Julien Brouillard <julienbrouillard1@gmail.com>
Co-authored-by: Mattias Eriksson <snaggen@gmail.com>
Co-authored-by: Thomas Worofsky <thomas.worofsky@gmail.com>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: oddib <60390653+oddib@users.noreply.github.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
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/el/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/fr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/ga/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-files/pt/
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-05-25 21:11:35 +02:00
45 changed files with 2596 additions and 1168 deletions

1223
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.0.13"
version = "1.2.0"
authors = ["Jeremy Soller <jeremy@system76.com>"]
edition = "2024"
license = "GPL-3.0-only"
@ -11,7 +11,7 @@ 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 = "160b086", optional = true }
cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "c253ec1", 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 }
@ -33,7 +33,7 @@ shlex = { version = "1.3" }
tempfile = "3"
tikv-jemallocator = { version = "0.6", optional = true }
tokio = { version = "1", features = ["process", "sync"] }
trash = { git = "https://github.com/jackpot51/trash-rs.git", branch = "cosmic" }
trash = "5.2.6"
url = "2.5"
walkdir = "2.5.0"
wayland-client = { version = "0.31.14", optional = true }
@ -56,6 +56,7 @@ slotmap = "1.1.1"
recently-used-xbel = "1.2.0"
zip = "8"
md-5 = "0.10.6"
sha2 = "0.10"
png = "0.18"
jxl-oxide = { version = "0.12.5", features = ["image"] }
num_cpus = "1.17.0"
@ -73,8 +74,9 @@ version = "0.18"
default-features = false
features = ["fs", "io", "macros", "polling", "runtime"]
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
# Yoda fork — depend on libcosmic-yoda directly by path (no git/no patch).
[dependencies.libcosmic-yoda]
path = "../libcosmic"
default-features = false
#TODO: a11y feature crashes
features = [
@ -83,7 +85,7 @@ features = [
"autosize",
"multi-window",
"tokio",
"winit",
"wayland",
"surface-message",
]
@ -111,15 +113,15 @@ default = [
"wayland",
"wgpu",
]
dbus-config = ["libcosmic/dbus-config"]
desktop = ["libcosmic/desktop", "dep:cosmic-mime-apps", "dep:xdg"]
dbus-config = ["libcosmic-yoda/dbus-config"]
desktop = ["libcosmic-yoda/desktop", "dep:cosmic-mime-apps", "dep:xdg"]
desktop-applet = []
gvfs = ["dep:gio", "dep:glib"]
io-uring = ["compio/io-uring"]
jemalloc = ["dep:tikv-jemallocator"]
notify = ["dep:notify-rust"]
wayland = ["libcosmic/wayland", "dep:cctk", "dep:wayland-client"]
wgpu = ["libcosmic/wgpu"]
wayland = ["libcosmic-yoda/wayland", "dep:cctk", "dep:wayland-client"]
wgpu = ["libcosmic-yoda/wgpu"]
[profile.dev]
opt-level = 1
@ -145,20 +147,23 @@ fastrand = "2"
test-log = "0.2"
tokio = { version = "1", features = ["rt", "macros"] }
# [patch.'https://github.com/pop-os/cosmic-text']
# cosmic-text = { path = "../cosmic-text" }
# Yoda fork — libcosmic dep is now a direct path dep (libcosmic-yoda above),
# no [patch] block needed anymore. Keeping the block below would be a no-op
# since nothing in the dep graph still asks for pop-os/libcosmic.git.
# [patch.'https://github.com/pop-os/libcosmic']
# libcosmic = { path = "../libcosmic" }
# cosmic-config = { path = "../libcosmic/cosmic-config" }
# cosmic-theme = { path = "../libcosmic/cosmic-theme" }
# libcosmic = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-config = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-theme = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# Yoda wayland cut: redirect window_clipboard (x11 gated behind opt-in
# feature) to our public Forgejo fork and cosmic-text to the local checkout
# carrying PR#503 plus the EAW monospace width fix. The window_clipboard patch
# is needed to consolidate the
# upstream pop-os/libcosmic chain (pulled by cosmic-settings-daemon) onto
# the same fork iced uses, otherwise cargo compiles two versions.
[patch.'https://github.com/pop-os/window_clipboard.git']
window_clipboard = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
dnd = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
mime = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
# [patch.'https://github.com/pop-os/smithay-clipboard']
# smithay-clipboard = { path = "../smithay-clipboard" }
[patch.'https://github.com/pop-os/cosmic-text.git']
cosmic-text = { path = "../cosmic-text" }
[workspace]
members = ["cosmic-files-applet"]

View file

@ -1,6 +1,6 @@
[package]
name = "cosmic-files-applet"
version = "1.0.13"
version = "1.2.0"
edition = "2024"
[dependencies]

30
debian/changelog vendored
View file

@ -1,3 +1,33 @@
cosmic-files (1.2.0) noble; urgency=medium
* Epoch 1.2.0 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 30 Jun 2026 12:21:07 -0600
cosmic-files (1.1.0) noble; urgency=medium
* Epoch 1.1.0 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 23 Jun 2026 08:47:18 -0600
cosmic-files (1.0.16) noble; urgency=medium
* Epoch 1.0.16 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 09 Jun 2026 10:26:05 -0600
cosmic-files (1.0.15) noble; urgency=medium
* Epoch 1.0.15 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 02 Jun 2026 11:02:29 -0600
cosmic-files (1.0.14) noble; urgency=medium
* Epoch 1.0.14 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 26 May 2026 08:30:59 -0600
cosmic-files (1.0.13) noble; urgency=medium
* Epoch 1.0.13 version update

View file

@ -0,0 +1,78 @@
# Local performance and portal notes
Date: 2026-05-05
This repository carries a local patch aimed at improving initial directory
display latency in large folders, plus a system-level workaround for a COSMIC
portal FileChooser crash observed with Firefox and Chromium.
## Directory listing latency
The slow path was the initial construction of `Item` values in `src/tab.rs`.
On a test folder with about 2000 entries, raw filesystem enumeration and stat
calls completed in a few milliseconds, while COSMIC Files took multiple seconds
before showing the directory.
The local patch keeps initial item construction cheap:
- directory child counts are no longer computed synchronously in
`item_from_entry` and `item_from_gvfs_info`;
- initial MIME detection uses extension-based `mime_guess`;
- regular files use a generic file icon during the initial scan instead of
resolving the full MIME icon immediately.
This keeps folders and `.desktop` files special-cased, while avoiding expensive
per-file work for ordinary files during first paint.
Measured locally during investigation:
- before the MIME/icon changes: about 3.1 seconds for `~/Téléchargements`;
- after avoiding full MIME icon resolution during scan: below the temporary
100 ms perf-log threshold for the same folder.
## File chooser portal workaround
Firefox and Chromium were failing to open `Save As` on the first attempt because
`xdg-desktop-portal-cosmic` crashed while handling `FileChooser`.
Logs showed:
```text
Backend call failed: Remote peer disconnected
xdg-desktop-portal-cosmic ... status=11/SEGV
```
The working local system workaround is to remove
`org.freedesktop.impl.portal.FileChooser` from:
```text
/usr/share/xdg-desktop-portal/portals/cosmic.portal
```
so the file chooser falls back to GTK. The resulting local `cosmic.portal`
keeps COSMIC for the other interfaces:
```ini
[portal]
DBusName=org.freedesktop.impl.portal.desktop.cosmic
Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.ScreenCast
UseIn=COSMIC
```
Backups created locally:
```text
/usr/share/xdg-desktop-portal/portals/cosmic.portal.bak-codex-20260505-filechooser
/usr/share/xdg-desktop-portal/cosmic-portals.conf.bak-codex-20260505
/usr/share/xdg-desktop-portal/cosmic-portals.conf.bak-codex-20260505-2
```
After editing portal files, restart:
```sh
systemctl --user restart xdg-desktop-portal.service xdg-desktop-portal-gtk.service
```
Package updates may overwrite `/usr/share/xdg-desktop-portal/portals/cosmic.portal`.
If `Save As` starts needing two attempts again, re-check that `FileChooser` has
not been reintroduced in `cosmic.portal`.

View file

@ -1,11 +1,11 @@
cosmic-files = Fitxers del COSMIC
empty-folder = Carpeta buida
empty-folder-hidden = Carpeta buida (té elements ocults)
empty-folder-hidden = Carpeta buida (conté elements ocults)
no-results = No s'ha trobat cap resultat
filesystem = Sistema de fitxers
home = Inici
networks = Xarxes
notification-in-progress = Hi ha operacions amb fitxers en curs.
notification-in-progress = Operacions de fitxers en curs
trash = Paperera
recents = Recents
undo = Desfés
@ -29,14 +29,14 @@ details = Detalls
dismiss = Descarta el missatge
operations-running =
{ $running } { $running ->
[one] operacion
[one] operació
*[other] operacions
} en curs ({ $percent }%)...
operations-running-finished =
{ $running }{ $running ->
[one] operacion
[one] operació
*[other] operacions
} en curs ({ $percent }%), { $finished } acabat...
} en curs ({ $percent }%), { $finished } acabada/es...
pause = Pausa
resume = Reprèn
@ -54,7 +54,7 @@ extract-password-required = Cal una contrasenya
## Empty Trash Dialog
empty-trash = Buida la paperera
empty-trash-warning = Voleu suprimir permanentment tots els fitxers de la paperera?
empty-trash-warning = Els elements dins la Paperera s'eliminaran permanentment
## Mount Error Dialog
@ -64,12 +64,12 @@ mount-error = No es pot accedir a la unitat
create-new-file = Crea un nou fitxer
create-new-folder = Crea una nova carpeta
file-name = Nom del fixer
file-name = Nom del fitxer
folder-name = Nom de la carpeta
file-already-exists = Ja existeix un fitxer amb aquest nom.
folder-already-exists = Ja existeix una carpeta amb aquest nom.
name-hidden = Els noms que comencin amb "." seran ocults.
name-invalid = El nom no pot ser "{ $filename }".
file-already-exists = Ja existeix un fitxer amb aquest nom
folder-already-exists = Ja existeix una carpeta amb aquest nom
name-hidden = Els noms que comencin amb "." s'ocultaran.
name-invalid = El nom no pot ser "{ $filename }"
name-no-slashes = El nom no pot contenir barres.
## Open/Save Dialog
@ -353,3 +353,13 @@ sort-oldest-first = Primer més antics
sort-smallest-to-largest = De petit a gran
sort-largest-to-smallest = De gran a petit
run = Executa
extract-to-title = Extreu a una carpeta
empty-trash-title = Voleu buidar la paperera?
extract-to = Extreu a...
delete = Suprimeix
comment = Gestor de fitxers de l'escriptori COSMIC
copy-to-title = Escolliu el destí de la còpia
copy-to-button-label = Copia
move-to-title = Escolliu el destí del trasllat
move-to-button-label = Mou
keywords = Carpeta;Fitxers;Arxius;Gestor;Explorador;

View file

@ -427,3 +427,4 @@ context-action-confirm-warning =
*[other] položek
}.
run = Spustit
rename-confirm = Přejmenovat

View file

@ -437,3 +437,4 @@ context-action-confirm-warning =
*[other] Elementen
}.
run = Ausführen
rename-confirm = Umbenennen

View file

@ -327,3 +327,4 @@ context-action-confirm-warning =
[one] στοιχείο
*[other] στοιχεία
}.
rename-confirm = Μετονομασία

View file

@ -96,6 +96,7 @@ save-file = Save file
## Open With Dialog
open-with-title = How do you want to open "{$name}"?
open-with-set-default = Always use this app for this file type
browse-store = Browse {$store}
other-apps = Other applications
related-apps = Related applications
@ -139,6 +140,11 @@ open-with = Open with
owner = Owner
group = Group
other = Other
toolbar = Toolbar
toolbar-available = Available
toolbar-empty-hint = No buttons. Drag or add from below.
toolbar-reset = Reset to defaults
parent-directory = Parent directory
mixed = Mixed
### Mode 0
none = None
@ -303,6 +309,9 @@ item-created = Created: {$created}
item-modified = Modified: {$modified}
item-accessed = Accessed: {$accessed}
calculating = Calculating...
checksum = {$kind} checksum
calculate = Calculate
error = Error
## Settings
settings = Settings

View file

@ -414,3 +414,4 @@ context-action-confirm-warning =
*[other] kohdetta
}.
run = Suorita
rename-confirm = Nimeä uudelleen

View file

@ -92,6 +92,7 @@ save-file = Enregistrer fichier
## Open With Dialog
open-with-title = Comment souhaitez-vous ouvrir "{ $name }"?
open-with-set-default = Toujours utiliser cette application pour ce type de fichier
browse-store = Parcourir { $store }
## Permanently delete Dialog
@ -130,6 +131,11 @@ open-with = Ouvrir avec
owner = Propriétaire
group = Groupe
other = Autre
toolbar = Barre d'outils
toolbar-available = Disponibles
toolbar-empty-hint = Aucun bouton. Glisser-déposer ou ajouter depuis la liste ci-dessous.
toolbar-reset = Rétablir par défaut
parent-directory = Dossier parent
### Mode 0
@ -445,3 +451,4 @@ context-action-confirm-warning =
}.
run = Exécuter
context-action = Action contextuelle
rename-confirm = Renommer

View file

@ -442,3 +442,4 @@ context-action-confirm-warning =
*[other] míreanna
}.
run = Rith
rename-confirm = Athainmnigh

View file

@ -2,7 +2,7 @@ cosmic-files = COSMIC Fájlok
comment = Fájlkezelő a COSMIC asztali környezethez
keywords = mappa;fájl;kezelő;
empty-folder = Üres mappa
empty-folder-hidden = Üres mappa (Rejtett elemeket tartalmaz)
empty-folder-hidden = Üres mappa (rejtett elemeket tartalmaz)
no-results = Nincs találat
filesystem = Fájlrendszer
home = Saját mappa
@ -17,7 +17,7 @@ desktop-view-options = Asztali nézet beállításai…
show-on-desktop = Megjelenítés az asztalon
desktop-folder-content = Asztal mappa tartalma
mounted-drives = Csatolt meghajtók
trash-folder-icon = Kuka ikonja
trash-folder-icon = Kuka ikon
icon-size-and-spacing = Ikonméret és távolság
icon-size = Ikonméret
grid-spacing = Rácsköz
@ -47,18 +47,18 @@ resume = Folytatás
## Compress Dialog
create-archive = Tömörített fájl létrehozása
create-archive = Archívum létrehozása
## Extract Dialog
extract-password-required = Jelszó szükséges
extract-to = Kibontás ide…
extract-to-title = Kibontás mappába
extract-to-title = Kibontási cél kiválasztása
## Empty Trash Dialog
empty-trash = Kuka ürítése
empty-trash-warning = A kukában lévő összes elem véglegesen törölve lesz
empty-trash = A Kuka ürítése
empty-trash-warning = A Kukában lévő összes elem véglegesen törölve lesz
## Mount Error Dialog
@ -93,7 +93,7 @@ save-file = Fájl mentése
## Open With Dialog
open-with-title = Hogyan szeretnéd megnyitni a következőt: „{ $name }”?
open-with-title = Hogyan szeretnéd megnyitni a(z) „{ $name }” fájlt?
browse-store = { $store } böngészése
other-apps = Egyéb alkalmazások
related-apps = Hasonló alkalmazások
@ -103,7 +103,7 @@ related-apps = Hasonló alkalmazások
selected-items = A(z) { $items } kijelölt elem
permanently-delete-question = Végleges törlés?
delete = Törlés
permanently-delete-warning = { $target } véglegesen törölve lesz. A művelet nem visszavonható.
permanently-delete-warning = { $target } véglegesen törölve lesz. A művelet nem vonható vissza.
## Rename Dialog
@ -113,7 +113,7 @@ rename-folder = Mappa átnevezése
## Replace Dialog
replace = Csere
replace-title = „{ $filename }” már létezik
replace-title = „{ $filename }” már létezik ezen a helyen
replace-warning = Szeretnéd lecserélni a meglévő fájlt a mentendő fájllal? A cserével felülírod annak tartalmát.
replace-warning-operation = Szeretnéd lecserélni? A csere felülírja annak tartalmát.
original-file = Eredeti fájl
@ -133,7 +133,7 @@ set-and-launch = Beállítás és indítás
open-with = Megnyitás ezzel
owner = Tulajdonos
group = Csoport
other = Többi
other = Mások
### Mode 0
@ -172,7 +172,7 @@ read-write-execute = Olvasás, írás és végrehajtás
favorite-path-error = Hiba a könyvtár megnyitásakor
favorite-path-error-description =
Nem sikerült megnyitni ezt: „{ $path }”
„{ $path }” lehet nem létezik, vagy nincs jogosultságod a megnyitásához
A(z) „{ $path }” útvonal lehet, hogy nem létezik, vagy nincs jogosultságod a megnyitásához
Szeretnéd eltávolítani az oldalsávról?
remove = Eltávolítás
@ -187,9 +187,9 @@ keep = Megtartás
## Add Network Drive
add-network-drive = Hálózati meghajtó hozzáadása
connect = Csatlakozás
connect-anonymously = Csatlakozás névtelenül
connecting = Csatlakozás…
connect = Kapcsolódás
connect-anonymously = Kapcsolódás névtelenül
connecting = Kapcsolódás…
domain = Tartomány
enter-server-address = Add meg a kiszolgáló címét
network-drive-description =
@ -209,7 +209,7 @@ network-drive-schemes =
network-drive-error = Nem érhető el a hálózati meghajtó
password = Jelszó
remember-password = Jelszó megjegyzése
try-again = Újra
try-again = Próbáld újra
username = Felhasználónév
## Operations
@ -217,7 +217,7 @@ username = Felhasználónév
cancelled = Megszakítva
edit-history = Fájlműveleti előzmények
history = Előzmények
no-history = Nem találhatók elemek az előzményekben
no-history = Nem találhatók elemek az előzményekben.
pending = Függőben
progress = { $percent }%
progress-cancelled = { $percent }%, megszakítva
@ -251,13 +251,13 @@ deleting =
{ $items } { $items ->
[one] elem
*[other] elem
} törlése a kukából ({ $progress })…
} törlése a Kukából ({ $progress })…
deleted =
{ $items } { $items ->
[one] elem
*[other] elem
} törölve a kukából
emptying-trash = { trash } kiürítése ({ $progress })…
} törölve a Kukából
emptying-trash = { trash } ürítése ({ $progress })…
emptied-trash = { trash } kiürítve
extracting =
{ $items } { $items ->
@ -309,12 +309,12 @@ restoring =
{ $items } { $items ->
[one] elem
*[other] elem
} visszaállítása a kukából ({ $progress })…
} visszaállítása a Kukából ({ $progress })…
restored =
{ $items } { $items ->
[one] elem
*[other] elem
} visszaállítva a kukából
} visszaállítva a Kukából
unknown-folder = ismeretlen mappa
## Open with
@ -330,7 +330,7 @@ items = Elemek: { $items }
item-size = Méret: { $size }
item-created = Létrehozva: { $created }
item-modified = Módosítva: { $modified }
item-accessed = Hozzáférve: { $accessed }
item-accessed = Utolsó hozzáférés: { $accessed }
calculating = Számítás…
## Settings
@ -349,8 +349,8 @@ light = Világos
### Type to Search
type-to-search = Gépeléssel keresés
type-to-search-recursive = Keresés a jelenlegi mappában és almappákban
type-to-search-enter-path = Elérési út megadása
type-to-search-recursive = Keresés a jelenlegi mappában és az almappákban
type-to-search-enter-path = Elérési út megnyitása
# Context menu
add-to-sidebar = Hozzáadás az oldalsávhoz
compress = Tömörítés…
@ -360,8 +360,8 @@ extract-here = Kibontás
new-file = Új fájl…
new-folder = Új mappa…
open-in-terminal = Megnyitás a terminálban
move-to-trash = Kukába helyezés
restore-from-trash = Visszaállítás a kukából
move-to-trash = Áthelyezés a Kukába
restore-from-trash = Visszaállítás a Kukából
remove-from-sidebar = Eltávolítás az oldalsávról
sort-by-name = Név szerinti rendezés
sort-by-modified = Módosítás szerinti rendezés
@ -372,8 +372,8 @@ remove-from-recents = Eltávolítás a legutóbbiak közül
## Desktop
change-wallpaper = Háttérkép cseréje…
desktop-appearance = Asztali megjelenés…
display-settings = Képernyő-beállítások…
desktop-appearance = Asztal megjelenése
display-settings = Kijelzőbeállítások…
# Menu
@ -406,7 +406,7 @@ grid-view = Rácsnézet
list-view = Listanézet
show-hidden-files = Rejtett fájlok megjelenítése
list-directories-first = Könyvtárak listázása először
gallery-preview = Galéria előnézet
gallery-preview = Galéria-előnézet
menu-settings = Beállítások…
menu-about = A COSMIC Fájlok névjegye…
@ -415,14 +415,14 @@ menu-about = A COSMIC Fájlok névjegye…
sort = Rendezés
sort-a-z = A-Z
sort-z-a = Z-A
sort-newest-first = Legújabb előre
sort-oldest-first = Legrégibb előre
sort-newest-first = Legújabb elöl
sort-oldest-first = Legrégibb elöl
sort-smallest-to-largest = Legkisebbtől a legnagyobbig
sort-largest-to-smallest = Legnagyobbtól a legkisebbig
repository = Tároló
support = Támogatás
progress-failed = { $percent }%, sikertelen
empty-trash-title = Kuka ürítése?
empty-trash-title = Kiüríted a Kukát?
type-to-search-select = Kijelöli az első egyező fájlt vagy mappát
pasted-image = Beillesztett kép
pasted-text = Beillesztett szöveg
@ -445,3 +445,4 @@ context-action-confirm-warning =
*[other] elemen
} fog lefutni.
run = Futtatás
rename-confirm = Átnevezés

View file

@ -327,3 +327,4 @@ context-action-confirm-warning =
[one] item
*[other] item
}.
rename-confirm = Ganti nama

View file

@ -327,3 +327,4 @@ context-action-confirm-warning =
*[other] нәрсеге
}.
run = Орындау
rename-confirm = Атын өзгерту

View file

@ -0,0 +1,330 @@
sort-newest-first = Pêşî ya herî nû
connect = Girê bide
deleted =
{ $items } hat jêbirin{ $items ->
[one] hêman
*[other] hêman
} ji { trash }
dismiss = Peyamê paşguh bike
list-view = Dîtina lîsteyê
copy_noun = Jê bigire
favorite-path-error = Şaşetiya vekirina rêgehê
progress = { $percent }%
open-file = Pelê veke
remove-from-sidebar = Ji benda kêlekê rake
related-apps = Bernameyên têkildar
network-drive-error = Nikaribû bigihêje ajokarê torê
gallery-preview = Pêşdîtina galeriyê
sort-smallest-to-largest = Biçûktir bo mezintir
zoom-in = Nêzîk bike
select-all = Hemûyan hilbijêre
removing-from-recents =
{ $items } tê rakirin { $items ->
[one] hêman
*[other] hêman
} ji { recents }
password = Borînpeyv
cosmic-files = Pelên COSMIC
emptying-trash = { trash } tê valakirin({ $progress })...
new-window = Çarçoveya nû
remove = Rake
zoom-out = Dûr bike
compressing =
{ $items } tê guvaştin { $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }" ({ $progress })...
move-to-trash = Bilivîne bo jêbirdankê
menu-about = Derbarê Pelên COSMIC...
file-already-exists = Pelek bi heman navî jixwe heye
setting-executable-and-launching = "{ $name }" wekî pelê xebitandinê tê sazkirin û xebitandin
open-multiple-files = Gelek pelan veke
default-size = Mezinahiya berdest
name-hidden = Navên ku bi "." tê destpêkirin wê werin veşartin
extracted =
{ $items } hate derxistin { $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }"
create-new-folder = Peldanka nû biafrîne
folder-already-exists = Peldankek bi heman navî jixwe heye
create-archive = Erşîvê biafrîne
permanently-delete-warning = { $target } wê bi mayînde were jêbirin û nayê vegerandin.
favorite-path-error-description =
Nikaribû "{ $path }" veke.
"{ $path }" dibe ku tune be yan jî mafê te yê
vekirina wê tune be.
Tu dixwazî wê ji benda kêlekê rakî?
empty-trash-warning = Hêmanên di jêbirdankê de wê bi mayînde werin jêbirin
other-apps = Bernameyên din
set-permissions = Maf hatine sazkirin ji bo "{ $name }" bo { $mode }
quit = Biqedîne
sort-by-size = Li gorî mezinahiyê rêz bike
rename = Navê biguhêrîne...
empty-folder-hidden = Peldanka vala (tiştên veşartî tê de hene)
keep = Bihêle
operations-running =
{ $running } { $running ->
[one] pêvajo
*[other] pêvajo
} dixebite ({ $percent }%)...
permanently-deleting =
{ $items } bi mayînde tê jêbirin { $items ->
[one] hêman
*[other] hêman
}
edit = Biguhêrîne
copy = Jê bigire
none = Ne yek
no-results = Tu encam nehatin dîtin
theme = Rûkar
compressed =
{ $items } hate guvaştin{ $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }"
appearance = Xuyang
rename-folder = Navê peldankê biguherîne
new-file = Pela nû...
close-tab = Rûgerê bigire
open-in-terminal = Di termînalê de veke
open-multiple-folders = Gelek peldankan veke
remember-password = Borînpeyvê bi bîr bîne
username = Navê bikarhêner
light = Ronî
extract-to = Derxîne bo...
add-network-drive = Ajokarê torê tevlî bike
copying =
{ $items } tê jêgirtin{ $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }" ({ $progress })...
create-new-file = Pelê nû biafrîne
sort-by-trashed = Li gorî demê rêz bike
support = Piştgirî
try-again = Dîsa hewl bide
eject = Biavêje
copied =
{ $items } hate jêgirtin { $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }"
open-in-new-window = Di çarçoveyeke nû de veke
empty-folder = Peldanka vala
empty-trash = Jêbirdankê vala bike
sort-by-modified = Li gorî guhertinê rêz bike
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
empty-trash-title = Jêbirdankê vala bike?
menu-settings = Sazkarî...
moving =
{ $items } tê livandin{ $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }" ({ $progress })...
change-wallpaper = Wêneyê dîwêr biguhêrîne...
network-drive-description =
Di nav navnîşanên rajekarê de pêşgira protokolekê û navnîşanek heye.
Mînak: ssh://192.168.0.1, ftp://[2001:db8::1]
deleting =
{ $items } tê jêbirin { $items ->
[one] hêman
*[other] hêman
} ji { trash } ({ $progress })...
single-click = Tikandina yekane bo vekriinê
view = Dîtin
setting-permissions = Maf tên sazkirin ji bo "{ $name }" bo { $mode }
creating = "{ $name }" tê afirandin di "{ $parent }" de
execute-only = Tenê xebitandin
open-item-location = Cihê hêmanê veke
type-to-search = Bo lêgerînê binivîse
set-executable-and-launched = "{ $name }" wekî pelê xebitandinê hat sazkirin û xebitandin
mounted-drives = Ajokarên siwarkirî
sort-a-z = A-Z
mount-error = Nikare bigihêje ajokerê
extract-here = Derxîne
grid-view = Dîtina torê
add-to-sidebar = Tevlî benda kêlekê bike
dark = Tarî
notification-in-progress = Pêvajoyên pelê didome
home = Rûpela sereke
sort-z-a = Z-A
type-to-search-recursive = Di peldank û binpeldankên heyî de digere
history = Dîrok
progress-paused = { $percent }%, hate rawestandin
desktop-view-options = Vebijêrkên dîtina sermaseyê...
show-on-desktop = Li ser sermaseyê nîşan bide
cancelled = Têkbirî
new-folder = Peldankan nû...
match-desktop = Li gorî sermaseyê
name-no-slashes = Nav nabe ku "/" tê de hebe
operations-running-finished =
{ $running } { $running ->
[one] pêvajo
*[other] pêvajo
} dixebite ({ $percent }%), { $finished } qediya...
sort-by-name = Li gorî navê rêz bike
edit-history = Dîrokê biguhêrînê
show-hidden-files = Pelên veşartî nîşan bide
progress-failed = { $percent }%, têk çû
trash-folder-icon = Îkona peldanka jêbirdankê
extract-to-title = Derxîne bo peldankê
icon-size = Mezinahiya îkonê
open-with-title = Tu çawa dixwazî "{ $name }" vekî?
extracting =
{ $items } tê derxistin { $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo"{ $to }" ({ $progress })...
settings = Sazkarî
complete = Qediya
permanently-delete-question = Bi mayînde jê bibe?
extract-password-required = Borînpeyv pêdivî ye
pending = Li bendê ye
desktop-folder-content = Naveroka peldanka sermaseyê
no-history = Tu hêman di dîrok de tune ne.
open-folder = Peldankê veke
emptied-trash = { trash } hate valakirin
rename-file = Navê pelê biguherîne
sort-largest-to-smallest = Mezintir bo biçûktir
restore-from-trash = Ji jêbirdankê vegerîne
cut = Jê bike
moved =
{ $items } hate livandin { $items ->
[one] hêman
*[other] hêman
} ji "{ $from }" bo "{ $to }"
progress-cancelled = { $percent }%, hat têkbirin
open-in-new-tab = Di rûgerê nû de veke
file = Pel
file-name = Navê pelê
save-file = Pelê tomar bike
created = "{ $name }" hate afirandin di "{ $parent }" de
delete-permanently = Bi mayînde jê bibe
display-settings = Sazkariyên dîmenderê...
new-tab = Rûgera nû
failed = Têk çû
name-invalid = Nav nabe ku \"{ $filename }\" be
desktop-appearance = Xuyanga sermaseyê...
comment = Rêveberê pelê ji bo sermaseya COSMIC
keywords = Rêveber;Peldank;
filesystem = Pergala pelê
networks = Tor
recents = Dawî
open = Veke
cancel = Têk bibe
trash = Jêbirdank
undo = Vegerîne
today = Îro
name = Nav
modified = Guhertî
size = Mezinahî
details = Hûrgilî
pause = Rawestîne
resume = Bidomîne
copy-to-title = Rêgeha jêgirtinê hilbijêre
copy-to-button-label = Jê bigire
move-to-title = Rêgeha livandinê hilbijêre
move-to-button-label = Bilivîne
create = Biafrîne
save = Tomar bike
context-action = Çalakiya naverokê
context-action-confirm-title = "{ $name }" bixebtîne?
context-action-confirm-warning =
Ev ê li ser { $items } { $items ->
[one] hêman
*[other] hêman
} bixebite.
run = Bixebitîne
selected-items = { $items } hêmanên hilbijartî
delete = Jê bibe
rename-confirm = Navê biguherîne
pasted-image = Wêneya pêvekirî
pasted-text = Nivîsa pêvekirî
pasted-video = Vîdiyoya pêvekirî
copy-path = Rêgehê jê bigire
show-recents = Peldanka dawî di benda kêlekê de
clear-recents-history = Dîroka dawî pak bike
compress = Biguvşîne...
copy-to = Jê bigire bo...
move-to = Bilivîne bo...
default-app = { $name } (berdest)
renamed = Nav hate guhertin ji "{ $from }" bo "{ $to }"
read-execute = Xwendin û xebitandin
item-modified = Guhertin: { $modified }
reload-folder = Peldankê ji nû ve bar bike
restoring =
{ $items } tê vegerandin { $items ->
[one] hêman
*[other] hêman
} ji { trash } ({ $progress })...
icon-size-and-spacing = Mezinahiya îkonan û valahiya di navbera wan de
type-to-search-enter-path = Riya rêgehê yan jî pelê têdixîne
menu-open-with = Veke bi...
original-file = Pelê resen
read-write-execute = Xwendin, nivîsandin û xebitandin
calculating = Tê jimartin...
item-size = Mezinahî: { $size }
connecting = Tê girêdan...
read-write = Xwendin û nivîsandin
items = Hêman: { $items }
type = Cûre: { $mime }
replace-warning = Tu dixwazî bi ya ku tu niha tomar dikî biguhêrînî? Naveroka berê wê were jêbirin.
show-details = Hûrgiliyan nîşan bide
grid-spacing = Valahiya torê
sort-oldest-first = Pêşî ya herî kevn
repository = Embar
replace-warning-operation = Tu dixwazî biguherînî? Naveroka berê wê were jêbirin.
other = Ên din
read-only = Tenê xwendin
enter-server-address = Navnîşana rajekarê têxîne
connect-anonymously = Bi awayekî anonîm girê bide
group = Kom
apply-to-all = Bo hemûyan bisepîne
skip = Derbas bike
replace-with = Biguhêrîne bi
owner = Xwedî
set-and-launch = Saz bike û dest pê bike
removed-from-recents =
{ $items } hate rakirin { $items ->
[one] hêman
*[other] hêman
} ji { recents }
item-created = Afirandin:{ $created }
network-drive-schemes =
Protokolên berdest,pêşgirê
AppleTalk,afp://
Protokola şandina pelê,ftp:// an ftps://
Pergala pelê torê,nfs://
Astengiya peyama rajekarê,smb://
SSH protokola şandina pelê,sftp:// an ssh://
WebDAV, dav:// an davs://
set-executable-and-launch = Wekî pelê xebitandinê saz bike û bixebitîne
restored =
{ $items } hate vegerandin { $items ->
[one] hêman
*[other] hêman
} ji { trash }
domain = Navper
sort = Rêz bike
item-accessed = Gihîştin: { $accessed }
open-with = Veke bi
keep-both = Herduyan bihêle
permanently-deleted =
{ $items } bi mayînde hate jêbirin{ $items ->
[one] hêman
*[other] hêman
}
write-execute = Nivîsandin û xebitandin
renaming = Navê ji "{ $from }" biguhêrîne bo "{ $to }"
set-executable-and-launch-description = Tu dixwazî "{ $name }" wekî pelê xebitandinê saz bikî û bixebtînî?
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
mixed = Tevlihev
type-to-search-select = Pel an peldanka yekem a lihevhatî hildibijêre

View file

@ -98,7 +98,7 @@ permanently-delete-question = Slett permanent
permanently-delete-warning = Er du sukker på om du vil permanent slette { $target }? Dette kan ikke angres.
rename-file = Endre filnavn
rename-folder = Endre mappenavn
replace-title = "{ $filename }" finnes allerede på denne plasseringen.
replace-title = «{ $filename }» finnes allerede på denne plasseringen
replace-warning = Vil du bytte den ut med filen du lagrer? Dette vil overskrive innholdet.
replace-warning-operation = Vil du erstatte den? Dette vil overskrive innholdet.
original-file = Orginalfil
@ -122,8 +122,8 @@ read-write = Kun skrive- og lesbar
read-write-execute = Lesbar, skrivbar og kjørbar
favorite-path-error = Feil ved åpning av mappen
favorite-path-error-description =
Kunne ikke åpne "{ $path }".
Den kan ikke eksistere eller så har du ikke tilgang til å åpne den.
Klarte ikke åpne «{ $path }».
«{ $path }» finnes ikke eller så har du ikke tilgang til å åpne den.
Vil du fjerne den fra sidepanelet?
keep = Behold
@ -140,7 +140,7 @@ zoom-out = Zoom ut
cut = Klipp ut
set-and-launch = Still inn og start
domain = Domene
enter-server-address = Angi serveradresse
enter-server-address = Angi tjeneradresse
network-drive-description =
Serveradresser inkluderer ett protokollprefiks og en addresse.
Examples: ssh://192.168.0.1, ftp://[2001:db8::1]

View file

@ -446,3 +446,4 @@ context-action-confirm-warning =
*[other] elementów
}.
run = Uruchom
rename-confirm = Zmień nazwę

View file

@ -445,3 +445,4 @@ context-action-confirm-warning =
*[other] itens
}.
run = Executar
rename-confirm = Renomear

View file

@ -1,12 +1,12 @@
cosmic-files = Ficheiros COSMIC
comment = Gerenciador de arquivos do COSMIC
keywords = Pastas;Gerenciador;Arquivos;Gestor;Explorer;
keywords = Pasta;Gestor;
empty-folder = Pasta vazia
empty-folder-hidden = Pasta vazia (tem ficheiros ocultos)
no-results = Nenhum resultado encontrado
filesystem = Sistema de ficheiros
home = Pasta Pessoal
notification-in-progress = Operações em curso.
notification-in-progress = Operações em curso
trash = Lixo
undo = Anular
# List view
@ -204,7 +204,7 @@ desktop-view-options = Opções de visualização da área de trabalho...
show-on-desktop = Mostrar na área de trabalho
desktop-folder-content = Conteúdo da pasta da área de trabalho
mounted-drives = Dispositivos montados
trash-folder-icon = Ícone do lixo
trash-folder-icon = Ícone da pasta lixo
icon-size-and-spacing = Tamanho e espaçamento do ícone
icon-size = Tamanho do ícone
grid-spacing = Espaçamento entre ícones
@ -354,3 +354,4 @@ sort-smallest-to-largest = Do menor para o maior
sort-largest-to-smallest = Do maior para o menor
context-action-confirm-title = Executar "{ $name }"?
run = Executar
rename-confirm = Renomear

View file

@ -389,3 +389,4 @@ context-action-confirm-warning =
*[other] элем.
}.
run = Выполнить
rename-confirm = Переименовать

View file

@ -327,3 +327,4 @@ compress = Сажми...
copy-to = Умножи у...
move-to = Помери у...
copy-path = Умножи путању
rename-confirm = Преименуј

View file

@ -418,3 +418,4 @@ context-action-confirm-warning =
*[other] objekt
}.
run = Kör
rename-confirm = Byt namn

View file

@ -82,7 +82,7 @@ copying =
Копіювання { $items } { $items ->
[one] елемента
*[other] елементів
} з «{ $from }» в «{ $to }» ({ $progress })...
} з «{ $from }» до «{ $to }» ({ $progress })...
copied =
Скопійовано { $items } { $items ->
[one] елемент
@ -95,7 +95,7 @@ moving =
Переміщення { $items } { $items ->
[one] елемента
*[other] елементів
} з «{ $from }» в «{ $to }» ({ $progress })...
} з «{ $from }» до «{ $to }» ({ $progress })...
moved =
Переміщено { $items } { $items ->
[one] елемент
@ -299,7 +299,7 @@ extracting =
Видобування { $items } { $items ->
[one] елемента
*[other] елементів
} з «{ $from }» в «{ $to }» ({ $progress })...
} з «{ $from }» до «{ $to }» ({ $progress })...
extracted =
Видобуто { $items } { $items ->
[one] елемент
@ -364,10 +364,10 @@ removed-from-recents =
*[other] елементів
} з { recents }
empty-trash-title = Спорожити смітник?
type-to-search-select = Вибирає перший відповідний файл або папку
type-to-search-select = Вибирає перший відповідний файл або теку
pasted-image = Вставлене зображення
pasted-text = Вставлений текст
pasted-video = Вставлене відео
pasted-video = Вставлене видиво
copy-to-button-label = Копіювати
move-to-button-label = Перемістити
copy-to = Копіювати до…
@ -387,3 +387,5 @@ context-action-confirm-warning =
*[other] елементів
}.
context-action = Контекстна дія
rename-confirm = Перейменувати
mixed = Змішано

View file

@ -445,3 +445,4 @@ context-action-confirm-warning =
*[other] 项目
} 上运行。
context-action = 环境行动
rename-confirm = 重命名

View file

@ -392,3 +392,4 @@ context-action-confirm-warning =
*[other] 項目
} 上執行。
run = 執行
rename-confirm = 重新命名

View file

@ -48,7 +48,7 @@ build-debug *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}}
@ -111,7 +111,7 @@ uninstall:
vendor:
#!/usr/bin/env bash
mkdir -p .cargo
cargo vendor --sync Cargo.toml | head -n -1 > .cargo/config.toml
cargo vendor --locked --sync Cargo.toml | head -n -1 > .cargo/config.toml
echo 'directory = "vendor"' >> .cargo/config.toml
echo >> .cargo/config.toml
echo '[env]' >> .cargo/config.toml

View file

@ -7,6 +7,7 @@ use cosmic::iced::clipboard::dnd::DndAction;
use cosmic::iced::core::SmolStr;
use cosmic::iced::core::widget::operation::focusable::unfocus;
use cosmic::iced::futures::{self, SinkExt};
use cosmic::iced::keyboard::key::Physical;
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Modifiers};
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
use cosmic::iced::platform_specific::shell::wayland::commands::overlap_notify::overlap_notify;
@ -41,15 +42,21 @@ use notify_debouncer_full::notify::{self, RecommendedWatcher};
use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
use rustc_hash::{FxHashMap, FxHashSet};
use slotmap::Key as SlotMapKey;
use std::any::TypeId;
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use std::future::Future;
use std::num::NonZeroU16;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{self, Duration, Instant};
use std::{env, fmt, fs, io, process};
#[cfg(feature = "notify")]
use std::sync::Mutex;
use std::{
any::TypeId,
collections::{BTreeMap, BTreeSet, HashMap, VecDeque},
env, fmt, fs,
future::Future,
io,
num::NonZeroU16,
path::{Path, PathBuf},
pin::Pin,
process,
sync::{Arc, LazyLock},
time::{self, Duration, Instant},
};
use tokio::sync::mpsc;
use trash::TrashItem;
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
@ -61,12 +68,12 @@ use crate::clipboard::{
};
use crate::config::{
AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig,
TimeConfig, TypeToSearch,
TimeConfig, ToolbarAction, TypeToSearch, default_toolbar,
};
use crate::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
use crate::key_bind::key_binds;
use crate::localize::LANGUAGE_SORTER;
use crate::mime_app::{self, MimeApp, MimeAppCache};
use crate::mime_app::{self, MimeApp, MimeAppCache, MimeAppMatch};
use crate::mounter::{
MOUNTERS, MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage,
};
@ -127,6 +134,105 @@ pub struct Flags {
pub uris: Vec<url::Url>,
}
/// Yoda phase 3: MIME for the DnD payload carried when a user drags a
/// toolbar row in the Settings editor. A single byte = ToolbarAction
/// discriminant (see `ToolbarAction::to_u8`).
const TOOLBAR_MIME: &str = "application/x-cosmic-files-toolbar-action";
/// Yoda phase 3: DnD payload wrapping a ToolbarAction discriminant.
#[derive(Clone, Debug)]
pub struct ToolbarActionPayload(pub u8);
impl cosmic::iced::clipboard::mime::AsMimeTypes for ToolbarActionPayload {
fn available(&self) -> std::borrow::Cow<'static, [String]> {
std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()])
}
fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
if mime_type == TOOLBAR_MIME {
Some(std::borrow::Cow::Owned(vec![self.0]))
} else {
None
}
}
}
impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ToolbarActionPayload {
fn allowed() -> std::borrow::Cow<'static, [String]> {
std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()])
}
}
impl TryFrom<(Vec<u8>, String)> for ToolbarActionPayload {
type Error = ();
fn try_from((data, _mime): (Vec<u8>, String)) -> Result<Self, Self::Error> {
if data.len() == 1 {
Ok(Self(data[0]))
} else {
Err(())
}
}
}
/// Yoda phase 3 helper: map a ToolbarAction to its button UI (icon name,
/// localized label, app Message). Shared by the toolbar renderer in
/// `view()` and by the Settings page row renderer so the two stay in
/// sync.
fn toolbar_action_ui(a: ToolbarAction) -> (&'static str, String, Message) {
match a {
ToolbarAction::LocationUp => (
"go-up-symbolic",
fl!("parent-directory"),
Action::LocationUp.message(None),
),
ToolbarAction::Reload => (
"view-refresh-symbolic",
fl!("reload-folder"),
Action::Reload.message(None),
),
ToolbarAction::NewFolder => (
"folder-new-symbolic",
fl!("new-folder"),
Action::NewFolder.message(None),
),
ToolbarAction::NewFile => (
"document-new-symbolic",
fl!("new-file"),
Action::NewFile.message(None),
),
ToolbarAction::Rename => (
"pencil-symbolic",
fl!("rename"),
Action::Rename.message(None),
),
ToolbarAction::Delete => (
"edit-delete-symbolic",
fl!("delete"),
Action::Delete.message(None),
),
ToolbarAction::Cut => ("edit-cut-symbolic", fl!("cut"), Action::Cut.message(None)),
ToolbarAction::Copy => (
"edit-copy-symbolic",
fl!("copy"),
Action::Copy.message(None),
),
ToolbarAction::Paste => (
"edit-paste-symbolic",
fl!("paste"),
Action::Paste.message(None),
),
ToolbarAction::ToggleShowHidden => (
"view-reveal-symbolic",
fl!("show-hidden-files"),
Action::ToggleShowHidden.message(None),
),
ToolbarAction::OpenTerminal => (
"utilities-terminal-symbolic",
fl!("open-in-terminal"),
Action::OpenTerminal.message(None),
),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
About,
@ -154,6 +260,8 @@ pub enum Action {
HistoryPrevious,
ItemDown,
ItemLeft,
ItemPageDown,
ItemPageUp,
ItemRight,
ItemUp,
LocationUp,
@ -227,6 +335,8 @@ impl Action {
Self::HistoryPrevious => Message::TabMessage(entity_opt, tab::Message::GoPrevious),
Self::ItemDown => Message::TabMessage(entity_opt, tab::Message::ItemDown),
Self::ItemLeft => Message::TabMessage(entity_opt, tab::Message::ItemLeft),
Self::ItemPageDown => Message::TabMessage(entity_opt, tab::Message::ItemPageDown),
Self::ItemPageUp => Message::TabMessage(entity_opt, tab::Message::ItemPageUp),
Self::ItemRight => Message::TabMessage(entity_opt, tab::Message::ItemRight),
Self::ItemUp => Message::TabMessage(entity_opt, tab::Message::ItemUp),
Self::LocationUp => Message::TabMessage(entity_opt, tab::Message::LocationUp),
@ -355,7 +465,7 @@ pub enum Message {
ExtractToResult(DialogResult),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Focused(window::Id),
Key(window::Id, Modifiers, Key, Option<SmolStr>),
Key(window::Id, Modifiers, Key, Physical, Option<SmolStr>),
LaunchUrl(String),
MaybeExit,
ModifiersChanged(window::Id, Modifiers),
@ -389,6 +499,7 @@ pub enum Message {
OpenWithBrowse,
OpenWithDialog(Option<Entity>),
OpenWithSelection(usize),
OpenWithToggleDefault(bool),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Overlap(window::Id, OverlapNotifyEvent),
Paste(Option<Entity>),
@ -415,6 +526,7 @@ pub enum Message {
PendingPauseAll(bool),
PermanentlyDelete(Option<Entity>),
Preview(Option<Entity>),
ReloadMimeAppCache,
ReorderTab(ReorderEvent),
RescanRecents,
RescanTrash,
@ -429,6 +541,22 @@ pub enum Message {
SearchInput(String),
SetShowDetails(bool),
SetShowRecents(bool),
/// Yoda phase 3 — toolbar editing messages.
ToolbarAdd(ToolbarAction),
ToolbarRemove(ToolbarAction),
ToolbarReorder {
src: ToolbarAction,
target: ToolbarAction,
},
/// Move one step up (toward index 0) inside the enabled toolbar list.
ToolbarMoveUp(ToolbarAction),
/// Move one step down (toward the end) inside the enabled toolbar list.
ToolbarMoveDown(ToolbarAction),
ToolbarReset,
/// Click on a toolbar button (via segmented_button activation).
ToolbarTabActivate(segmented_button::Entity),
/// Drag-reorder inside the toolbar (via segmented_button drag).
ToolbarTabReorder(segmented_button::ReorderEvent),
SetTypeToSearch(TypeToSearch),
SystemThemeModeChange,
Size(window::Id, Size),
@ -558,7 +686,10 @@ pub enum DialogPage {
path: PathBuf,
mime: mime_guess::Mime,
selected: usize,
store_opt: Option<MimeApp>,
store_opt: Option<Arc<MimeApp>>,
/// When true, the chosen app is written to mimeapps.list as the
/// default handler for `mime` after it spawns.
set_default: bool,
},
PermanentlyDelete {
paths: Box<[PathBuf]>,
@ -654,13 +785,6 @@ impl DialogPages {
pub struct FavoriteIndex(usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MimeAppMatch {
Exact,
Related,
Other,
}
pub struct MounterData(MounterKey, MounterItem);
#[derive(Clone, Debug)]
@ -716,6 +840,11 @@ pub struct App {
nav_bar_context_id: segmented_button::Entity,
nav_model: segmented_button::SingleSelectModel,
tab_model: segmented_button::Model<segmented_button::SingleSelect>,
/// Yoda phase 3: segmented_button model mirroring config.toolbar so the
/// toolbar row gets free drag-reorder + click activation (same widget
/// that powers the tab bar, its reorder is proven to work in this
/// setup unlike the generic dnd_source/dnd_destination wrappers).
toolbar_model: segmented_button::Model<segmented_button::SingleSelect>,
config_handler: Option<cosmic_config::Config>,
state_handler: Option<cosmic_config::Config>,
config: Config,
@ -1010,6 +1139,12 @@ impl App {
.and_then(|first| first.as_ref().parent())
.map(Path::to_path_buf)
{
let mut tasks = Vec::new();
if let Some(old_dialog) = self.file_dialog_opt.take() {
let old_id = old_dialog.window_id();
self.windows.remove(&old_id);
tasks.push(window::close(old_id));
}
let (mut dialog, dialog_task) = Dialog::new(
DialogSettings::new()
.kind(DialogKind::OpenFolder)
@ -1026,7 +1161,9 @@ impl App {
))),
);
self.file_dialog_opt = Some(dialog);
Task::batch([set_title_task, dialog_task])
tasks.push(set_title_task);
tasks.push(dialog_task);
Task::batch(tasks)
} else {
Task::none()
}
@ -1071,7 +1208,7 @@ impl App {
.sort_by(|a, b| (b.1.width * b.1.height).total_cmp(&(a.1.width * b.1.height)));
for (w_id, overlap) in sorted_overlaps {
let Some((bl, br, tl, tr, mut size)) = self.layer_sizes.get(w_id).map(|s| {
let Some((bl, br, tl, tr, size)) = self.layer_sizes.get(w_id).map(|s| {
(
Rectangle::new(
Point::new(0., s.height / 2.),
@ -1121,30 +1258,18 @@ impl App {
if tl && !(tr || bl) {
*top += min_dim.1;
*left += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
}
if tr && !(tl || br) {
*top += min_dim.1;
*right += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
}
if bl && !(br || tl) {
*bottom += min_dim.1;
*left += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
}
if br && !(bl || tr) {
*bottom += min_dim.1;
*right += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
}
}
self.margin = overlaps;
@ -1503,12 +1628,18 @@ impl App {
) -> Task<Message> {
log::info!("rescan_tab {entity:?} {location:?} {selection_paths:?}");
let icon_sizes = self.config.tab.icon_sizes;
#[cfg(feature = "gvfs")]
let mounter_items = self.mounter_items.clone();
Task::future(async move {
let location2 = location.clone();
match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await {
Ok((parent_item_opt, mut items)) => {
Ok((parent_item_opt, items)) => {
#[cfg(feature = "gvfs")]
let mut items = items;
#[cfg(not(feature = "gvfs"))]
let items = items;
#[cfg(feature = "gvfs")]
{
let mounter_paths: Box<[_]> = mounter_items
@ -1678,6 +1809,7 @@ impl App {
fn update_config(&mut self) -> Task<Message> {
self.update_nav_model();
self.rebuild_toolbar_model();
// Tabs are collected first to placate the borrowck
let tabs: Box<[_]> = self.tab_model.iter().collect();
// Update main conf and each tab with the new config
@ -1691,6 +1823,52 @@ impl App {
Task::batch(commands)
}
/// Yoda phase 3: rebuild `toolbar_model` so it matches `config.toolbar`.
/// Called on init and on every config update. Each entity carries the
/// associated `ToolbarAction` as data so click/reorder handlers can
/// round-trip Entity → ToolbarAction without maintaining a side map.
///
/// We insert ONLY the icon (no `.text()`) so the toolbar renders as a
/// clean icon row — user-visible labels stay in Settings/tooltips on
/// other surfaces.
fn rebuild_toolbar_model(&mut self) {
self.toolbar_model.clear();
for action in self.config.toolbar.iter().copied() {
let (icon_name, _label, _msg) = toolbar_action_ui(action);
self.toolbar_model
.insert()
.icon(widget::icon::from_name(icon_name).size(16).icon())
.data::<ToolbarAction>(action);
}
}
/// Yoda phase 3: after a drag-reorder, sync `config.toolbar` with the
/// new entity order in `toolbar_model`. Inlines what `config_set!`
/// would do (the macro lives inside update()).
fn sync_toolbar_config_from_model(&mut self) -> Task<Message> {
let new_toolbar: Vec<ToolbarAction> = self
.toolbar_model
.iter()
.filter_map(|e| self.toolbar_model.data::<ToolbarAction>(e).copied())
.collect();
if new_toolbar == self.config.toolbar {
return Task::none();
}
match self.config_handler.as_ref() {
Some(h) => {
if let Err(err) = self.config.set_toolbar(h, new_toolbar) {
log::warn!("failed to save toolbar order: {err}");
}
}
None => self.config.toolbar = new_toolbar,
}
// Don't call update_config() — that would rebuild the
// toolbar_model from config and undo the reorder the user just
// made. The model already has the new order; config is just
// catching up for persistence.
Task::none()
}
fn update_desktop(&mut self) -> Task<Message> {
let needs_reload: Box<[_]> = (self.tab_model.iter())
.filter_map(|entity| {
@ -2268,47 +2446,132 @@ impl App {
.toggler(self.config.show_recents, Message::SetShowRecents)
})
.into(),
// Yoda phase 3: toolbar editor. Two stacked lists:
// - top: enabled buttons in their current order (drag to reorder)
// - bottom: available (not-yet-enabled) buttons
// Each row's toggle adds/removes; enabled rows are also
// drag sources + drop targets.
self.toolbar_settings_section(),
])
.into()
}
fn get_apps_for_mime(&self, mime_type: &Mime) -> Vec<(&MimeApp, MimeAppMatch)> {
let mut results = Vec::new();
/// Yoda phase 3: build the Toolbar settings section.
fn toolbar_settings_section(&self) -> Element<'_, Message> {
use iced::clipboard::dnd::DndAction;
let enabled = &self.config.toolbar;
let disabled: Vec<ToolbarAction> = ToolbarAction::ALL
.iter()
.copied()
.filter(|a| !enabled.contains(a))
.collect();
let mut dedupe = FxHashSet::default();
let space_xxs = theme::active().cosmic().spacing.space_xxs;
// start with exact matches
results.extend(
self.mime_app_cache
.get(mime_type)
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Exact)),
);
let drag_icon = |size: u16| -> Element<'static, Message> {
widget::icon::from_name("list-drag-handle-symbolic")
.size(size)
.into()
};
// grab matches based off of subclass / parent mime type
if let Some(parent_types) = mime_icon::parent_mime_types(mime_type) {
for parent_type in parent_types {
results.extend(
self.mime_app_cache
.get(&parent_type)
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Related)),
);
let row_enabled =
|action: ToolbarAction, pos: usize, last: usize| -> Element<'_, Message> {
let (icon, label, _msg) = toolbar_action_ui(action);
let up_btn =
widget::button::icon(widget::icon::from_name("go-up-symbolic").size(14));
let up_btn = if pos > 0 {
up_btn.on_press(Message::ToolbarMoveUp(action))
} else {
up_btn
};
let down_btn =
widget::button::icon(widget::icon::from_name("go-down-symbolic").size(14));
let down_btn = if pos < last {
down_btn.on_press(Message::ToolbarMoveDown(action))
} else {
down_btn
};
let row_content: Element<_> = widget::row::with_children(vec![
drag_icon(14),
widget::icon::from_name(icon).size(16).into(),
widget::text::body(label).width(Length::Fill).into(),
up_btn.into(),
down_btn.into(),
widget::button::icon(widget::icon::from_name("list-remove-symbolic").size(14))
.on_press(Message::ToolbarRemove(action))
.into(),
])
.spacing(space_xxs)
.align_y(Alignment::Center)
.into();
let row_container = widget::container(row_content)
.width(Length::Fill)
.padding(space_xxs);
// Wrap as DnD source (drags itself) + DnD destination (accepts
// drops from other enabled rows; on drop, move the src before
// this row).
let source = widget::dnd_source::<Message, ToolbarActionPayload>(row_container)
.drag_content(move || ToolbarActionPayload(action.to_u8()));
widget::dnd_destination(source, vec![std::borrow::Cow::Borrowed(TOOLBAR_MIME)])
.data_received_for::<ToolbarActionPayload>(
move |payload: Option<ToolbarActionPayload>| {
match payload.and_then(|p| ToolbarAction::from_u8(p.0)) {
Some(src) if src != action => Message::ToolbarReorder {
src,
target: action,
},
// No-op if payload missing / malformed / same row.
_ => Message::ToolbarReorder {
src: action,
target: action,
},
}
},
)
.action(DndAction::Move)
.into()
};
let row_disabled = |action: ToolbarAction| -> Element<'_, Message> {
let (icon, label, _msg) = toolbar_action_ui(action);
widget::row::with_children(vec![
widget::icon::from_name(icon).size(16).into(),
widget::text::body(label).width(Length::Fill).into(),
widget::button::icon(widget::icon::from_name("list-add-symbolic").size(14))
.on_press(Message::ToolbarAdd(action))
.into(),
])
.spacing(space_xxs)
.align_y(Alignment::Center)
.padding(space_xxs)
.into()
};
let mut section = widget::settings::section().title(fl!("toolbar"));
if enabled.is_empty() {
section = section.add(widget::text::body(fl!("toolbar-empty-hint")));
} else {
let last = enabled.len() - 1;
for (pos, a) in enabled.iter().copied().enumerate() {
section = section.add(row_enabled(a, pos, last));
}
}
// Add other apps
results.extend(
self.mime_app_cache
.apps()
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Other)),
);
results
let mut col = widget::column::with_capacity(3).spacing(space_xxs);
col = col.push(section);
if !disabled.is_empty() {
let mut avail = widget::settings::section().title(fl!("toolbar-available"));
for a in disabled {
avail = avail.add(row_disabled(a));
}
col = col.push(avail);
}
col = col
.push(widget::button::standard(fl!("toolbar-reset")).on_press(Message::ToolbarReset));
col.into()
}
// Update favorites based on renaming or moving dirs.
@ -2443,6 +2706,7 @@ impl Application for App {
nav_bar_context_id: segmented_button::Entity::null(),
nav_model: segmented_button::ModelBuilder::default().build(),
tab_model: segmented_button::ModelBuilder::default().build(),
toolbar_model: segmented_button::ModelBuilder::default().build(),
config_handler: flags.config_handler,
state_handler: flags.state_handler,
config: flags.config,
@ -2553,7 +2817,7 @@ impl Application for App {
.on_middle_press(|entity| {
cosmic::Action::App(Message::NavMenuAction(NavMenuAction::OpenInNewTab(entity)))
})
.context_menu(self.nav_context_menu(self.nav_bar_context_id))
.context_menu(self.nav_context_menu())
.close_icon(icon::from_name("media-eject-symbolic").size(16).icon())
.into_container();
@ -2568,8 +2832,8 @@ impl Application for App {
fn nav_context_menu(
&self,
entity: widget::nav_bar::Id,
) -> Option<Vec<widget::menu::Tree<cosmic::Action<Self::Message>>>> {
let entity = self.nav_bar_context_id;
let favorite_index_opt = self.nav_model.data::<FavoriteIndex>(entity);
let location_opt = self.nav_model.data::<Location>(entity);
@ -3222,9 +3486,10 @@ impl Application for App {
path,
mime,
selected,
set_default,
..
} => {
let available_apps = self.get_apps_for_mime(&mime);
let available_apps = self.mime_app_cache.get_apps_for_mime(&mime, true);
if let Some((app, _)) = available_apps.get(selected) {
if let Some(mut command) =
@ -3240,6 +3505,11 @@ impl Application for App {
None,
);
}
// Yoda: persist as default if the user asked for it.
if set_default {
self.mime_app_cache
.set_default(mime.clone(), app.id.clone());
}
}
Err(err) => {
log::warn!(
@ -3351,7 +3621,7 @@ impl Application for App {
return dialog.update(dialog_message);
}
}
Message::Key(window_id, modifiers, key, text) => {
Message::Key(window_id, modifiers, key, physical_key, text) => {
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
let in_surface_ids = self.surface_ids.values().any(|id| *id == window_id);
#[cfg(not(all(feature = "wayland", feature = "desktop-applet")))]
@ -3359,7 +3629,7 @@ impl Application for App {
if self.core.main_window_id() == Some(window_id) || in_surface_ids {
let entity = self.tab_model.active();
for (key_bind, action) in &self.key_binds {
if key_bind.matches(modifiers, &key) {
if key_bind.matches(modifiers, &key, Some(&physical_key)) {
return self.update(action.message(Some(entity)));
}
}
@ -3862,6 +4132,7 @@ impl Application for App {
.and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned()
}),
set_default: false,
},
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
);
@ -3873,6 +4144,13 @@ impl Application for App {
*selected = index;
}
}
Message::OpenWithToggleDefault(enabled) => {
if let Some(DialogPage::OpenWith { set_default, .. }) =
self.dialog_pages.front_mut()
{
*set_default = enabled;
}
}
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)
@ -4219,6 +4497,9 @@ impl Application for App {
let paths: Box<[_]> = self.selected_paths(entity_opt).collect();
return self.operation(Operation::RemoveFromRecents { paths });
}
Message::ReloadMimeAppCache => {
self.mime_app_cache.reload();
}
Message::RescanRecents => {
return self.rescan_recents();
}
@ -4349,6 +4630,86 @@ impl Application for App {
config_set!(show_recents, show_recents);
return self.update_config();
}
Message::ToolbarAdd(action) => {
let mut tb = self.config.toolbar.clone();
if !tb.contains(&action) {
tb.push(action);
}
config_set!(toolbar, tb);
return self.update_config();
}
Message::ToolbarRemove(action) => {
let mut tb = self.config.toolbar.clone();
tb.retain(|a| a != &action);
config_set!(toolbar, tb);
return self.update_config();
}
Message::ToolbarReorder { src, target } => {
let mut tb = self.config.toolbar.clone();
if let (Some(src_idx), Some(tgt_idx)) = (
tb.iter().position(|a| a == &src),
tb.iter().position(|a| a == &target),
) && src_idx != tgt_idx
{
// Pull src out, then insert before the target's new position.
let item = tb.remove(src_idx);
let new_tgt = if src_idx < tgt_idx {
tgt_idx - 1
} else {
tgt_idx
};
tb.insert(new_tgt, item);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarMoveUp(action) => {
let mut tb = self.config.toolbar.clone();
if let Some(i) = tb.iter().position(|a| a == &action)
&& i > 0
{
tb.swap(i, i - 1);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarMoveDown(action) => {
let mut tb = self.config.toolbar.clone();
if let Some(i) = tb.iter().position(|a| a == &action)
&& i + 1 < tb.len()
{
tb.swap(i, i + 1);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarReset => {
config_set!(toolbar, default_toolbar());
return self.update_config();
}
Message::ToolbarTabActivate(entity) => {
// Dispatch the stored ToolbarAction's message, then clear
// the "active" selection so the button doesn't stay
// highlighted after a click (we use segmented_button for
// layout/drag but toolbar buttons are action-firing, not
// a mutual-exclusive choice).
let action = self.toolbar_model.data::<ToolbarAction>(entity).copied();
self.toolbar_model.deactivate();
if let Some(action) = action {
let (_, _, msg) = toolbar_action_ui(action);
return self.update(msg);
}
return Task::none();
}
Message::ToolbarTabReorder(event) => {
let _ = self
.toolbar_model
.reorder(event.dragged, event.target, event.position);
return self.sync_toolbar_config_from_model();
}
Message::SetTypeToSearch(type_to_search) => {
config_set!(type_to_search, type_to_search);
return self.update_config();
@ -4524,6 +4885,7 @@ impl Application for App {
commands.push(self.update(Message::CheckClipboard));
commands.push(self.update(Message::Surface(
cosmic::surface::action::app_popup(
|_: &Self| cosmic::surface::action::LiveSettings::default(),
move |app: &mut Self| -> SctkPopupSettings {
let anchor_rect = Rectangle {
x: point.x as i32,
@ -5108,6 +5470,7 @@ impl Application for App {
.and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned()
}),
set_default: false,
},
None,
);
@ -5951,7 +6314,7 @@ impl Application for App {
mime,
selected,
store_opt,
..
set_default,
} => {
let name = match path.file_name() {
Some(file_name) => file_name.to_str(),
@ -5959,7 +6322,7 @@ impl Application for App {
};
let mut column = widget::list_column();
let available_apps = self.get_apps_for_mime(mime);
let available_apps = self.mime_app_cache.get_apps_for_mime(mime, true);
let item_height = 32.0;
let mut displayed_default = false;
let mut last_kind = MimeAppMatch::Exact;
@ -5980,8 +6343,8 @@ impl Application for App {
widget::mouse_area(
widget::button::custom(
widget::row::with_children([
icon(app.icon.clone()).size(32).into(),
if app.is_default && !displayed_default {
icon(app.icon()).size(32).into(),
if app.is_default(mime) && !displayed_default {
displayed_default = true;
widget::text::body(fl!(
"default-app",
@ -6036,7 +6399,21 @@ impl Application for App {
} else {
Length::Shrink
}
}));
}))
// Yoda: let the user make this choice stick. A plain row
// instead of settings::item::builder because the latter
// returns a section Item, not an Element usable in .control().
.control(
widget::row::with_children([
widget::text::body(fl!("open-with-set-default")).into(),
widget::space::horizontal().into(),
widget::toggler(*set_default)
.on_toggle(Message::OpenWithToggleDefault)
.into(),
])
.spacing(space_s)
.align_y(Alignment::Center),
);
if let Some(app) = store_opt {
dialog = dialog.tertiary_action(
@ -6442,7 +6819,10 @@ impl Application for App {
/// Creates a view after each update.
fn view(&self) -> Element<'_, Self::Message> {
let cosmic_theme::Spacing {
space_xxs, space_s, ..
space_xxs,
space_xs,
space_s,
..
} = theme::spacing();
let mut tab_column = widget::column::with_capacity(4);
@ -6486,6 +6866,36 @@ impl Application for App {
);
}
// Yoda phase 3: Dolphin-style quick actions toolbar via
// segmented_button::horizontal — the same widget that powers the
// tab bar, so its built-in drag reorder works reliably (unlike the
// generic dnd_source+dnd_destination pairing we tried earlier).
// Short click = action (ToolbarTabActivate → dispatch the stored
// ToolbarAction's message). Drag past threshold = reorder
// (ToolbarTabReorder → model.reorder + sync to config).
if !self.config.toolbar.is_empty() {
// Use Control style (no TabBar underline, no bottom border)
// and let each button shrink to its icon. Spacing = space_xs
// keeps the buttons visually separated so it looks like an
// icon toolbar rather than a conjoined segmented control.
let toolbar = widget::segmented_button::horizontal(&self.toolbar_model)
.style(theme::SegmentedButton::Control)
.button_height(32)
.button_spacing(space_xs)
.button_alignment(Alignment::Center)
.minimum_button_width(32)
.maximum_button_width(32)
.enable_tab_drag(String::from("x-cosmic-files/toolbar-dnd"))
.on_reorder(Message::ToolbarTabReorder)
.tab_drag_threshold(8.)
.on_activate(Message::ToolbarTabActivate);
tab_column = tab_column.push(
widget::container(toolbar)
.width(Length::Shrink)
.padding([space_xxs, space_s]),
);
}
let entity = self.tab_model.active();
if let Some(tab) = self.tab_model.data::<Tab>(entity) {
let tab_view = tab
@ -6633,11 +7043,14 @@ impl Application for App {
},
Event::Keyboard(KeyEvent::KeyPressed {
key,
physical_key,
modifiers,
text,
..
}) => match status {
event::Status::Ignored => Some(Message::Key(window_id, modifiers, key, text)),
event::Status::Ignored => {
Some(Message::Key(window_id, modifiers, key, physical_key, text))
}
event::Status::Captured => None,
},
Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => {
@ -6769,6 +7182,22 @@ impl Application for App {
},
)
}),
#[cfg(feature = "desktop")]
Subscription::run(|| {
stream::channel(
1,
|mut output: futures::channel::mpsc::Sender<Message>| async move {
mime_app::watch(move || {
futures::executor::block_on(async {
_ = output.send(Message::ReloadMimeAppCache).await;
})
})
.await;
std::future::pending().await
},
)
}),
Subscription::run_with(TypeId::of::<TrashWatcherSubscription>(), |_| {
stream::channel(
1,

View file

@ -1,5 +1,5 @@
use crate::mime_icon::mime_for_path;
use crate::operation::{Controller, OpReader, OperationError, OperationErrorType, sync_to_disk};
use crate::operation::{Controller, OpReader, OperationError, OperationErrorType};
use cosmic::iced::futures;
use jiff::Zoned;
use jiff::civil::DateTime;
@ -45,7 +45,7 @@ pub fn extract(
new_dir: &Path,
password: &Option<String>,
controller: &Controller,
) -> Result<(), OperationError> {
) -> Result<(Vec<PathBuf>, HashSet<PathBuf>), OperationError> {
let mime = mime_for_path(path, None, false);
let password = password.as_deref();
match mime.essence_str() {
@ -55,13 +55,15 @@ pub fn extract(
.map(flate2::read::GzDecoder::new)
.map(tar::Archive::new)
.and_then(|mut archive| archive.unpack(new_dir))
.map_err(|e| OperationError::from_err(e, controller))?;
.map_err(|e| OperationError::from_err(e, controller))
.map(|_| Default::default())
}
"application/x-tar" => OpReader::new(path, controller.clone())
.map(io::BufReader::new)
.map(tar::Archive::new)
.and_then(|mut archive| archive.unpack(new_dir))
.map_err(|e| OperationError::from_err(e, controller))?,
.map_err(|e| OperationError::from_err(e, controller))
.map(|_| Default::default()),
"application/zip" => fs::File::open(path)
.map(io::BufReader::new)
.map(zip::ZipArchive::new)
@ -75,7 +77,7 @@ pub fn extract(
OperationError::from_kind(OperationErrorType::PasswordRequired, controller)
}
_ => OperationError::from_err(e, controller),
})?,
}),
#[cfg(feature = "bzip2")]
"application/x-bzip"
| "application/x-bzip-compressed-tar"
@ -85,7 +87,8 @@ pub fn extract(
.map(bzip2::read::BzDecoder::new)
.map(tar::Archive::new)
.and_then(|mut archive| archive.unpack(new_dir))
.map_err(|e| OperationError::from_err(e, controller))?,
.map_err(|e| OperationError::from_err(e, controller))
.map(|_| Default::default()),
#[cfg(feature = "lzma-rust2")]
"application/x-xz" | "application/x-xz-compressed-tar" => {
OpReader::new(path, controller.clone())
@ -93,14 +96,14 @@ pub fn extract(
.map(|reader| lzma_rust2::XzReader::new(reader, true))
.map(tar::Archive::new)
.and_then(|mut archive| archive.unpack(new_dir))
.map_err(|e| OperationError::from_err(e, controller))?;
.map_err(|e| OperationError::from_err(e, controller))
.map(|_| Default::default())
}
_ => Err(OperationError::from_err(
format!("unsupported mime type {mime:?}"),
controller,
))?,
)),
}
Ok(())
}
// From https://docs.rs/zip/latest/zip/read/struct.ZipArchive.html#method.extract, with cancellation and progress added
@ -109,7 +112,7 @@ fn zip_extract<R: io::Read + io::Seek, P: AsRef<Path>>(
directory: P,
password: Option<&str>,
controller: Controller,
) -> zip::result::ZipResult<()> {
) -> zip::result::ZipResult<(Vec<PathBuf>, HashSet<PathBuf>)> {
use std::ffi::OsString;
use std::fs;
use zip::result::ZipError;
@ -282,10 +285,7 @@ fn zip_extract<R: io::Read + io::Seek, P: AsRef<Path>>(
}
}
// Flush files to disk
futures::executor::block_on(async { sync_to_disk(written_files, target_dirs).await });
Ok(())
Ok((written_files, target_dirs))
}
fn zip_date_time_to_system_time(date_time: zip::DateTime) -> Option<SystemTime> {

View file

@ -130,9 +130,11 @@ impl TryFrom<(Vec<u8>, String)> for ClipboardPaste {
match mime.as_str() {
"text/uri-list" => {
let text = str::from_utf8(&data)?;
let _lines = text.lines();
for line in text.lines() {
for line in text.lines().filter(|line| {
let line = line.trim();
!line.is_empty() && !line.starts_with('#')
}) {
let url = Url::parse(line)?;
match url.to_file_path() {
Ok(path) => paths.push(path),

View file

@ -170,6 +170,11 @@ pub struct Config {
pub show_details: bool,
pub show_recents: bool,
pub tab: TabConfig,
/// Yoda phase 3: Dolphin-style quick actions toolbar. An ordered list
/// of enabled buttons — position in the vec drives the toolbar order.
/// Reorder in Settings via drag-drop; items not in the vec are
/// hidden. Default = the minimal-6 set from phase 1.
pub toolbar: Vec<ToolbarAction>,
pub type_to_search: TypeToSearch,
}
@ -234,11 +239,97 @@ impl Default for Config {
show_details: false,
show_recents: true,
tab: TabConfig::default(),
toolbar: default_toolbar(),
type_to_search: TypeToSearch::Recursive,
}
}
}
/// Yoda phase 3: ordered enum of quick-action toolbar buttons.
/// The Config stores `Vec<ToolbarAction>` so the user can pick BOTH
/// visibility (just include/exclude the variant) AND order (position in
/// the vec). Drag-drop reorder in the Settings page moves items around.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub enum ToolbarAction {
LocationUp,
Reload,
NewFolder,
NewFile,
Rename,
Delete,
Cut,
Copy,
Paste,
ToggleShowHidden,
OpenTerminal,
}
impl ToolbarAction {
/// Stable list of every supported action. Ordered roughly by logical
/// grouping (location → create/edit → clipboard → view/misc) so that
/// the default enabled set follows a sensible shape and the Settings
/// row for a not-yet-enabled action lands in a predictable spot.
pub const ALL: &'static [Self] = &[
Self::LocationUp,
Self::Reload,
Self::NewFolder,
Self::NewFile,
Self::Rename,
Self::Delete,
Self::Cut,
Self::Copy,
Self::Paste,
Self::ToggleShowHidden,
Self::OpenTerminal,
];
/// u8 discriminant used to carry the action over a DnD mime payload.
pub const fn to_u8(self) -> u8 {
match self {
Self::LocationUp => 0,
Self::Reload => 1,
Self::NewFolder => 2,
Self::NewFile => 3,
Self::Rename => 4,
Self::Delete => 5,
Self::Cut => 6,
Self::Copy => 7,
Self::Paste => 8,
Self::ToggleShowHidden => 9,
Self::OpenTerminal => 10,
}
}
pub const fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::LocationUp),
1 => Some(Self::Reload),
2 => Some(Self::NewFolder),
3 => Some(Self::NewFile),
4 => Some(Self::Rename),
5 => Some(Self::Delete),
6 => Some(Self::Cut),
7 => Some(Self::Copy),
8 => Some(Self::Paste),
9 => Some(Self::ToggleShowHidden),
10 => Some(Self::OpenTerminal),
_ => None,
}
}
}
/// Default set shown on a fresh install — same "minimal 6" as phase 1/2.
pub fn default_toolbar() -> Vec<ToolbarAction> {
vec![
ToolbarAction::NewFolder,
ToolbarAction::Rename,
ToolbarAction::Delete,
ToolbarAction::Cut,
ToolbarAction::Copy,
ToolbarAction::Paste,
]
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct DesktopConfig {

View file

@ -6,7 +6,7 @@ use cosmic::app::{Core, Task, context_drawer};
use cosmic::iced::core::SmolStr;
use cosmic::iced::core::widget::operation;
use cosmic::iced::futures::{self, SinkExt};
use cosmic::iced::keyboard::key::Named;
use cosmic::iced::keyboard::key::{Named, Physical};
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Modifiers};
use cosmic::iced::platform_specific::shell::{self as iced_winit, SurfaceIdWrapper};
use cosmic::iced::widget::scrollable;
@ -40,7 +40,7 @@ use crate::localize::LANGUAGE_SORTER;
use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage};
use crate::tab::{self, ItemMetadata, Location, SearchLocation, Tab};
use crate::zoom::{zoom_in_view, zoom_out_view, zoom_to_default};
use crate::{fl, home_dir, menu};
use crate::{fl, home_dir, menu, mime_icon};
#[derive(Clone, Debug)]
pub struct DialogMessage(cosmic::Action<Message>);
@ -456,7 +456,7 @@ enum Message {
Escape,
Filename(String),
Filter(usize),
Key(Modifiers, Key, Option<SmolStr>),
Key(Modifiers, Key, Physical, Option<SmolStr>),
ModifiersChanged(Modifiers),
MounterItems(MounterKey, MounterItems),
Mouse(window::Id, mouse::Button),
@ -732,11 +732,17 @@ impl App {
fn rescan_tab(&self, selection_paths: Option<Vec<PathBuf>>) -> Task<Message> {
let location = self.tab.location.clone();
let icon_sizes = self.tab.config.icon_sizes;
#[cfg(feature = "gvfs")]
let mounter_items = self.mounter_items.clone();
Task::future(async move {
let location2 = location.clone();
match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await {
Ok((parent_item_opt, mut items)) => {
Ok((parent_item_opt, items)) => {
#[cfg(feature = "gvfs")]
let mut items = items;
#[cfg(not(feature = "gvfs"))]
let items = items;
#[cfg(feature = "gvfs")]
{
let mounter_paths: Box<[_]> = mounter_items
@ -1450,16 +1456,16 @@ impl Application for App {
}
return self.rescan_tab(None);
}
Message::Key(modifiers, key, text) => {
Message::Key(modifiers, key, physical_key, text) => {
for (key_bind, action) in &self.key_binds {
if key_bind.matches(modifiers, &key) {
if key_bind.matches(modifiers, &key, Some(&physical_key)) {
return self.update(Message::from(action.message()));
}
}
// Check key binds from accept label
if let Some(key_bind) = &self.accept_label.key_bind_opt
&& key_bind.matches(modifiers, &key)
&& key_bind.matches(modifiers, &key, Some(&physical_key))
{
return self.update(if self.flags.kind.save() {
Message::Save(false)
@ -1795,6 +1801,7 @@ impl Application for App {
let autosize_id = widget::Id::unique();
commands.push(self.update(Message::Surface(
cosmic::surface::action::app_popup(
|_: &Self| cosmic::surface::action::LiveSettings::default(),
move |app: &mut Self| -> SctkPopupSettings {
let anchor_rect = Rectangle {
x: point.x as i32,
@ -1919,11 +1926,13 @@ impl Application for App {
items.retain(|item| {
// Directories are always shown
item.metadata.is_dir()
// Check for mime type match (first because it is faster)
|| mimes.iter().any(|filter_mime| {
if filter_mime.subtype() == mime::STAR {
filter_mime.type_() == item.mime.type_()
} else {
*filter_mime == item.mime
|| mime_icon::is_mime_subclass_of(&item.mime, filter_mime)
}
})
// Check for glob match (last because it is slower)
@ -2050,11 +2059,14 @@ impl Application for App {
},
Event::Keyboard(KeyEvent::KeyPressed {
key,
physical_key,
modifiers,
text,
..
}) => match status {
event::Status::Ignored => Some(Message::Key(modifiers, key, text)),
event::Status::Ignored => {
Some(Message::Key(modifiers, key, physical_key, text))
}
event::Status::Captured => {
if key == Key::Named(Named::Escape) {
Some(Message::Escape)

View file

@ -30,12 +30,16 @@ pub fn key_binds(mode: &tab::Mode) -> HashMap<KeyBind, Action> {
bind!([], Key::Named(Named::F5), Reload);
bind!([], Key::Named(Named::Home), SelectFirst);
bind!([], Key::Named(Named::End), SelectLast);
bind!([], Key::Named(Named::PageDown), ItemPageDown);
bind!([], Key::Named(Named::PageUp), ItemPageUp);
bind!([Shift], Key::Named(Named::ArrowDown), ItemDown);
bind!([Shift], Key::Named(Named::ArrowLeft), ItemLeft);
bind!([Shift], Key::Named(Named::ArrowRight), ItemRight);
bind!([Shift], Key::Named(Named::ArrowUp), ItemUp);
bind!([Shift], Key::Named(Named::Home), SelectFirst);
bind!([Shift], Key::Named(Named::End), SelectLast);
bind!([Shift], Key::Named(Named::PageDown), ItemPageDown);
bind!([Shift], Key::Named(Named::PageUp), ItemPageUp);
bind!([Ctrl, Shift], Key::Character("n".into()), NewFolder);
bind!([], Key::Named(Named::Enter), Open);
bind!([Ctrl], Key::Character(" ".into()), Preview);

View file

@ -38,6 +38,7 @@ mod zoom;
pub(crate) type FxOrderMap<K, V> = ordermap::OrderMap<K, V, rustc_hash::FxBuildHasher>;
#[cfg(feature = "gvfs")]
pub(crate) fn err_str<T: ToString>(err: T) -> String {
err.to_string()
}

View file

@ -10,6 +10,7 @@ use cosmic::widget::{
self, Row, button, column, container, divider, responsive_menu_bar, space, text,
};
use cosmic::{Element, theme};
#[cfg(feature = "desktop")]
use i18n_embed::LanguageLoader;
use mime_guess::Mime;
use std::collections::HashMap;
@ -18,7 +19,9 @@ use std::sync::LazyLock;
use crate::app::{Action, Message};
use crate::config::{Config, ContextActionPreset};
use crate::fl;
use crate::tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab};
use crate::tab::{
self, HeadingOptions, ItemMetadata, Location, LocationMenuAction, SearchLocation, Tab,
};
use crate::trash::{Trash, TrashExt};
static MENU_ID: LazyLock<cosmic::widget::Id> =
@ -67,17 +70,19 @@ pub fn context_menu<'a>(
String::new()
};
fn key_style(theme: &cosmic::Theme) -> TextStyle {
let mut color = theme.cosmic().background.component.on;
let mut color = theme.cosmic().background(false).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.component.on;
let mut color = theme.cosmic().background(false).component.on;
color.alpha *= 0.5;
TextStyle {
color: Some(color.into()),
..Default::default()
}
}
@ -105,18 +110,25 @@ pub fn context_menu<'a>(
let (sort_name, sort_direction, _) = tab.sort_options();
let sort_item = |label, variant| {
menu_item(
format!(
"{} {}",
label,
match (sort_name == variant, sort_direction) {
(true, true) => "\u{2B07}",
(true, false) => "\u{2B06}",
_ => "",
}
),
Action::ToggleSort(variant),
let key = find_key(&Action::ToggleSort(variant));
let leading: Element<'a, tab::Message> = 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()
} 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()
};
@ -126,6 +138,7 @@ pub fn context_menu<'a>(
let mut selected_desktop_entry = None;
let mut selected_types: Vec<Mime> = vec![];
let mut selected_mount_point = 0;
let mut any_trash_item = false;
if let Some(items) = tab.items_opt() {
for item in items {
if item.selected {
@ -146,6 +159,9 @@ pub fn context_menu<'a>(
}
_ => (),
}
if matches!(&item.metadata, ItemMetadata::Trash { .. }) {
any_trash_item = true;
}
selected_types.push(item.mime.clone());
}
}
@ -190,11 +206,11 @@ pub fn context_menu<'a>(
if !Trash::is_empty() {
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into());
}
} else if let Some(entry) = selected_desktop_entry {
} else if let Some(_entry) = selected_desktop_entry {
children.push(menu_item(fl!("open"), Action::Open).into());
#[cfg(feature = "desktop")]
{
children.extend(entry.desktop_actions.into_iter().enumerate().map(
children.extend(_entry.desktop_actions.into_iter().enumerate().map(
|(i, action)| menu_item(action.name, Action::ExecEntryAction(i)).into(),
));
}
@ -267,27 +283,38 @@ pub fn context_menu<'a>(
//TODO: Print?
children.push(menu_item(fl!("show-details"), Action::Preview).into());
if matches!(tab.mode, tab::Mode::App) {
if any_trash_item {
children.push(divider::horizontal::light().into());
children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
}
children.push(divider::horizontal::light().into());
if tab.location.is_recents() {
children.push(
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into(),
);
children.push(divider::horizontal::light().into());
}
if selected_mount_point == 0 {
if modifiers.shift() && !modifiers.control() {
children.push(
menu_item(fl!("delete-permanently"), Action::PermanentlyDelete).into(),
);
} else {
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
children.push(menu_item(fl!("delete-permanently"), Action::Delete).into());
} 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(divider::horizontal::light().into());
if tab.location.is_recents() {
children.push(
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
);
children.push(divider::horizontal::light().into());
}
if selected_mount_point == 0 {
if modifiers.shift() && !modifiers.control() {
children.push(
menu_item(fl!("delete-permanently"), Action::PermanentlyDelete)
.into(),
);
} else {
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
}
} else if selected == 1 {
children.push(menu_item(fl!("eject"), Action::Eject).into());
}
} else if selected == 1 {
children.push(menu_item(fl!("eject"), Action::Eject).into());
}
} else {
//TODO: need better designs for menu with no selection
@ -415,7 +442,7 @@ pub fn context_menu<'a>(
//TODO: move style to libcosmic
.style(|theme| {
let cosmic = theme.cosmic();
let component = &cosmic.background.component;
let component = &cosmic.background(false).component;
container::Style {
icon_color: Some(component.on.into()),
text_color: Some(component.on.into()),
@ -809,7 +836,7 @@ pub fn location_context_menu<'a>(ancestor_index: usize) -> Element<'a, tab::Mess
.padding(1)
.style(|theme| {
let cosmic = theme.cosmic();
let component = &cosmic.background.component;
let component = &cosmic.background(false).component;
container::Style {
icon_color: Some(component.on.into()),
text_color: Some(component.on.into()),

View file

@ -2,18 +2,68 @@
// SPDX-License-Identifier: GPL-3.0-only
use bstr::{BString, ByteSlice, ByteVec};
#[cfg(feature = "desktop")]
use cosmic::desktop;
use cosmic::widget;
pub use mime_guess::Mime;
use rustc_hash::FxHashMap;
use std::cmp::Ordering;
#[cfg(feature = "desktop")]
use notify_debouncer_full::notify;
use rustc_hash::{FxHashMap, FxHashSet};
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::time::Instant;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, RwLock, atomic};
use std::time::{self, Instant};
use std::{fs, io, process};
#[cfg(feature = "desktop")]
pub async fn watch(mut emitter: impl FnMut() + 'static + Send) {
let watcher_result = notify_debouncer_full::new_debouncer(
time::Duration::from_millis(250),
Some(time::Duration::from_millis(250)),
move |event_res: notify_debouncer_full::DebounceEventResult| {
let Ok(events) = event_res else {
return;
};
if events.iter().any(|event| {
event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()
}) {
emitter();
}
},
);
if let Ok(mut watcher) = watcher_result {
let system_paths = cosmic_mime_apps::list_paths();
let local_paths = (|| {
let base_dirs = xdg::BaseDirectories::new();
let Some(home) = base_dirs.get_config_home() else {
return Err(std::io::Error::other("XDG config home not set"));
};
let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") else {
return Err(std::io::Error::other("XDG_CURRENT_DESKTOP unset"));
};
let default_mimeapps = home.join("mimeapps.list");
let desktop_mimeapps =
home.join([&desktop.to_ascii_lowercase(), "-mimeapps.list"].concat());
Ok([desktop_mimeapps, default_mimeapps])
})()
.ok();
for path in system_paths
.iter()
.chain(local_paths.as_ref().into_iter().flatten())
{
_ = watcher.watch(path.as_path(), notify::RecursiveMode::NonRecursive);
}
std::future::pending().await
}
}
pub fn exec_to_command(
exec: &str,
entry_name: &str,
@ -126,14 +176,23 @@ pub fn exec_to_command(
Some(commands)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MimeAppMatch {
Exact,
Related,
Other,
}
#[derive(Clone, Debug)]
pub struct MimeApp {
pub id: String,
pub path: Option<PathBuf>,
pub name: String,
pub exec: Option<String>,
pub icon: widget::icon::Handle,
pub is_default: bool,
icon_name: Box<str>,
icon: std::sync::OnceLock<widget::icon::Handle>,
is_default: Arc<RwLock<FxHashSet<Box<str>>>>,
no_display: Arc<AtomicBool>,
}
impl MimeApp {
@ -146,6 +205,27 @@ impl MimeApp {
path_opt,
)
}
pub fn is_default(&self, mime: &Mime) -> bool {
self.is_default.read().unwrap().contains(mime.essence_str())
}
pub fn no_display(&self) -> bool {
self.no_display.load(atomic::Ordering::Relaxed)
}
pub fn icon(&self) -> widget::icon::Handle {
self.icon
.get_or_init(|| {
let name = &*self.icon_name;
if name.starts_with('/') {
cosmic::widget::icon::from_path(PathBuf::from(name))
} else {
cosmic::widget::icon::from_name(name).size(32).handle()
}
})
.clone()
}
}
// This allows usage of MimeApp in a dropdown
@ -155,38 +235,10 @@ impl AsRef<str> for MimeApp {
}
}
#[cfg(feature = "desktop")]
impl From<&desktop::DesktopEntryData> for MimeApp {
fn from(app: &desktop::DesktopEntryData) -> Self {
Self {
id: app.id.clone(),
path: app.path.clone(),
name: app.name.clone(),
exec: app.exec.clone(),
icon: match &app.icon {
desktop::fde::IconSource::Name(name) => {
widget::icon::from_name(name.as_str()).size(32).handle()
}
desktop::fde::IconSource::Path(path) => widget::icon::from_path(path.clone()),
},
is_default: false,
}
}
}
#[cfg(feature = "desktop")]
fn filename_eq(path_opt: &Option<PathBuf>, filename: &str) -> bool {
path_opt
.as_ref()
.and_then(|path| path.file_name())
.is_some_and(|x| x == filename)
}
pub struct MimeAppCache {
apps: Vec<MimeApp>,
cache: FxHashMap<Mime, Vec<MimeApp>>,
icons: FxHashMap<Mime, Box<[widget::icon::Handle]>>,
terminals: Vec<MimeApp>,
apps: Vec<Arc<MimeApp>>,
cache: FxHashMap<Mime, Vec<Arc<MimeApp>>>,
terminals: Vec<Arc<MimeApp>>,
}
impl MimeAppCache {
@ -194,154 +246,273 @@ impl MimeAppCache {
let mut mime_app_cache = Self {
apps: Vec::new(),
cache: FxHashMap::default(),
icons: FxHashMap::default(),
terminals: Vec::new(),
};
mime_app_cache.reload();
mime_app_cache
}
pub fn get_apps_for_mime(
&self,
mime_type: &Mime,
include_other: bool,
) -> Vec<(&Arc<MimeApp>, MimeAppMatch)> {
let mut results = Vec::new();
let mut dedupe = FxHashSet::default();
// start with exact matches
results.extend(
self.get(mime_type)
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Exact)),
);
let include_mime = match mime_type.type_().as_str() {
"audio" => Some("video/mp4".parse::<Mime>().expect("video/mp4 mime")),
"text" => Some(mime_guess::mime::TEXT_PLAIN),
_ => None,
};
if let Some(mime) = include_mime {
results.extend(
self.get(&mime)
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Exact)),
);
}
// grab matches based off of subclass / parent mime type
if let Some(parent_types) = crate::mime_icon::parent_mime_types(mime_type) {
for parent_type in parent_types {
results.extend(
self.get(&parent_type)
.iter()
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Related)),
);
}
}
if include_other {
results.extend({
let mut apps = self
.apps()
.iter()
.filter(|mime_app| !mime_app.no_display())
.filter(|&mime_app| dedupe.insert(&mime_app.id))
.map(|mime_app| (mime_app, MimeAppMatch::Other))
.collect::<Vec<_>>();
apps.sort_by(|(a, _), (b, _)| {
crate::localize::LANGUAGE_SORTER.compare(&a.name, &b.name)
});
apps
});
}
results
}
#[cfg(not(feature = "desktop"))]
pub fn reload(&mut self) {}
// Only available when using desktop feature of libcosmic, which only works on Unix-likes
/// Reload mime types and their known app associations and defaults.
#[cfg(feature = "desktop")]
pub fn reload(&mut self) {
use crate::localize::LANGUAGE_SORTER;
use crate::mime_icon;
use cosmic::desktop::fde;
use std::borrow::Cow;
let start = Instant::now();
self.apps.clear();
self.cache.clear();
self.icons.clear();
self.terminals.clear();
//TODO: get proper locale?
let locale = &[];
// Load desktop applications by supported mime types
//TODO: hashmap for all apps by id?
let all_apps: Box<[_]> = desktop::load_applications(locale, false, None).collect();
for app in &all_apps {
//TODO: just collect apps that can be executed with a file argument?
if !app.mime_types.is_empty() {
self.apps.push(MimeApp::from(app));
}
for mime in &app.mime_types {
let apps = self
.cache
.entry(mime.clone())
.or_insert_with(|| Vec::with_capacity(1));
if !apps.iter().any(|x| x.id == app.id) {
apps.push(MimeApp::from(app));
}
}
for category in &app.categories {
if category == "TerminalEmulator" {
self.terminals.push(MimeApp::from(app));
break;
}
}
}
let mut list = cosmic_mime_apps::List::default();
let paths = cosmic_mime_apps::list_paths();
list.load_from_paths(&paths);
let locales = fde::get_languages_from_env();
let desktop_entries = fde::Iter::new(fde::default_paths()).entries(Some(&locales));
let mime_icon_cache = mime_icon::MIME_ICON_CACHE.lock().unwrap();
let shared_mime_info = &mime_icon_cache.shared_mime_info;
let mut aliased_mimes = FxHashMap::default();
for (mime, filenames) in list
.added_associations
.iter()
.chain(list.default_apps.iter())
{
for filename in filenames {
log::trace!("add {mime}={filename}");
let apps = self
.cache
.entry(mime.clone())
.or_insert_with(|| Vec::with_capacity(1));
if !apps.iter().any(|x| filename_eq(&x.path, filename)) {
if let Some(app) = all_apps.iter().find(|&x| filename_eq(&x.path, filename)) {
apps.push(MimeApp::from(app));
} else {
log::info!(
"failed to add association for {mime:?}: application {filename:?} not found"
);
for desktop_entry in desktop_entries {
let name = desktop_entry
.name(&locales)
.unwrap_or_else(|| Cow::Borrowed(desktop_entry.id()));
let app = Arc::new(MimeApp {
id: desktop_entry.appid.clone(),
path: Some(desktop_entry.path.clone()),
name: name.into(),
exec: desktop_entry.exec().map(String::from),
icon_name: desktop_entry.icon().unwrap_or_default().into(),
icon: std::sync::OnceLock::new(),
is_default: Arc::new(RwLock::default()),
no_display: Arc::new(AtomicBool::new(false)),
});
tracing::info!(target: "mime-apps", id = app.id, "detected desktop entry");
self.apps.push(app.clone());
if desktop_entry
.categories()
.into_iter()
.flatten()
.any(|c| c == "TerminalEmulator")
{
self.terminals.push(app.clone());
}
// Cache associations defined by the desktop entry.
let mime_types = desktop_entry.mime_type().unwrap_or_else(Vec::new);
let associated_mime_types = mime_types.iter().filter_map(|m| {
m.parse::<Mime>().ok().map(|mime| {
if let Some(unaliased) = shared_mime_info.unalias_mime_type(&mime) {
aliased_mimes.insert(unaliased.clone(), mime);
return unaliased;
}
mime
})
});
for mime in associated_mime_types {
let apps = self.cache.entry(mime.clone()).or_default();
if apps.iter().all(|cached_app| cached_app.id != app.id) {
apps.push(app.clone());
}
}
}
// Cache added associations from mimeapps lists.
for (mut added_mime, added_apps) in &list.added_associations {
let _unaliased;
if let Some(unaliased) = shared_mime_info.unalias_mime_type(added_mime) {
aliased_mimes.insert(unaliased.clone(), added_mime.clone());
_unaliased = unaliased;
added_mime = &_unaliased;
}
for added_app in added_apps {
if let Some(app) = self
.apps
.iter()
.find(|cached| cached.id.as_str() == added_app.as_ref())
{
let apps = self.cache.entry(added_mime.clone()).or_default();
if apps.iter().all(|cached_app| cached_app.id != app.id) {
apps.push(app.clone());
}
}
}
}
for (mime, filenames) in list.removed_associations.iter() {
for filename in filenames {
log::trace!("remove {mime}={filename}");
if let Some(apps) = self.cache.get_mut(mime) {
apps.retain(|x| !filename_eq(&x.path, filename));
// Remove associations
for (mut removed_mime, removed_apps) in &list.removed_associations {
let _unaliased;
if let Some(unaliased) = shared_mime_info.unalias_mime_type(removed_mime) {
aliased_mimes.insert(unaliased.clone(), removed_mime.clone());
_unaliased = unaliased;
removed_mime = &_unaliased;
}
for removed_app in removed_apps {
if let Some(app) = self
.apps
.iter()
.find(|cached| cached.id.as_str() == removed_app.as_ref())
&& let Some(apps) = self.cache.get_mut(removed_mime)
{
apps.retain(|cached_app| cached_app.id != app.id);
}
}
}
for (mime, filenames) in list.default_apps.iter() {
for filename in filenames {
log::trace!("default {mime}={filename}");
if let Some(apps) = self.cache.get_mut(mime) {
let mut found = false;
for app in apps.iter_mut() {
if filename_eq(&app.path, filename) {
app.is_default = true;
found = true;
} else {
app.is_default = false;
}
}
// Fetch defaults and sort apps by their default precedence.
for (mime, mut apps) in std::mem::take(&mut self.cache).into_iter() {
let defaults = list.default_app_for(&mime);
let aliased_defaults = aliased_mimes
.get(&mime)
.and_then(|mime| list.default_app_for(mime));
let cache = self
.cache
.entry(mime.clone())
.or_insert_with(|| Vec::with_capacity(apps.len()));
// Sort cached apps for this mime by default precedence.
for default in defaults
.into_iter()
.flatten()
.chain(aliased_defaults.into_iter().flatten())
{
let default = default.strip_suffix(".desktop").unwrap_or(default.as_ref());
let mut found_any = false;
apps.retain(|app| {
let found = app.id.as_str() == default;
if found {
break;
app.is_default
.write()
.unwrap()
.insert(mime.essence_str().into());
cache.push(app.clone());
found_any = true;
}
log::debug!(
"failed to set default for {mime:?}: application {filename:?} not found"
);
!found
});
if !found_any && let Some(app) = self.apps.iter().find(|app| app.id == default) {
app.is_default
.write()
.unwrap()
.insert(mime.essence_str().into());
cache.push(app.clone());
}
}
// Sort remaining apps by name
apps.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.name, &b.name));
cache.extend_from_slice(&apps);
tracing::debug!(target: "mime-apps", mime = mime.essence_str(), apps = ?(cache.iter().map(|app| &*app.id).collect::<Vec<&str>>()), "mime defaults found")
}
// Sort apps by name
self.apps
.sort_by(|a, b| match (a.is_default, b.is_default) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => LANGUAGE_SORTER.compare(&a.name, &b.name),
});
for apps in self.cache.values_mut() {
apps.sort_by(|a, b| match (a.is_default, b.is_default) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => LANGUAGE_SORTER.compare(&a.name, &b.name),
});
let associated: rustc_hash::FxHashSet<&str> = self
.cache
.values()
.flatten()
.map(|app| app.id.as_str())
.collect();
for app in &self.apps {
app.no_display.store(
!associated.contains(app.id.as_str()),
atomic::Ordering::Relaxed,
);
}
// Copy icons to special cache
//TODO: adjust dropdown API so this is no longer needed
self.icons.extend(self.cache.iter().map(|(mime, apps)| {
(
mime.clone(),
apps.iter().map(|app| app.icon.clone()).collect(),
)
}));
let elapsed = start.elapsed();
log::info!("loaded mime app cache in {elapsed:?}");
tracing::info!(target: "mime-apps", "loaded mime app cache in {elapsed:?}");
}
pub fn apps(&self) -> &[MimeApp] {
pub fn apps(&self) -> &[Arc<MimeApp>] {
&self.apps
}
pub fn get(&self, key: &Mime) -> &[MimeApp] {
pub fn get(&self, key: &Mime) -> &[Arc<MimeApp>] {
self.cache.get(key).map_or(&[], Vec::as_slice)
}
pub fn icons(&self, key: &Mime) -> &[widget::icon::Handle] {
self.icons.get(key).map_or(&[], Box::as_ref)
pub fn icons(&self, key: &Mime) -> Vec<widget::icon::Handle> {
self.cache
.get(key)
.map_or_else(Vec::new, |apps| apps.iter().map(|app| app.icon()).collect())
}
fn get_default_terminal(&self) -> Option<String> {
@ -359,14 +530,19 @@ impl MimeAppCache {
.map(|string| string.trim().replace(".desktop", ""))
}
pub fn terminal(&self) -> Option<&MimeApp> {
pub fn terminal(&self) -> Option<&Arc<MimeApp>> {
//TODO: consider rules in https://github.com/Vladimir-csp/xdg-terminal-exec
// The current approach works but might not adhere to the spec (yet)
// Look for and return preferred terminals
//TODO: fallback order beyond cosmic-term?
let mut preference_order = vec!["com.system76.CosmicTerm".to_string()];
// Yoda: cosmic-yoterm (our fork) wins over upstream cosmic-term if both
// are installed — useful when xdg-mime default is not set.
let mut preference_order = vec![
"com.aditua.CosmicYoterm".to_string(),
"com.system76.CosmicTerm".to_string(),
];
if let Some(id) = self.get_default_terminal() {
preference_order.insert(0, id);

View file

@ -15,28 +15,21 @@ struct MimeIconKey {
size: u16,
}
struct MimeIconCache {
#[derive(Default)]
pub struct MimeIconCache {
cache: FxHashMap<MimeIconKey, Option<icon::Handle>>,
#[cfg(unix)]
shared_mime_info: xdg_mime::SharedMimeInfo,
pub shared_mime_info: xdg_mime::SharedMimeInfo,
}
impl MimeIconCache {
pub fn new() -> Self {
Self {
cache: FxHashMap::default(),
#[cfg(unix)]
shared_mime_info: xdg_mime::SharedMimeInfo::new(),
}
}
#[cfg(not(unix))]
pub fn get(&mut self, _key: MimeIconKey) -> Option<icon::Handle> {
None
}
#[cfg(unix)]
pub fn get(&mut self, key: MimeIconKey) -> Option<icon::Handle> {
fn get(&mut self, key: MimeIconKey) -> Option<icon::Handle> {
self.cache
.entry(key)
.or_insert_with_key(|key| {
@ -56,8 +49,9 @@ impl MimeIconCache {
.clone()
}
}
static MIME_ICON_CACHE: LazyLock<Mutex<MimeIconCache>> =
LazyLock::new(|| Mutex::new(MimeIconCache::new()));
pub static MIME_ICON_CACHE: LazyLock<Mutex<MimeIconCache>> =
LazyLock::new(|| Mutex::new(MimeIconCache::default()));
#[cfg(not(unix))]
pub fn mime_for_path(
@ -78,6 +72,7 @@ pub fn mime_for_path(
let mime_icon_cache = MIME_ICON_CACHE.lock().unwrap();
// Try the shared mime info cache first
let mut gb = mime_icon_cache.shared_mime_info.guess_mime_type();
gb.zero_size(false);
if remote {
if let Some(file_name) = path.file_name().and_then(std::ffi::OsStr::to_str) {
gb.file_name(file_name);
@ -129,3 +124,11 @@ pub fn parent_mime_types(mime: &Mime) -> Option<Vec<Mime>> {
let mime_icon_cache = MIME_ICON_CACHE.lock().unwrap();
mime_icon_cache.shared_mime_info.get_parents_aliased(mime)
}
pub fn is_mime_subclass_of(mime_type: &Mime, base: &Mime) -> bool {
let mime_icon_cache = MIME_ICON_CACHE.lock().unwrap();
mime_icon_cache
.shared_mime_info
.mime_type_subclass(mime_type, base)
}

View file

@ -14,7 +14,7 @@ use tokio::sync::mpsc;
use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage};
use crate::config::IconSizes;
use crate::err_str;
use crate::tab::{self, DirSize, ItemMetadata, ItemThumbnail, Location};
use crate::tab::{self, ChecksumState, DirSize, ItemMetadata, ItemThumbnail, Location};
const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri";
@ -216,6 +216,7 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
//TODO: scan directory size on gvfs mounts?
dir_size: DirSize::NotDirectory,
cut: false,
checksums: ChecksumState::default(),
});
}
Ok(items)

View file

@ -75,10 +75,10 @@ impl MounterItem {
}
}
pub fn icon(&self, symbolic: bool) -> Option<widget::icon::Handle> {
pub fn icon(&self, _symbolic: bool) -> Option<widget::icon::Handle> {
match self {
#[cfg(feature = "gvfs")]
Self::Gvfs(item) => item.icon(symbolic),
Self::Gvfs(item) => item.icon(_symbolic),
Self::None => unreachable!(),
}
}
@ -103,6 +103,7 @@ impl MounterItem {
pub type MounterItems = Vec<MounterItem>;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum MounterMessage {
Items(MounterItems),
MountResult(MounterItem, Result<bool, String>),

View file

@ -967,11 +967,13 @@ impl Operation {
password,
} => {
let controller_clone = controller.clone();
compio::runtime::spawn_blocking(
move || -> Result<OperationSelection, OperationError> {
compio::runtime::spawn(async move {
let extracted = compio::runtime::spawn_blocking(move || {
let controller = controller_clone;
let total_paths = paths.len();
let mut op_sel = OperationSelection::default();
let mut written_files = Vec::new();
let mut target_dirs = std::collections::HashSet::new();
for (i, path) in paths.iter().enumerate() {
futures::executor::block_on(async {
controller
@ -995,16 +997,32 @@ impl Operation {
op_sel.ignored.push(path.clone());
op_sel.selected.push(new_dir.clone());
crate::archive::extract(path, &new_dir, &password, &controller)?;
let (files, dirs) = crate::archive::extract(
path,
&new_dir,
&password,
&controller,
)?;
written_files.extend(files);
target_dirs.extend(dirs);
}
}
Ok(op_sel)
},
)
Ok::<_, OperationError>((op_sel, written_files, target_dirs))
})
.await
.map_err(wrap_compio_spawn_error)??;
let (op_sel, written_files, target_dirs) = extracted;
if !written_files.is_empty() || !target_dirs.is_empty() {
sync_to_disk(written_files, target_dirs).await;
}
Ok::<_, OperationError>(op_sel)
})
.await
.map_err(wrap_compio_spawn_error)?
}
.await
.map_err(wrap_compio_spawn_error)?,
Self::Move {
paths,
to,
@ -1138,10 +1156,26 @@ impl Operation {
paths.push(item.original_path());
compio::runtime::spawn_blocking(|| trash::os_limited::restore_all([item]))
.await
.map_err(wrap_compio_spawn_error)?
.map_err(|e| OperationError::from_err(e, &controller))?;
// Items with .trashinfo id use standard restore; sub-items use manual move
if item
.id
.to_str()
.map_or(false, |s| s.ends_with(".trashinfo"))
{
compio::runtime::spawn_blocking(|| trash::os_limited::restore_all([item]))
.await
.map_err(wrap_compio_spawn_error)?
.map_err(|e| OperationError::from_err(e, &controller))?;
} else {
let from = PathBuf::from(&item.id);
let to = item.original_path();
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| OperationError::from_err(e, &controller))?;
}
std::fs::rename(&from, &to)
.map_err(|e| OperationError::from_err(e, &controller))?;
}
}
Ok(OperationSelection {
ignored: Vec::new(),

View file

@ -10,6 +10,7 @@ use compio::driver::ToSharedFd;
use compio::driver::op::AsyncifyFd;
use compio::io::{AsyncReadAt, AsyncWriteAt};
use cosmic::iced::futures;
#[cfg(feature = "gvfs")]
use futures::{FutureExt, StreamExt};
use std::cell::Cell;
use std::error::Error;

View file

@ -18,6 +18,7 @@ use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::{self, DndDestination, DndSource, Id, RcElementWrapper, Widget, space};
use cosmic::{Apply, Element, cosmic_theme, font, theme};
#[cfg(feature = "desktop")]
use i18n_embed::LanguageLoader;
use icu::datetime::input::DateTime;
use icu::datetime::options::TimePrecision;
@ -28,6 +29,7 @@ use jiff_icu::ConvertFrom;
use mime_guess::{Mime, mime};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::cell::Cell;
use std::cmp::{Ordering, Reverse};
@ -179,7 +181,7 @@ fn button_appearance(
appearance.icon_color = Some(Color::from(cosmic.on_bg_component_color()));
appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
appearance.text_color = Some(Color::from(cosmic.background(false).component.on_disabled));
} else {
appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
}
@ -190,12 +192,12 @@ fn button_appearance(
appearance.background = Some(Color::from(cosmic.bg_color()).into());
appearance.icon_color = Some(Color::from(cosmic.on_bg_color()));
if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.disabled));
appearance.text_color = Some(Color::from(cosmic.background(false).component.disabled));
} else {
appearance.text_color = Some(Color::from(cosmic.on_bg_color()));
}
} else if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
appearance.text_color = Some(Color::from(cosmic.background(false).component.on_disabled));
}
if focused && accent {
appearance.outline_width = 1.0;
@ -288,6 +290,26 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han
.handle()
}
fn generic_file_icons(
sizes: IconSizes,
) -> (
widget::icon::Handle,
widget::icon::Handle,
widget::icon::Handle,
) {
(
widget::icon::from_name("text-x-generic")
.size(sizes.grid())
.handle(),
widget::icon::from_name("text-x-generic")
.size(sizes.list())
.handle(),
widget::icon::from_name("text-x-generic")
.size(sizes.list_condensed())
.handle(),
)
}
//TODO: replace with Path::has_trailing_sep when stable
fn has_trailing_sep(path: &Path) -> bool {
path.as_os_str()
@ -544,7 +566,7 @@ pub fn fs_kind(_metadata: &Metadata) -> FsKind {
}
#[cfg(not(feature = "desktop"))]
fn get_desktop_file_display_name(path: &Path) -> Option<String> {
fn get_desktop_file_display_name(_path: &Path) -> Option<String> {
None
}
@ -563,7 +585,7 @@ fn get_desktop_file_display_name(path: &Path) -> Option<String> {
}
#[cfg(not(feature = "desktop"))]
fn get_desktop_file_icon(path: &Path) -> Option<String> {
fn get_desktop_file_icon(_path: &Path) -> Option<String> {
None
}
@ -654,9 +676,9 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
folder_icon(&path, sizes.list_condensed()),
)
} else {
// ALWAYS assume we're remote for mime guessing here, since gvfs reading can be expensive
// @todo - expose this as a config option?
let mime = mime_for_path(&path, None, true);
// Keep the initial directory scan cheap. Opening files still
// recalculates MIME from the real path before launching apps.
let mime = mime_guess::from_path(&path).first_or_octet_stream();
//TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" {
@ -673,28 +695,21 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
desktop_icon_handle(&icon_name, sizes.list_condensed()),
)
} else {
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
generic_file_icons(sizes);
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
)
}
};
let mut children_opt = None;
let children_opt = None;
let mut dir_size = DirSize::NotDirectory;
if is_dir && !remote {
dir_size = DirSize::Calculating(Controller::default());
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
}
}
}
let display_name = display_name_for_file(&path, &file_info.display_name(), false, is_desktop);
@ -731,6 +746,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
overlaps_drag_rect: false,
dir_size,
cut: false,
checksums: ChecksumState::default(),
}
}
@ -748,7 +764,10 @@ pub fn item_from_entry(
sizes: IconSizes,
) -> Item {
let mut is_desktop = false;
#[cfg(feature = "gvfs")]
let mut is_gvfs = false;
#[cfg(not(feature = "gvfs"))]
let is_gvfs = false;
let hidden = name.starts_with('.') || hidden_attribute(&metadata);
@ -796,7 +815,9 @@ pub fn item_from_entry(
folder_icon(&path, sizes.list_condensed()),
)
} else {
let mime = mime_for_path(&path, Some(&metadata), remote);
// Keep the initial directory scan cheap. Opening files still
// recalculates MIME from the real path before launching apps.
let mime = mime_guess::from_path(&path).first_or_octet_stream();
//TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" {
is_desktop = true;
@ -812,28 +833,21 @@ pub fn item_from_entry(
desktop_icon_handle(&icon_name, sizes.list_condensed()),
)
} else {
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
generic_file_icons(sizes);
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
)
}
};
let mut children_opt = None;
let children_opt = None;
let mut dir_size = DirSize::NotDirectory;
if metadata.is_dir() && !remote {
dir_size = DirSize::Calculating(Controller::default());
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
}
}
}
let display_name = display_name_for_file(&path, &name, is_gvfs, is_desktop);
@ -862,6 +876,7 @@ pub fn item_from_entry(
overlaps_drag_rect: false,
dir_size,
cut: false,
checksums: ChecksumState::default(),
}
}
@ -874,6 +889,8 @@ pub fn item_from_trash_entry(
let name = entry.name.to_string_lossy().into_owned();
let display_name = Item::display_name(&name);
let location = crate::trash::trash_item_path(&entry).map(Location::Path);
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size
{
trash::TrashItemSize::Entries(_) => (
@ -901,7 +918,7 @@ pub fn item_from_trash_entry(
is_mount_point: false,
metadata: ItemMetadata::Trash { metadata, entry },
hidden: false,
location_opt: None,
location_opt: location,
image_dimensions: (mime.type_() == mime::IMAGE)
.then(|| image::image_dimensions(&original_path).ok())
.flatten(),
@ -918,9 +935,35 @@ pub fn item_from_trash_entry(
overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory,
cut: false,
checksums: ChecksumState::default(),
}
}
fn item_from_trash_child(
path: PathBuf,
name: String,
metadata: fs::Metadata,
sizes: IconSizes,
) -> Option<Item> {
let original_path = crate::trash::original_path_for_trash_child(&path)?;
let entry = trash::TrashItem {
id: path.as_os_str().to_os_string(),
name: std::ffi::OsString::from(&name),
original_parent: original_path.parent()?.to_path_buf(),
time_deleted: 0,
};
let size = if metadata.is_dir() {
trash::TrashItemSize::Entries(0)
} else {
trash::TrashItemSize::Bytes(metadata.len())
};
Some(item_from_trash_entry(
entry,
trash::TrashItemMetadata { size },
sizes,
))
}
fn get_filename_from_path(path: &Path) -> Result<String, String> {
Ok(match path.file_name() {
Some(name_os) => name_os
@ -947,7 +990,10 @@ pub fn item_from_path<P: Into<PathBuf>>(path: P, sizes: IconSizes) -> Result<Ite
pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let mut items = Vec::new();
let mut hidden_files = Box::from([]);
#[cfg(feature = "gvfs")]
let mut remote_scannable = false;
#[cfg(not(feature = "gvfs"))]
let remote_scannable = false;
#[cfg(feature = "gvfs")]
{
@ -1036,7 +1082,12 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
})
.ok()?;
Some(item_from_entry(path, name, metadata, sizes))
let trash = crate::trash::is_trash_path(tab_path);
if trash {
item_from_trash_child(path, name, metadata, sizes)
} else {
Some(item_from_entry(path, name, metadata, sizes))
}
})
.collect();
}
@ -1341,6 +1392,7 @@ pub fn scan_desktop(
overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory,
cut: false,
checksums: ChecksumState::default(),
});
}
@ -1724,6 +1776,8 @@ pub enum Message {
GoPrevious,
ItemDown,
ItemLeft,
ItemPageDown,
ItemPageUp,
ItemRight,
ItemUp,
Location(Location),
@ -1760,6 +1814,9 @@ pub enum Message {
HighlightDeactivate(usize),
HighlightActivate(usize),
DirectorySize(PathBuf, DirSize),
Checksums(PathBuf, ChecksumState),
CalculateChecksums(PathBuf),
CopyChecksum(String),
ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation
}
@ -1787,6 +1844,23 @@ pub enum DirSize {
Error(String),
}
/// Checksums computed for a file. Only SHA256 is currently exposed; see
/// [`calculate_checksums`] for how to add more.
#[derive(Clone, Debug, Default)]
pub struct FileChecksums {
pub sha256: String,
}
/// State of checksum computation for a file.
#[derive(Clone, Debug, Default)]
pub enum ChecksumState {
#[default]
NotCalculated,
Calculating,
Calculated(FileChecksums),
Error(String),
}
#[derive(Clone, Debug)]
pub enum ItemMetadata {
Path {
@ -2238,6 +2312,7 @@ pub struct Item {
pub cut: bool,
pub overlaps_drag_rect: bool,
pub dir_size: DirSize,
pub checksums: ChecksumState,
}
impl Item {
@ -2368,20 +2443,24 @@ impl Item {
)));
let mut settings = Vec::new();
if let Some(mime_app_cache) = mime_app_cache_opt {
let mime_apps = mime_app_cache.get(&self.mime);
let mime_apps = mime_app_cache.get_apps_for_mime(&self.mime, false);
if !mime_apps.is_empty() {
let (names, icons) = mime_apps
.iter()
.map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
.collect::<(Vec<_>, Vec<_>)>();
settings.push(
widget::settings::item::builder(fl!("open-with")).control(
Element::from(
widget::dropdown(
mime_apps,
mime_apps.iter().position(|x| x.is_default),
names,
mime_apps.iter().position(|(x, _)| x.is_default(&self.mime)),
move |index| index,
)
.icons(Cow::Borrowed(mime_app_cache.icons(&self.mime))),
.icons(Cow::Owned(icons)),
)
.map(|index| {
let mime_app = &mime_apps[index];
.map(move |index| {
let mime_app = &mime_apps[index].0;
Message::SetOpenWith(self.mime.clone(), mime_app.id.clone())
}),
),
@ -2510,6 +2589,55 @@ impl Item {
}
column = column.push(details);
if let Some(metadata) = self.file_metadata()
&& !metadata.is_dir()
&& let Some(path) = self.path_opt()
{
let control: Element<'_, Message> = match &self.checksums {
ChecksumState::NotCalculated => widget::button::standard(fl!("calculate"))
.on_press(Message::CalculateChecksums(path.clone()))
.into(),
ChecksumState::Calculating => widget::row::with_capacity(2)
.align_y(Alignment::Center)
.spacing(space_xxxs)
.push(widget::indeterminate_circular().size(16.0))
.push(widget::text::body(fl!("calculating")))
.into(),
ChecksumState::Calculated(checksums) => {
let value = checksums.sha256.clone();
// Middle-ellipsize the digest to fit, full value on hover.
let value_text = widget::tooltip(
widget::text::body(value.clone())
.font(cosmic::font::mono())
.width(Length::Fill)
.wrapping(text::Wrapping::None)
.ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
1,
))),
widget::text::body(value.clone()),
widget::tooltip::Position::Bottom,
);
let copy_button = widget::button::icon(
widget::icon::from_name("edit-copy-symbolic").size(16),
)
.on_press(Message::CopyChecksum(value.clone()))
.tooltip(fl!("copy"));
widget::row::with_capacity(2)
.align_y(Alignment::Center)
.spacing(space_xxxs)
.push(value_text)
.push(copy_button)
.into()
}
ChecksumState::Error(err) => {
widget::text::body(format!("{}: {}", fl!("error"), err)).into()
}
};
settings.push(
widget::settings::item::builder(fl!("checksum", kind = "SHA256")).control(control),
);
}
if let Some(path) = self.path_opt()
&& self.selected
{
@ -2707,6 +2835,32 @@ async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64,
Ok(total)
}
/// Calculate file checksums in a single pass over the file. To add another
/// digest, hash it alongside `sha256_hasher` in the loop below and add a field
/// to [`FileChecksums`]; the file is only read once.
async fn calculate_checksums(path: &Path) -> Result<FileChecksums, String> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut file = File::open(&path).map_err(|e| e.to_string())?;
let mut sha256_hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer).map_err(|e| e.to_string())?;
if bytes_read == 0 {
break;
}
sha256_hasher.update(&buffer[..bytes_read]);
}
Ok(FileChecksums {
sha256: format!("{:x}", sha256_hasher.finalize()),
})
})
.await
.map_err(|e| e.to_string())?
}
fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) {
let path = path.as_ref();
let mut found_home = false;
@ -3126,6 +3280,14 @@ impl Tab {
item.pos_opt.get()
}
fn dehighlight_all(&mut self) {
if let Some(items) = self.items_opt.as_mut() {
for item in items.iter_mut() {
item.highlighted = false;
}
}
}
pub(crate) fn select_focus_scroll(&mut self) -> Option<AbsoluteOffset> {
let items = self.items_opt.as_ref()?;
let item = items.get(self.select_focus?)?;
@ -3161,6 +3323,20 @@ impl Tab {
}
}
fn rows_per_page(&self) -> usize {
let viewport_height = self.item_view_size_opt.get().map_or(0.0, |s| s.height);
let row_height = self
.select_focus
.and_then(|i| self.items_opt.as_ref()?.get(i))
.and_then(|item| item.rect_opt.get())
.map_or(0.0, |r| r.height);
if row_height > 0.0 && viewport_height > 0.0 {
(viewport_height / row_height).floor() as usize
} else {
1
}
}
fn select_range_start_pos_opt(&self) -> Option<(usize, usize)> {
let items = self.items_opt.as_ref()?;
let item = items.get(self.select_range.map(|r| r.0)?)?;
@ -3816,6 +3992,7 @@ impl Tab {
}
}
Message::ItemDown => {
self.dehighlight_all();
if let Some(edit_location) = &mut self.edit_location {
edit_location.select(true);
} else if self.gallery {
@ -3857,7 +4034,95 @@ impl Tab {
}
}
}
Message::ItemPageDown => {
self.dehighlight_all();
if let Some((row, col)) = self.select_focus_pos_opt().or(self.select_last_pos_opt())
{
if self.select_focus.is_none() {
self.select_position(row, col, mod_shift);
}
let rows_per_page = self.rows_per_page().max(1);
let target_row = row.saturating_add(rows_per_page);
if !self.select_position(target_row, col, mod_shift) {
// Fall back to the last item at or before target_row
let best = self.items_opt.as_ref().and_then(|items| {
items
.iter()
.filter_map(|item| item.pos_opt.get())
.filter(|(r, _)| *r <= target_row)
.max()
});
if let Some((best_row, best_col)) = best {
self.select_position(best_row, best_col, mod_shift);
}
}
} else {
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(
self.scrollable_id.clone(),
AbsoluteOffset {
x: Some(offset.x),
y: Some(offset.y),
},
)
.into(),
));
}
if let Some(id) = self.select_focus_id() {
commands.push(Command::Iced(widget::button::focus(id).into()));
}
}
Message::ItemPageUp => {
self.dehighlight_all();
if let Some((row, col)) =
self.select_focus_pos_opt().or(self.select_first_pos_opt())
{
if self.select_focus.is_none() {
self.select_position(row, col, mod_shift);
}
let rows_per_page = self.rows_per_page().max(1);
let target_row = row.saturating_sub(rows_per_page);
if !self.select_position(target_row, col, mod_shift) {
// Fall back to the first item at or after target_row
let best = self.items_opt.as_ref().and_then(|items| {
items
.iter()
.filter_map(|item| item.pos_opt.get())
.filter(|(r, _)| *r >= target_row)
.min()
});
if let Some((best_row, best_col)) = best {
self.select_position(best_row, best_col, mod_shift);
}
}
} else {
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(
self.scrollable_id.clone(),
AbsoluteOffset {
x: Some(offset.x),
y: Some(offset.y),
},
)
.into(),
));
}
if let Some(id) = self.select_focus_id() {
commands.push(Command::Iced(widget::button::focus(id).into()));
}
}
Message::ItemLeft => {
self.dehighlight_all();
if self.gallery {
commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
} else {
@ -3916,6 +4181,7 @@ impl Tab {
}
}
Message::ItemRight => {
self.dehighlight_all();
if self.gallery {
commands.append(&mut self.update(Message::GalleryNext, modifiers));
} else {
@ -3957,6 +4223,7 @@ impl Tab {
}
}
Message::ItemUp => {
self.dehighlight_all();
if let Some(edit_location) = &mut self.edit_location {
edit_location.select(false);
} else if self.gallery {
@ -4533,6 +4800,52 @@ impl Tab {
}
}
}
Message::Checksums(path, checksum_state) => {
let location = Location::Path(path);
if let Some(ref mut item) = self.parent_item_opt
&& item.location_opt.as_ref() == Some(&location)
{
item.checksums = checksum_state.clone();
}
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
item.checksums = checksum_state;
break;
}
}
}
}
Message::CalculateChecksums(path) => {
let location = Location::Path(path.clone());
if let Some(ref mut item) = self.parent_item_opt
&& item.location_opt.as_ref() == Some(&location)
{
item.checksums = ChecksumState::Calculating;
}
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
item.checksums = ChecksumState::Calculating;
break;
}
}
}
commands.push(Command::Iced(
cosmic::Task::future(async move {
match calculate_checksums(&path).await {
Ok(checksums) => {
Message::Checksums(path, ChecksumState::Calculated(checksums))
}
Err(err) => Message::Checksums(path, ChecksumState::Error(err)),
}
})
.into(),
));
}
Message::CopyChecksum(value) => {
commands.push(Command::Iced(cosmic::iced::clipboard::write(value).into()));
}
}
// Scroll to top if needed
@ -6448,21 +6761,25 @@ impl Tab {
.and_then(|(mime, _)| mime.parse::<Mime>().ok())
&& let Some(mime_app_cache) = mime_app_cache_opt
{
let mime_apps = mime_app_cache.get(&mime);
let mime_apps = mime_app_cache.get_apps_for_mime(&mime, false);
if !mime_apps.is_empty() {
let mime_closure = mime.clone();
let (names, icons) = mime_apps
.iter()
.map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
.collect::<(Vec<_>, Vec<_>)>();
settings.push(
widget::settings::item::builder(fl!("open-with")).control(
Element::from(
widget::dropdown(
mime_apps,
mime_apps.iter().position(|x| x.is_default),
names,
mime_apps.iter().position(|(x, _)| x.is_default(&mime)),
move |index| (index, mime_closure.clone()),
)
.icons(Cow::Borrowed(mime_app_cache.icons(&mime))),
.icons(Cow::Owned(icons)),
)
.map(|(index, mime)| {
let mime_app = &mime_apps[index];
.map(move |(index, mime)| {
let mime_app = &mime_apps[index].0;
Message::SetOpenWith(mime, mime_app.id.clone())
}),
),
@ -7580,7 +7897,10 @@ mod tests {
// Text editor content may add a trailing newline
assert_eq!(content.text().trim_end(), "ab");
}
_ => panic!("expected Text thumbnail with valid prefix only, got {:?}", thumb),
_ => panic!(
"expected Text thumbnail with valid prefix only, got {:?}",
thumb
),
}
Ok(())
}

View file

@ -5,10 +5,13 @@
use cosmic::desktop::fde::GenericEntry;
use mime_guess::Mime;
use rustc_hash::FxHashMap;
use std::path::Path;
use std::sync::{LazyLock, Mutex};
use std::time::Instant;
use std::{fs, process};
#[cfg(feature = "desktop")]
use std::{fs, time::Instant};
use std::{
path::Path,
process,
sync::{LazyLock, Mutex},
};
#[derive(Clone, Debug)]
pub struct Thumbnailer {

View file

@ -1,11 +1,30 @@
use cosmic::widget;
use regex::Regex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::config::IconSizes;
use crate::tab::{Item, SearchItem};
fn percent_decode(s: &str) -> Option<String> {
let (mut r, mut b) = (String::with_capacity(s.len()), s.bytes());
while let Some(c) = b.next() {
if c == b'%' {
let (hi, lo) = (b.next()?, b.next()?);
let h = |x: u8| match x {
b'0'..=b'9' => Some(x - b'0'),
b'a'..=b'f' => Some(x - b'a' + 10),
b'A'..=b'F' => Some(x - b'A' + 10),
_ => None,
};
r.push((h(hi)? << 4 | h(lo)?) as char);
} else {
r.push(c as char);
}
}
Some(r)
}
pub trait TrashExt {
fn is_empty() -> bool {
true
@ -49,6 +68,46 @@ pub trait TrashExt {
}
}
/// Derive the actual filesystem path of a trashed item from its .trashinfo path
/// (or return the id directly if it's already a filesystem path).
pub fn trash_item_path(item: &trash::TrashItem) -> Option<PathBuf> {
let id_path = Path::new(&item.id);
if id_path.extension().map_or(false, |e| e == "trashinfo") {
let trash_root = id_path.parent()?.parent()?;
let file_name = id_path.file_stem()?;
Some(trash_root.join("files").join(file_name))
} else {
Some(PathBuf::from(&item.id))
}
}
/// For a path inside a trash `files/` directory, reconstruct the original path
/// by reading the parent `.trashinfo` file.
///
/// Given `~/.local/share/Trash/files/folder/sub/file.txt`:
/// - The top-level trashed item is `folder`
/// - Read `~/.local/share/Trash/info/folder.trashinfo` to get the original path
/// - Compute: `<original_path>/sub/file.txt`
pub fn original_path_for_trash_child(p: &Path) -> Option<PathBuf> {
let files = p.ancestors().find(|a| a.ends_with("files"))?;
let root = files.parent()?;
let top = p.strip_prefix(files).ok()?.components().next()?;
let info =
std::fs::read_to_string(root.join("info").join(top).with_extension("trashinfo")).ok()?;
let orig = percent_decode(info.lines().find_map(|l| l.strip_prefix("Path="))?.trim())?;
let rel = p.strip_prefix(files.join(top)).ok()?;
let mut result = PathBuf::from(&orig);
if !rel.as_os_str().is_empty() {
result.push(rel);
}
Some(result)
}
/// Check whether a path is inside any trash `files/` directory.
pub fn is_trash_path(path: &Path) -> bool {
path.ancestors().any(|a| a.ends_with("files"))
}
pub struct Trash;
// This config statement is from trash::os_limited
@ -142,4 +201,12 @@ impl TrashExt for Trash {
not(target_os = "android")
)
)))]
impl TrashExt for Trash {}
impl TrashExt for Trash {
fn scan_search<F: Fn(SearchItem) -> bool + Sync>(callback: F, regex: &Regex) {
log::warn!(
"searching trash not supported on this platform for pattern {:?}",
regex.as_str()
);
drop(callback);
}
}