← luna

Commit 89ed8e7

10
files
+1244
added
-32
deleted
@@ -35,6 +35,21 @@ Dostępne binarki:
35 35 - zmiana nazwy (`F2`), nowy folder (`Ctrl+N`), odświeżanie (`F5`/`Ctrl+R`),
36 36 otwieranie plików przez `xdg-open`, terminal w katalogu (`F4`).
37 37
38 +**Archiwa**
39 +- pakowanie zaznaczenia do **ZIP** (menu kontekstowe → „Skompresuj do ZIP”),
40 + z unikalną nazwą (`Paczka.zip`, `Paczka (2).zip`), w tle z postępem i anulowaniem;
41 +- rozpakowywanie **ZIP / TAR / TAR.GZ / TAR.XZ / TAR.BZ2 / 7z / RAR** —
42 + „Wypakuj tutaj” i „Wypakuj do folderu” (podkatalog o nazwie archiwum);
43 +- korzysta z `bsdtar`/`tar`/`zip`/`7z`/`unrar` (libarchive), więc nie ciągniemy
44 + ciężkich bibliotek, a RAR działa mimo zamkniętego formatu.
45 +
46 +**Ustawienia i informacje**
47 +- okno **Ustawień** (`Ctrl+,` lub menu ☰): motyw (jasny/ciemny/auto), widok
48 + (lista/siatka), język (polski/angielski/niemiecki), pokazywanie plików ukrytych;
49 +- wybory są trwałe (`$XDG_CONFIG_HOME/luna/files.conf`), a zmiana języka działa
50 + natychmiast (także w panelu bocznym i menu);
51 +- **O aplikacji** — nazwa, wersja, licencja.
52 +
38 53 **Urządzenia**
39 54 - panel boczny: miejsca XDG (z `~/.config/user-dirs.dirs`), kosz, zamontowane dyski
40 55 z wolnym miejscem (rozmiar z `statvfs`);
@@ -54,7 +69,9 @@ Dostępne binarki:
54 69 | `list.rs` | lista z kolumnami, nagłówki sortowania, wirtualizacja wierszy |
55 70 | `trash.rs` | kosz XDG: `files/` + `info/*.trashinfo`, kolizje nazw, `EXDEV` |
56 71 | `places.rs` | miejsca XDG, montowania (`/proc/self/mountinfo`), `statvfs`, UDisks2 |
57 -| `ops.rs` | wielowątkowa kolejka kopiuj/przenieś/usuń/zmień nazwę |
72 +| `ops.rs` | wielowątkowa kolejka kopiuj/przenieś/usuń/zmień nazwę/pakuj/wypakuj |
73 +| `archive.rs` | wykrywanie formatów, plany poleceń `zip`/`tar`/`bsdtar`/`7z`, anulowanie |
74 +| `prefs.rs` | trwałe preferencje (`language`, `view`, `show_hidden`) w XDG |
58 75 | `app.rs` | stan aplikacji, historia, zakładki, powiadomienia, pętla klatki |
59 76 | `ui.rs` | layout, rysowanie, obsługa myszy/klawiatury, menu |
60 77
@@ -70,6 +87,7 @@ Dostępne binarki:
70 87 | `Ctrl+H` | ukryj/pokaż pliki ukryte |
71 88 | `F9` | pokaż/ukryj panel boczny |
72 89 | `Ctrl+N` | nowy folder |
90 +| `Ctrl+,` | Ustawienia |
73 91 | `F2` / `F3` / `F4` / `F5` | zmiana nazwy / dwa panele / terminal / odśwież |
74 92 | `Space` | podgląd |
75 93 | `Delete` / `Shift+Delete` | do kosza / trwale usuń |
@@ -79,7 +97,6 @@ Dostępne binarki:
79 97
80 98 - MTP/PTP (telefony) przez GVFS/libmtp, LUKS i montowanie partycji systemowych;
81 99 - miniatury obrazów i wideo, podgląd PDF, kolumny (Miller), masowa zmiana nazw;
82 -- archiwa (zip/tar/7z) bez zewnętrznych aplikacji;
83 100 - bezpośrednie sygnały UDisks2 (`InterfacesAdded/Removed`) zamiast odpytywania;
84 101 - systemowy schowek dla „Kopiuj ścieżkę” (wymaga API schowka w `libluna-platform`);
85 102 - „Otwórz za pomocą…” z wyborem aplikacji i zapamiętywaniem domyślnych.
@@ -5,19 +5,20 @@ use std::path::{Path, PathBuf};
5 5 use std::sync::Arc;
6 6 use std::time::{Duration, Instant};
7 7
8 -use libluna_core::ThemeSettings;
8 +use libluna_core::{ThemePreference, ThemeSettings};
9 9 use libluna_i18n::{set_locale, tr, tr_args, Locale};
10 10 use libluna_platform::{run as run_app, AppConfig, Frame};
11 11 use libluna_render::ImageData;
12 12 use libluna_widgets::{
13 13 AlertDialog, Entry as TextEntry, ItemView, Menu, NavEntry, NavList, PaintCtx, ScrollView,
14 - SearchEntry, Toast,
14 + SearchEntry, Toast, ViewMode,
15 15 };
16 16
17 17 use crate::model::{human_size, now_secs, DirView, Entry, Kind, SortKey};
18 18 use crate::ops::{Job, Ops, OpsEvent};
19 19 use crate::places::{self, Mount, Place};
20 -use crate::{trash, ui};
20 +use crate::prefs::Prefs;
21 +use crate::{archive, trash, ui};
21 22
22 23 /// Odstęp między panelami w trybie dzielonym.
23 24 pub const SPLIT_GAP: f64 = 4.0;
@@ -192,6 +193,16 @@ pub enum MenuCmd {
192 193 EmptyTrash,
193 194 Unmount(usize),
194 195 OpenDevice(usize),
196 + /// Otwiera okno Ustawień.
197 + Settings,
198 + /// Otwiera okno „O aplikacji”.
199 + About,
200 + /// Pakuje zaznaczone elementy do archiwum ZIP.
201 + Compress,
202 + /// Wypakowuje archiwum do jego katalogu.
203 + ExtractHere,
204 + /// Wypakowuje archiwum do podkatalogu o nazwie archiwum.
205 + ExtractTo,
195 206 }
196 207
197 208 pub struct Clipboard {
@@ -265,6 +276,16 @@ pub struct App {
265 276 pub status: String,
266 277 /// Zakładka pod kursorem (podświetlenie w pasku tytułu).
267 278 pub tab_hover: Option<usize>,
279 + /// Preferencje (język, domyślny widok, ukryte pliki).
280 + pub prefs: Prefs,
281 + /// Okno Ustawień (modal).
282 + pub settings_open: bool,
283 + /// Okno „O aplikacji” (modal).
284 + pub about: Option<AlertDialog>,
285 + /// Motyw wybrany w Ustawieniach — aplikowany przez pętlę klatki.
286 + pub pending_theme: Option<ThemePreference>,
287 + /// Bieżąca preferencja motywu (do podświetlenia w Ustawieniach).
288 + pub theme_pref: ThemePreference,
268 289 pub pending_close: bool,
269 290 pub pending_minimize: bool,
270 291 pub pending_maximize: bool,
@@ -273,10 +294,20 @@ pub struct App {
273 294
274 295 impl App {
275 296 pub fn new(start: PathBuf) -> Self {
297 + Self::with_prefs(start, Prefs::load())
298 + }
299 +
300 + pub fn with_prefs(start: PathBuf, prefs: Prefs) -> Self {
276 301 let home = places::home();
277 302 let start = if start.is_dir() { start } else { home };
303 + let mut panes = [Pane::new(start.clone()), Pane::new(start)];
304 + for pane in &mut panes {
305 + pane.view.set_mode(prefs.view);
306 + pane.dir.set_show_hidden(prefs.show_hidden);
307 + pane.sync_view();
308 + }
278 309 let mut app = Self {
279 - panes: [Pane::new(start.clone()), Pane::new(start)],
310 + panes,
280 311 active: 0,
281 312 split: false,
282 313 sidebar_visible: true,
@@ -301,6 +332,11 @@ impl App {
301 332 last_mount_refresh: Instant::now() - Duration::from_secs(10),
302 333 status: String::new(),
303 334 tab_hover: None,
335 + prefs,
336 + settings_open: false,
337 + about: None,
338 + pending_theme: None,
339 + theme_pref: ThemePreference::System,
304 340 pending_close: false,
305 341 pending_minimize: false,
306 342 pending_maximize: false,
@@ -310,6 +346,92 @@ impl App {
310 346 app
311 347 }
312 348
349 + /// Zapisuje bieżące preferencje (wywoływane po zmianie w Ustawieniach).
350 + pub fn save_prefs(&self) {
351 + self.prefs.save();
352 + }
353 +
354 + /// Ustawia język i odświeża wszystkie napisy zależne od katalogu tłumaczeń.
355 + pub fn set_language(&mut self, code: &str) {
356 + self.prefs.language = code.to_owned();
357 + set_locale(Locale::new(code));
358 + self.rebuild_sidebar();
359 + self.status = self.status_text();
360 + self.save_prefs();
361 + }
362 +
363 + /// Ustawia domyślny widok we wszystkich panelach.
364 + pub fn set_view(&mut self, mode: ViewMode) {
365 + self.prefs.view = mode;
366 + for pane in &mut self.panes {
367 + pane.view.set_mode(mode);
368 + pane.sync_view();
369 + }
370 + self.save_prefs();
371 + }
372 +
373 + /// Pokazuje/ukrywa pliki ukryte we wszystkich panelach.
374 + pub fn set_show_hidden(&mut self, show: bool) {
375 + self.prefs.show_hidden = show;
376 + for pane in &mut self.panes {
377 + pane.dir.set_show_hidden(show);
378 + pane.sync_view();
379 + }
380 + self.save_prefs();
381 + }
382 +
383 + /// Otwiera okno „O aplikacji”.
384 + pub fn show_about(&mut self) {
385 + let body = format!(
386 + "Luna Files {}\n{}\n\n© 2026 Luna Project · GPL-3.0-or-later",
387 + env!("CARGO_PKG_VERSION"),
388 + tr("File manager for the Luna desktop.")
389 + );
390 + self.about = Some(
391 + AlertDialog::new(tr("About Files"), body)
392 + .width(500.0)
393 + .button(tr("Done")),
394 + );
395 + }
396 +
397 + /// Tworzy archiwum ZIP z podanych elementów w katalogu `dir`.
398 + pub fn compress(&mut self, items: Vec<PathBuf>, dir: &Path) {
399 + if items.is_empty() {
400 + return;
401 + }
402 + let base = if items.len() == 1 {
403 + items[0]
404 + .file_stem()
405 + .map(|s| s.to_string_lossy().into_owned())
406 + .filter(|s| !s.is_empty())
407 + .unwrap_or_else(|| tr("Archive"))
408 + } else {
409 + tr("Archive")
410 + };
411 + let dest = archive::unique_path(dir, &base, "zip");
412 + self.submit(Job::Compress { items, dest });
413 + }
414 +
415 + /// Wypakowuje archiwum: `here = true` → do katalogu archiwum, inaczej do podkatalogu.
416 + pub fn extract(&mut self, archive_path: PathBuf, here: bool) {
417 + if !archive::is_archive(&archive_path) {
418 + self.show_toast(tr("Unsupported archive format"));
419 + return;
420 + }
421 + let Some(parent) = archive_path.parent() else {
422 + return;
423 + };
424 + let dest = if here {
425 + parent.to_path_buf()
426 + } else {
427 + parent.join(archive::archive_stem(&archive_path))
428 + };
429 + self.submit(Job::Extract {
430 + archive: archive_path,
431 + dest,
432 + });
433 + }
434 +
313 435 pub fn pane(&self) -> &Pane {
314 436 &self.panes[self.active]
315 437 }
@@ -805,7 +927,10 @@ fn spawn_terminal(program: &str, dir: &str) -> bool {
805 927
806 928 /// Uruchamia aplikację (binarki `luna-files` i `gaminedes`).
807 929 pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
808 - set_locale(Locale::from_env());
930 + // Język z konfiguracji aplikacji ma pierwszeństwo nad `LANG` — jego wybór
931 + // w Ustawieniach ma być trwały między uruchomieniami.
932 + let prefs = Prefs::load();
933 + set_locale(Locale::new(prefs.language.clone()));
809 934 libluna_i18n::load_catalogs_from_dirs();
810 935 let title = tr(title_key);
811 936
@@ -827,11 +952,12 @@ pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
827 952 .settings(settings)
828 953 .system_dark(system_dark);
829 954
830 - let mut app = App::new(start);
955 + let mut app = App::with_prefs(start, prefs);
831 956 app.status = app.status_text();
832 957
833 958 run_app(config, move |frame: &mut Frame| {
834 959 let theme = frame.settings().resolve_themed(frame.system_dark());
960 + app.theme_pref = frame.settings().preference();
835 961 app.drain_ops();
836 962 app.refresh_devices_if_needed();
837 963
@@ -853,6 +979,9 @@ pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
853 979 frame.begin_move();
854 980 app.pending_move = false;
855 981 }
982 + if let Some(pref) = app.pending_theme.take() {
983 + frame.set_preference(pref);
984 + }
856 985
857 986 app.update_preview();
858 987 app.status = app.status_text();
859 988 new file mode 100644
@@ -0,0 +1,461 @@
1 +//! Archiwa: rozpoznawanie formatu, budowa poleceń i uruchamianie narzędzi systemowych.
2 +//!
3 +//! Świadomie korzystamy z `bsdtar`/`tar`/`zip`/`7z`/`unrar` zamiast wciągać do
4 +//! aplikacji kilka ciężkich bibliotek: libarchive obsługuje ZIP, TAR (gz/xz/bz2),
5 +//! 7z i RAR, a tworzenie archiwów robią natywne narzędzia. Dzięki temu menedżer
6 +//! obsługuje też formaty, których czysto-rustowe biblioteki nie znają (RAR).
7 +
8 +use std::ffi::OsString;
9 +use std::io::{self, Read};
10 +use std::path::{Path, PathBuf};
11 +use std::process::{Command, Stdio};
12 +use std::sync::atomic::{AtomicBool, Ordering};
13 +use std::time::Duration;
14 +
15 +use libluna_i18n::tr;
16 +
17 +/// Format archiwum rozpoznawany po nazwie.
18 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 +pub enum Format {
20 + Zip,
21 + Tar,
22 + TarGz,
23 + TarXz,
24 + TarBz2,
25 + SevenZ,
26 + Rar,
27 +}
28 +
29 +impl Format {
30 + /// Rozszerzenie używane przy tworzeniu archiwum (bez kropki).
31 + pub fn extension(self) -> &'static str {
32 + match self {
33 + Format::Zip => "zip",
34 + Format::Tar => "tar",
35 + Format::TarGz => "tar.gz",
36 + Format::TarXz => "tar.xz",
37 + Format::TarBz2 => "tar.bz2",
38 + Format::SevenZ => "7z",
39 + Format::Rar => "rar",
40 + }
41 + }
42 +}
43 +
44 +/// Czy plik wygląda na archiwum obsługiwanego formatu.
45 +pub fn is_archive(path: &Path) -> bool {
46 + format_of(path).is_some()
47 +}
48 +
49 +/// Format archiwum na podstawie nazwy (małe/duże litery bez znaczenia).
50 +pub fn format_of(path: &Path) -> Option<Format> {
51 + let name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
52 + // Kolejność ma znaczenie: `.tar.gz` przed `.gz`.
53 + let suffixes: [(&str, Format); 7] = [
54 + (".tar.gz", Format::TarGz),
55 + (".tgz", Format::TarGz),
56 + (".tar.xz", Format::TarXz),
57 + (".txz", Format::TarXz),
58 + (".tar.bz2", Format::TarBz2),
59 + (".tbz2", Format::TarBz2),
60 + (".zip", Format::Zip),
61 + ];
62 + for (suffix, format) in suffixes {
63 + if name.ends_with(suffix) {
64 + return Some(format);
65 + }
66 + }
67 + if name.ends_with(".tar") {
68 + return Some(Format::Tar);
69 + }
70 + if name.ends_with(".7z") {
71 + return Some(Format::SevenZ);
72 + }
73 + if name.ends_with(".rar") {
74 + return Some(Format::Rar);
75 + }
76 + None
77 +}
78 +
79 +/// Nazwa archiwum bez rozszerzenia (`projekt.tar.gz` → `projekt`).
80 +pub fn archive_stem(path: &Path) -> String {
81 + let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
82 + return String::new();
83 + };
84 + let lower = name.to_ascii_lowercase();
85 + for suffix in [
86 + ".tar.gz", ".tar.xz", ".tar.bz2", ".tgz", ".txz", ".tbz2", ".zip", ".tar", ".7z", ".rar",
87 + ] {
88 + if lower.ends_with(suffix) {
89 + return name[..name.len() - suffix.len()].to_owned();
90 + }
91 + }
92 + name
93 +}
94 +
95 +/// Polecenie do uruchomienia.
96 +#[derive(Debug, Clone, PartialEq, Eq)]
97 +pub struct Plan {
98 + pub program: String,
99 + pub args: Vec<OsString>,
100 + pub cwd: Option<PathBuf>,
101 +}
102 +
103 +impl Plan {
104 + fn new(program: impl Into<String>, args: Vec<OsString>) -> Self {
105 + Self {
106 + program: program.into(),
107 + args,
108 + cwd: None,
109 + }
110 + }
111 +}
112 +
113 +/// Buduje polecenie pakowania `items` do `dest` (format z rozszerzenia `dest`).
114 +pub fn compress_plan(items: &[PathBuf], dest: &Path) -> io::Result<Plan> {
115 + let Some(format) = format_of(dest) else {
116 + return Err(invalid(tr("Unsupported archive format")));
117 + };
118 + if items.is_empty() {
119 + return Err(invalid(tr("Nothing selected")));
120 + }
121 + let parent = items[0]
122 + .parent()
123 + .ok_or_else(|| invalid(tr("Invalid path")))?
124 + .to_path_buf();
125 + if items.iter().any(|p| p.parent() != Some(parent.as_path())) {
126 + return Err(invalid(tr("Selected items must share one folder")));
127 + }
128 + // `zip`/`tar`/`7z` zapisują ścieżki względne — podajemy same nazwy, a katalog
129 + // roboczy ustawiamy na wspólny rodzic. Dzięki temu archiwum nie zawiera
130 + // `/home/u/...`, tylko wybrane pliki.
131 + let names: Vec<OsString> = items
132 + .iter()
133 + .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
134 + .collect();
135 + if names.len() != items.len() {
136 + return Err(invalid(tr("Invalid path")));
137 + }
138 + let dest_name = if dest.parent() == Some(parent.as_path()) {
139 + dest.file_name()
140 + .map(|n| n.to_os_string())
141 + .ok_or_else(|| invalid(tr("Invalid path")))?
142 + } else {
143 + dest.as_os_str().to_os_string()
144 + };
145 +
146 + let mut args: Vec<OsString> = Vec::new();
147 + let program = match format {
148 + Format::Zip => {
149 + args.push(OsString::from("-r"));
150 + args.push(OsString::from("-y")); // symlinki bez podążania
151 + args.push(OsString::from("-q"));
152 + args.push(dest_name);
153 + args.extend(names);
154 + "zip"
155 + }
156 + Format::SevenZ => {
157 + args.push(OsString::from("a"));
158 + args.push(OsString::from("-bd"));
159 + args.push(OsString::from("-y"));
160 + args.push(dest_name);
161 + args.push(OsString::from("--"));
162 + args.extend(names);
163 + "7z"
164 + }
165 + Format::Tar | Format::TarGz | Format::TarXz | Format::TarBz2 => {
166 + let flag = match format {
167 + Format::Tar => "-cf",
168 + Format::TarGz => "-czf",
169 + Format::TarXz => "-cJf",
170 + Format::TarBz2 => "-cjf",
171 + _ => unreachable!(),
172 + };
173 + args.push(OsString::from(flag));
174 + args.push(dest_name);
175 + args.push(OsString::from("--"));
176 + args.extend(names);
177 + "tar"
178 + }
179 + // RAR tworzymy tylko przez własnościowe `rar`; nasze archiwa tworzymy
180 + // w formatach otwartych — RAR obsługujemy wyłącznie do wypakowania.
181 + Format::Rar => return Err(invalid(tr("Unsupported archive format"))),
182 + };
183 + let mut plan = Plan::new(program, args);
184 + plan.cwd = Some(parent);
185 + Ok(plan)
186 +}
187 +
188 +/// Buduje polecenie wypakowania `archive` do katalogu `dest`.
189 +pub fn extract_plan(archive: &Path, dest: &Path) -> io::Result<Plan> {
190 + let Some(format) = format_of(archive) else {
191 + return Err(invalid(tr("Unsupported archive format")));
192 + };
193 + let mut plan = if on_path("bsdtar") {
194 + // libarchive: ZIP, TAR (gz/xz/bz2), 7z, RAR — jedna ścieżka dla wszystkiego.
195 + Plan::new(
196 + "bsdtar",
197 + vec![
198 + OsString::from("-xf"),
199 + archive.as_os_str().to_os_string(),
200 + OsString::from("-C"),
201 + dest.as_os_str().to_os_string(),
202 + ],
203 + )
204 + } else {
205 + match format {
206 + Format::Zip => Plan::new(
207 + "unzip",
208 + vec![
209 + OsString::from("-q"),
210 + OsString::from("-o"),
211 + archive.as_os_str().to_os_string(),
212 + OsString::from("-d"),
213 + dest.as_os_str().to_os_string(),
214 + ],
215 + ),
216 + Format::SevenZ => Plan::new(
217 + "7z",
218 + vec![
219 + OsString::from("x"),
220 + OsString::from("-y"),
221 + OsString::from("-bd"),
222 + OsString::from(format!("-o{}", dest.display())),
223 + archive.as_os_str().to_os_string(),
224 + ],
225 + ),
226 + Format::Rar => Plan::new(
227 + "unrar",
228 + vec![
229 + OsString::from("x"),
230 + OsString::from("-o+"),
231 + archive.as_os_str().to_os_string(),
232 + dest.as_os_str().to_os_string(),
233 + ],
234 + ),
235 + Format::Tar | Format::TarGz | Format::TarXz | Format::TarBz2 => Plan::new(
236 + "tar",
237 + vec![
238 + OsString::from("-xf"),
239 + archive.as_os_str().to_os_string(),
240 + OsString::from("-C"),
241 + dest.as_os_str().to_os_string(),
242 + ],
243 + ),
244 + }
245 + };
246 + // `unrar`/`7z` przyjmują katalog docelowy jako argument; tar/bsdtar przez `-C`.
247 + plan.cwd = None;
248 + Ok(plan)
249 +}
250 +
251 +/// Pakuje `items` do `dest`. Blokuje wątek operacji; `cancel` zatrzymuje proces.
252 +pub fn compress(items: &[PathBuf], dest: &Path, cancel: &AtomicBool) -> io::Result<()> {
253 + let plan = compress_plan(items, dest)?;
254 + run(&plan, cancel)
255 +}
256 +
257 +/// Wypakowuje `archive` do `dest` (katalog tworzony, jeśli nie istnieje).
258 +pub fn extract(archive: &Path, dest: &Path, cancel: &AtomicBool) -> io::Result<()> {
259 + std::fs::create_dir_all(dest)?;
260 + let plan = extract_plan(archive, dest)?;
261 + run(&plan, cancel)
262 +}
263 +
264 +/// Wolna nazwa w `dir`: `base.zip`, `base (2).zip`, …
265 +pub fn unique_path(dir: &Path, base: &str, extension: &str) -> PathBuf {
266 + for n in 1.. {
267 + let name = if n == 1 {
268 + format!("{base}.{extension}")
269 + } else {
270 + format!("{base} ({n}).{extension}")
271 + };
272 + let candidate = dir.join(name);
273 + if !candidate.exists() {
274 + return candidate;
275 + }
276 + }
277 + unreachable!()
278 +}
279 +
280 +fn run(plan: &Plan, cancel: &AtomicBool) -> io::Result<()> {
281 + let mut cmd = Command::new(&plan.program);
282 + cmd.args(&plan.args)
283 + .stdin(Stdio::null())
284 + .stdout(Stdio::null())
285 + .stderr(Stdio::piped());
286 + if let Some(cwd) = &plan.cwd {
287 + cmd.current_dir(cwd);
288 + }
289 + let mut child = cmd.spawn().map_err(|e| {
290 + io::Error::new(
291 + e.kind(),
292 + format!("{}: {}", tr("Archive tool not found"), plan.program),
293 + )
294 + })?;
295 + loop {
296 + if cancel.load(Ordering::SeqCst) {
297 + let _ = child.kill();
298 + let _ = child.wait();
299 + return Err(io::Error::new(io::ErrorKind::Interrupted, tr("Cancelled")));
300 + }
301 + match child.try_wait()? {
302 + Some(status) => {
303 + let mut stderr = String::new();
304 + if let Some(mut pipe) = child.stderr.take() {
305 + let _ = pipe.read_to_string(&mut stderr);
306 + }
307 + if status.success() {
308 + return Ok(());
309 + }
310 + let message = stderr.trim();
311 + return Err(io::Error::other(if message.is_empty() {
312 + format!("{}: {status}", plan.program)
313 + } else {
314 + message.to_owned()
315 + }));
316 + }
317 + None => std::thread::sleep(Duration::from_millis(60)),
318 + }
319 + }
320 +}
321 +
322 +/// Czy program jest w `PATH` (żeby wybrać `bsdtar` zamiast `unzip`/`tar`).
323 +fn on_path(program: &str) -> bool {
324 + let Some(path) = std::env::var_os("PATH") else {
325 + return false;
326 + };
327 + std::env::split_paths(&path).any(|dir| {
328 + let candidate = dir.join(program);
329 + candidate.is_file()
330 + })
331 +}
332 +
333 +fn invalid(message: impl Into<String>) -> io::Error {
334 + io::Error::new(io::ErrorKind::InvalidInput, message.into())
335 +}
336 +
337 +#[cfg(test)]
338 +mod tests {
339 + use super::*;
340 +
341 + fn p(s: &str) -> PathBuf {
342 + PathBuf::from(s)
343 + }
344 +
345 + fn temp_dir(name: &str) -> PathBuf {
346 + let dir = std::env::temp_dir().join(format!("luna-files-archive-{name}"));
347 + let _ = std::fs::remove_dir_all(&dir);
348 + std::fs::create_dir_all(&dir).unwrap();
349 + dir
350 + }
351 +
352 + #[test]
353 + fn zip_roundtrip_with_real_tool() {
354 + if !on_path("zip") || !on_path("bsdtar") {
355 + return; // środowisko bez narzędzi — reszta testów pokrywa plany
356 + }
357 + let dir = temp_dir("zip-roundtrip");
358 + let src = dir.join("hello.txt");
359 + std::fs::write(&src, b"luna").unwrap();
360 + let dest = dir.join("pack.zip");
361 + let cancel = AtomicBool::new(false);
362 + compress(std::slice::from_ref(&src), &dest, &cancel).unwrap();
363 + assert!(dest.is_file());
364 + std::fs::remove_file(&src).unwrap();
365 + let out = dir.join("out");
366 + extract(&dest, &out, &cancel).unwrap();
367 + assert_eq!(std::fs::read(out.join("hello.txt")).unwrap(), b"luna");
368 + let _ = std::fs::remove_dir_all(&dir);
369 + }
370 +
371 + #[test]
372 + fn tar_gz_roundtrip_with_real_tool() {
373 + if !on_path("tar") || !on_path("bsdtar") {
374 + return;
375 + }
376 + let dir = temp_dir("tar-roundtrip");
377 + let src = dir.join("nested.txt");
378 + std::fs::write(&src, b"selene").unwrap();
379 + let dest = dir.join("pack.tar.gz");
380 + let cancel = AtomicBool::new(false);
381 + compress(std::slice::from_ref(&src), &dest, &cancel).unwrap();
382 + std::fs::remove_file(&src).unwrap();
383 + let out = dir.join("out");
384 + extract(&dest, &out, &cancel).unwrap();
385 + assert_eq!(std::fs::read(out.join("nested.txt")).unwrap(), b"selene");
386 + let _ = std::fs::remove_dir_all(&dir);
387 + }
388 +
389 + #[test]
390 + fn detects_archive_formats_case_insensitively() {
391 + assert_eq!(format_of(&p("a.zip")), Some(Format::Zip));
392 + assert_eq!(format_of(&p("A.TAR.GZ")), Some(Format::TarGz));
393 + assert_eq!(format_of(&p("x.tgz")), Some(Format::TarGz));
394 + assert_eq!(format_of(&p("x.tar.xz")), Some(Format::TarXz));
395 + assert_eq!(format_of(&p("x.tbz2")), Some(Format::TarBz2));
396 + assert_eq!(format_of(&p("x.7z")), Some(Format::SevenZ));
397 + assert_eq!(format_of(&p("x.rar")), Some(Format::Rar));
398 + assert_eq!(format_of(&p("x.txt")), None);
399 + }
400 +
401 + #[test]
402 + fn archive_stem_strips_double_extension() {
403 + assert_eq!(archive_stem(&p("projekt.tar.gz")), "projekt");
404 + assert_eq!(archive_stem(&p("projekt.ZIP")), "projekt");
405 + assert_eq!(archive_stem(&p("bez-rozszerzenia")), "bez-rozszerzenia");
406 + }
407 +
408 + #[test]
409 + fn zip_plan_uses_relative_names_and_parent_cwd() {
410 + let items = vec![p("/home/u/dir/a.txt"), p("/home/u/dir/b")];
411 + let plan = compress_plan(&items, &p("/home/u/dir/pack.zip")).unwrap();
412 + assert_eq!(plan.program, "zip");
413 + assert_eq!(plan.cwd.as_deref(), Some(Path::new("/home/u/dir")));
414 + let args: Vec<String> = plan
415 + .args
416 + .iter()
417 + .map(|a| a.to_string_lossy().into_owned())
418 + .collect();
419 + assert!(args.contains(&"pack.zip".to_owned()));
420 + assert!(args.contains(&"a.txt".to_owned()));
421 + assert!(args.contains(&"b".to_owned()));
422 + assert!(!args.iter().any(|a| a.starts_with("/home/u")));
423 + }
424 +
425 + #[test]
426 + fn tar_plan_picks_compression_flag() {
427 + let items = vec![p("/tmp/x")];
428 + let plan = compress_plan(&items, &p("/tmp/x.tar.xz")).unwrap();
429 + assert_eq!(plan.program, "tar");
430 + assert!(plan.args[0].to_string_lossy().contains("J"));
431 + }
432 +
433 + #[test]
434 + fn extract_plan_contains_archive_and_destination() {
435 + let plan = extract_plan(&p("/tmp/a.zip"), &p("/tmp/out")).unwrap();
436 + let args: Vec<String> = plan
437 + .args
438 + .iter()
439 + .map(|a| a.to_string_lossy().into_owned())
440 + .collect();
441 + assert!(args.iter().any(|a| a == "/tmp/a.zip"));
442 + assert!(args.iter().any(|a| a == "/tmp/out"));
443 + }
444 +
445 + #[test]
446 + fn rejects_items_from_different_folders() {
447 + let items = vec![p("/tmp/a/one"), p("/tmp/b/two")];
448 + assert!(compress_plan(&items, &p("/tmp/a/x.zip")).is_err());
449 + }
450 +
451 + #[test]
452 + fn unique_path_numbers_collisions() {
453 + let dir = std::env::temp_dir().join("luna-files-archive-test");
454 + let _ = std::fs::remove_dir_all(&dir);
455 + std::fs::create_dir_all(&dir).unwrap();
456 + std::fs::write(dir.join("Paczka.zip"), b"x").unwrap();
457 + let next = unique_path(&dir, "Paczka", "zip");
458 + assert_eq!(next.file_name().unwrap(), "Paczka (2).zip");
459 + let _ = std::fs::remove_dir_all(&dir);
460 + }
461 +}
@@ -1,10 +1,12 @@
1 1 //! Luna Files — menedżer plików środowiska Luna na libLunie.
2 2
3 3 pub mod app;
4 +pub mod archive;
4 5 pub mod list;
5 6 pub mod model;
6 7 pub mod ops;
7 8 pub mod places;
9 +pub mod prefs;
8 10 pub mod trash;
9 11 pub mod ui;
10 12
@@ -19,15 +19,46 @@ use crate::trash;
19 19 /// Zadanie do wykonania w tle.
20 20 #[derive(Debug, Clone)]
21 21 pub enum Job {
22 - Copy { srcs: Vec<PathBuf>, dst: PathBuf },
23 - Move { srcs: Vec<PathBuf>, dst: PathBuf },
24 - Trash { paths: Vec<PathBuf> },
25 - Delete { paths: Vec<PathBuf> },
26 - Rename { from: PathBuf, to: PathBuf },
27 - NewFolder { parent: PathBuf },
28 - Mkdir { path: PathBuf },
29 - Restore { path: PathBuf },
30 - Purge { path: PathBuf },
22 + Copy {
23 + srcs: Vec<PathBuf>,
24 + dst: PathBuf,
25 + },
26 + Move {
27 + srcs: Vec<PathBuf>,
28 + dst: PathBuf,
29 + },
30 + Trash {
31 + paths: Vec<PathBuf>,
32 + },
33 + Delete {
34 + paths: Vec<PathBuf>,
35 + },
36 + Rename {
37 + from: PathBuf,
38 + to: PathBuf,
39 + },
40 + NewFolder {
41 + parent: PathBuf,
42 + },
43 + Mkdir {
44 + path: PathBuf,
45 + },
46 + Restore {
47 + path: PathBuf,
48 + },
49 + Purge {
50 + path: PathBuf,
51 + },
52 + /// Pakuje `items` do archiwum `dest` (format wynika z rozszerzenia).
53 + Compress {
54 + items: Vec<PathBuf>,
55 + dest: PathBuf,
56 + },
57 + /// Wypakowuje `archive` do katalogu `dest`.
58 + Extract {
59 + archive: PathBuf,
60 + dest: PathBuf,
61 + },
31 62 }
32 63
33 64 /// Decyzja przy konflikcie nazw (dialog w UI).
@@ -185,6 +216,8 @@ fn run(job: Job, tx: &Sender<OpsEvent>, cancel: &AtomicBool, decisions: &Receive
185 216 Job::Mkdir { .. } => "Creating folder",
186 217 Job::Restore { .. } => "Restoring",
187 218 Job::Purge { .. } => "Deleting",
219 + Job::Compress { .. } => "Creating archive",
220 + Job::Extract { .. } => "Extracting",
188 221 });
189 222 let _ = tx.send(OpsEvent::Started {
190 223 label: label.clone(),
@@ -304,6 +337,40 @@ fn run(job: Job, tx: &Sender<OpsEvent>, cancel: &AtomicBool, decisions: &Receive
304 337 Ok(()) => ok = 1,
305 338 Err(e) => failed.push(format!("{}: {e}", path.display())),
306 339 },
340 + Job::Compress { items, dest } => {
341 + let _ = tx.send(OpsEvent::Progress {
342 + done: 0,
343 + total: 0,
344 + current: dest
345 + .file_name()
346 + .map(|n| n.to_string_lossy().into_owned())
347 + .unwrap_or_default(),
348 + });
349 + match crate::archive::compress(&items, &dest, cancel) {
350 + Ok(()) => {
351 + ok = 1;
352 + created = Some(dest);
353 + }
354 + Err(e) => failed.push(format!("{}: {e}", dest.display())),
355 + }
356 + }
357 + Job::Extract { archive, dest } => {
358 + let _ = tx.send(OpsEvent::Progress {
359 + done: 0,
360 + total: 0,
361 + current: archive
362 + .file_name()
363 + .map(|n| n.to_string_lossy().into_owned())
364 + .unwrap_or_default(),
365 + });
366 + match crate::archive::extract(&archive, &dest, cancel) {
367 + Ok(()) => {
368 + ok = 1;
369 + created = Some(dest);
370 + }
371 + Err(e) => failed.push(format!("{}: {e}", archive.display())),
372 + }
373 + }
307 374 }
308 375
309 376 let _ = tx.send(OpsEvent::Finished {
310 377 new file mode 100644
@@ -0,0 +1,163 @@
1 +//! Preferencje `luna-files` (język, domyślny widok, ukryte pliki).
2 +//!
3 +//! Trzymamy je w `$XDG_CONFIG_HOME/luna/files.conf` (albo `~/.config/...`).
4 +//! Format jest celowo prosty — `klucz=wartość`, jedna para na linię — żeby plik
5 +//! dało się edytować ręcznie i żeby nie ciągnąć do aplikacji formatu serializacji.
6 +
7 +use std::path::{Path, PathBuf};
8 +
9 +use libluna_widgets::ViewMode;
10 +
11 +/// Języki, które aplikacja potrafi wybrać w Ustawieniach.
12 +pub const LANGUAGES: [&str; 3] = ["pl", "en", "de"];
13 +
14 +/// Preferencje aplikacji.
15 +#[derive(Debug, Clone, PartialEq, Eq)]
16 +pub struct Prefs {
17 + pub language: String,
18 + pub view: ViewMode,
19 + pub show_hidden: bool,
20 +}
21 +
22 +impl Default for Prefs {
23 + fn default() -> Self {
24 + Self {
25 + language: libluna_i18n::locale().language().to_owned(),
26 + view: ViewMode::List,
27 + show_hidden: false,
28 + }
29 + }
30 +}
31 +
32 +impl Prefs {
33 + /// Wczytuje preferencje użytkownika (brak pliku = wartości domyślne).
34 + pub fn load() -> Self {
35 + config_path()
36 + .filter(|p| p.exists())
37 + .map(|p| Self::load_from(&p))
38 + .unwrap_or_default()
39 + }
40 +
41 + /// Zapisuje preferencje; błędy (np. brak miejsca) są ignorowane — aplikacja
42 + /// nie może się wywrócić tylko dlatego, że nie da się zapisać konfiguracji.
43 + pub fn save(&self) {
44 + if let Some(path) = config_path() {
45 + let _ = self.save_to(&path);
46 + }
47 + }
48 +
49 + fn load_from(path: &Path) -> Self {
50 + let mut prefs = Self::default();
51 + let Ok(text) = std::fs::read_to_string(path) else {
52 + return prefs;
53 + };
54 + for line in text.lines() {
55 + let line = line.trim();
56 + if line.is_empty() || line.starts_with('#') {
57 + continue;
58 + }
59 + let Some((key, value)) = line.split_once('=') else {
60 + continue;
61 + };
62 + let value = value.trim();
63 + match key.trim() {
64 + "language" => {
65 + if LANGUAGES.contains(&value) {
66 + prefs.language = value.to_owned();
67 + }
68 + }
69 + "view" => {
70 + prefs.view = if value.eq_ignore_ascii_case("grid") {
71 + ViewMode::Grid
72 + } else {
73 + ViewMode::List
74 + };
75 + }
76 + "show_hidden" => prefs.show_hidden = matches!(value, "1" | "true" | "yes"),
77 + _ => {}
78 + }
79 + }
80 + prefs
81 + }
82 +
83 + fn save_to(&self, path: &Path) -> std::io::Result<()> {
84 + if let Some(parent) = path.parent() {
85 + std::fs::create_dir_all(parent)?;
86 + }
87 + let text = format!(
88 + "# luna-files\nlanguage={}\nview={}\nshow_hidden={}\n",
89 + self.language,
90 + match self.view {
91 + ViewMode::List => "list",
92 + ViewMode::Grid => "grid",
93 + },
94 + if self.show_hidden { "true" } else { "false" }
95 + );
96 + // Zapis przez plik tymczasowy: przerwany zapis nie zostawia pustej konfiguracji.
97 + let tmp = path.with_extension("conf.tmp");
98 + std::fs::write(&tmp, text)?;
99 + std::fs::rename(tmp, path)
100 + }
101 +}
102 +
103 +/// Indeks języka w [`LANGUAGES`] (dla kontrolki w Ustawieniach).
104 +pub fn language_index(code: &str) -> usize {
105 + LANGUAGES.iter().position(|l| *l == code).unwrap_or(0)
106 +}
107 +
108 +/// Ścieżka pliku konfiguracji.
109 +fn config_path() -> Option<PathBuf> {
110 + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
111 + if !dir.is_empty() {
112 + return Some(PathBuf::from(dir).join("luna/files.conf"));
113 + }
114 + }
115 + let home = std::env::var_os("HOME")?;
116 + Some(PathBuf::from(home).join(".config/luna/files.conf"))
117 +}
118 +
119 +#[cfg(test)]
120 +mod tests {
121 + use super::*;
122 +
123 + #[test]
124 + fn parses_all_known_keys() {
125 + let dir = std::env::temp_dir().join("luna-files-prefs-test");
126 + std::fs::create_dir_all(&dir).unwrap();
127 + let path = dir.join("files.conf");
128 + std::fs::write(
129 + &path,
130 + "# komentarz\nlanguage=de\nview=grid\nshow_hidden=true\nnieznane=1\n",
131 + )
132 + .unwrap();
133 + let prefs = Prefs::load_from(&path);
134 + assert_eq!(prefs.language, "de");
135 + assert_eq!(prefs.view, ViewMode::Grid);
136 + assert!(prefs.show_hidden);
137 + let _ = std::fs::remove_dir_all(&dir);
138 + }
139 +
140 + #[test]
141 + fn rejects_unsupported_language_and_roundtrips() {
142 + let dir = std::env::temp_dir().join("luna-files-prefs-roundtrip");
143 + std::fs::create_dir_all(&dir).unwrap();
144 + let path = dir.join("files.conf");
145 + std::fs::write(&path, "language=xx\nview=grid\n").unwrap();
146 + let mut prefs = Prefs::load_from(&path);
147 + assert!(LANGUAGES.contains(&prefs.language.as_str()));
148 + prefs.language = "pl".to_owned();
149 + prefs.show_hidden = true;
150 + prefs.save_to(&path).unwrap();
151 + let again = Prefs::load_from(&path);
152 + assert_eq!(again, prefs);
153 + let _ = std::fs::remove_dir_all(&dir);
154 + }
155 +
156 + #[test]
157 + fn language_index_maps_codes() {
158 + assert_eq!(language_index("pl"), 0);
159 + assert_eq!(language_index("en"), 1);
160 + assert_eq!(language_index("de"), 2);
161 + assert_eq!(language_index("nieznany"), 0);
162 + }
163 +}
@@ -7,19 +7,23 @@ use std::time::{Duration, Instant};
7 7
8 8 use libluna_core::style::weight;
9 9 use libluna_core::{
10 - ButtonState, Color, Event, Insets, KeyEvent, Modifiers, Point, PointerButton, Rect, Size, Theme,
10 + ButtonState, Color, Event, Insets, KeyEvent, Modifiers, Point, PointerButton, Rect, Size,
11 + Theme, ThemePreference,
11 12 };
12 13 use libluna_render::{Scene, TextEngine};
13 14 use libluna_widgets::{
14 - icon, AlertDialog, ControlsSide, Entry as TextEntry, HeaderBar, IconName, Menu, MenuEntry,
15 - PaintCtx, PathBar, Tab, TabBar, TextInput, ViewMode, WindowControl,
15 + icon, AlertDialog, Button, ControlsSide, Entry as TextEntry, HeaderBar, IconName, Menu,
16 + MenuEntry, PaintCtx, PathBar, PreferencesDialog, Switch, Tab, TabBar, TextInput, ViewMode,
17 + ViewSwitcher, WindowControl,
16 18 };
17 19
18 20 use crate::app::{App, ContextMenu, EditState, MenuCmd, MenuTarget, Pane, SideAction, SPLIT_GAP};
21 +use crate::archive;
19 22 use crate::list;
20 23 use crate::model::{format_mtime, human_size, Kind, SortKey};
21 24 use crate::ops::Job;
22 25 use crate::places;
26 +use crate::prefs;
23 27 use crate::trash;
24 28
25 29 const SIDEBAR_W: f64 = 216.0;
@@ -298,6 +302,12 @@ pub fn paint(app: &App, scene: &mut Scene, size: Size, theme: &Theme, text: &Tex
298 302 if let Some(confirm) = &app.confirm_empty {
299 303 confirm.paint(&mut ctx, size);
300 304 }
305 + if app.settings_open {
306 + paint_settings(&mut ctx, app, size);
307 + }
308 + if let Some(about) = &app.about {
309 + about.paint(&mut ctx, size);
310 + }
301 311 }
302 312
303 313 fn paint_header(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
@@ -582,13 +592,15 @@ fn paint_status(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
582 592 } else {
583 593 0.0
584 594 };
585 - let bar = Rect::from_xywh(
586 - lay.status.x(),
587 - lay.status.y() - 3.0,
588 - lay.status.width() * ratio,
589 - 3.0,
590 - );
591 - ctx.scene.fill_rect(bar, colors.accent);
595 + if total > 0 {
596 + let bar = Rect::from_xywh(
597 + lay.status.x(),
598 + lay.status.y() - 3.0,
599 + lay.status.width() * ratio,
600 + 3.0,
601 + );
602 + ctx.scene.fill_rect(bar, colors.accent);
603 + }
592 604 let speed = if app.op_speed > 1.0 {
593 605 format!("{}/s", human_size(app.op_speed as u64))
594 606 } else {
@@ -600,11 +612,17 @@ fn paint_status(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
600 612 } else {
601 613 String::new()
602 614 };
603 - let mut label = format!(
604 - "{} {}%",
605 - app.op_label.clone().unwrap_or_default(),
606 - (ratio * 100.0).round() as i64
607 - );
615 + // Operacje bez znanego rozmiaru (np. archiwa) nie mają procentów — pokazujemy
616 + // samą nazwę zadania i bieżący element.
617 + let mut label = if total > 0 {
618 + format!(
619 + "{} {}%",
620 + app.op_label.clone().unwrap_or_default(),
621 + (ratio * 100.0).round() as i64
622 + )
623 + } else {
624 + app.op_label.clone().unwrap_or_default()
625 + };
608 626 if !speed.is_empty() {
609 627 label.push_str(" · ");
610 628 label.push_str(&speed);
@@ -762,6 +780,158 @@ fn segment_path(path: &Path, index: usize) -> PathBuf {
762 780 // Zdarzenia
763 781 // ---------------------------------------------------------------------------
764 782
783 +// ---------------------------------------------------------------------------
784 +// Ustawienia / O aplikacji
785 +// ---------------------------------------------------------------------------
786 +
787 +struct SettingsLayout {
788 + rows: [Rect; 4],
789 + theme_seg: Rect,
790 + view_seg: Rect,
791 + lang_seg: Rect,
792 + hidden_switch: Rect,
793 + done: Rect,
794 +}
795 +
796 +fn settings_dialog() -> PreferencesDialog {
797 + PreferencesDialog::new(libluna_i18n::tr("Settings"), 376.0)
798 +}
799 +
800 +fn settings_layout(size: Size, theme: &Theme) -> SettingsLayout {
801 + let rects = settings_dialog().rects(size, theme);
802 + let pad = theme.spacing.lg;
803 + let inner = rects.content.deflate(Insets::new(pad, pad, pad, pad));
804 + let row_h = 46.0;
805 + let rows: [Rect; 4] = std::array::from_fn(|i| {
806 + Rect::from_xywh(
807 + inner.x(),
808 + inner.y() + i as f64 * row_h,
809 + inner.width(),
810 + row_h,
811 + )
812 + });
813 + let seg_w = 232.0_f64.min(inner.width() * 0.55);
814 + let seg = |i: usize| {
815 + Rect::from_xywh(
816 + inner.right() - seg_w,
817 + rows[i].center().y - 15.0,
818 + seg_w,
819 + 30.0,
820 + )
821 + };
822 + let (sw, sh) = Switch::new(false).size(theme);
823 + let hidden_switch = Rect::from_xywh(inner.right() - sw, rows[3].center().y - sh / 2.0, sw, sh);
824 + let bh = theme.metrics.button.height;
825 + let done = Rect::from_xywh(inner.right() - 120.0, inner.bottom() - bh, 120.0, bh);
826 + SettingsLayout {
827 + rows,
828 + theme_seg: seg(0),
829 + view_seg: seg(1),
830 + lang_seg: seg(2),
831 + hidden_switch,
832 + done,
833 + }
834 +}
835 +
836 +fn paint_settings(ctx: &mut PaintCtx, app: &App, size: Size) {
837 + settings_dialog().paint(ctx, size);
838 + let theme = *ctx.theme;
839 + let lay = settings_layout(size, &theme);
840 + let body = theme.typography.body.size;
841 + let labels = [
842 + libluna_i18n::tr("Theme"),
843 + libluna_i18n::tr("View"),
844 + libluna_i18n::tr("Language"),
845 + libluna_i18n::tr("Show hidden files"),
846 + ];
847 + for (i, label) in labels.iter().enumerate() {
848 + ctx.label_left(
849 + lay.rows[i].x() + 12.0,
850 + lay.rows[i].center().y,
851 + label,
852 + body,
853 + weight::NORMAL,
854 + theme.colors.text,
855 + );
856 + if i > 0 {
857 + ctx.scene.fill_rect(
858 + Rect::from_xywh(lay.rows[i].x(), lay.rows[i].y(), lay.rows[i].width(), 1.0),
859 + theme.colors.border,
860 + );
861 + }
862 + }
863 +
864 + let mut theme_switcher = ViewSwitcher::new([
865 + libluna_i18n::tr("Light"),
866 + libluna_i18n::tr("Dark"),
867 + libluna_i18n::tr("Auto"),
868 + ]);
869 + let theme_index = ThemePreference::ALL
870 + .iter()
871 + .position(|p| *p == app.theme_pref)
872 + .unwrap_or(2);
873 + theme_switcher.set_selected(theme_index);
874 + theme_switcher.paint(ctx, lay.theme_seg);
875 +
876 + let mut view_switcher = ViewSwitcher::new([libluna_i18n::tr("List"), libluna_i18n::tr("Grid")]);
877 + view_switcher.set_selected(match app.pane().view.mode() {
878 + ViewMode::List => 0,
879 + ViewMode::Grid => 1,
880 + });
881 + view_switcher.paint(ctx, lay.view_seg);
882 +
883 + // Nazwy języków pokazujemy natywnie — tak jak w systemowych ustawieniach.
884 + let mut lang_switcher = ViewSwitcher::new(["Polski", "English", "Deutsch"]);
885 + lang_switcher.set_selected(prefs::language_index(&app.prefs.language));
886 + lang_switcher.paint(ctx, lay.lang_seg);
887 +
888 + Switch::new(app.pane().dir.show_hidden()).paint(ctx, lay.hidden_switch);
889 + Button::new(libluna_i18n::tr("Done")).paint(ctx, lay.done);
890 +}
891 +
892 +/// Obsługuje klik w otwartym oknie Ustawień (modal blokuje resztę).
893 +fn on_settings_press(app: &mut App, p: Point, size: Size, theme: &Theme) {
894 + let lay = settings_layout(size, theme);
895 + let dialog = settings_dialog();
896 + if dialog.close_at(size, theme, p) || dialog.scrim_at(size, theme, p) || lay.done.contains(p) {
897 + app.settings_open = false;
898 + return;
899 + }
900 +
901 + let theme_switcher = ViewSwitcher::new([
902 + libluna_i18n::tr("Light"),
903 + libluna_i18n::tr("Dark"),
904 + libluna_i18n::tr("Auto"),
905 + ]);
906 + if let Some(i) = theme_switcher.index_at(lay.theme_seg, p) {
907 + let pref = ThemePreference::ALL[i];
908 + app.theme_pref = pref;
909 + app.pending_theme = Some(pref);
910 + return;
911 + }
912 +
913 + let view_switcher = ViewSwitcher::new([libluna_i18n::tr("List"), libluna_i18n::tr("Grid")]);
914 + if let Some(i) = view_switcher.index_at(lay.view_seg, p) {
915 + app.set_view(if i == 0 {
916 + ViewMode::List
917 + } else {
918 + ViewMode::Grid
919 + });
920 + return;
921 + }
922 +
923 + let lang_switcher = ViewSwitcher::new(["Polski", "English", "Deutsch"]);
924 + if let Some(i) = lang_switcher.index_at(lay.lang_seg, p) {
925 + app.set_language(prefs::LANGUAGES[i]);
926 + return;
927 + }
928 +
929 + if lay.hidden_switch.contains(p) {
930 + let on = app.pane().dir.show_hidden();
931 + app.set_show_hidden(!on);
932 + }
933 +}
934 +
765 935 pub fn handle_events(
766 936 app: &mut App,
767 937 events: &[Event],
@@ -772,6 +942,36 @@ pub fn handle_events(
772 942 let lay = layout(app, size, theme, text);
773 943 let ch = chrome(app, &lay, theme);
774 944
945 + // Modale (Ustawienia, O aplikacji) pochłaniają całe wejście.
946 + if app.settings_open {
947 + for event in events {
948 + if let Event::PointerButton(pe) = event {
949 + if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
950 + on_settings_press(app, pe.position, size, theme);
951 + }
952 + }
953 + }
954 + return;
955 + }
956 + if app.about.is_some() {
957 + for event in events {
958 + if let Event::PointerButton(pe) = event {
959 + if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
960 + let close = app.about.as_ref().is_some_and(|about| {
961 + let r = about.rects(size, theme, text);
962 + about.action_at(size, theme, text, pe.position).is_some()
963 + || r.dialog.close.contains(pe.position)
964 + || !r.dialog.panel.contains(pe.position)
965 + });
966 + if close {
967 + app.about = None;
968 + }
969 + }
970 + }
971 + }
972 + return;
973 + }
974 +
775 975 // Modal konfliktu blokuje resztę interfejsu.
776 976 if app.conflict.is_some() {
777 977 for event in events {
@@ -1291,6 +1491,7 @@ fn on_key(
1291 1491 }
1292 1492 0x74 => app.new_tab(), // Ctrl+T
1293 1493 0x77 => app.close_tab(), // Ctrl+W
1494 + 0x2c => app.settings_open = true, // Ctrl+,
1294 1495 0x63 => copy_selection(app, false), // Ctrl+C
1295 1496 0x78 => copy_selection(app, true), // Ctrl+X
1296 1497 0x76 => paste(app), // Ctrl+V
@@ -1663,6 +1864,14 @@ fn menu_entries(app: &App, target: &MenuTarget) -> (Vec<MenuEntry>, Vec<Option<M
1663 1864 m.action(tr("Open"), Some("Enter"), MenuCmd::Open);
1664 1865 m.action(tr("Open in Terminal"), Some("F4"), MenuCmd::OpenTerminal);
1665 1866 m.sep();
1867 + m.action(tr("Compress to ZIP"), None, MenuCmd::Compress);
1868 + if let MenuTarget::Item(path, is_dir) = target {
1869 + if !*is_dir && archive::is_archive(path) {
1870 + m.action(tr("Extract Here"), None, MenuCmd::ExtractHere);
1871 + m.action(tr("Extract to Folder"), None, MenuCmd::ExtractTo);
1872 + }
1873 + }
1874 + m.sep();
1666 1875 m.action(tr("Copy"), Some("Ctrl+C"), MenuCmd::Copy);
1667 1876 m.action(tr("Cut"), Some("Ctrl+X"), MenuCmd::Cut);
1668 1877 m.sep();
@@ -1748,6 +1957,11 @@ fn menu_entries(app: &App, target: &MenuTarget) -> (Vec<MenuEntry>, Vec<Option<M
1748 1957 m.sep();
1749 1958 m.action(tr("Refresh"), Some("F5"), MenuCmd::Refresh);
1750 1959 m.action(tr("Empty Trash"), None, MenuCmd::EmptyTrash);
1960 + if matches!(target, MenuTarget::AppMenu) {
1961 + m.sep();
1962 + m.action(tr("Settings"), Some("Ctrl+,"), MenuCmd::Settings);
1963 + m.action(tr("About Files"), None, MenuCmd::About);
1964 + }
1751 1965 }
1752 1966 }
1753 1967 (m.entries, m.cmds)
@@ -1868,6 +2082,24 @@ fn run_menu(app: &mut App, index: usize) {
1868 2082 app.navigate(app.active, mount);
1869 2083 }
1870 2084 }
2085 + MenuCmd::Settings => app.settings_open = true,
2086 + MenuCmd::About => app.show_about(),
2087 + MenuCmd::Compress => {
2088 + if let Some((path, _)) = target {
2089 + // Kompresujemy całe zaznaczenie, jeśli kliknięty element jest jego częścią.
2090 + let mut items = app.pane().selection();
2091 + if !items.contains(&path) {
2092 + items = vec![path];
2093 + }
2094 + let dir = app.pane().dir.path.clone();
2095 + app.compress(items, &dir);
2096 + }
2097 + }
2098 + MenuCmd::ExtractHere | MenuCmd::ExtractTo => {
2099 + if let Some((path, _)) = target {
2100 + app.extract(path, matches!(cmd, MenuCmd::ExtractHere));
2101 + }
2102 + }
1871 2103 }
1872 2104 }
1873 2105
@@ -1911,3 +2143,105 @@ fn show_properties(app: &mut App, path: &Path) {
1911 2143 );
1912 2144 app.show_toast(msg);
1913 2145 }
2146 +
2147 +#[cfg(test)]
2148 +mod tests {
2149 + use super::*;
2150 +
2151 + fn app() -> App {
2152 + App::new(std::env::temp_dir())
2153 + }
2154 +
2155 + #[test]
2156 + fn settings_dialog_renders_panel() {
2157 + let mut app = app();
2158 + app.settings_open = true;
2159 + let theme = Theme::light();
2160 + let text = TextEngine::system();
2161 + let size = Size::new(1120.0, 720.0);
2162 + let mut scene = Scene::new();
2163 + paint(&app, &mut scene, size, &theme, &text);
2164 + let mut renderer = libluna_render::SoftwareRenderer::new(1120, 720);
2165 + renderer.draw(&scene, 1.0, theme.colors.window_bg);
2166 + let panel = settings_dialog().rects(size, &theme).panel;
2167 + let px = |x: usize, y: usize| {
2168 + let i = (y * 1120 + x) * 4;
2169 + renderer.data()[i..i + 4].to_vec()
2170 + };
2171 + // Wnętrze panelu ma inne tło niź obszar poza modalem (przygaszenie + karta).
2172 + assert_ne!(
2173 + px(panel.center().x as usize, panel.center().y as usize),
2174 + px(10, 700)
2175 + );
2176 + }
2177 +
2178 + #[test]
2179 + fn about_dialog_can_be_opened() {
2180 + let mut app = app();
2181 + assert!(app.about.is_none());
2182 + app.show_about();
2183 + assert!(app.about.is_some());
2184 + }
2185 +
2186 + #[test]
2187 + fn settings_controls_stay_inside_dialog() {
2188 + let theme = Theme::light();
2189 + let size = Size::new(1120.0, 720.0);
2190 + let lay = settings_layout(size, &theme);
2191 + let dialog = settings_dialog();
2192 + let panel = dialog.rects(size, &theme).panel;
2193 + for row in lay.rows {
2194 + assert!(panel.contains(row.center()), "wiersz poza panelem");
2195 + }
2196 + assert!(panel.contains(lay.theme_seg.center()));
2197 + assert!(panel.contains(lay.view_seg.center()));
2198 + assert!(panel.contains(lay.lang_seg.center()));
2199 + assert!(panel.contains(lay.hidden_switch.center()));
2200 + assert!(panel.contains(lay.done.center()));
2201 + // Kontrolki nie nachodzą na przycisk zamknięcia w nagłówku.
2202 + let close = dialog.rects(size, &theme).close;
2203 + assert!(!lay.theme_seg.intersects(&close));
2204 + }
2205 +
2206 + #[test]
2207 + fn app_menu_has_settings_and_about_at_the_end() {
2208 + let app = app();
2209 + let (_, cmds) = menu_entries(&app, &MenuTarget::AppMenu);
2210 + let tail: Vec<MenuCmd> = cmds.iter().rev().filter_map(|c| *c).take(2).collect();
2211 + assert_eq!(tail.len(), 2);
2212 + assert!(matches!(tail[0], MenuCmd::About));
2213 + assert!(matches!(tail[1], MenuCmd::Settings));
2214 + }
2215 +
2216 + #[test]
2217 + fn archive_item_menu_offers_extract_actions() {
2218 + let app = app();
2219 + let dir = std::env::temp_dir();
2220 + let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("paczka.zip"), false));
2221 + let has = |needle: fn(&MenuCmd) -> bool| cmds.iter().flatten().any(needle);
2222 + assert!(has(|c| matches!(c, MenuCmd::Compress)));
2223 + assert!(has(|c| matches!(c, MenuCmd::ExtractHere)));
2224 + assert!(has(|c| matches!(c, MenuCmd::ExtractTo)));
2225 + }
2226 +
2227 + #[test]
2228 + fn regular_item_menu_has_no_extract_actions() {
2229 + let app = app();
2230 + let dir = std::env::temp_dir();
2231 + let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("notatka.txt"), false));
2232 + assert!(cmds
2233 + .iter()
2234 + .flatten()
2235 + .all(|c| !matches!(c, MenuCmd::ExtractHere | MenuCmd::ExtractTo)));
2236 + assert!(cmds
2237 + .iter()
2238 + .flatten()
2239 + .any(|c| matches!(c, MenuCmd::Compress)));
2240 + // Katalog (nawet o nazwie „.zip”) nie jest archiwum w menu.
2241 + let (_, cmds) = menu_entries(&app, &MenuTarget::Item(dir.join("folder.zip"), true));
2242 + assert!(cmds
2243 + .iter()
2244 + .flatten()
2245 + .all(|c| !matches!(c, MenuCmd::ExtractHere | MenuCmd::ExtractTo)));
2246 + }
2247 +}
@@ -148,3 +148,16 @@ Replace All = Alle ersetzen
148 148 {label}: {n} done, {skipped} skipped = {label}: {n} erledigt, {skipped} übersprungen
149 149 {time} left = noch {time}
150 150 Permanently delete all items in the Trash? = Alle Elemente im Papierkorb endgültig löschen?
151 +Done = Fertig
152 +About Files = Über Dateien
153 +File manager for the Luna desktop. = Dateimanager für die Luna-Arbeitsumgebung.
154 +Compress to ZIP = Als ZIP komprimieren
155 +Extract Here = Hier entpacken
156 +Extract to Folder = In Ordner entpacken
157 +Creating archive = Archiv wird erstellt
158 +Extracting = Wird entpackt
159 +Unsupported archive format = Nicht unterstütztes Archivformat
160 +Nothing selected = Nichts ausgewählt
161 +Invalid path = Ungültiger Pfad
162 +Selected items must share one folder = Ausgewählte Elemente müssen im selben Ordner liegen
163 +Archive tool not found = Archivwerkzeug nicht gefunden
@@ -149,3 +149,16 @@ Replace All = Replace All
149 149 {label}: {n} done, {skipped} skipped = {label}: {n} done, {skipped} skipped
150 150 {time} left = {time} left
151 151 Permanently delete all items in the Trash? = Permanently delete all items in the Trash?
152 +Done = Done
153 +About Files = About Files
154 +File manager for the Luna desktop. = File manager for the Luna desktop.
155 +Compress to ZIP = Compress to ZIP
156 +Extract Here = Extract Here
157 +Extract to Folder = Extract to Folder
158 +Creating archive = Creating archive
159 +Extracting = Extracting
160 +Unsupported archive format = Unsupported archive format
161 +Nothing selected = Nothing selected
162 +Invalid path = Invalid path
163 +Selected items must share one folder = Selected items must share one folder
164 +Archive tool not found = Archive tool not found
@@ -148,3 +148,16 @@ Replace All = Zastąp wszystkie
148 148 {label}: {n} done, {skipped} skipped = {label}: {n} — gotowe, pominięto {skipped}
149 149 {time} left = pozostało {time}
150 150 Permanently delete all items in the Trash? = Trwale usunąć wszystkie elementy z kosza?
151 +Done = Gotowe
152 +About Files = O aplikacji Pliki
153 +File manager for the Luna desktop. = Menedżer plików dla środowiska Luna.
154 +Compress to ZIP = Skompresuj do ZIP
155 +Extract Here = Wypakuj tutaj
156 +Extract to Folder = Wypakuj do folderu
157 +Creating archive = Tworzenie archiwum
158 +Extracting = Wypakowywanie
159 +Unsupported archive format = Nieobsługiwany format archiwum
160 +Nothing selected = Nic nie zaznaczono
161 +Invalid path = Nieprawidłowa ścieżka
162 +Selected items must share one folder = Zaznaczone elementy muszą być w jednym folderze
163 +Archive tool not found = Nie znaleziono narzędzia do archiwów