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