← luna

Commit 3716f9c

21
files
+5276
added
-3
deleted
@@ -638,6 +638,19 @@ dependencies = [
638 638 "tiny-skia",
639 639 ]
640 640
641 +[[package]]
642 +name = "luna-files"
643 +version = "0.0.1"
644 +dependencies = [
645 + "libc",
646 + "libluna-core",
647 + "libluna-i18n",
648 + "libluna-platform",
649 + "libluna-render",
650 + "libluna-widgets",
651 + "rustix 0.38.44",
652 +]
653 +
641 654 [[package]]
642 655 name = "memchr"
643 656 version = "2.8.3"
@@ -8,6 +8,7 @@ members = [
8 8 "crates/libluna-platform",
9 9 "crates/libluna-widgets",
10 10 "apps/luna-demo",
11 + "apps/luna-files",
11 12 "de/luna",
12 13 "de/selene",
13 14 ]
14 15 new file mode 100644
@@ -0,0 +1,17 @@
1 +[package]
2 +name = "luna-files"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Luna Files — menedżer plików środowiska Luna (libLuna, bez GTK): nawigacja, kosz XDG, urządzenia, operacje w tle."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +libluna-i18n.workspace = true
13 +libluna-render.workspace = true
14 +libluna-platform.workspace = true
15 +libluna-widgets.workspace = true
16 +rustix = { version = "0.38", features = ["fs"] }
17 +libc = "0.2"
0 18 new file mode 100644
@@ -0,0 +1,85 @@
1 +# Luna Files (`luna-files`)
2 +
3 +Pierwsza „duża” aplikacja uniwersum Luna: menedżer plików zbudowany **wyłącznie na
4 +libLunie** (`libluna-platform` + `libluna-widgets`), bez GTK, uruchamiany na
5 +kompozytorze Selene.
6 +
7 +Dostępne binarki:
8 +
9 +| Binarka | Opis |
10 +|---|---|
11 +| `luna-files` | podstawowa nazwa aplikacji (okno „Pliki”) |
12 +| `gaminedes` | alias — ta sama aplikacja, inna nazwa okna i App ID |
13 +
14 +## Co już działa
15 +
16 +**Nawigacja i widok**
17 +- breadcrumb z klikalnymi segmentami, `Ctrl+L` — edycja ścieżki, `Backspace` — w górę,
18 + `Alt+←/→` — historia, przyciski myszy Back/Forward;
19 +- **lista z kolumnami** (Nazwa / Rozmiar / Typ / Modyfikacja) i klikalnymi nagłówkami
20 + sortowania (strzałka pokazuje kierunek), **siatka** kafelków z ikonami typów plików;
21 +- sortowanie (nazwa/rozmiar/data/typ, rosnąco/malejąco), ukrywanie plików (`Ctrl+H`),
22 + filtr na żywo (`Ctrl+F`), panel boczny chowany `F9`;
23 +- zakładki (`Ctrl+T`, `Ctrl+W`, `Tab`), widok dzielony (`F3`), podgląd pliku (`Space`:
24 + tekst/kod oraz PNG, dla reszty metadane);
25 +- wielokrotne zaznaczanie: `Ctrl+klik`, `Shift+klik` (zakres), `Shift+↑/↓`, `Ctrl+A`;
26 + belka statusu pokazuje liczbę **i łączny rozmiar** zaznaczonych;
27 +- menu kontekstowe (prawy przycisk) i menu główne (☰) — wszystkie napisy przez i18n.
28 +
29 +**Pliki**
30 +- kopiowanie/wycinanie/wklejanie (`Ctrl+C/X/V`) z **dialogiem konfliktów**
31 + (Zastąp / Pomiń / Zmień nazwę / Zastąp wszystkie);
32 +- przenoszenie do kosza zgodnego z **XDG Trash Spec** (`Delete`) i trwałe usuwanie
33 + (`Shift+Delete`), **przywracanie z kosza** na pierwotną ścieżkę oraz opróżnianie
34 + kosza z potwierdzeniem;
35 +- zmiana nazwy (`F2`), nowy folder (`Ctrl+N`), odświeżanie (`F5`/`Ctrl+R`),
36 + otwieranie plików przez `xdg-open`, terminal w katalogu (`F4`).
37 +
38 +**Urządzenia**
39 +- panel boczny: miejsca XDG (z `~/.config/user-dirs.dirs`), kosz, zamontowane dyski
40 + z wolnym miejscem (rozmiar z `statvfs`);
41 +- montowanie/odmontowanie przez UDisks2 (`udisksctl` → D-Bus, hasło pyta `luna-polkit`);
42 +- lista odświeża się co 2 s, więc podłączenie pendrive'a pojawia się „samo”.
43 +
44 +**Operacje w tle**
45 +- kolejka zadań w osobnym wątku (`ops.rs`): postęp w belce statusu z **prędkością
46 + i szacowanym czasem**, anulowanie (`✕` lub `Escape`), wynik zbiorczy w powiadomieniu;
47 + UI nigdy nie blokuje się na dużym kopiowaniu.
48 +
49 +## Architektura
50 +
51 +| Plik | Rola |
52 +|---|---|
53 +| `model.rs` | wpisy katalogu, sortowanie, filtr, rozmiary i daty (testy bez UI) |
54 +| `list.rs` | lista z kolumnami, nagłówki sortowania, wirtualizacja wierszy |
55 +| `trash.rs` | kosz XDG: `files/` + `info/*.trashinfo`, kolizje nazw, `EXDEV` |
56 +| `places.rs` | miejsca XDG, montowania (`/proc/self/mountinfo`), `statvfs`, UDisks2 |
57 +| `ops.rs` | wielowątkowa kolejka kopiuj/przenieś/usuń/zmień nazwę |
58 +| `app.rs` | stan aplikacji, historia, zakładki, powiadomienia, pętla klatki |
59 +| `ui.rs` | layout, rysowanie, obsługa myszy/klawiatury, menu |
60 +
61 +## Skróty
62 +
63 +| Skrót | Akcja |
64 +|---|---|
65 +| `Ctrl+L` | wpisz ścieżkę |
66 +| `Ctrl+F` | szukaj w katalogu |
67 +| `Ctrl+T` / `Ctrl+W` | nowa karta / zamknij kartę |
68 +| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | kopiuj / wytnij / wklej |
69 +| `Ctrl+A` | zaznacz wszystko |
70 +| `Ctrl+H` | ukryj/pokaż pliki ukryte |
71 +| `F9` | pokaż/ukryj panel boczny |
72 +| `Ctrl+N` | nowy folder |
73 +| `F2` / `F3` / `F4` / `F5` | zmiana nazwy / dwa panele / terminal / odśwież |
74 +| `Space` | podgląd |
75 +| `Delete` / `Shift+Delete` | do kosza / trwale usuń |
76 +| `Escape` | zamknij podgląd/menu, wyczyść zaznaczenie |
77 +
78 +## Następne kroki
79 +
80 +- MTP/PTP (telefony) przez GVFS/libmtp, LUKS i montowanie partycji systemowych;
81 +- miniatury obrazów i wideo, podgląd PDF, kolumny (Miller), masowa zmiana nazw;
82 +- archiwa (zip/tar/7z) bez zewnętrznych aplikacji;
83 +- bezpośrednie sygnały UDisks2 (`InterfacesAdded/Removed`) zamiast odpytywania;
84 +- systemowy schowek dla „Kopiuj ścieżkę” (wymaga API schowka w `libluna-platform`);
85 +- „Otwórz za pomocą…” z wyborem aplikacji i zapamiętywaniem domyślnych.
0 86 new file mode 100644
@@ -0,0 +1,881 @@
1 +//! Stan aplikacji i pętla klatki (platforma libLuny).
2 +
3 +use std::error::Error;
4 +use std::path::{Path, PathBuf};
5 +use std::sync::Arc;
6 +use std::time::{Duration, Instant};
7 +
8 +use libluna_core::ThemeSettings;
9 +use libluna_i18n::{set_locale, tr, tr_args, Locale};
10 +use libluna_platform::{run as run_app, AppConfig, Frame};
11 +use libluna_render::ImageData;
12 +use libluna_widgets::{
13 + AlertDialog, Entry as TextEntry, ItemView, Menu, NavEntry, NavList, PaintCtx, ScrollView,
14 + SearchEntry, Toast,
15 +};
16 +
17 +use crate::model::{human_size, now_secs, DirView, Entry, Kind, SortKey};
18 +use crate::ops::{Job, Ops, OpsEvent};
19 +use crate::places::{self, Mount, Place};
20 +use crate::{trash, ui};
21 +
22 +/// Odstęp między panelami w trybie dzielonym.
23 +pub const SPLIT_GAP: f64 = 4.0;
24 +
25 +/// Edycja ścieżki albo nazwy (pole tekstowe w pasku adresu).
26 +pub struct EditState {
27 + pub entry: TextEntry,
28 + pub rename: Option<PathBuf>,
29 + pub new_folder: bool,
30 +}
31 +
32 +impl EditState {
33 + pub fn path(path: &Path) -> Self {
34 + let mut entry = TextEntry::new(tr("Path"));
35 + entry.set_text(path.display().to_string());
36 + entry.set_focused(true);
37 + entry.select_all();
38 + Self {
39 + entry,
40 + rename: None,
41 + new_folder: false,
42 + }
43 + }
44 +
45 + pub fn rename(path: &Path) -> Self {
46 + let mut entry = TextEntry::new(tr("Name"));
47 + entry.set_text(
48 + path.file_name()
49 + .map(|n| n.to_string_lossy().into_owned())
50 + .unwrap_or_default(),
51 + );
52 + entry.set_focused(true);
53 + entry.select_all();
54 + Self {
55 + entry,
56 + rename: Some(path.to_path_buf()),
57 + new_folder: false,
58 + }
59 + }
60 +}
61 +
62 +/// Podgląd pliku (Space).
63 +pub enum Preview {
64 + Text(String),
65 + Image(Arc<ImageData>),
66 + /// Brak podglądu (np. JPG bez dekodera) — pokazujemy metadane.
67 + None,
68 +}
69 +
70 +/// Jedna zakładka/panel z własną ścieżką i stanem widoku.
71 +pub struct Pane {
72 + pub dir: DirView,
73 + pub view: ItemView,
74 + pub scroll: ScrollView,
75 + pub tabs: Vec<PathBuf>,
76 + pub tab: usize,
77 + pub back: Vec<PathBuf>,
78 + pub forward: Vec<PathBuf>,
79 + pub edit: Option<EditState>,
80 + pub search: SearchEntry,
81 + pub search_active: bool,
82 + pub preview: bool,
83 + pub preview_cache: Option<(PathBuf, Preview)>,
84 +}
85 +
86 +impl Pane {
87 + fn new(path: PathBuf) -> Self {
88 + let dir = DirView::load(&path).unwrap_or_else(|_| {
89 + DirView::load(places::home()).unwrap_or_else(|_| DirView::load("/").expect("/"))
90 + });
91 + let mut view = ItemView::new(dir.items());
92 + view.set_mode(libluna_widgets::ViewMode::List);
93 + Self {
94 + tabs: vec![dir.path.clone()],
95 + tab: 0,
96 + back: Vec::new(),
97 + forward: Vec::new(),
98 + edit: None,
99 + search: SearchEntry::new(tr("Search")),
100 + search_active: false,
101 + preview: false,
102 + preview_cache: None,
103 + scroll: ScrollView::new(),
104 + dir,
105 + view,
106 + }
107 + }
108 +
109 + /// Indeksy zaznaczonych wpisów (widoczne).
110 + pub fn selection(&self) -> Vec<PathBuf> {
111 + let visible = self.dir.visible_entries();
112 + self.view
113 + .selected_indices()
114 + .iter()
115 + .filter_map(|i| visible.get(*i))
116 + .map(|e| e.path.clone())
117 + .collect()
118 + }
119 +
120 + pub fn primary(&self) -> Option<&Entry> {
121 + self.view.selected().and_then(|i| self.dir.entry(i))
122 + }
123 +
124 + /// Po zmianie katalogu/porządku odświeża `ItemView`, zachowując zaznaczenie
125 + /// po ścieżce (jeśli element nadal istnieje).
126 + pub fn sync_view(&mut self) {
127 + let keep = self.primary().map(|e| e.path.clone());
128 + let items = self.dir.items();
129 + self.view.set_items(items);
130 + if let Some(path) = keep {
131 + if let Some(pos) = (0..self.view.len())
132 + .find(|i| self.dir.entry(*i).map(|e| e.path == path).unwrap_or(false))
133 + {
134 + self.view.set_selected(Some(pos));
135 + }
136 + }
137 + self.scroll = ScrollView::new();
138 + self.preview_cache = None;
139 + }
140 +
141 + pub fn selected_title(&self) -> Option<String> {
142 + self.primary().map(|e| e.name.clone())
143 + }
144 +}
145 +
146 +/// Pozycja panelu bocznego.
147 +pub enum SideAction {
148 + Go(PathBuf),
149 + Unmount(usize),
150 + Mount(usize),
151 +}
152 +
153 +/// Co otworzyło menu kontekstowe.
154 +pub enum MenuTarget {
155 + Item(PathBuf, bool),
156 + TrashItem(PathBuf),
157 + Background,
158 + Device(usize),
159 + AppMenu,
160 +}
161 +
162 +pub struct ContextMenu {
163 + pub menu: Menu,
164 + pub panel: libluna_core::Rect,
165 + pub target: MenuTarget,
166 + /// Komenda dla każdego wpisu menu (`None` = sekcja/separator).
167 + pub cmds: Vec<Option<MenuCmd>>,
168 +}
169 +
170 +/// Akcja wybrana z menu (indeks wpisu → komenda).
171 +#[derive(Debug, Clone, Copy)]
172 +pub enum MenuCmd {
173 + Open,
174 + OpenTerminal,
175 + Copy,
176 + Cut,
177 + Paste,
178 + SelectAll,
179 + Rename,
180 + Trash,
181 + Delete,
182 + Properties,
183 + Restore,
184 + Purge,
185 + NewFolder,
186 + NewTab,
187 + Split,
188 + ToggleHidden,
189 + Sort(SortKey),
190 + ToggleAsc,
191 + Refresh,
192 + EmptyTrash,
193 + Unmount(usize),
194 + OpenDevice(usize),
195 +}
196 +
197 +pub struct Clipboard {
198 + pub paths: Vec<PathBuf>,
199 + pub cut: bool,
200 +}
201 +
202 +impl Clone for Clipboard {
203 + fn clone(&self) -> Self {
204 + Self {
205 + paths: self.paths.clone(),
206 + cut: self.cut,
207 + }
208 + }
209 +}
210 +
211 +/// Dialog konfliktu nazw (blokuje operację do decyzji użytkownika).
212 +pub struct ConflictPrompt {
213 + pub dialog: AlertDialog,
214 + pub src: PathBuf,
215 + pub dst: PathBuf,
216 +}
217 +
218 +impl ConflictPrompt {
219 + pub fn new(src: PathBuf, dst: PathBuf) -> Self {
220 + let name = src
221 + .file_name()
222 + .map(|n| n.to_string_lossy().into_owned())
223 + .unwrap_or_default();
224 + let body = tr_args(
225 + "{name} already exists in the destination.",
226 + &[("name", &name)],
227 + );
228 + let dialog = AlertDialog::new(tr("File exists"), body)
229 + .width(520.0)
230 + .button(tr("Replace"))
231 + .button(tr("Skip"))
232 + .button(tr("Rename"))
233 + .button(tr("Replace All"));
234 + Self { dialog, src, dst }
235 + }
236 +}
237 +
238 +/// Cały stan aplikacji.
239 +pub struct App {
240 + pub panes: [Pane; 2],
241 + pub active: usize,
242 + pub split: bool,
243 + pub sidebar_visible: bool,
244 + pub places: Vec<Place>,
245 + pub mounts: Vec<Mount>,
246 + pub sidebar: NavList,
247 + pub sidebar_actions: Vec<SideAction>,
248 + /// Dla każdego wpisu `sidebar.entries()` — indeks akcji (None = sekcja).
249 + pub sidebar_entry_action: Vec<Option<usize>>,
250 + pub context: Option<ContextMenu>,
251 + pub conflict: Option<ConflictPrompt>,
252 + /// Potwierdzenie opróżnienia kosza (modal).
253 + pub confirm_empty: Option<AlertDialog>,
254 + pub ops: Ops,
255 + pub op_label: Option<String>,
256 + pub op_progress: Option<(u64, u64)>,
257 + pub op_current: String,
258 + pub op_speed: f64,
259 + op_sample: Option<(Instant, u64)>,
260 + op_started: Option<Instant>,
261 + pub toast: Toast,
262 + pub clipboard: Option<Clipboard>,
263 + pub last_click: Option<(usize, Instant)>,
264 + pub last_mount_refresh: Instant,
265 + pub status: String,
266 + pub pending_close: bool,
267 + pub pending_minimize: bool,
268 + pub pending_maximize: bool,
269 + pub pending_move: bool,
270 +}
271 +
272 +impl App {
273 + pub fn new(start: PathBuf) -> Self {
274 + let home = places::home();
275 + let start = if start.is_dir() { start } else { home };
276 + let mut app = Self {
277 + panes: [Pane::new(start.clone()), Pane::new(start)],
278 + active: 0,
279 + split: false,
280 + sidebar_visible: true,
281 + places: Vec::new(),
282 + mounts: Vec::new(),
283 + sidebar: NavList::new(Vec::new()),
284 + sidebar_actions: Vec::new(),
285 + sidebar_entry_action: Vec::new(),
286 + context: None,
287 + conflict: None,
288 + confirm_empty: None,
289 + ops: Ops::new(),
290 + op_label: None,
291 + op_progress: None,
292 + op_current: String::new(),
293 + op_speed: 0.0,
294 + op_sample: None,
295 + op_started: None,
296 + toast: Toast::new(""),
297 + clipboard: None,
298 + last_click: None,
299 + last_mount_refresh: Instant::now() - Duration::from_secs(10),
300 + status: String::new(),
301 + pending_close: false,
302 + pending_minimize: false,
303 + pending_maximize: false,
304 + pending_move: false,
305 + };
306 + app.rebuild_sidebar();
307 + app
308 + }
309 +
310 + pub fn pane(&self) -> &Pane {
311 + &self.panes[self.active]
312 + }
313 +
314 + pub fn pane_mut(&mut self) -> &mut Pane {
315 + let i = self.active;
316 + &mut self.panes[i]
317 + }
318 +
319 + pub fn other_pane_mut(&mut self) -> &mut Pane {
320 + let i = 1 - self.active;
321 + &mut self.panes[i]
322 + }
323 +
324 + /// Nawigacja z zapisem historii.
325 + pub fn navigate(&mut self, pane: usize, path: PathBuf) {
326 + if !path.is_dir() {
327 + return;
328 + }
329 + if debug() {
330 + eprintln!("luna-files: navigate {}", path.display());
331 + }
332 + let previous = self.panes[pane].dir.path.clone();
333 + match DirView::load(&path) {
334 + Ok(dir) => {
335 + let p = &mut self.panes[pane];
336 + p.dir = dir;
337 + p.back.push(previous);
338 + p.forward.clear();
339 + p.tabs[p.tab] = p.dir.path.clone();
340 + p.search.clear();
341 + p.search_active = false;
342 + p.dir.set_filter("");
343 + p.edit = None;
344 + p.sync_view();
345 + self.select_sidebar_for(&path);
346 + }
347 + Err(e) => self.show_toast(format!("{}: {e}", path.display())),
348 + }
349 + }
350 +
351 + pub fn reload_pane(&mut self, pane: usize) {
352 + if self.panes[pane].dir.reload().is_ok() {
353 + self.panes[pane].sync_view();
354 + }
355 + }
356 +
357 + pub fn reload_all(&mut self) {
358 + for i in 0..2 {
359 + if self.panes[i].dir.reload().is_ok() {
360 + self.panes[i].sync_view();
361 + }
362 + }
363 + }
364 +
365 + pub fn back(&mut self) {
366 + let p = &mut self.panes[self.active];
367 + let Some(prev) = p.back.pop() else { return };
368 + p.forward.push(p.dir.path.clone());
369 + if let Ok(dir) = DirView::load(&prev) {
370 + p.dir = dir;
371 + p.tabs[p.tab] = p.dir.path.clone();
372 + p.sync_view();
373 + let path = p.dir.path.clone();
374 + self.select_sidebar_for(&path);
375 + }
376 + }
377 +
378 + pub fn forward(&mut self) {
379 + let p = &mut self.panes[self.active];
380 + let Some(next) = p.forward.pop() else {
381 + return;
382 + };
383 + p.back.push(p.dir.path.clone());
384 + if let Ok(dir) = DirView::load(&next) {
385 + p.dir = dir;
386 + p.tabs[p.tab] = p.dir.path.clone();
387 + p.sync_view();
388 + let path = p.dir.path.clone();
389 + self.select_sidebar_for(&path);
390 + }
391 + }
392 +
393 + pub fn up(&mut self) {
394 + let path = self.panes[self.active].dir.parent();
395 + if let Some(path) = path {
396 + self.navigate(self.active, path);
397 + }
398 + }
399 +
400 + /// Otwiera zaznaczony wpis (katalog → nawigacja, plik → `xdg-open`).
401 + pub fn open_selected(&mut self) {
402 + let Some(entry) = self.pane().primary().cloned() else {
403 + return;
404 + };
405 + if entry.is_dir {
406 + self.navigate(self.active, entry.path);
407 + } else {
408 + let _ = std::process::Command::new("xdg-open")
409 + .arg(&entry.path)
410 + .spawn();
411 + }
412 + }
413 +
414 + /// Otwiera terminal w bieżącym katalogu.
415 + pub fn open_terminal(&mut self) {
416 + let dir = self.pane().dir.path.clone();
417 + self.open_terminal_at(&dir);
418 + }
419 +
420 + /// Otwiera terminal w podanym katalogu.
421 + pub fn open_terminal_at(&mut self, dir: &Path) {
422 + let dir_arg = dir.display().to_string();
423 + let terminal = std::env::var("TERMINAL").ok();
424 + let spawned = match terminal.as_deref() {
425 + Some(t) if !t.is_empty() => std::process::Command::new(t)
426 + .current_dir(dir)
427 + .spawn()
428 + .is_ok(),
429 + _ => ["xfce4-terminal", "foot", "alacritty", "kitty", "wezterm"]
430 + .iter()
431 + .any(|t| spawn_terminal(t, &dir_arg)),
432 + };
433 + if !spawned {
434 + self.show_toast(tr("No terminal emulator found"));
435 + }
436 + }
437 +
438 + pub fn new_tab(&mut self) {
439 + let p = self.pane_mut();
440 + p.tabs.push(p.dir.path.clone());
441 + p.tab = p.tabs.len() - 1;
442 + }
443 +
444 + pub fn close_tab(&mut self) {
445 + let p = self.pane_mut();
446 + if p.tabs.len() <= 1 {
447 + return;
448 + }
449 + p.tabs.remove(p.tab);
450 + p.tab = p.tab.min(p.tabs.len() - 1);
451 + let path = p.tabs[p.tab].clone();
452 + if let Ok(dir) = DirView::load(&path) {
453 + p.dir = dir;
454 + p.sync_view();
455 + }
456 + }
457 +
458 + pub fn select_tab(&mut self, index: usize) {
459 + let p = self.pane_mut();
460 + if index < p.tabs.len() {
461 + p.tab = index;
462 + let path = p.tabs[index].clone();
463 + if let Ok(dir) = DirView::load(&path) {
464 + p.dir = dir;
465 + p.sync_view();
466 + }
467 + }
468 + }
469 +
470 + pub fn submit(&mut self, job: Job) {
471 + if !self.ops.submit(job) {
472 + self.show_toast(tr("Another operation is still running"));
473 + }
474 + }
475 +
476 + pub fn drain_ops(&mut self) {
477 + for ev in self.ops.poll() {
478 + match ev {
479 + OpsEvent::Started { label } => {
480 + self.op_label = Some(label);
481 + self.op_progress = Some((0, 0));
482 + self.op_current.clear();
483 + self.op_speed = 0.0;
484 + self.op_sample = None;
485 + self.op_started = Some(Instant::now());
486 + }
487 + OpsEvent::Progress {
488 + done,
489 + total,
490 + current,
491 + } => {
492 + let now = Instant::now();
493 + if let Some((t0, d0)) = self.op_sample {
494 + let dt = now.duration_since(t0).as_secs_f64();
495 + if dt >= 0.3 {
496 + let inst = done.saturating_sub(d0) as f64 / dt;
497 + self.op_speed = if self.op_speed <= 0.0 {
498 + inst
499 + } else {
500 + self.op_speed * 0.7 + inst * 0.3
501 + };
502 + self.op_sample = Some((now, done));
503 + }
504 + } else {
505 + self.op_sample = Some((now, done));
506 + }
507 + self.op_progress = Some((done, total));
508 + self.op_current = current;
509 + }
510 + OpsEvent::Conflict { src, dst, .. } => {
511 + self.conflict = Some(ConflictPrompt::new(src, dst));
512 + }
513 + OpsEvent::Finished {
514 + label,
515 + ok,
516 + skipped,
517 + failed,
518 + created,
519 + } => {
520 + if debug() {
521 + eprintln!(
522 + "luna-files: ops '{label}' ok={ok} skipped={skipped} failed={failed:?} created={created:?}"
523 + );
524 + }
525 + self.op_label = None;
526 + self.op_progress = None;
527 + self.op_current.clear();
528 + self.op_speed = 0.0;
529 + self.op_sample = None;
530 + self.op_started = None;
531 + self.conflict = None;
532 + self.reload_all();
533 + self.refresh_devices();
534 + if let Some(path) = created {
535 + self.select_path(&path);
536 + }
537 + let msg = if skipped > 0 {
538 + tr_args(
539 + "{label}: {n} done, {skipped} skipped",
540 + &[
541 + ("label", &label),
542 + ("n", &ok.to_string()),
543 + ("skipped", &skipped.to_string()),
544 + ],
545 + )
546 + } else {
547 + tr_args(
548 + "{label}: {n} done",
549 + &[("label", &label), ("n", &ok.to_string())],
550 + )
551 + };
552 + if let Some(first) = failed.first() {
553 + self.show_toast(format!("{msg} — {first}"));
554 + } else {
555 + self.show_toast(msg);
556 + }
557 + }
558 + }
559 + }
560 + }
561 +
562 + /// Odpowiedź na dialog konfliktu (indeks przycisku).
563 + pub fn answer_conflict(&mut self, index: usize) {
564 + use crate::ops::Decision;
565 + let decision = match index {
566 + 0 => Decision::Overwrite,
567 + 1 => Decision::Skip,
568 + 2 => Decision::Rename,
569 + 3 => Decision::OverwriteAll,
570 + _ => Decision::Skip,
571 + };
572 + if debug() {
573 + eprintln!("luna-files: conflict -> {decision:?}");
574 + }
575 + self.ops.decide(decision);
576 + self.conflict = None;
577 + }
578 +
579 + pub fn cancel_op(&mut self) {
580 + self.ops.cancel();
581 + }
582 +
583 + pub fn refresh_devices(&mut self) {
584 + let mounts = places::mounts();
585 + let changed = mounts.len() != self.mounts.len()
586 + || mounts
587 + .iter()
588 + .zip(self.mounts.iter())
589 + .any(|(a, b)| a.dev != b.dev || a.mount != b.mount);
590 + self.mounts = mounts;
591 + if changed {
592 + self.rebuild_sidebar();
593 + let path = self.pane().dir.path.clone();
594 + self.select_sidebar_for(&path);
595 + }
596 + self.last_mount_refresh = Instant::now();
597 + }
598 +
599 + pub fn refresh_devices_if_needed(&mut self) {
600 + if self.last_mount_refresh.elapsed() >= Duration::from_secs(2) {
601 + self.refresh_devices();
602 + }
603 + }
604 +
605 + pub fn rebuild_sidebar(&mut self) {
606 + self.places = places::places();
607 + let mut entries = Vec::new();
608 + let mut actions = Vec::new();
609 + let mut mapping = Vec::new();
610 +
611 + entries.push(NavEntry::Section(tr("Places")));
612 + mapping.push(None);
613 + for place in &self.places {
614 + entries.push(NavEntry::Item(place.label.clone()));
615 + actions.push(SideAction::Go(place.path.clone()));
616 + mapping.push(Some(actions.len() - 1));
617 + }
618 + entries.push(NavEntry::Item(tr("Trash")));
619 + actions.push(SideAction::Go(trash::trash_root().join("files")));
620 + mapping.push(Some(actions.len() - 1));
621 +
622 + if !self.mounts.is_empty() {
623 + entries.push(NavEntry::Section(tr("Devices")));
624 + mapping.push(None);
625 + for (i, m) in self.mounts.iter().enumerate() {
626 + let free = human_size(m.free);
627 + let label = tr_args(
628 + "{name} — {free} free",
629 + &[("name", &m.label), ("free", &free)],
630 + );
631 + entries.push(NavEntry::Item(label));
632 + actions.push(SideAction::Go(m.mount.clone()));
633 + mapping.push(Some(actions.len() - 1));
634 + // Lewy klik nawiguje; odmontowanie jest w menu kontekstowym.
635 + let _ = (i, SideAction::Unmount(i));
636 + }
637 + }
638 +
639 + self.sidebar = NavList::new(entries);
640 + self.sidebar_actions = actions;
641 + self.sidebar_entry_action = mapping;
642 + }
643 +
644 + fn select_sidebar_for(&mut self, path: &Path) {
645 + let mut best: Option<(usize, usize)> = None;
646 + for (ai, act) in self.sidebar_actions.iter().enumerate() {
647 + if let SideAction::Go(p) = act {
648 + if path.starts_with(p) {
649 + let depth = p.components().count();
650 + if best.map(|(_, d)| depth > d).unwrap_or(true) {
651 + best = Some((ai, depth));
652 + }
653 + }
654 + }
655 + }
656 + if let Some((ai, _)) = best {
657 + if let Some(entry) = self
658 + .sidebar_entry_action
659 + .iter()
660 + .position(|a| *a == Some(ai))
661 + {
662 + self.sidebar.set_selected(Some(entry));
663 + }
664 + }
665 + }
666 +
667 + /// Zaznacza plik w bieżącym panelu (np. po utworzeniu folderu).
668 + pub fn select_path(&mut self, path: &Path) {
669 + let p = self.pane_mut();
670 + let visible = p.dir.visible_entries();
671 + if let Some(i) = visible.iter().position(|e| e.path == *path) {
672 + p.view.set_selected(Some(i));
673 + }
674 + }
675 +
676 + pub fn show_toast(&mut self, message: impl Into<String>) {
677 + self.toast = Toast::new(message);
678 + self.toast.show_animated();
679 + }
680 +
681 + /// Status w dolnej belce.
682 + pub fn status_text(&self) -> String {
683 + let p = self.pane();
684 + let visible = p.view.len();
685 + let selected = p.view.selected_indices().len();
686 + let mut text = tr_args("{n} items", &[("n", &visible.to_string())]);
687 + if selected > 0 {
688 + let n = selected.to_string();
689 + let visible_entries = p.dir.visible_entries();
690 + let size: u64 = p
691 + .view
692 + .selected_indices()
693 + .iter()
694 + .filter_map(|i| visible_entries.get(*i))
695 + .map(|e| if e.is_dir { 0 } else { e.size })
696 + .sum();
697 + text = format!(
698 + "{text} · {}",
699 + tr_args(
700 + "{n} selected ({size})",
701 + &[("n", &n), ("size", &human_size(size))]
702 + )
703 + );
704 + }
705 + if let Some((total, free)) = places::statvfs(&p.dir.path) {
706 + let pct = if total > 0 {
707 + (total.saturating_sub(free)) as f64 / total as f64 * 100.0
708 + } else {
709 + 0.0
710 + };
711 + text = format!(
712 + "{text} · {} {} ({}%)",
713 + human_size(free),
714 + tr("free"),
715 + pct.round() as i64
716 + );
717 + }
718 + text
719 + }
720 +
721 + /// Odświeża podgląd zaznaczonego pliku.
722 + pub fn update_preview(&mut self) {
723 + let p = self.pane_mut();
724 + if !p.preview {
725 + p.preview_cache = None;
726 + return;
727 + }
728 + let Some(entry) = p.primary().cloned() else {
729 + p.preview_cache = None;
730 + return;
731 + };
732 + if p.preview_cache
733 + .as_ref()
734 + .map(|(path, _)| *path == entry.path)
735 + .unwrap_or(false)
736 + {
737 + return;
738 + }
739 + let preview = preview_for(&entry);
740 + p.preview_cache = Some((entry.path, preview));
741 + }
742 +}
743 +
744 +/// Diagnostyka `LUNA_FILES_DEBUG=1`.
745 +pub fn debug() -> bool {
746 + std::env::var("LUNA_FILES_DEBUG").is_ok()
747 +}
748 +
749 +fn preview_for(entry: &Entry) -> Preview {
750 + if entry.is_dir {
751 + return Preview::None;
752 + }
753 + match entry.kind {
754 + Kind::Image => {
755 + // Dekoder PNG jest w libluna-render; inne formaty → metadane.
756 + if entry
757 + .path
758 + .extension()
759 + .and_then(|e| e.to_str())
760 + .map(|e| e.eq_ignore_ascii_case("png"))
761 + .unwrap_or(false)
762 + {
763 + if let Ok(bytes) = std::fs::read(&entry.path) {
764 + if let Some(img) = libluna_render::wallpaper::decode_png(&bytes) {
765 + return Preview::Image(Arc::new(img));
766 + }
767 + }
768 + }
769 + Preview::None
770 + }
771 + Kind::Text | Kind::Code => {
772 + use std::io::Read;
773 + let mut file = match std::fs::File::open(&entry.path) {
774 + Ok(f) => f,
775 + Err(_) => return Preview::None,
776 + };
777 + let mut buf = vec![0u8; 64 * 1024];
778 + let n = file.read(&mut buf).unwrap_or(0);
779 + buf.truncate(n);
780 + let text = String::from_utf8_lossy(&buf);
781 + let lines: Vec<&str> = text.lines().take(200).collect();
782 + Preview::Text(lines.join("\n"))
783 + }
784 + _ => Preview::None,
785 + }
786 +}
787 +
788 +fn spawn_terminal(program: &str, dir: &str) -> bool {
789 + let args: &[&str] = match program {
790 + "xfce4-terminal" => &["--working-directory", dir],
791 + "gnome-terminal" => &["--working-directory", dir],
792 + "kitty" => &["--directory", dir],
793 + "alacritty" => &["--working-directory", dir],
794 + _ => &[],
795 + };
796 + std::process::Command::new(program)
797 + .args(args)
798 + .current_dir(dir)
799 + .spawn()
800 + .is_ok()
801 +}
802 +
803 +/// Uruchamia aplikację (binarki `luna-files` i `gaminedes`).
804 +pub fn run(title_key: &str, app_id: &str) -> Result<(), Box<dyn Error>> {
805 + set_locale(Locale::from_env());
806 + libluna_i18n::load_catalogs_from_dirs();
807 + let title = tr(title_key);
808 +
809 + let settings = ThemeSettings::load();
810 + let system_dark = std::env::var("LUNA_SYSTEM_DARK")
811 + .map(|v| !v.is_empty() && v != "0")
812 + .unwrap_or(false);
813 +
814 + let start = std::env::args()
815 + .nth(1)
816 + .map(PathBuf::from)
817 + .filter(|p| p.is_dir())
818 + .or_else(|| std::env::current_dir().ok())
819 + .unwrap_or_else(places::home);
820 +
821 + let config = AppConfig::new(title)
822 + .app_id(app_id)
823 + .initial_size(1120, 720)
824 + .settings(settings)
825 + .system_dark(system_dark);
826 +
827 + let mut app = App::new(start);
828 + app.status = app.status_text();
829 +
830 + run_app(config, move |frame: &mut Frame| {
831 + let theme = frame.settings().resolve_themed(frame.system_dark());
832 + app.drain_ops();
833 + app.refresh_devices_if_needed();
834 +
835 + ui::handle_events(&mut app, frame.events, frame.size, &theme, frame.text);
836 +
837 + if app.pending_close {
838 + frame.close();
839 + app.pending_close = false;
840 + }
841 + if app.pending_minimize {
842 + frame.minimize();
843 + app.pending_minimize = false;
844 + }
845 + if app.pending_maximize {
846 + frame.toggle_maximize();
847 + app.pending_maximize = false;
848 + }
849 + if app.pending_move {
850 + frame.begin_move();
851 + app.pending_move = false;
852 + }
853 +
854 + app.update_preview();
855 + app.status = app.status_text();
856 +
857 + ui::paint(&app, frame.scene, frame.size, &theme, frame.text);
858 +
859 + if app.toast.is_animating() {
860 + app.toast.advance(frame.delta_time(), 0.3);
861 + frame.request_frame();
862 + }
863 + if app.ops.busy() {
864 + frame.request_frame();
865 + }
866 + })
867 +}
868 +
869 +/// Pomocnik dla UI: `PaintCtx` z klatki.
870 +pub fn paint_ctx<'a>(
871 + scene: &'a mut libluna_render::Scene,
872 + theme: &'a libluna_core::Theme,
873 + text: &'a libluna_render::TextEngine,
874 +) -> PaintCtx<'a> {
875 + PaintCtx::new(scene, theme, text)
876 +}
877 +
878 +/// Na potrzeby UI: bieżący czas do animacji/migań.
879 +pub fn tick() -> i64 {
880 + now_secs()
881 +}
0 882 new file mode 100644
@@ -0,0 +1,7 @@
1 +//! `gaminedes` — alias menedżera plików Luny (ta sama aplikacja, inna nazwa okna).
2 +
3 +use std::error::Error;
4 +
5 +fn main() -> Result<(), Box<dyn Error>> {
6 + luna_files::run_gaminedes()
7 +}
0 8 new file mode 100644
@@ -0,0 +1,21 @@
1 +//! Luna Files — menedżer plików środowiska Luna na libLunie.
2 +
3 +pub mod app;
4 +pub mod list;
5 +pub mod model;
6 +pub mod ops;
7 +pub mod places;
8 +pub mod trash;
9 +pub mod ui;
10 +
11 +use std::error::Error;
12 +
13 +/// Binarka `luna-files`.
14 +pub fn run_files() -> Result<(), Box<dyn Error>> {
15 + app::run("Files", "eu.luna.Files")
16 +}
17 +
18 +/// Binarka `gaminedes` — ta sama aplikacja pod inną nazwą (alias).
19 +pub fn run_gaminedes() -> Result<(), Box<dyn Error>> {
20 + app::run("Gaminedes", "eu.luna.Gaminedes")
21 +}
0 22 new file mode 100644
@@ -0,0 +1,277 @@
1 +//! Widok listy z kolumnami (Nazwa / Rozmiar / Typ / Modyfikacja).
2 +//!
3 +//! `ItemView` z libLuny rysuje tylko etykietę z dopiskiem — do menedżera plików
4 +//! potrzebne są prawdziwe kolumny, klikalne nagłówki i wirtualizacja. Ten moduł
5 +//! rysuje listę bezpośrednio z `DirView`, więc przewijanie i zaznaczanie zostają
6 +//! spójne z resztą aplikacji.
7 +
8 +use libluna_core::style::weight;
9 +use libluna_core::{Point, Rect, Theme};
10 +use libluna_widgets::{icon, IconName, PaintCtx};
11 +
12 +use crate::model::{format_mtime, human_size, DirView, Kind, SortKey};
13 +
14 +/// Wysokość paska nagłówków kolumn.
15 +pub const HEADER_H: f64 = 26.0;
16 +
17 +#[derive(Debug, Clone, Copy)]
18 +pub struct Columns {
19 + pub name: Rect,
20 + pub size: Rect,
21 + pub kind: Rect,
22 + pub modified: Rect,
23 +}
24 +
25 +/// Szerokości kolumn: nazwa bierze resztę, reszta ma stałe minima.
26 +pub fn columns(rect: Rect, _theme: &Theme) -> Columns {
27 + let size_w = 90.0;
28 + let kind_w = 120.0;
29 + let date_w = 140.0;
30 + let name_w = (rect.width() - size_w - kind_w - date_w).max(100.0);
31 + Columns {
32 + name: Rect::from_xywh(rect.x(), rect.y(), name_w, rect.height()),
33 + size: Rect::from_xywh(rect.x() + name_w, rect.y(), size_w, rect.height()),
34 + kind: Rect::from_xywh(rect.x() + name_w + size_w, rect.y(), kind_w, rect.height()),
35 + modified: Rect::from_xywh(
36 + rect.x() + name_w + size_w + kind_w,
37 + rect.y(),
38 + date_w,
39 + rect.height(),
40 + ),
41 + }
42 +}
43 +
44 +/// Obszar wierszy (poniżej nagłówka).
45 +pub fn rows_rect(rect: Rect) -> Rect {
46 + Rect::from_xywh(
47 + rect.x(),
48 + rect.y() + HEADER_H,
49 + rect.width(),
50 + (rect.height() - HEADER_H).max(1.0),
51 + )
52 +}
53 +
54 +/// Trafienie w nagłówek kolumny (sortowanie).
55 +pub fn header_at(rect: Rect, p: Point, theme: &Theme) -> Option<SortKey> {
56 + if p.y < rect.y() || p.y >= rect.y() + HEADER_H {
57 + return None;
58 + }
59 + let c = columns(rect, theme);
60 + if c.name.contains(p) {
61 + Some(SortKey::Name)
62 + } else if c.size.contains(p) {
63 + Some(SortKey::Size)
64 + } else if c.kind.contains(p) {
65 + Some(SortKey::Kind)
66 + } else if c.modified.contains(p) {
67 + Some(SortKey::Modified)
68 + } else {
69 + None
70 + }
71 +}
72 +
73 +/// Prostokąt wiersza (z przewinięciem).
74 +pub fn row_rect(rows: Rect, i: usize, scroll: f64, theme: &Theme) -> Rect {
75 + Rect::from_xywh(
76 + rows.x(),
77 + rows.y() + i as f64 * theme.metrics.itemview.row_h - scroll,
78 + rows.width(),
79 + theme.metrics.itemview.row_h,
80 + )
81 +}
82 +
83 +/// Indeks wiersza pod punktem.
84 +pub fn index_at(rows: Rect, p: Point, scroll: f64, len: usize, theme: &Theme) -> Option<usize> {
85 + if !rows.contains(p) {
86 + return None;
87 + }
88 + let row_h = theme.metrics.itemview.row_h;
89 + let index = ((p.y - rows.y() + scroll) / row_h).floor();
90 + if index < 0.0 {
91 + return None;
92 + }
93 + let index = index as usize;
94 + (index < len).then_some(index)
95 +}
96 +
97 +/// Rysuje nagłówek + widoczne wiersze.
98 +pub fn paint(
99 + ctx: &mut PaintCtx,
100 + rect: Rect,
101 + dir: &DirView,
102 + selection: &[usize],
103 + hovered: Option<usize>,
104 + scroll: f64,
105 + theme: &Theme,
106 +) {
107 + let colors = theme.colors;
108 + let c = columns(rect, theme);
109 + let rows = rows_rect(rect);
110 +
111 + // Nagłówek
112 + let header = Rect::from_xywh(rect.x(), rect.y(), rect.width(), HEADER_H);
113 + ctx.scene.fill_rect(header, colors.headerbar_bg);
114 + ctx.scene.fill_rect(
115 + Rect::from_xywh(header.x(), header.bottom() - 1.0, header.width(), 1.0),
116 + colors.border,
117 + );
118 + let caption = theme.typography.caption.size;
119 + let arrow = if dir.ascending() { "▲" } else { "▼" };
120 + let labels = [
121 + (c.name, SortKey::Name, "Name", false),
122 + (c.size, SortKey::Size, "Size", true),
123 + (c.kind, SortKey::Kind, "Type", false),
124 + (c.modified, SortKey::Modified, "Modified", false),
125 + ];
126 + for (area, key, label, right) in labels {
127 + let text = if dir.sort_key() == key {
128 + format!("{} {arrow}", libluna_i18n::tr(label))
129 + } else {
130 + libluna_i18n::tr(label)
131 + };
132 + let color = if dir.sort_key() == key {
133 + colors.text
134 + } else {
135 + colors.dim_text
136 + };
137 + let cy = header.center().y;
138 + if right {
139 + ctx.label_right(
140 + area.right() - 10.0,
141 + cy,
142 + &text,
143 + caption,
144 + weight::MEDIUM,
145 + color,
146 + );
147 + } else {
148 + ctx.label_left(area.x() + 10.0, cy, &text, caption, weight::MEDIUM, color);
149 + }
150 + }
151 +
152 + // Wiersze (wirtualizacja)
153 + ctx.scene.push_clip(rows);
154 + let row_h = theme.metrics.itemview.row_h;
155 + let first = ((0.0f64).max(scroll / row_h)).floor() as usize;
156 + let visible = (rows.height() / row_h).ceil() as usize + 2;
157 + let visible_indices = dir.visible();
158 + for i in first..(first + visible).min(visible_indices.len()) {
159 + let entry = &dir.entries[visible_indices[i]];
160 + let r = row_rect(rows, i, scroll, theme);
161 + if selection.binary_search(&i).is_ok() {
162 + ctx.scene
163 + .fill_rounded_rect(r, theme.radius.small, colors.shade.scale_alpha(1.8));
164 + } else if hovered == Some(i) {
165 + ctx.scene
166 + .fill_rounded_rect(r, theme.radius.small, colors.shade);
167 + }
168 + let icon_box = Rect::from_xywh(r.x() + 10.0, r.center().y - 9.0, 18.0, 18.0);
169 + draw_kind(ctx, entry.kind, icon_box, colors.accent, colors.dim_text);
170 + let name_color = if entry.hidden {
171 + colors.dim_text
172 + } else {
173 + colors.text
174 + };
175 + ctx.label_left(
176 + c.name.x() + 38.0,
177 + r.center().y,
178 + &entry.name,
179 + theme.typography.body.size,
180 + weight::NORMAL,
181 + name_color,
182 + );
183 + if !entry.is_dir {
184 + ctx.label_right(
185 + c.size.right() - 10.0,
186 + r.center().y,
187 + &human_size(entry.size),
188 + theme.typography.caption.size,
189 + weight::NORMAL,
190 + colors.dim_text,
191 + );
192 + }
193 + ctx.label_left(
194 + c.kind.x() + 10.0,
195 + r.center().y,
196 + &entry.kind.label(),
197 + theme.typography.caption.size,
198 + weight::NORMAL,
199 + colors.dim_text,
200 + );
201 + ctx.label_left(
202 + c.modified.x() + 10.0,
203 + r.center().y,
204 + &format_mtime(entry.modified),
205 + theme.typography.caption.size,
206 + weight::NORMAL,
207 + colors.dim_text,
208 + );
209 + }
210 + ctx.scene.pop_clip();
211 +}
212 +
213 +fn draw_kind(
214 + ctx: &mut PaintCtx,
215 + kind: Kind,
216 + rect: Rect,
217 + folder: libluna_core::Color,
218 + file: libluna_core::Color,
219 +) {
220 + match kind {
221 + Kind::Folder => icon::folder(ctx, folder, rect),
222 + Kind::Image => icon::paint_icon(ctx, IconName::Image, rect, file),
223 + Kind::Audio => icon::paint_icon(ctx, IconName::Music, rect, file),
224 + Kind::Video => icon::paint_icon(ctx, IconName::Video, rect, file),
225 + Kind::Archive => icon::paint_icon(ctx, IconName::Archive, rect, file),
226 + Kind::Document => icon::paint_icon(ctx, IconName::Document, rect, file),
227 + Kind::Code => icon::paint_icon(ctx, IconName::Code, rect, file),
228 + Kind::Text => icon::paint_icon(ctx, IconName::Document, rect, file),
229 + Kind::Other => icon::file(ctx, file, rect),
230 + }
231 +}
232 +
233 +#[cfg(test)]
234 +mod tests {
235 + use super::*;
236 +
237 + fn rect() -> Rect {
238 + Rect::from_xywh(0.0, 0.0, 700.0, 300.0)
239 + }
240 +
241 + #[test]
242 + fn columns_tile_the_width() {
243 + let theme = Theme::light();
244 + let c = columns(rect(), &theme);
245 + assert!(
246 + (c.name.width() + c.size.width() + c.kind.width() + c.modified.width() - 700.0).abs()
247 + < 0.01
248 + );
249 + assert!(c.modified.right() <= 700.0 + 0.01);
250 + }
251 +
252 + #[test]
253 + fn header_hits_sort_columns() {
254 + let theme = Theme::light();
255 + let c = columns(rect(), &theme);
256 + let p = Point::new(c.size.center().x, 12.0);
257 + assert_eq!(header_at(rect(), p, &theme), Some(SortKey::Size));
258 + assert_eq!(header_at(rect(), Point::new(5.0, 40.0), &theme), None);
259 + }
260 +
261 + #[test]
262 + fn row_index_respects_scroll() {
263 + let theme = Theme::light();
264 + let rows = rows_rect(rect());
265 + let row_h = theme.metrics.itemview.row_h;
266 + // Drugi wiersz przy przewinięciu 0.
267 + let p = Point::new(20.0, rows.y() + row_h * 1.5);
268 + assert_eq!(index_at(rows, p, 0.0, 10, &theme), Some(1));
269 + // Przy przewinięciu o jeden wiersz ten sam punkt to trzeci wiersz.
270 + assert_eq!(index_at(rows, p, row_h, 10, &theme), Some(2));
271 + // Poza listą.
272 + assert_eq!(
273 + index_at(rows, Point::new(20.0, rows.bottom() + 5.0), 0.0, 10, &theme),
274 + None
275 + );
276 + }
277 +}
0 278 new file mode 100644
@@ -0,0 +1,7 @@
1 +//! `luna-files` — menedżer plików Luny.
2 +
3 +use std::error::Error;
4 +
5 +fn main() -> Result<(), Box<dyn Error>> {
6 + luna_files::run_files()
7 +}
0 8 new file mode 100644
@@ -0,0 +1,472 @@
1 +//! Model katalogu: wpisy, sortowanie, filtrowanie i formatowanie.
2 +//!
3 +//! Warstwa niezależna od UI — da się ją testować bez okna i bez Waylanda.
4 +
5 +use std::fs;
6 +use std::io;
7 +use std::path::{Path, PathBuf};
8 +use std::time::UNIX_EPOCH;
9 +
10 +use libluna_i18n::tr;
11 +use libluna_widgets::{Item, ItemKind};
12 +
13 +/// Klucz sortowania listy.
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 +pub enum SortKey {
16 + Name,
17 + Size,
18 + Modified,
19 + Kind,
20 +}
21 +
22 +impl SortKey {
23 + pub fn label(self) -> String {
24 + tr(match self {
25 + SortKey::Name => "Name",
26 + SortKey::Size => "Size",
27 + SortKey::Modified => "Modified",
28 + SortKey::Kind => "Type",
29 + })
30 + }
31 +
32 + pub fn next(self) -> Self {
33 + match self {
34 + SortKey::Name => SortKey::Size,
35 + SortKey::Size => SortKey::Modified,
36 + SortKey::Modified => SortKey::Kind,
37 + SortKey::Kind => SortKey::Name,
38 + }
39 + }
40 +}
41 +
42 +/// Rodzaj pliku (na podstawie rozszerzenia) — do ikon, podpisów i kolumny „Typ”.
43 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 +pub enum Kind {
45 + Folder,
46 + Image,
47 + Audio,
48 + Video,
49 + Archive,
50 + Document,
51 + Code,
52 + Text,
53 + Other,
54 +}
55 +
56 +impl Kind {
57 + pub fn label(self) -> String {
58 + tr(match self {
59 + Kind::Folder => "Folder",
60 + Kind::Image => "Image",
61 + Kind::Audio => "Audio",
62 + Kind::Video => "Video",
63 + Kind::Archive => "Archive",
64 + Kind::Document => "Document",
65 + Kind::Code => "Code",
66 + Kind::Text => "Text file",
67 + Kind::Other => "File",
68 + })
69 + }
70 +
71 + pub fn widget_kind(self) -> ItemKind {
72 + match self {
73 + Kind::Folder => ItemKind::Folder,
74 + Kind::Image => ItemKind::Image,
75 + Kind::Audio => ItemKind::Audio,
76 + Kind::Video => ItemKind::Video,
77 + Kind::Archive => ItemKind::Archive,
78 + Kind::Document => ItemKind::Document,
79 + Kind::Code => ItemKind::Code,
80 + Kind::Text => ItemKind::Text,
81 + Kind::Other => ItemKind::File,
82 + }
83 + }
84 +
85 + fn from_name(name: &str, is_dir: bool) -> Self {
86 + if is_dir {
87 + return Kind::Folder;
88 + }
89 + let ext = Path::new(name)
90 + .extension()
91 + .and_then(|e| e.to_str())
92 + .unwrap_or("")
93 + .to_ascii_lowercase();
94 + match ext.as_str() {
95 + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "svg" | "ico" | "tiff" | "avif"
96 + | "heic" => Kind::Image,
97 + "mp3" | "flac" | "ogg" | "oga" | "wav" | "opus" | "m4a" | "aac" | "wma" => Kind::Audio,
98 + "mp4" | "mkv" | "webm" | "avi" | "mov" | "m4v" | "wmv" | "flv" => Kind::Video,
99 + "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "deb" | "rpm"
100 + | "iso" => Kind::Archive,
101 + "pdf" | "odt" | "ods" | "odp" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
102 + | "epub" | "rtf" => Kind::Document,
103 + "rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
104 + | "java" | "sh" | "toml" | "yaml" | "yml" | "json" | "xml" | "css" | "html" | "lua"
105 + | "rb" | "php" | "cs" | "kt" | "swift" => Kind::Code,
106 + "txt" | "md" | "log" | "conf" | "ini" | "cfg" | "csv" | "lang" => Kind::Text,
107 + _ => Kind::Other,
108 + }
109 + }
110 +}
111 +
112 +/// Pojedynczy wpis w katalogu.
113 +#[derive(Debug, Clone)]
114 +pub struct Entry {
115 + pub name: String,
116 + pub path: PathBuf,
117 + pub is_dir: bool,
118 + pub size: u64,
119 + /// Czas modyfikacji (sekundy od epoki).
120 + pub modified: Option<i64>,
121 + pub hidden: bool,
122 + pub symlink: bool,
123 + pub kind: Kind,
124 +}
125 +
126 +impl Entry {
127 + pub fn detail(&self) -> String {
128 + if self.is_dir {
129 + Kind::Folder.label()
130 + } else {
131 + human_size(self.size)
132 + }
133 + }
134 +
135 + pub fn item(&self) -> Item {
136 + Item::new(self.name.clone())
137 + .detail(self.detail())
138 + .kind(self.kind.widget_kind())
139 + }
140 +}
141 +
142 +/// Zawartość katalogu + ustawienia prezentacji.
143 +#[derive(Debug, Clone)]
144 +pub struct DirView {
145 + pub path: PathBuf,
146 + pub entries: Vec<Entry>,
147 + sort: SortKey,
148 + ascending: bool,
149 + show_hidden: bool,
150 + filter: String,
151 +}
152 +
153 +impl DirView {
154 + /// Wczytuje katalog (bez podążania za `..`).
155 + pub fn load(path: impl Into<PathBuf>) -> io::Result<Self> {
156 + let path = path.into();
157 + let entries = read_entries(&path)?;
158 + let mut view = Self {
159 + path,
160 + entries,
161 + sort: SortKey::Name,
162 + ascending: true,
163 + show_hidden: false,
164 + filter: String::new(),
165 + };
166 + view.resort();
167 + Ok(view)
168 + }
169 +
170 + pub fn reload(&mut self) -> io::Result<()> {
171 + self.entries = read_entries(&self.path)?;
172 + self.resort();
173 + Ok(())
174 + }
175 +
176 + pub fn sort_key(&self) -> SortKey {
177 + self.sort
178 + }
179 +
180 + pub fn set_sort_key(&mut self, key: SortKey) {
181 + self.sort = key;
182 + self.resort();
183 + }
184 +
185 + pub fn ascending(&self) -> bool {
186 + self.ascending
187 + }
188 +
189 + pub fn set_ascending(&mut self, ascending: bool) {
190 + self.ascending = ascending;
191 + self.resort();
192 + }
193 +
194 + pub fn show_hidden(&self) -> bool {
195 + self.show_hidden
196 + }
197 +
198 + pub fn set_show_hidden(&mut self, show: bool) {
199 + self.show_hidden = show;
200 + }
201 +
202 + pub fn filter(&self) -> &str {
203 + &self.filter
204 + }
205 +
206 + pub fn set_filter(&mut self, filter: impl Into<String>) {
207 + self.filter = filter.into().to_lowercase();
208 + }
209 +
210 + pub fn title(&self) -> String {
211 + self.path
212 + .file_name()
213 + .map(|n| n.to_string_lossy().into_owned())
214 + .unwrap_or_else(|| self.path.display().to_string())
215 + }
216 +
217 + pub fn parent(&self) -> Option<PathBuf> {
218 + self.path.parent().map(Path::to_path_buf)
219 + }
220 +
221 + /// Indeksy wpisów widocznych po filtrze i ukrywaniu.
222 + pub fn visible(&self) -> Vec<usize> {
223 + self.entries
224 + .iter()
225 + .enumerate()
226 + .filter(|(_, e)| (self.show_hidden || !e.hidden) && self.matches(e))
227 + .map(|(i, _)| i)
228 + .collect()
229 + }
230 +
231 + fn matches(&self, e: &Entry) -> bool {
232 + self.filter.is_empty() || e.name.to_lowercase().contains(&self.filter)
233 + }
234 +
235 + /// Lista do `ItemView` (kolejność = `visible()`).
236 + pub fn items(&self) -> Vec<Item> {
237 + self.visible()
238 + .into_iter()
239 + .map(|i| self.entries[i].item())
240 + .collect()
241 + }
242 +
243 + /// Widoczne wpisy w kolejności prezentacji.
244 + pub fn visible_entries(&self) -> Vec<&Entry> {
245 + self.visible()
246 + .into_iter()
247 + .filter_map(|i| self.entries.get(i))
248 + .collect()
249 + }
250 +
251 + pub fn entry(&self, visible_index: usize) -> Option<&Entry> {
252 + let i = *self.visible().get(visible_index)?;
253 + self.entries.get(i)
254 + }
255 +
256 + fn resort(&mut self) {
257 + let key = self.sort;
258 + let asc = self.ascending;
259 + self.entries.sort_by(|a, b| {
260 + // Katalogi zawsze przed plikami (też przy sortowaniu malejąco).
261 + b.is_dir.cmp(&a.is_dir).then_with(|| {
262 + let ord = match key {
263 + SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
264 + SortKey::Size => a.size.cmp(&b.size),
265 + SortKey::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
266 + SortKey::Kind => a
267 + .kind
268 + .label()
269 + .cmp(&b.kind.label())
270 + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
271 + };
272 + if asc {
273 + ord
274 + } else {
275 + ord.reverse()
276 + }
277 + })
278 + });
279 + }
280 +}
281 +
282 +fn read_entries(path: &Path) -> io::Result<Vec<Entry>> {
283 + let mut out = Vec::new();
284 + for entry in fs::read_dir(path)? {
285 + let Ok(entry) = entry else { continue };
286 + let name = entry.file_name().to_string_lossy().into_owned();
287 + let full = entry.path();
288 + let file_type = entry.file_type().ok();
289 + let symlink = file_type.map(|t| t.is_symlink()).unwrap_or(false);
290 + // Dla symlinków idziemy za celem (katalog-link ma być katalogiem).
291 + let meta = if symlink {
292 + fs::metadata(&full).ok()
293 + } else {
294 + entry.metadata().ok()
295 + };
296 + let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
297 + let size = meta
298 + .as_ref()
299 + .map(|m| if m.is_dir() { 0 } else { m.len() })
300 + .unwrap_or(0);
301 + let modified = meta
302 + .as_ref()
303 + .and_then(|m| m.modified().ok())
304 + .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
305 + .map(|d| d.as_secs() as i64);
306 + out.push(Entry {
307 + hidden: name.starts_with('.'),
308 + kind: Kind::from_name(&name, is_dir),
309 + name,
310 + path: full,
311 + is_dir,
312 + size,
313 + modified,
314 + symlink,
315 + });
316 + }
317 + Ok(out)
318 +}
319 +
320 +/// Rozmiar w jednostkach binarnych (np. `1,4 MiB`).
321 +pub fn human_size(bytes: u64) -> String {
322 + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
323 + let mut value = bytes as f64;
324 + let mut unit = 0;
325 + while value >= 1024.0 && unit + 1 < UNITS.len() {
326 + value /= 1024.0;
327 + unit += 1;
328 + }
329 + if unit == 0 {
330 + format!("{bytes} B")
331 + } else if value < 10.0 {
332 + format!("{value:.1} {}", UNITS[unit])
333 + } else {
334 + format!("{value:.0} {}", UNITS[unit])
335 + }
336 +}
337 +
338 +/// Data i godzina według czasu lokalnego (`YYYY-MM-DD HH:MM`).
339 +pub fn format_mtime(secs: Option<i64>) -> String {
340 + let Some(secs) = secs else {
341 + return String::new();
342 + };
343 + let (y, m, d, h, mi, _) = local_time(secs);
344 + format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}")
345 +}
346 +
347 +/// Format `DeletionDate` z XDG Trash Spec (`YYYY-MM-DDThh:mm:ss`), czas lokalny.
348 +pub fn trash_date(secs: i64) -> String {
349 + let (y, m, d, h, mi, s) = local_time(secs);
350 + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
351 +}
352 +
353 +/// Bieżący czas w sekundach od epoki.
354 +pub fn now_secs() -> i64 {
355 + std::time::SystemTime::now()
356 + .duration_since(UNIX_EPOCH)
357 + .map(|d| d.as_secs() as i64)
358 + .unwrap_or(0)
359 +}
360 +
361 +/// Rozbicie czasu na pola lokalne (rok, miesiąc, dzień, godzina, minuta, sekunda).
362 +fn local_time(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
363 + // SAFETY: `localtime_r` wypełnia strukturę `tm` i nie alokuje; przekazujemy
364 + // poprawny wskaźnik na `time_t`.
365 + let mut tm: libc::tm = unsafe { std::mem::zeroed() };
366 + let t = secs as libc::time_t;
367 + let ok = unsafe { !libc::localtime_r(&t, &mut tm).is_null() };
368 + if !ok {
369 + return (1970, 1, 1, 0, 0, 0);
370 + }
371 + (
372 + tm.tm_year + 1900,
373 + (tm.tm_mon + 1) as u32,
374 + tm.tm_mday as u32,
375 + tm.tm_hour as u32,
376 + tm.tm_min as u32,
377 + tm.tm_sec as u32,
378 + )
379 +}
380 +
381 +#[cfg(test)]
382 +mod tests {
383 + use super::*;
384 +
385 + #[test]
386 + fn human_size_scales_binary_units() {
387 + assert_eq!(human_size(0), "0 B");
388 + assert_eq!(human_size(999), "999 B");
389 + assert_eq!(human_size(1024), "1.0 KiB");
390 + assert_eq!(human_size(1536), "1.5 KiB");
391 + assert_eq!(human_size(10 * 1024), "10 KiB");
392 + assert_eq!(human_size(5 * 1024 * 1024), "5.0 MiB");
393 + }
394 +
395 + #[test]
396 + fn kind_is_derived_from_extension() {
397 + assert_eq!(Kind::from_name("a.png", false), Kind::Image);
398 + assert_eq!(Kind::from_name("a.MP4", false), Kind::Video);
399 + assert_eq!(Kind::from_name("a.tar.gz", false), Kind::Archive);
400 + assert_eq!(Kind::from_name("a.rs", false), Kind::Code);
401 + assert_eq!(Kind::from_name("a.unknownext", false), Kind::Other);
402 + assert_eq!(Kind::from_name("katalog", true), Kind::Folder);
403 + }
404 +
405 + fn view_with(names: &[(&str, bool)]) -> DirView {
406 + let mut entries = Vec::new();
407 + for (name, is_dir) in names {
408 + entries.push(Entry {
409 + name: (*name).to_owned(),
410 + path: PathBuf::from("/tmp").join(name),
411 + is_dir: *is_dir,
412 + size: name.len() as u64,
413 + modified: None,
414 + hidden: name.starts_with('.'),
415 + symlink: false,
416 + kind: Kind::from_name(name, *is_dir),
417 + });
418 + }
419 + DirView {
420 + path: PathBuf::from("/tmp"),
421 + entries,
422 + sort: SortKey::Name,
423 + ascending: true,
424 + show_hidden: false,
425 + filter: String::new(),
426 + }
427 + }
428 +
429 + #[test]
430 + fn directories_sort_first_and_hidden_are_filtered() {
431 + let mut v = view_with(&[
432 + ("b.txt", false),
433 + ("a", true),
434 + (".hidden", false),
435 + ("c", true),
436 + ]);
437 + v.resort();
438 + let names: Vec<&str> = v
439 + .visible()
440 + .into_iter()
441 + .map(|i| v.entries[i].name.as_str())
442 + .collect();
443 + assert_eq!(names, vec!["a", "c", "b.txt"]);
444 + v.set_show_hidden(true);
445 + assert_eq!(v.visible().len(), 4);
446 + }
447 +
448 + #[test]
449 + fn filter_matches_case_insensitively() {
450 + let mut v = view_with(&[("Raport.pdf", false), ("notatki.txt", false)]);
451 + v.set_filter("RAP");
452 + let names: Vec<String> = v
453 + .visible()
454 + .into_iter()
455 + .map(|i| v.entries[i].name.clone())
456 + .collect();
457 + assert_eq!(names, vec!["Raport.pdf"]);
458 + }
459 +
460 + #[test]
461 + fn descending_keeps_directories_first() {
462 + let mut v = view_with(&[("b.txt", false), ("a.txt", false), ("dir", true)]);
463 + v.resort();
464 + v.set_ascending(false);
465 + let names: Vec<&str> = v
466 + .visible()
467 + .into_iter()
468 + .map(|i| v.entries[i].name.as_str())
469 + .collect();
470 + assert_eq!(names, vec!["dir", "b.txt", "a.txt"]);
471 + }
472 +}
0 473 new file mode 100644
@@ -0,0 +1,615 @@
1 +//! Operacje na plikach w tle: kolejka, postęp, anulowanie.
2 +//!
3 +//! UI nigdy nie kopiuje ani nie usuwa synchronicznie — wysyła [`Job`] do wątku
4 +//! roboczego i dostaje [`OpsEvent`] (postęp, wynik). Dzięki temu duże pliki nie
5 +//! zamrażają okna.
6 +
7 +use std::fs;
8 +use std::io::{self, Read, Write};
9 +use std::path::{Path, PathBuf};
10 +use std::sync::atomic::{AtomicBool, Ordering};
11 +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
12 +use std::sync::Arc;
13 +use std::time::{Duration, Instant};
14 +
15 +use libluna_i18n::tr;
16 +
17 +use crate::trash;
18 +
19 +/// Zadanie do wykonania w tle.
20 +#[derive(Debug, Clone)]
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 },
31 +}
32 +
33 +/// Decyzja przy konflikcie nazw (dialog w UI).
34 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 +pub enum Decision {
36 + Overwrite,
37 + Skip,
38 + Rename,
39 + OverwriteAll,
40 + SkipAll,
41 + RenameAll,
42 +}
43 +
44 +/// Zdarzenia z wątku operacji.
45 +#[derive(Debug, Clone)]
46 +pub enum OpsEvent {
47 + Started {
48 + label: String,
49 + },
50 + Progress {
51 + done: u64,
52 + total: u64,
53 + current: String,
54 + },
55 + /// Cel już istnieje — UI pokazuje dialog i wysyła [`Decision`].
56 + Conflict {
57 + src: PathBuf,
58 + dst: PathBuf,
59 + is_dir: bool,
60 + },
61 + Finished {
62 + label: String,
63 + ok: usize,
64 + skipped: usize,
65 + failed: Vec<String>,
66 + created: Option<PathBuf>,
67 + },
68 +}
69 +
70 +/// Uchwyt do kolejki operacji (wątek roboczy żyje do końca procesu).
71 +pub struct Ops {
72 + tx: Sender<Job>,
73 + rx: Receiver<OpsEvent>,
74 + decisions: Sender<Decision>,
75 + cancel: Arc<AtomicBool>,
76 + busy: bool,
77 +}
78 +
79 +impl Ops {
80 + pub fn new() -> Self {
81 + let (job_tx, job_rx) = mpsc::channel::<Job>();
82 + let (ev_tx, ev_rx) = mpsc::channel::<OpsEvent>();
83 + let (decision_tx, decision_rx) = mpsc::channel::<Decision>();
84 + let cancel = Arc::new(AtomicBool::new(false));
85 + let flag = Arc::clone(&cancel);
86 + std::thread::Builder::new()
87 + .name("luna-files-ops".into())
88 + .spawn(move || {
89 + while let Ok(job) = job_rx.recv() {
90 + flag.store(false, Ordering::SeqCst);
91 + run(job, &ev_tx, &flag, &decision_rx);
92 + }
93 + })
94 + .expect("wątek operacji");
95 + Self {
96 + tx: job_tx,
97 + rx: ev_rx,
98 + decisions: decision_tx,
99 + cancel,
100 + busy: false,
101 + }
102 + }
103 +
104 + /// Dodaje zadanie; `false`, gdy poprzednie jeszcze trwa.
105 + pub fn submit(&mut self, job: Job) -> bool {
106 + if self.busy {
107 + return false;
108 + }
109 + if self.tx.send(job).is_ok() {
110 + self.busy = true;
111 + true
112 + } else {
113 + false
114 + }
115 + }
116 +
117 + /// Odbiera zdarzenia (do wywołania raz na klatkę).
118 + pub fn poll(&mut self) -> Vec<OpsEvent> {
119 + let mut out = Vec::new();
120 + while let Ok(ev) = self.rx.try_recv() {
121 + if matches!(ev, OpsEvent::Finished { .. }) {
122 + self.busy = false;
123 + }
124 + out.push(ev);
125 + }
126 + out
127 + }
128 +
129 + pub fn cancel(&self) {
130 + self.cancel.store(true, Ordering::SeqCst);
131 + }
132 +
133 + /// Odpowiedź na dialog konfliktu.
134 + pub fn decide(&self, decision: Decision) {
135 + let _ = self.decisions.send(decision);
136 + }
137 +
138 + pub fn busy(&self) -> bool {
139 + self.busy
140 + }
141 +}
142 +
143 +impl Default for Ops {
144 + fn default() -> Self {
145 + Self::new()
146 + }
147 +}
148 +
149 +struct Ctx<'a> {
150 + tx: &'a Sender<OpsEvent>,
151 + cancel: &'a AtomicBool,
152 + done: u64,
153 + total: u64,
154 + last: Instant,
155 +}
156 +
157 +impl Ctx<'_> {
158 + fn cancelled(&self) -> bool {
159 + self.cancel.load(Ordering::SeqCst)
160 + }
161 +
162 + fn progress(&mut self, current: &Path, force: bool) {
163 + if force || self.last.elapsed() >= Duration::from_millis(100) {
164 + self.last = Instant::now();
165 + let _ = self.tx.send(OpsEvent::Progress {
166 + done: self.done,
167 + total: self.total,
168 + current: current
169 + .file_name()
170 + .map(|n| n.to_string_lossy().into_owned())
171 + .unwrap_or_default(),
172 + });
173 + }
174 + }
175 +}
176 +
177 +fn run(job: Job, tx: &Sender<OpsEvent>, cancel: &AtomicBool, decisions: &Receiver<Decision>) {
178 + let label = tr(match &job {
179 + Job::Copy { .. } => "Copying",
180 + Job::Move { .. } => "Moving",
181 + Job::Trash { .. } => "Moving to Trash",
182 + Job::Delete { .. } => "Deleting",
183 + Job::Rename { .. } => "Renaming",
184 + Job::NewFolder { .. } => "Creating folder",
185 + Job::Mkdir { .. } => "Creating folder",
186 + Job::Restore { .. } => "Restoring",
187 + Job::Purge { .. } => "Deleting",
188 + });
189 + let _ = tx.send(OpsEvent::Started {
190 + label: label.clone(),
191 + });
192 +
193 + let mut ok = 0usize;
194 + let mut skipped = 0usize;
195 + let mut failed = Vec::new();
196 + let mut created = None;
197 +
198 + match job {
199 + Job::Copy { srcs, dst } => {
200 + let total = srcs.iter().map(|p| path_size(p)).sum();
201 + let mut ctx = Ctx {
202 + tx,
203 + cancel,
204 + done: 0,
205 + total,
206 + last: Instant::now(),
207 + };
208 + let mut policy = Policy::Ask;
209 + for src in &srcs {
210 + if ctx.cancelled() {
211 + failed.push(tr("Cancelled"));
212 + break;
213 + }
214 + let Some(name) = src.file_name() else {
215 + continue;
216 + };
217 + let Some(dest) = resolve_dest(name, &dst, &mut policy, tx, decisions, cancel)
218 + else {
219 + skipped += 1;
220 + continue;
221 + };
222 + match copy_path(src, &dest, &mut ctx) {
223 + Ok(()) => ok += 1,
224 + Err(e) => failed.push(format!("{}: {e}", src.display())),
225 + }
226 + }
227 + }
228 + Job::Move { srcs, dst } => {
229 + let mut policy = Policy::Ask;
230 + for src in &srcs {
231 + if cancel.load(Ordering::SeqCst) {
232 + failed.push(tr("Cancelled"));
233 + break;
234 + }
235 + let Some(name) = src.file_name() else {
236 + continue;
237 + };
238 + let Some(dest) = resolve_dest(name, &dst, &mut policy, tx, decisions, cancel)
239 + else {
240 + skipped += 1;
241 + continue;
242 + };
243 + // Przy „nadpisz” stary cel musi zniknąć (rename nie nadpisze katalogu).
244 + if dest.symlink_metadata().is_ok() {
245 + let _ = remove_path(&dest);
246 + }
247 + match move_path(src, &dest) {
248 + Ok(()) => ok += 1,
249 + Err(e) => failed.push(format!("{}: {e}", src.display())),
250 + }
251 + }
252 + }
253 + Job::Trash { paths } => {
254 + for path in paths {
255 + match trash::move_to_trash(&path) {
256 + Ok(_) => ok += 1,
257 + Err(e) => failed.push(format!("{}: {e}", path.display())),
258 + }
259 + }
260 + }
261 + Job::Delete { paths } => {
262 + for path in paths {
263 + match remove_path(&path) {
264 + Ok(()) => ok += 1,
265 + Err(e) => failed.push(format!("{}: {e}", path.display())),
266 + }
267 + }
268 + }
269 + Job::Rename { from, to } => {
270 + if to.exists() {
271 + failed.push(tr("A file with that name already exists"));
272 + } else {
273 + match fs::rename(&from, &to) {
274 + Ok(()) => ok = 1,
275 + Err(e) => failed.push(format!("{}: {e}", from.display())),
276 + }
277 + }
278 + }
279 + Job::NewFolder { parent } => {
280 + let dest = unique_dest(&parent, std::ffi::OsStr::new(&tr("New Folder")));
281 + match fs::create_dir(&dest) {
282 + Ok(()) => {
283 + ok = 1;
284 + created = Some(dest);
285 + }
286 + Err(e) => failed.push(format!("{}: {e}", dest.display())),
287 + }
288 + }
289 + Job::Mkdir { path } => match fs::create_dir(&path) {
290 + Ok(()) => {
291 + ok = 1;
292 + created = Some(path);
293 + }
294 + Err(e) => failed.push(format!("{}: {e}", path.display())),
295 + },
296 + Job::Restore { path } => match trash::restore(&path) {
297 + Ok(dest) => {
298 + ok = 1;
299 + created = Some(dest);
300 + }
301 + Err(e) => failed.push(format!("{}: {e}", path.display())),
302 + },
303 + Job::Purge { path } => match trash::purge(&path) {
304 + Ok(()) => ok = 1,
305 + Err(e) => failed.push(format!("{}: {e}", path.display())),
306 + },
307 + }
308 +
309 + let _ = tx.send(OpsEvent::Finished {
310 + label,
311 + ok,
312 + skipped,
313 + failed,
314 + created,
315 + });
316 +}
317 +
318 +/// Polityka rozwiązywania konfliktów w obrębie jednego zadania.
319 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320 +enum Policy {
321 + Ask,
322 + Overwrite,
323 + Skip,
324 + Rename,
325 +}
326 +
327 +/// Ustala nazwę docelową; przy istniejącym celu pyta UI ([`OpsEvent::Conflict`]).
328 +fn resolve_dest(
329 + name: &std::ffi::OsStr,
330 + dst_dir: &Path,
331 + policy: &mut Policy,
332 + tx: &Sender<OpsEvent>,
333 + decisions: &Receiver<Decision>,
334 + cancel: &AtomicBool,
335 +) -> Option<PathBuf> {
336 + let dest = dst_dir.join(name);
337 + if dest.symlink_metadata().is_err() {
338 + return Some(dest);
339 + }
340 + loop {
341 + match *policy {
342 + Policy::Overwrite => return Some(dest),
343 + Policy::Skip => return None,
344 + Policy::Rename => return Some(unique_dest(dst_dir, name)),
345 + Policy::Ask => {
346 + let is_dir = fs::symlink_metadata(&dest)
347 + .map(|m| m.is_dir())
348 + .unwrap_or(false);
349 + let _ = tx.send(OpsEvent::Conflict {
350 + src: dst_dir.join(name),
351 + dst: dest.clone(),
352 + is_dir,
353 + });
354 + let decision = loop {
355 + if cancel.load(Ordering::SeqCst) {
356 + return None;
357 + }
358 + match decisions.recv_timeout(Duration::from_millis(200)) {
359 + Ok(d) => break d,
360 + Err(RecvTimeoutError::Timeout) => continue,
361 + Err(RecvTimeoutError::Disconnected) => return None,
362 + }
363 + };
364 + match decision {
365 + Decision::Overwrite => return Some(dest),
366 + Decision::Skip => return None,
367 + Decision::Rename => return Some(unique_dest(dst_dir, name)),
368 + Decision::OverwriteAll => {
369 + *policy = Policy::Overwrite;
370 + return Some(dest);
371 + }
372 + Decision::SkipAll => {
373 + *policy = Policy::Skip;
374 + return None;
375 + }
376 + Decision::RenameAll => {
377 + *policy = Policy::Rename;
378 + return Some(unique_dest(dst_dir, name));
379 + }
380 + }
381 + }
382 + }
383 + }
384 +}
385 +
386 +/// Kopiuje plik/katalog, raportując postęp (256 KiB na chunk).
387 +fn copy_path(src: &Path, dst: &Path, ctx: &mut Ctx<'_>) -> io::Result<()> {
388 + if ctx.cancelled() {
389 + return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
390 + }
391 + let meta = fs::symlink_metadata(src)?;
392 + if meta.is_dir() {
393 + fs::create_dir_all(dst)?;
394 + for entry in fs::read_dir(src)? {
395 + let entry = entry?;
396 + copy_path(&entry.path(), &dst.join(entry.file_name()), ctx)?;
397 + }
398 + Ok(())
399 + } else if meta.file_type().is_symlink() {
400 + let target = fs::read_link(src)?;
401 + std::os::unix::fs::symlink(target, dst)
402 + } else {
403 + let mut input = fs::File::open(src)?;
404 + let mut output = fs::File::create(dst)?;
405 + let mut buf = vec![0u8; 256 * 1024];
406 + loop {
407 + if ctx.cancelled() {
408 + return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
409 + }
410 + let n = input.read(&mut buf)?;
411 + if n == 0 {
412 + break;
413 + }
414 + output.write_all(&buf[..n])?;
415 + ctx.done += n as u64;
416 + ctx.progress(dst, false);
417 + }
418 + Ok(())
419 + }
420 +}
421 +
422 +/// Przenosi plik/katalog (rename, a przy `EXDEV` kopiuje i usuwa oryginał).
423 +fn move_path(src: &Path, dst: &Path) -> io::Result<()> {
424 + match fs::rename(src, dst) {
425 + Ok(()) => Ok(()),
426 + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
427 + copy_tree(src, dst)?;
428 + remove_path(src)
429 + }
430 + Err(e) => Err(e),
431 + }
432 +}
433 +
434 +fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
435 + let meta = fs::symlink_metadata(src)?;
436 + if meta.is_dir() {
437 + fs::create_dir_all(dst)?;
438 + for entry in fs::read_dir(src)? {
439 + let entry = entry?;
440 + copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
441 + }
442 + Ok(())
443 + } else {
444 + fs::copy(src, dst).map(|_| ())
445 + }
446 +}
447 +
448 +pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
449 + let meta = fs::symlink_metadata(path)?;
450 + if meta.is_dir() {
451 + fs::remove_dir_all(path)
452 + } else {
453 + fs::remove_file(path)
454 + }
455 +}
456 +
457 +fn path_size(path: &Path) -> u64 {
458 + let Ok(meta) = fs::symlink_metadata(path) else {
459 + return 0;
460 + };
461 + if meta.is_dir() {
462 + let mut sum = 0;
463 + if let Ok(entries) = fs::read_dir(path) {
464 + for entry in entries.flatten() {
465 + sum += path_size(&entry.path());
466 + }
467 + }
468 + sum
469 + } else {
470 + meta.len()
471 + }
472 +}
473 +
474 +/// Wolna nazwa w katalogu docelowym: `raport.pdf`, `raport (1).pdf`, …
475 +pub(crate) fn unique_dest(dir: &Path, name: &std::ffi::OsStr) -> PathBuf {
476 + let candidate = dir.join(name);
477 + if candidate.symlink_metadata().is_err() {
478 + return candidate;
479 + }
480 + let name = name.to_string_lossy();
481 + let (stem, ext) = match name.rsplit_once('.') {
482 + Some((s, e)) if !s.is_empty() => (s.to_owned(), format!(".{e}")),
483 + _ => (name.into_owned(), String::new()),
484 + };
485 + let mut n = 1u32;
486 + loop {
487 + let candidate = dir.join(format!("{stem} ({n}){ext}"));
488 + if candidate.symlink_metadata().is_err() {
489 + return candidate;
490 + }
491 + n += 1;
492 + }
493 +}
494 +
495 +#[cfg(test)]
496 +mod tests {
497 + use super::*;
498 +
499 + fn temp_dir(tag: &str) -> PathBuf {
500 + let dir = std::env::temp_dir().join(format!(
501 + "luna-files-ops-{tag}-{}-{:?}",
502 + std::process::id(),
503 + std::thread::current().id()
504 + ));
505 + let _ = fs::remove_dir_all(&dir);
506 + fs::create_dir_all(&dir).unwrap();
507 + dir
508 + }
509 +
510 + fn wait(ops: &mut Ops) -> OpsEvent {
511 + let deadline = Instant::now() + Duration::from_secs(10);
512 + loop {
513 + for ev in ops.poll() {
514 + if matches!(ev, OpsEvent::Finished { .. }) {
515 + return ev;
516 + }
517 + }
518 + assert!(
519 + Instant::now() < deadline,
520 + "operacja nie skończyła się w czasie"
521 + );
522 + std::thread::sleep(Duration::from_millis(10));
523 + }
524 + }
525 +
526 + #[test]
527 + fn unique_dest_adds_number() {
528 + let dir = temp_dir("unique");
529 + fs::write(dir.join("a.txt"), "x").unwrap();
530 + let dest = unique_dest(&dir, std::ffi::OsStr::new("a.txt"));
531 + assert_eq!(dest.file_name().unwrap(), "a (1).txt");
532 + }
533 +
534 + #[test]
535 + fn copy_job_copies_file_content() {
536 + let dir = temp_dir("copy");
537 + fs::write(dir.join("src.txt"), "abc").unwrap();
538 + fs::create_dir(dir.join("out")).unwrap();
539 + let mut ops = Ops::new();
540 + assert!(ops.submit(Job::Copy {
541 + srcs: vec![dir.join("src.txt")],
542 + dst: dir.join("out"),
543 + }));
544 + match wait(&mut ops) {
545 + OpsEvent::Finished { ok, failed, .. } => {
546 + assert_eq!(ok, 1, "{failed:?}");
547 + assert!(failed.is_empty());
548 + }
549 + _ => unreachable!(),
550 + }
551 + assert_eq!(fs::read_to_string(dir.join("out/src.txt")).unwrap(), "abc");
552 + }
553 +
554 + #[test]
555 + fn conflict_policy_can_skip_rename_or_overwrite() {
556 + let dir = temp_dir("conflict-policy");
557 + let dst = dir.join("out");
558 + fs::create_dir(&dst).unwrap();
559 + fs::write(dst.join("a.txt"), "stary").unwrap();
560 + let (tx, _rx) = mpsc::channel();
561 + let (_dtx, drx) = mpsc::channel();
562 + let cancel = AtomicBool::new(false);
563 +
564 + let mut policy = Policy::Skip;
565 + assert!(resolve_dest(
566 + std::ffi::OsStr::new("a.txt"),
567 + &dst,
568 + &mut policy,
569 + &tx,
570 + &drx,
571 + &cancel
572 + )
573 + .is_none());
574 +
575 + policy = Policy::Rename;
576 + let renamed = resolve_dest(
577 + std::ffi::OsStr::new("a.txt"),
578 + &dst,
579 + &mut policy,
580 + &tx,
581 + &drx,
582 + &cancel,
583 + )
584 + .unwrap();
585 + assert_eq!(renamed.file_name().unwrap(), "a (1).txt");
586 +
587 + policy = Policy::Overwrite;
588 + let replaced = resolve_dest(
589 + std::ffi::OsStr::new("a.txt"),
590 + &dst,
591 + &mut policy,
592 + &tx,
593 + &drx,
594 + &cancel,
595 + )
596 + .unwrap();
597 + assert_eq!(replaced, dst.join("a.txt"));
598 + }
599 +
600 + #[test]
601 + fn new_folder_job_reports_created_path() {
602 + let dir = temp_dir("mkdir");
603 + let mut ops = Ops::new();
604 + assert!(ops.submit(Job::NewFolder {
605 + parent: dir.clone(),
606 + }));
607 + match wait(&mut ops) {
608 + OpsEvent::Finished { created, .. } => {
609 + let created = created.expect("ścieżka nowego katalogu");
610 + assert!(created.is_dir());
611 + }
612 + _ => unreachable!(),
613 + }
614 + }
615 +}
0 616 new file mode 100644
@@ -0,0 +1,280 @@
1 +//! Miejsca (XDG) i urządzenia (montowania) do panelu bocznego.
2 +//!
3 +//! Lista montowań czytana jest z `/proc/self/mountinfo`, a rozmiary przez `statvfs`.
4 +//! Montowanie/odmontowanie idzie przez `udisksctl` (UDisks2 po D-Busie, z polkit —
5 +//! hasło pyta `luna-polkit`). Docelowo podmienimy to na bezpośrednie sygnały D-Bus;
6 +//! interfejs funkcji zostaje ten sam.
7 +
8 +use std::collections::BTreeMap;
9 +use std::env;
10 +use std::fs;
11 +use std::path::{Path, PathBuf};
12 +use std::process::Command;
13 +
14 +use libluna_i18n::tr;
15 +
16 +#[derive(Debug, Clone)]
17 +pub struct Place {
18 + pub label: String,
19 + pub path: PathBuf,
20 +}
21 +
22 +impl Place {
23 + fn new(label: &str, path: PathBuf) -> Self {
24 + Self {
25 + label: tr(label),
26 + path,
27 + }
28 + }
29 +}
30 +
31 +/// Katalogi użytkownika z `~/.config/user-dirs.dirs` (z wartościami domyślnymi).
32 +pub fn user_dirs() -> BTreeMap<String, PathBuf> {
33 + let home = home();
34 + let mut map = BTreeMap::new();
35 + for key in [
36 + "DESKTOP",
37 + "DOCUMENTS",
38 + "DOWNLOAD",
39 + "PICTURES",
40 + "MUSIC",
41 + "VIDEOS",
42 + "TEMPLATES",
43 + "PUBLICSHARE",
44 + ] {
45 + map.insert(key.to_owned(), home.clone());
46 + }
47 + let config = env::var_os("XDG_CONFIG_HOME")
48 + .map(PathBuf::from)
49 + .unwrap_or_else(|| home.join(".config"))
50 + .join("user-dirs.dirs");
51 + if let Ok(text) = fs::read_to_string(config) {
52 + for line in text.lines() {
53 + let line = line.trim();
54 + if line.starts_with('#') {
55 + continue;
56 + }
57 + let Some((key, value)) = line.split_once('=') else {
58 + continue;
59 + };
60 + let Some(key) = key.trim().strip_prefix("XDG_") else {
61 + continue;
62 + };
63 + let Some(key) = key.strip_suffix("_DIR") else {
64 + continue;
65 + };
66 + let value = value.trim().trim_matches('"');
67 + let path = value.replace("$HOME", &home.display().to_string());
68 + if !path.is_empty() {
69 + map.insert(key.to_owned(), PathBuf::from(path));
70 + }
71 + }
72 + }
73 + map
74 +}
75 +
76 +pub fn home() -> PathBuf {
77 + env::var_os("HOME")
78 + .map(PathBuf::from)
79 + .unwrap_or_else(|| PathBuf::from("/"))
80 +}
81 +
82 +/// Miejsca w panelu bocznym (tylko istniejące katalogi).
83 +pub fn places() -> Vec<Place> {
84 + let dirs = user_dirs();
85 + let mut out = vec![Place::new("Home", home())];
86 + for (key, label) in [
87 + ("DESKTOP", "Desktop"),
88 + ("DOCUMENTS", "Documents"),
89 + ("DOWNLOAD", "Downloads"),
90 + ("PICTURES", "Pictures"),
91 + ("MUSIC", "Music"),
92 + ("VIDEOS", "Videos"),
93 + ] {
94 + if let Some(path) = dirs.get(key) {
95 + if path.is_dir() && path != &home() {
96 + out.push(Place::new(label, path.clone()));
97 + }
98 + }
99 + }
100 + out.push(Place::new("File System", PathBuf::from("/")));
101 + out
102 +}
103 +
104 +/// Zamontowane urządzenie widoczne w panelu bocznym.
105 +#[derive(Debug, Clone)]
106 +pub struct Mount {
107 + pub dev: String,
108 + pub label: String,
109 + pub mount: PathBuf,
110 + pub fs: String,
111 + pub total: u64,
112 + pub free: u64,
113 +}
114 +
115 +/// Lista montowań „użytkowych” (pomija pseudosystemy i montowania systemowe).
116 +pub fn mounts() -> Vec<Mount> {
117 + let Ok(text) = fs::read_to_string("/proc/self/mountinfo") else {
118 + return Vec::new();
119 + };
120 + let mut out = Vec::new();
121 + let mut seen = std::collections::HashSet::new();
122 + for line in text.lines() {
123 + let Some(m) = parse_mountinfo(line) else {
124 + continue;
125 + };
126 + if !user_visible_mount(&m.mount, &m.fs) {
127 + continue;
128 + }
129 + if !seen.insert(m.mount.clone()) {
130 + continue;
131 + }
132 + let (total, free) = statvfs(&m.mount).unwrap_or((0, 0));
133 + out.push(Mount {
134 + label: mount_label(&m.dev).unwrap_or_else(|| {
135 + m.mount
136 + .file_name()
137 + .map(|n| n.to_string_lossy().into_owned())
138 + .unwrap_or_else(|| m.dev.clone())
139 + }),
140 + dev: m.dev,
141 + mount: m.mount,
142 + fs: m.fs,
143 + total,
144 + free,
145 + });
146 + }
147 + out.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
148 + out
149 +}
150 +
151 +struct MountInfo {
152 + dev: String,
153 + mount: PathBuf,
154 + fs: String,
155 +}
156 +
157 +/// Parsuje jedną linię `/proc/self/mountinfo`:
158 +/// `36 35 98:0 /root /mnt rw - ext4 /dev/sda1 rw`.
159 +fn parse_mountinfo(line: &str) -> Option<MountInfo> {
160 + let parts: Vec<&str> = line.split_whitespace().collect();
161 + if parts.len() < 7 {
162 + return None;
163 + }
164 + let mount = PathBuf::from(unescape(parts[4]));
165 + let sep = parts.iter().position(|p| *p == "-")?;
166 + let fs = parts.get(sep + 1)?.to_string();
167 + let dev = parts.get(sep + 2).unwrap_or(&"").to_string();
168 + Some(MountInfo { dev, mount, fs })
169 +}
170 +
171 +fn user_visible_mount(mount: &Path, fs: &str) -> bool {
172 + const FS: [&str; 18] = [
173 + "ext2", "ext3", "ext4", "btrfs", "xfs", "f2fs", "vfat", "exfat", "ntfs", "ntfs3",
174 + "fuseblk", "iso9660", "udf", "hfsplus", "hfs", "msdos", "jfs", "reiserfs",
175 + ];
176 + if !FS.contains(&fs) {
177 + return false;
178 + }
179 + const SYSTEM: [&str; 9] = [
180 + "/",
181 + "/boot",
182 + "/boot/efi",
183 + "/home",
184 + "/tmp",
185 + "/var",
186 + "/usr",
187 + "/opt",
188 + "/srv",
189 + ];
190 + if SYSTEM.iter().any(|s| mount == Path::new(s)) {
191 + return false;
192 + }
193 + true
194 +}
195 +
196 +/// Odkodowuje oktalne sekwencje z mountinfo (`\040` = spacja).
197 +fn unescape(text: &str) -> String {
198 + let bytes = text.as_bytes();
199 + let mut out = Vec::with_capacity(bytes.len());
200 + let mut i = 0;
201 + while i < bytes.len() {
202 + if bytes[i] == b'\\' && i + 3 < bytes.len() {
203 + if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 4], 8) {
204 + out.push(v);
205 + i += 4;
206 + continue;
207 + }
208 + }
209 + out.push(bytes[i]);
210 + i += 1;
211 + }
212 + String::from_utf8_lossy(&out).into_owned()
213 +}
214 +
215 +/// Etykieta wolumenu z `/dev/disk/by-label` (np. „PENDRIVE”).
216 +pub fn mount_label(dev: &str) -> Option<String> {
217 + let dir = Path::new("/dev/disk/by-label");
218 + let target = fs::canonicalize(dev).ok()?;
219 + for entry in fs::read_dir(dir).ok()?.flatten() {
220 + if fs::canonicalize(entry.path()).ok().as_ref() == Some(&target) {
221 + return Some(entry.file_name().to_string_lossy().replace("\\x20", " "));
222 + }
223 + }
224 + None
225 +}
226 +
227 +/// `(rozmiar, wolne)` w bajtach.
228 +pub fn statvfs(path: &Path) -> Option<(u64, u64)> {
229 + let st = rustix::fs::statvfs(path).ok()?;
230 + let unit = if st.f_frsize != 0 {
231 + st.f_frsize
232 + } else {
233 + st.f_bsize
234 + };
235 + Some((st.f_blocks * unit, st.f_bavail * unit))
236 +}
237 +
238 +/// Montuje urządzenie przez UDisks2 (polkit pyta o hasło, gdy trzeba).
239 +pub fn mount_device(dev: &str) {
240 + spawn_udisks(&["mount", "-b", dev]);
241 +}
242 +
243 +/// Odmontowuje urządzenie przez UDisks2.
244 +pub fn unmount_device(dev: &str) {
245 + spawn_udisks(&["unmount", "-b", dev]);
246 +}
247 +
248 +fn spawn_udisks(args: &[&str]) {
249 + if let Err(e) = Command::new("udisksctl").args(args).spawn() {
250 + eprintln!("luna-files: udisksctl {:?}: {e}", args);
251 + }
252 +}
253 +
254 +#[cfg(test)]
255 +mod tests {
256 + use super::*;
257 +
258 + #[test]
259 + fn mountinfo_line_is_parsed() {
260 + let line =
261 + "36 35 98:0 /mnt1 /run/media/patryk/PEN\\040DRIVE rw,noatime - vfat /dev/sdb1 rw";
262 + let m = parse_mountinfo(line).unwrap();
263 + assert_eq!(m.mount, PathBuf::from("/run/media/patryk/PEN DRIVE"));
264 + assert_eq!(m.fs, "vfat");
265 + assert_eq!(m.dev, "/dev/sdb1");
266 + }
267 +
268 + #[test]
269 + fn system_mounts_are_hidden() {
270 + assert!(user_visible_mount(Path::new("/run/media/x/USB"), "vfat"));
271 + assert!(!user_visible_mount(Path::new("/"), "ext4"));
272 + assert!(!user_visible_mount(Path::new("/home"), "ext4"));
273 + assert!(!user_visible_mount(Path::new("/mnt/x"), "proc"));
274 + }
275 +
276 + #[test]
277 + fn octal_sequences_are_unescaped() {
278 + assert_eq!(unescape("a\\040b\\134c"), "a b\\c");
279 + }
280 +}
0 281 new file mode 100644
@@ -0,0 +1,415 @@
1 +//! Kosz zgodny ze specyfikacją XDG Trash (`.trashinfo`).
2 +//!
3 +//! MVP używa kosza domowego (`$XDG_DATA_HOME/Trash`). Dla plików z innego systemu
4 +//! plików `rename` zwróci `EXDEV` — wtedy kopiujemy i usuwamy oryginał (specyfikacja
5 +//! dopuszcza też kosz na szczycie montowania; to kolejny krok).
6 +
7 +use std::env;
8 +use std::fs;
9 +use std::io::{self, Write};
10 +use std::path::{Path, PathBuf};
11 +
12 +use crate::model::{now_secs, trash_date};
13 +
14 +pub fn home() -> PathBuf {
15 + env::var_os("HOME")
16 + .map(PathBuf::from)
17 + .unwrap_or_else(|| PathBuf::from("/"))
18 +}
19 +
20 +/// Katalog kosza użytkownika.
21 +pub fn trash_root() -> PathBuf {
22 + env::var_os("XDG_DATA_HOME")
23 + .map(PathBuf::from)
24 + .unwrap_or_else(|| home().join(".local").join("share"))
25 + .join("Trash")
26 +}
27 +
28 +/// Przenosi plik/katalog do kosza; zwraca ścieżkę w `Trash/files`.
29 +pub fn move_to_trash(path: &Path) -> io::Result<PathBuf> {
30 + move_to_trash_in(path, &trash_root())
31 +}
32 +
33 +/// Jak [`move_to_trash`], ale z jawnym katalogiem kosza (testy, inne montowania).
34 +pub fn move_to_trash_in(path: &Path, root: &Path) -> io::Result<PathBuf> {
35 + let files = root.join("files");
36 + let info = root.join("info");
37 + fs::create_dir_all(&files)?;
38 + fs::create_dir_all(&info)?;
39 +
40 + let name = path
41 + .file_name()
42 + .and_then(|n| n.to_str())
43 + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "zła nazwa pliku"))?;
44 + let original = absolute(path);
45 +
46 + // Unikalna nazwa w koszu: `raport.pdf`, `raport.2.pdf`, …
47 + let (stem, ext) = split_name(name);
48 + let mut candidate = name.to_owned();
49 + let mut n = 1u32;
50 + while files.join(&candidate).symlink_metadata().is_ok()
51 + || info.join(format!("{candidate}.trashinfo")).exists()
52 + {
53 + n += 1;
54 + candidate = if ext.is_empty() {
55 + format!("{stem}.{n}")
56 + } else {
57 + format!("{stem}.{n}.{ext}")
58 + };
59 + }
60 +
61 + let dest = files.join(&candidate);
62 + move_path(path, &dest)?;
63 +
64 + let mut f = fs::File::create(info.join(format!("{candidate}.trashinfo")))?;
65 + writeln!(f, "[Trash Info]")?;
66 + writeln!(f, "Path={}", encode_path(&original))?;
67 + writeln!(f, "DeletionDate={}", trash_date(now_secs()))?;
68 + Ok(dest)
69 +}
70 +
71 +/// Wpis w koszu (z `.trashinfo`).
72 +#[derive(Debug, Clone)]
73 +pub struct Trashed {
74 + pub name: String,
75 + /// Ścieżka w `Trash/files`.
76 + pub path: PathBuf,
77 + /// Oryginalna lokalizacja.
78 + pub original: PathBuf,
79 + pub deleted: String,
80 +}
81 +
82 +/// Zawartość kosza (bez sortowania — robi to UI).
83 +pub fn list() -> Vec<Trashed> {
84 + list_in(&trash_root())
85 +}
86 +
87 +pub fn list_in(root: &Path) -> Vec<Trashed> {
88 + let mut out = Vec::new();
89 + let info = root.join("info");
90 + let Ok(entries) = fs::read_dir(&info) else {
91 + return out;
92 + };
93 + for entry in entries.flatten() {
94 + let path = entry.path();
95 + if path.extension().and_then(|e| e.to_str()) != Some("trashinfo") {
96 + continue;
97 + }
98 + let Some(name) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
99 + continue;
100 + };
101 + let Ok(text) = fs::read_to_string(&path) else {
102 + continue;
103 + };
104 + let mut original = PathBuf::new();
105 + let mut deleted = String::new();
106 + for line in text.lines() {
107 + if let Some(v) = line.strip_prefix("Path=") {
108 + original = PathBuf::from(decode_path(v));
109 + } else if let Some(v) = line.strip_prefix("DeletionDate=") {
110 + deleted = v.to_owned();
111 + }
112 + }
113 + out.push(Trashed {
114 + path: root.join("files").join(&name),
115 + name,
116 + original,
117 + deleted,
118 + });
119 + }
120 + out
121 +}
122 +
123 +/// Opróżnia kosz; zwraca `(liczba plików, zwolnione bajty)`.
124 +pub fn empty_trash() -> io::Result<(usize, u64)> {
125 + empty_trash_in(&trash_root())
126 +}
127 +
128 +pub fn empty_trash_in(root: &Path) -> io::Result<(usize, u64)> {
129 + let files = root.join("files");
130 + let info = root.join("info");
131 + let mut count = 0usize;
132 + let mut bytes = 0u64;
133 + if let Ok(entries) = fs::read_dir(&files) {
134 + for entry in entries.flatten() {
135 + let path = entry.path();
136 + bytes += path_size(&path);
137 + remove_path(&path)?;
138 + count += 1;
139 + }
140 + }
141 + if let Ok(entries) = fs::read_dir(&info) {
142 + for entry in entries.flatten() {
143 + let _ = fs::remove_file(entry.path());
144 + }
145 + }
146 + Ok((count, bytes))
147 +}
148 +
149 +/// Ścieżka `.trashinfo` odpowiadająca plikowi w `Trash/files`.
150 +pub fn info_for(trashed: &Path) -> Option<PathBuf> {
151 + let name = trashed.file_name()?.to_string_lossy();
152 + let root = trashed.parent()?.parent()?;
153 + Some(root.join("info").join(format!("{name}.trashinfo")))
154 +}
155 +
156 +/// Przywraca plik z kosza na jego pierwotną ścieżkę (z unikalną nazwą,
157 +/// jeśli coś już tam jest). Usuwa wpis `.trashinfo`.
158 +pub fn restore(trashed: &Path) -> io::Result<PathBuf> {
159 + let info = info_for(trashed).ok_or_else(|| {
160 + io::Error::new(io::ErrorKind::InvalidInput, "nieprawidłowa ścieżka kosza")
161 + })?;
162 + let text = fs::read_to_string(&info)?;
163 + let original = text
164 + .lines()
165 + .find_map(|line| line.strip_prefix("Path="))
166 + .map(|v| PathBuf::from(decode_path(v)))
167 + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "brak Path w .trashinfo"))?;
168 + if let Some(parent) = original.parent() {
169 + fs::create_dir_all(parent)?;
170 + }
171 + let dest = if original.symlink_metadata().is_ok() {
172 + let dir = original.parent().unwrap_or_else(|| Path::new("/"));
173 + let name = original
174 + .file_name()
175 + .map(|n| n.to_os_string())
176 + .unwrap_or_else(|| std::ffi::OsString::from("plik"));
177 + let name_str = name.to_string_lossy().into_owned();
178 + let (stem, ext) = split_name(&name_str);
179 + let mut n = 1u32;
180 + loop {
181 + let candidate = if ext.is_empty() {
182 + dir.join(format!("{stem} ({n})"))
183 + } else {
184 + dir.join(format!("{stem} ({n}).{ext}"))
185 + };
186 + if candidate.symlink_metadata().is_err() {
187 + break candidate;
188 + }
189 + n += 1;
190 + }
191 + } else {
192 + original
193 + };
194 + move_path(trashed, &dest)?;
195 + let _ = fs::remove_file(&info);
196 + Ok(dest)
197 +}
198 +
199 +/// Usuwa wpis z kosza trwale (plik + `.trashinfo`).
200 +pub fn purge(trashed: &Path) -> io::Result<()> {
201 + remove_path(trashed)?;
202 + if let Some(info) = info_for(trashed) {
203 + let _ = fs::remove_file(info);
204 + }
205 + Ok(())
206 +}
207 +
208 +fn split_name(name: &str) -> (&str, &str) {
209 + match name.rsplit_once('.') {
210 + Some((stem, ext)) if !stem.is_empty() => (stem, ext),
211 + _ => (name, ""),
212 + }
213 +}
214 +
215 +fn absolute(path: &Path) -> PathBuf {
216 + if path.is_absolute() {
217 + return path.to_path_buf();
218 + }
219 + env::current_dir()
220 + .map(|cwd| cwd.join(path))
221 + .unwrap_or_else(|_| path.to_path_buf())
222 +}
223 +
224 +fn move_path(src: &Path, dst: &Path) -> io::Result<()> {
225 + match fs::rename(src, dst) {
226 + Ok(()) => Ok(()),
227 + // Inny system plików (pendrive, inna partycja): kopiujemy i usuwamy oryginał.
228 + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
229 + copy_path(src, dst)?;
230 + remove_path(src)
231 + }
232 + Err(e) => Err(e),
233 + }
234 +}
235 +
236 +fn copy_path(src: &Path, dst: &Path) -> io::Result<()> {
237 + let meta = fs::symlink_metadata(src)?;
238 + if meta.is_dir() {
239 + fs::create_dir_all(dst)?;
240 + for entry in fs::read_dir(src)? {
241 + let entry = entry?;
242 + copy_path(&entry.path(), &dst.join(entry.file_name()))?;
243 + }
244 + Ok(())
245 + } else if meta.file_type().is_symlink() {
246 + let target = fs::read_link(src)?;
247 + std::os::unix::fs::symlink(target, dst)
248 + } else {
249 + fs::copy(src, dst).map(|_| ())
250 + }
251 +}
252 +
253 +fn remove_path(path: &Path) -> io::Result<()> {
254 + let meta = fs::symlink_metadata(path)?;
255 + if meta.is_dir() {
256 + fs::remove_dir_all(path)
257 + } else {
258 + fs::remove_file(path)
259 + }
260 +}
261 +
262 +fn path_size(path: &Path) -> u64 {
263 + let Ok(meta) = fs::symlink_metadata(path) else {
264 + return 0;
265 + };
266 + if meta.is_dir() {
267 + let mut sum = 0;
268 + if let Ok(entries) = fs::read_dir(path) {
269 + for entry in entries.flatten() {
270 + sum += path_size(&entry.path());
271 + }
272 + }
273 + sum
274 + } else {
275 + meta.len()
276 + }
277 +}
278 +
279 +/// Procentowe kodowanie ścieżki zgodnie z XDG Trash Spec (bez kodowania `/`).
280 +pub fn encode_path(path: &Path) -> String {
281 + let mut out = String::new();
282 + for &b in path.as_os_str().as_encoded_bytes() {
283 + match b {
284 + b'/' | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
285 + out.push(b as char)
286 + }
287 + _ => out.push_str(&format!("%{b:02X}")),
288 + }
289 + }
290 + out
291 +}
292 +
293 +fn decode_path(text: &str) -> String {
294 + let bytes = text.as_bytes();
295 + let mut out = Vec::with_capacity(bytes.len());
296 + let mut i = 0;
297 + while i < bytes.len() {
298 + if bytes[i] == b'%' && i + 2 < bytes.len() {
299 + if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 3], 16) {
300 + out.push(v);
301 + i += 3;
302 + continue;
303 + }
304 + }
305 + out.push(bytes[i]);
306 + i += 1;
307 + }
308 + String::from_utf8_lossy(&out).into_owned()
309 +}
310 +
311 +#[cfg(test)]
312 +mod tests {
313 + use super::*;
314 +
315 + fn temp_dir(tag: &str) -> PathBuf {
316 + let dir = env::temp_dir().join(format!(
317 + "luna-files-trash-{tag}-{}-{:?}",
318 + std::process::id(),
319 + std::thread::current().id()
320 + ));
321 + let _ = fs::remove_dir_all(&dir);
322 + fs::create_dir_all(&dir).unwrap();
323 + dir
324 + }
325 +
326 + #[test]
327 + fn trashing_keeps_content_and_writes_info() {
328 + let base = temp_dir("basic");
329 + let datadir = base.join("Trash");
330 + let file = base.join("notatka.txt");
331 + fs::write(&file, "treść").unwrap();
332 +
333 + let dest = move_to_trash_in(&file, &datadir).unwrap();
334 + assert!(!file.exists());
335 + assert_eq!(fs::read_to_string(&dest).unwrap(), "treść");
336 +
337 + let info = fs::read_to_string(datadir.join("info/notatka.txt.trashinfo")).unwrap();
338 + assert!(info.starts_with("[Trash Info]\n"));
339 + assert!(info.contains("Path="));
340 + assert!(info.contains("DeletionDate="));
341 + assert!(!info.contains("Path=%2F")); // slashe nie są kodowane
342 + }
343 +
344 + #[test]
345 + fn name_collisions_get_a_suffix() {
346 + let base = temp_dir("collision");
347 + let root = base.join("Trash");
348 + let a = base.join("dokument.txt");
349 + let b = base.join("dokument.txt");
350 + fs::write(&a, "a").unwrap();
351 + move_to_trash_in(&a, &root).unwrap();
352 + fs::write(&b, "b").unwrap();
353 + let dest = move_to_trash_in(&b, &root).unwrap();
354 + assert_eq!(dest.file_name().unwrap(), "dokument.2.txt");
355 + assert!(root.join("info/dokument.2.txt.trashinfo").exists());
356 + }
357 +
358 + #[test]
359 + fn listing_reads_original_path() {
360 + let base = temp_dir("list");
361 + let root = base.join("Trash");
362 + let file = base.join("plik.bin");
363 + fs::write(&file, [1u8, 2, 3]).unwrap();
364 + move_to_trash_in(&file, &root).unwrap();
365 + let items = list_in(&root);
366 + assert_eq!(items.len(), 1);
367 + assert_eq!(items[0].name, "plik.bin");
368 + assert_eq!(items[0].original, file);
369 + }
370 +
371 + #[test]
372 + fn emptying_clears_files_and_info() {
373 + let base = temp_dir("empty");
374 + let root = base.join("Trash");
375 + let a = base.join("a.txt");
376 + fs::write(&a, "12345").unwrap();
377 + move_to_trash_in(&a, &root).unwrap();
378 + let (count, bytes) = empty_trash_in(&root).unwrap();
379 + assert_eq!(count, 1);
380 + assert_eq!(bytes, 5);
381 + assert!(list_in(&root).is_empty());
382 + }
383 +
384 + #[test]
385 + fn restore_puts_file_back() {
386 + let base = temp_dir("restore");
387 + let root = base.join("Trash");
388 + let original = base.join("dane.txt");
389 + fs::write(&original, "abc").unwrap();
390 + move_to_trash_in(&original, &root).unwrap();
391 + assert!(!original.exists());
392 + let restored = restore(&root.join("files/dane.txt")).unwrap();
393 + assert_eq!(restored, original);
394 + assert_eq!(fs::read_to_string(&restored).unwrap(), "abc");
395 + assert!(!root.join("info/dane.txt.trashinfo").exists());
396 + }
397 +
398 + #[test]
399 + fn purge_removes_file_and_info() {
400 + let base = temp_dir("purge");
401 + let root = base.join("Trash");
402 + let file = base.join("x.bin");
403 + fs::write(&file, [0u8; 4]).unwrap();
404 + let dest = move_to_trash_in(&file, &root).unwrap();
405 + purge(&dest).unwrap();
406 + assert!(!dest.exists());
407 + assert!(!root.join("info/x.bin.trashinfo").exists());
408 + }
409 +
410 + #[test]
411 + fn path_encoding_escapes_spaces() {
412 + assert_eq!(encode_path(Path::new("/a b/c.txt")), "/a%20b/c.txt");
413 + assert_eq!(decode_path("/a%20b/c.txt"), "/a b/c.txt");
414 + }
415 +}
0 416 new file mode 100644
@@ -0,0 +1,1820 @@
1 +//! Warstwa UI: układ, rysowanie i obsługa wejścia.
2 +//!
3 +//! Wszystkie napisy idą przez `libluna_i18n::tr` (klucz = angielski tekst).
4 +
5 +use std::path::{Component, Path, PathBuf};
6 +use std::time::{Duration, Instant};
7 +
8 +use libluna_core::style::weight;
9 +use libluna_core::{
10 + ButtonState, Color, Event, Insets, KeyEvent, Modifiers, Point, PointerButton, Rect, Size, Theme,
11 +};
12 +use libluna_render::{Scene, TextEngine};
13 +use libluna_widgets::{
14 + icon, AlertDialog, ControlsSide, Entry as TextEntry, HeaderBar, IconName, Menu, MenuEntry,
15 + PaintCtx, PathBar, Tab, TabBar, TextInput, ViewMode, WindowControl,
16 +};
17 +
18 +use crate::app::{App, ContextMenu, EditState, MenuCmd, MenuTarget, Pane, SideAction, SPLIT_GAP};
19 +use crate::list;
20 +use crate::model::{format_mtime, human_size, Kind, SortKey};
21 +use crate::ops::Job;
22 +use crate::places;
23 +use crate::trash;
24 +
25 +const SIDEBAR_W: f64 = 216.0;
26 +const TABS_H: f64 = 34.0;
27 +const NAV_H: f64 = 40.0;
28 +const STATUS_H: f64 = 28.0;
29 +const PREVIEW_W: f64 = 300.0;
30 +const PAD: f64 = 8.0;
31 +const BTN: f64 = 28.0;
32 +const PANE_PATH_H: f64 = 26.0;
33 +
34 +struct Layout {
35 + header: Rect,
36 + tabs: Rect,
37 + nav: Rect,
38 + sidebar: Rect,
39 + status: Rect,
40 + panes: [Rect; 2],
41 +}
42 +
43 +struct Chrome {
44 + back: Rect,
45 + forward: Rect,
46 + up: Rect,
47 + search: Rect,
48 + view: Rect,
49 + menu: Rect,
50 + plus: Rect,
51 + path: Rect,
52 +}
53 +
54 +#[derive(Clone, Copy)]
55 +struct PaneRects {
56 + path: Option<Rect>,
57 + items: Rect,
58 + preview: Option<Rect>,
59 +}
60 +
61 +/// Obszar interaktywnych wierszy (w trybie listy bez paska nagłówków).
62 +fn file_rows(pane: &Pane, items: Rect) -> Rect {
63 + if pane.view.mode() == ViewMode::List {
64 + list::rows_rect(items)
65 + } else {
66 + items
67 + }
68 +}
69 +
70 +/// Trafienie w wiersz zależnie od trybu widoku.
71 +fn hit_index(pane: &Pane, rows: Rect, p: Point, theme: &Theme) -> Option<usize> {
72 + match pane.view.mode() {
73 + ViewMode::List => list::index_at(rows, p, pane.view.scroll(), pane.view.len(), theme),
74 + ViewMode::Grid => pane.view.index_at(rows, p, theme),
75 + }
76 +}
77 +
78 +fn layout(app: &App, size: Size, theme: &Theme) -> Layout {
79 + let hh = theme.metrics.headerbar.height;
80 + let header = Rect::from_xywh(0.0, 0.0, size.width, hh);
81 + let tabs = Rect::from_xywh(0.0, hh, size.width, TABS_H);
82 + let nav = Rect::from_xywh(0.0, tabs.bottom(), size.width, NAV_H);
83 + let status = Rect::from_xywh(
84 + 0.0,
85 + (size.height - STATUS_H).max(nav.bottom()),
86 + size.width,
87 + STATUS_H,
88 + );
89 + let content_h = (status.y() - nav.bottom()).max(0.0);
90 + let sidebar_w = if app.sidebar_visible && size.width > 720.0 {
91 + SIDEBAR_W.min(size.width * 0.35)
92 + } else {
93 + 0.0
94 + };
95 + let sidebar = Rect::from_xywh(0.0, nav.bottom(), sidebar_w, content_h);
96 + let area = Rect::from_xywh(
97 + sidebar.right(),
98 + nav.bottom(),
99 + (size.width - sidebar.right()).max(1.0),
100 + content_h,
101 + );
102 + let mut panes = [Rect::ZERO; 2];
103 + if app.split {
104 + let w = ((area.width() - SPLIT_GAP) / 2.0).max(1.0);
105 + panes[0] = Rect::from_xywh(area.x(), area.y(), w, area.height());
106 + panes[1] = Rect::from_xywh(area.x() + w + SPLIT_GAP, area.y(), w, area.height());
107 + } else {
108 + panes[0] = area;
109 + }
110 + Layout {
111 + header,
112 + tabs,
113 + nav,
114 + sidebar,
115 + status,
116 + panes,
117 + }
118 +}
119 +
120 +fn chrome(lay: &Layout, size: Size) -> Chrome {
121 + let cy = lay.nav.center().y;
122 + let mut x = lay.nav.x() + PAD;
123 + let btn = |x: &mut f64| {
124 + let r = Rect::from_xywh(*x, cy - BTN / 2.0, BTN, BTN);
125 + *x += BTN + 2.0;
126 + r
127 + };
128 + let back = btn(&mut x);
129 + let forward = btn(&mut x);
130 + let up = btn(&mut x);
131 +
132 + let right = lay.nav.right() - PAD;
133 + let menu = Rect::from_xywh(right - BTN, cy - BTN / 2.0, BTN, BTN);
134 + let view = Rect::from_xywh(menu.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN);
135 + let search = Rect::from_xywh(view.x() - BTN - 4.0, cy - BTN / 2.0, BTN, BTN);
136 + let path = Rect::from_xywh(
137 + up.right() + PAD,
138 + lay.nav.y() + 5.0,
139 + (search.x() - PAD - (up.right() + PAD)).max(40.0),
140 + NAV_H - 10.0,
141 + );
142 + let plus = Rect::from_xywh(
143 + (size.width - PAD - BTN).max(0.0),
144 + lay.tabs.center().y - BTN / 2.0,
145 + BTN,
146 + BTN,
147 + );
148 + Chrome {
149 + back,
150 + forward,
151 + up,
152 + search,
153 + view,
154 + menu,
155 + plus,
156 + path,
157 + }
158 +}
159 +
160 +fn pane_rects(app: &App, lay: &Layout, i: usize, theme: &Theme) -> PaneRects {
161 + let pane = &app.panes[i];
162 + let rect = lay.panes[i];
163 + let (path, body) = if app.split {
164 + (
165 + Some(Rect::from_xywh(
166 + rect.x(),
167 + rect.y(),
168 + rect.width(),
169 + PANE_PATH_H,
170 + )),
171 + Rect::from_xywh(
172 + rect.x(),
173 + rect.y() + PANE_PATH_H,
174 + rect.width(),
175 + (rect.height() - PANE_PATH_H).max(0.0),
176 + ),
177 + )
178 + } else {
179 + (None, rect)
180 + };
181 + let preview = if pane.preview && body.width() > 560.0 {
182 + Some(Rect::from_xywh(
183 + body.right() - PREVIEW_W,
184 + body.y(),
185 + PREVIEW_W,
186 + body.height(),
187 + ))
188 + } else {
189 + None
190 + };
191 + let items = match preview {
192 + Some(p) => Rect::from_xywh(
193 + body.x() + 6.0,
194 + body.y() + 4.0,
195 + (p.x() - body.x() - 12.0).max(1.0),
196 + (body.height() - 8.0).max(1.0),
197 + ),
198 + None => Rect::from_xywh(
199 + body.x() + 6.0,
200 + body.y() + 4.0,
201 + (body.width() - 12.0).max(1.0),
202 + (body.height() - 8.0).max(1.0),
203 + ),
204 + };
205 + let _ = theme;
206 + PaneRects {
207 + path,
208 + items,
209 + preview,
210 + }
211 +}
212 +
213 +// ---------------------------------------------------------------------------
214 +// Rysowanie
215 +// ---------------------------------------------------------------------------
216 +
217 +pub fn paint(app: &App, scene: &mut Scene, size: Size, theme: &Theme, text: &TextEngine) {
218 + let lay = layout(app, size, theme);
219 + let ch = chrome(&lay, size);
220 + let mut ctx = PaintCtx::new(scene, theme, text);
221 +
222 + ctx.scene
223 + .fill_rect(Rect::from_size(size), theme.colors.window_bg);
224 +
225 + paint_header(&mut ctx, app, &lay);
226 + paint_tabs(&mut ctx, app, &lay, &ch);
227 + paint_nav(&mut ctx, app, &lay, &ch);
228 + paint_sidebar(&mut ctx, app, &lay);
229 + paint_panes(&mut ctx, app, &lay);
230 + paint_status(&mut ctx, app, &lay);
231 +
232 + if let Some(context) = &app.context {
233 + context.menu.paint(&mut ctx, context.panel);
234 + }
235 + app.toast.paint(&mut ctx, size);
236 + if let Some(conflict) = &app.conflict {
237 + conflict.dialog.paint(&mut ctx, size);
238 + }
239 + if let Some(confirm) = &app.confirm_empty {
240 + confirm.paint(&mut ctx, size);
241 + }
242 +}
243 +
244 +fn paint_header(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
245 + let title = if app.pane().dir.path == Path::new("/") {
246 + libluna_i18n::tr("File System")
247 + } else {
248 + app.pane().dir.title()
249 + };
250 + let header = HeaderBar::new().title(title).controls(ControlsSide::Right);
251 + header.paint(ctx, lay.header, None);
252 +}
253 +
254 +fn paint_tabs(ctx: &mut PaintCtx, app: &App, lay: &Layout, ch: &Chrome) {
255 + let pane = app.pane();
256 + let tabs: Vec<Tab> = pane
257 + .tabs
258 + .iter()
259 + .map(|p| Tab::new(tab_title(p)).closable(pane.tabs.len() > 1))
260 + .collect();
261 + let mut bar = TabBar::with_tabs(tabs).expand(false);
262 + bar.set_selected(pane.tab);
263 + let rect = Rect::from_xywh(
264 + lay.tabs.x(),
265 + lay.tabs.y(),
266 + (ch.plus.x() - lay.tabs.x() - PAD).max(1.0),
267 + lay.tabs.height(),
268 + );
269 + bar.paint(ctx, rect);
270 + icon_button(ctx, ch.plus, IconName::Plus, true, false);
271 +}
272 +
273 +fn paint_nav(ctx: &mut PaintCtx, app: &App, lay: &Layout, ch: &Chrome) {
274 + let pane = app.pane();
275 + ctx.scene.fill_rect(lay.nav, ctx.theme.colors.headerbar_bg);
276 +
277 + icon_button(
278 + ctx,
279 + ch.back,
280 + IconName::ChevronLeft,
281 + !pane.back.is_empty(),
282 + false,
283 + );
284 + icon_button(
285 + ctx,
286 + ch.forward,
287 + IconName::ChevronRight,
288 + !pane.forward.is_empty(),
289 + false,
290 + );
291 + icon_up(ctx, ch.up, pane.dir.parent().is_some());
292 +
293 + if let Some(edit) = &pane.edit {
294 + edit.entry.paint(ctx, ch.path);
295 + } else if pane.search_active {
296 + pane.search.paint(ctx, ch.path);
297 + } else {
298 + let bar = PathBar::new(path_components(&pane.dir.path));
299 + bar.paint(ctx, ch.path);
300 + }
301 +
302 + icon_button(ctx, ch.search, IconName::Search, true, pane.search_active);
303 + let view_icon = match pane.view.mode() {
304 + ViewMode::List => IconName::Grid,
305 + ViewMode::Grid => IconName::List,
306 + };
307 + icon_button(ctx, ch.view, view_icon, true, false);
308 + icon_button(ctx, ch.menu, IconName::Menu, true, false);
309 +}
310 +
311 +fn paint_sidebar(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
312 + if lay.sidebar.width() <= 0.0 {
313 + return;
314 + }
315 + ctx.scene
316 + .fill_rect(lay.sidebar, ctx.theme.colors.sidebar_bg);
317 + ctx.scene.fill_rect(
318 + Rect::from_xywh(
319 + lay.sidebar.right() - 1.0,
320 + lay.sidebar.y(),
321 + 1.0,
322 + lay.sidebar.height(),
323 + ),
324 + ctx.theme.colors.border,
325 + );
326 + app.sidebar
327 + .paint(ctx, lay.sidebar.deflate(Insets::splat(6.0)));
328 +}
329 +
330 +fn paint_panes(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
331 + let count = if app.split { 2 } else { 1 };
332 + for i in 0..count {
333 + let pane = &app.panes[i];
334 + let rect = lay.panes[i];
335 + let pr = pane_rects(app, lay, i, ctx.theme);
336 +
337 + if app.split {
338 + let active = i == app.active;
339 + ctx.scene.stroke_rounded_rect(
340 + rect,
341 + 6.0,
342 + if active { 1.5 } else { 1.0 },
343 + if active {
344 + ctx.theme.colors.accent
345 + } else {
346 + ctx.theme.colors.border
347 + },
348 + );
349 + if let Some(path) = pr.path {
350 + ctx.scene.fill_rect(path, ctx.theme.colors.headerbar_bg);
351 + let title = path_text(&pane.dir.path);
352 + ctx.label_left(
353 + path.x() + 10.0,
354 + path.center().y,
355 + &title,
356 + ctx.theme.typography.caption.size,
357 + weight::MEDIUM,
358 + ctx.theme.colors.dim_text,
359 + );
360 + }
361 + }
362 +
363 + // Pasek przewijania i zawartość (lista ma własne kolumny).
364 + let rows = file_rows(pane, pr.items);
365 + let mut sv = pane.scroll.clone();
366 + sv.set_content_height(pane.view.content_height(rows, ctx.theme));
367 + sv.set_offset(pane.view.scroll(), rows);
368 + sv.paint_scrollbar(ctx, rows);
369 +
370 + if pane.view.mode() == ViewMode::List {
371 + list::paint(
372 + ctx,
373 + pr.items,
374 + &pane.dir,
375 + pane.view.selected_indices(),
376 + pane.view.hovered(),
377 + pane.view.scroll(),
378 + ctx.theme,
379 + );
380 + } else {
381 + pane.view.paint(ctx, rows);
382 + }
383 +
384 + if pane.view.is_empty() {
385 + let msg = if !pane.dir.filter().is_empty() {
386 + libluna_i18n::tr("No matching files")
387 + } else {
388 + libluna_i18n::tr("Folder is empty")
389 + };
390 + ctx.label_center(
391 + rows.center().x,
392 + rows.center().y,
393 + &msg,
394 + ctx.theme.typography.body.size,
395 + weight::NORMAL,
396 + ctx.theme.colors.dim_text,
397 + );
398 + }
399 +
400 + if let Some(prect) = pr.preview {
401 + paint_preview(ctx, pane, prect);
402 + }
403 + }
404 +}
405 +
406 +fn paint_preview(ctx: &mut PaintCtx, pane: &Pane, rect: Rect) {
407 + ctx.scene
408 + .fill_rounded_rect(rect, 8.0, ctx.theme.colors.card_bg);
409 + ctx.scene
410 + .stroke_rounded_rect(rect, 8.0, 1.0, ctx.theme.colors.border);
411 + let inner = rect.deflate(Insets::splat(10.0));
412 + let Some(entry) = pane.primary() else {
413 + ctx.label_center(
414 + inner.center().x,
415 + inner.center().y,
416 + &libluna_i18n::tr("No preview"),
417 + ctx.theme.typography.body.size,
418 + weight::NORMAL,
419 + ctx.theme.colors.dim_text,
420 + );
421 + return;
422 + };
423 + let body = ctx.theme.typography.body.size;
424 + let caption = ctx.theme.typography.caption.size;
425 + ctx.label_left(
426 + inner.x(),
427 + inner.y() + 8.0,
428 + &entry.name,
429 + body,
430 + weight::BOLD,
431 + ctx.theme.colors.text,
432 + );
433 + let below = Rect::from_xywh(
434 + inner.x(),
435 + inner.y() + 22.0,
436 + inner.width(),
437 + (inner.height() - 22.0).max(1.0),
438 + );
439 + match pane.preview_cache.as_ref().map(|(_, p)| p) {
440 + Some(crate::app::Preview::Text(text)) => {
441 + ctx.scene.push_clip(below);
442 + let line_h = body * 1.25;
443 + let max_lines = (below.height() / line_h).floor().max(1.0) as usize;
444 + for (i, line) in text.lines().take(max_lines).enumerate() {
445 + ctx.label_left(
446 + below.x(),
447 + below.y() + i as f64 * line_h + line_h * 0.75,
448 + line,
449 + body,
450 + weight::NORMAL,
451 + ctx.theme.colors.text,
452 + );
453 + }
454 + ctx.scene.pop_clip();
455 + }
456 + Some(crate::app::Preview::Image(img)) => {
457 + let scale = (below.width() / img.width as f64)
458 + .min(below.height() / img.height as f64)
459 + .max(0.0);
460 + let w = img.width as f64 * scale;
461 + let h = img.height as f64 * scale;
462 + let target = Rect::from_xywh(
463 + below.center().x - w / 2.0,
464 + below.center().y - h / 2.0,
465 + w.max(1.0),
466 + h.max(1.0),
467 + );
468 + ctx.scene.image(target, std::sync::Arc::clone(img));
469 + }
470 + _ => {
471 + let mut y = below.y() + 8.0;
472 + for (label, value) in [
473 + (libluna_i18n::tr("Type"), entry.kind.label()),
474 + (libluna_i18n::tr("Size"), human_size(entry.size)),
475 + (libluna_i18n::tr("Modified"), format_mtime(entry.modified)),
476 + (libluna_i18n::tr("Path"), entry.path.display().to_string()),
477 + ] {
478 + ctx.label_left(
479 + below.x(),
480 + y,
481 + &format!("{label}: {value}"),
482 + caption,
483 + weight::NORMAL,
484 + ctx.theme.colors.dim_text,
485 + );
486 + y += caption * 1.8;
487 + }
488 + }
489 + }
490 +}
491 +
492 +fn paint_status(ctx: &mut PaintCtx, app: &App, lay: &Layout) {
493 + let colors = ctx.theme.colors;
494 + ctx.scene.fill_rect(lay.status, colors.headerbar_bg);
495 + ctx.label_left(
496 + lay.status.x() + PAD,
497 + lay.status.center().y,
498 + &app.status,
499 + ctx.theme.typography.caption.size,
500 + weight::NORMAL,
501 + colors.dim_text,
502 + );
503 + if let Some((done, total)) = app.op_progress {
504 + let ratio = if total > 0 {
505 + (done as f64 / total as f64).clamp(0.0, 1.0)
506 + } else {
507 + 0.0
508 + };
509 + let bar = Rect::from_xywh(
510 + lay.status.x(),
511 + lay.status.y() - 3.0,
512 + lay.status.width() * ratio,
513 + 3.0,
514 + );
515 + ctx.scene.fill_rect(bar, colors.accent);
516 + let speed = if app.op_speed > 1.0 {
517 + format!("{}/s", human_size(app.op_speed as u64))
518 + } else {
519 + String::new()
520 + };
521 + let eta = if app.op_speed > 1.0 && total > done {
522 + let secs = ((total - done) as f64 / app.op_speed).round() as u64;
523 + format!("{:02}:{:02}", secs / 60, secs % 60)
524 + } else {
525 + String::new()
526 + };
527 + let mut label = format!(
528 + "{} {}%",
529 + app.op_label.clone().unwrap_or_default(),
530 + (ratio * 100.0).round() as i64
531 + );
532 + if !speed.is_empty() {
533 + label.push_str(" · ");
534 + label.push_str(&speed);
535 + }
536 + if !eta.is_empty() {
537 + label.push_str(" · ");
538 + label.push_str(&libluna_i18n::tr_args("{time} left", &[("time", &eta)]));
539 + }
540 + if !app.op_current.is_empty() {
541 + label.push_str(" — ");
542 + label.push_str(&app.op_current);
543 + }
544 + let cancel = op_cancel_rect(lay);
545 + ctx.label_right(
546 + cancel.x() - PAD,
547 + lay.status.center().y,
548 + &label,
549 + ctx.theme.typography.caption.size,
550 + weight::NORMAL,
551 + colors.accent,
552 + );
553 + icon::paint_icon(
554 + ctx,
555 + IconName::Close,
556 + cancel.deflate(Insets::splat(4.0)),
557 + colors.text,
558 + );
559 + }
560 +}
561 +
562 +fn icon_button(ctx: &mut PaintCtx, rect: Rect, name: IconName, enabled: bool, active: bool) {
563 + if active || enabled {
564 + let bg = if active {
565 + ctx.theme.colors.shade
566 + } else {
567 + Color::TRANSPARENT
568 + };
569 + if active {
570 + ctx.scene.fill_rounded_rect(rect, 6.0, bg);
571 + }
572 + }
573 + let color = if enabled {
574 + ctx.theme.colors.text
575 + } else {
576 + ctx.theme.colors.dim_text.scale_alpha(0.5)
577 + };
578 + icon::paint_icon(ctx, name, rect.deflate(Insets::splat(6.0)), color);
579 +}
580 +
581 +fn icon_up(ctx: &mut PaintCtx, rect: Rect, enabled: bool) {
582 + let color = if enabled {
583 + ctx.theme.colors.text
584 + } else {
585 + ctx.theme.colors.dim_text.scale_alpha(0.5)
586 + };
587 + icon::chevron_up(ctx, color, rect.center(), 5.0, 1.6);
588 +}
589 +
590 +/// Przycisk anulowania operacji w belce statusu.
591 +fn op_cancel_rect(lay: &Layout) -> Rect {
592 + Rect::from_xywh(
593 + lay.status.right() - PAD - 18.0,
594 + lay.status.center().y - 9.0,
595 + 18.0,
596 + 18.0,
597 + )
598 +}
599 +
600 +// ---------------------------------------------------------------------------
601 +// Ścieżki i zakładki
602 +// ---------------------------------------------------------------------------
603 +
604 +fn tab_title(path: &Path) -> String {
605 + if path == Path::new("/") {
606 + "/".to_owned()
607 + } else if path == places::home() {
608 + "~".to_owned()
609 + } else {
610 + path.file_name()
611 + .map(|n| n.to_string_lossy().into_owned())
612 + .unwrap_or_else(|| path.display().to_string())
613 + }
614 +}
615 +
616 +fn path_text(path: &Path) -> String {
617 + if path == places::home() {
618 + return "~".to_owned();
619 + }
620 + if let Ok(rest) = path.strip_prefix(places::home()) {
621 + return format!("~/{}", rest.display());
622 + }
623 + path.display().to_string()
624 +}
625 +
626 +fn path_components(path: &Path) -> Vec<String> {
627 + let home = places::home();
628 + if path == home {
629 + return vec!["~".to_owned()];
630 + }
631 + if let Ok(rest) = path.strip_prefix(&home) {
632 + let mut out = vec!["~".to_owned()];
633 + for c in rest.components() {
634 + if let Component::Normal(s) = c {
635 + out.push(s.to_string_lossy().into_owned());
636 + }
637 + }
638 + return out;
639 + }
640 + let mut out = vec!["/".to_owned()];
641 + for c in path.components() {
642 + if let Component::Normal(s) = c {
643 + out.push(s.to_string_lossy().into_owned());
644 + }
645 + }
646 + if out.len() == 1 {
647 + return vec!["/".to_owned()];
648 + }
649 + out
650 +}
651 +
652 +fn segment_path(path: &Path, index: usize) -> PathBuf {
653 + let comps = path_components(path);
654 + if index == 0 {
655 + return if comps[0] == "~" {
656 + places::home()
657 + } else {
658 + PathBuf::from("/")
659 + };
660 + }
661 + let base = if comps[0] == "~" {
662 + places::home()
663 + } else {
664 + PathBuf::from("/")
665 + };
666 + let mut out = base;
667 + for c in comps.iter().skip(1).take(index) {
668 + out.push(c);
669 + }
670 + out
671 +}
672 +
673 +// ---------------------------------------------------------------------------
674 +// Zdarzenia
675 +// ---------------------------------------------------------------------------
676 +
677 +pub fn handle_events(
678 + app: &mut App,
679 + events: &[Event],
680 + size: Size,
681 + theme: &Theme,
682 + text: &TextEngine,
683 +) {
684 + let lay = layout(app, size, theme);
685 + let ch = chrome(&lay, size);
686 +
687 + // Modal konfliktu blokuje resztę interfejsu.
688 + if app.conflict.is_some() {
689 + for event in events {
690 + if let Event::PointerButton(pe) = event {
691 + if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
692 + let index = app
693 + .conflict
694 + .as_ref()
695 + .and_then(|c| c.dialog.action_at(size, theme, text, pe.position));
696 + if let Some(index) = index {
697 + app.answer_conflict(index);
698 + }
699 + }
700 + }
701 + }
702 + return;
703 + }
704 +
705 + // Modal potwierdzenia (opróżnienie kosza).
706 + if app.confirm_empty.is_some() {
707 + for event in events {
708 + if let Event::PointerButton(pe) = event {
709 + if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
710 + let index = app
711 + .confirm_empty
712 + .as_ref()
713 + .and_then(|d| d.action_at(size, theme, text, pe.position));
714 + match index {
715 + Some(0) => {
716 + if crate::app::debug() {
717 + eprintln!("luna-files: confirm empty trash");
718 + }
719 + let _ = trash::empty_trash();
720 + app.confirm_empty = None;
721 + app.reload_all();
722 + app.refresh_devices();
723 + app.show_toast(libluna_i18n::tr("Trash emptied"));
724 + }
725 + Some(_) => {
726 + if crate::app::debug() {
727 + eprintln!("luna-files: confirm cancelled");
728 + }
729 + app.confirm_empty = None;
730 + }
731 + None => {}
732 + }
733 + }
734 + }
735 + }
736 + return;
737 + }
738 +
739 + for event in events {
740 + match event {
741 + Event::PointerButton(pe) => match (pe.button, pe.state) {
742 + (PointerButton::Left, ButtonState::Pressed) => {
743 + on_left_press(app, pe.position, pe.modifiers, &lay, &ch, size, theme, text)
744 + }
745 + (PointerButton::Right, ButtonState::Pressed) => {
746 + on_right_press(app, pe.position, &lay, size, theme, text)
747 + }
748 + (PointerButton::Back, ButtonState::Pressed) => app.back(),
749 + (PointerButton::Forward, ButtonState::Pressed) => app.forward(),
750 + (_, ButtonState::Released) => {}
751 + _ => {}
752 + },
753 + Event::PointerMotion { position, .. } => {
754 + let p = *position;
755 + if let Some(context) = app.context.as_mut() {
756 + context.menu.hover_at(context.panel, Some(p), theme);
757 + continue;
758 + }
759 + let _hovered =
760 + app.sidebar
761 + .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
762 + app.sidebar.set_hovered(_hovered);
763 + for i in 0..if app.split { 2 } else { 1 } {
764 + let pr = pane_rects(app, &lay, i, theme);
765 + let rows = file_rows(&app.panes[i], pr.items);
766 + let hovered = hit_index(&app.panes[i], rows, p, theme);
767 + app.panes[i].view.set_hovered(hovered);
768 + app.panes[i]
769 + .search
770 + .set_hover_clear(app.panes[i].search.clear_at(ch.path, theme, p));
771 + }
772 + }
773 + Event::PointerLeave => {
774 + app.sidebar.set_hovered(None);
775 + for pane in &mut app.panes {
776 + pane.view.set_hovered(None);
777 + }
778 + }
779 + Event::Scroll(se) => {
780 + let count = if app.split { 2 } else { 1 };
781 + for i in 0..count {
782 + let pr = pane_rects(app, &lay, i, theme);
783 + let rows = file_rows(&app.panes[i], pr.items);
784 + if !rows.contains(se.position) {
785 + continue;
786 + }
787 + app.active = i;
788 + let step = theme.metrics.itemview.row_h;
789 + let pane = &mut app.panes[i];
790 + let max = pane.view.max_scroll(rows, theme);
791 + let next = (pane.view.scroll() + se.dy * step).clamp(0.0, max);
792 + pane.view.set_scroll(next);
793 + pane.scroll.set_offset(next, rows);
794 + break;
795 + }
796 + }
797 + Event::Key(ke) if ke.state == ButtonState::Pressed => {
798 + on_key(app, ke, &lay, &ch, size, theme, text)
799 + }
800 + _ => {}
801 + }
802 + }
803 +}
804 +
805 +fn on_left_press(
806 + app: &mut App,
807 + p: Point,
808 + mods: Modifiers,
809 + lay: &Layout,
810 + ch: &Chrome,
811 + size: Size,
812 + theme: &Theme,
813 + text: &TextEngine,
814 +) {
815 + // Anulowanie trwającej operacji ma pierwszeństwo.
816 + if app.op_progress.is_some() && op_cancel_rect(lay).contains(p) {
817 + app.cancel_op();
818 + return;
819 + }
820 + // 1. Menu kontekstowe ma pierwszeństwo.
821 + if let Some(context) = &mut app.context {
822 + if let Some(index) = context.menu.click_at(context.panel, p, theme) {
823 + run_menu(app, index);
824 + } else if !context.panel.contains(p) {
825 + app.context = None;
826 + }
827 + return;
828 + }
829 +
830 + // 2. Kontrolki okna (nasz HeaderBar).
831 + let header = HeaderBar::new().controls(ControlsSide::Right);
832 + if lay.header.contains(p) {
833 + if let Some(control) = header.control_at(lay.header, theme, p) {
834 + match control {
835 + WindowControl::Close => app.pending_close = true,
836 + WindowControl::Minimize => app.pending_minimize = true,
837 + WindowControl::Maximize => app.pending_maximize = true,
838 + }
839 + return;
840 + }
841 + // Przeciąganie za wolny obszar paska.
842 + let content = header.controls_layout(lay.header, theme).content;
843 + if content.contains(p) {
844 + app.pending_move = true;
845 + }
846 + return;
847 + }
848 +
849 + // 3. Zakładki.
850 + if lay.tabs.contains(p) {
851 + if ch.plus.contains(p) {
852 + app.new_tab();
853 + return;
854 + }
855 + let pane = app.pane();
856 + let tabs: Vec<Tab> = pane
857 + .tabs
858 + .iter()
859 + .map(|tp| Tab::new(tab_title(tp)).closable(pane.tabs.len() > 1))
860 + .collect();
861 + let mut bar = TabBar::with_tabs(tabs).expand(false);
862 + bar.set_selected(pane.tab);
863 + let rect = Rect::from_xywh(
864 + lay.tabs.x(),
865 + lay.tabs.y(),
866 + (ch.plus.x() - lay.tabs.x() - PAD).max(1.0),
867 + lay.tabs.height(),
868 + );
869 + if let Some(i) = bar.close_at(rect, text, theme, p) {
870 + app.select_tab(i);
871 + app.close_tab();
872 + } else if let Some(i) = bar.index_at(rect, text, theme, p) {
873 + app.select_tab(i);
874 + }
875 + return;
876 + }
877 +
878 + // 4. Pasek nawigacji.
879 + if lay.nav.contains(p) {
880 + if ch.back.contains(p) {
881 + app.back();
882 + return;
883 + }
884 + if ch.forward.contains(p) {
885 + app.forward();
886 + return;
887 + }
888 + if ch.up.contains(p) {
889 + app.up();
890 + return;
891 + }
892 + if ch.search.contains(p) {
893 + let pane = app.pane_mut();
894 + pane.search_active = !pane.search_active;
895 + pane.search.set_focused(pane.search_active);
896 + if !pane.search_active {
897 + pane.search.clear();
898 + pane.dir.set_filter("");
899 + pane.sync_view();
900 + }
901 + return;
902 + }
903 + if ch.view.contains(p) {
904 + let pane = app.pane_mut();
905 + pane.view.toggle_mode();
906 + return;
907 + }
908 + if ch.menu.contains(p) {
909 + open_menu(app, p, MenuTarget::AppMenu, size, theme, text);
910 + return;
911 + }
912 + if ch.path.contains(p) {
913 + let pane = app.pane_mut();
914 + if pane.edit.is_some() {
915 + pane.edit.as_mut().unwrap().entry.set_focused(true);
916 + return;
917 + }
918 + if pane.search_active {
919 + pane.search.set_focused(true);
920 + return;
921 + }
922 + let path = pane.dir.path.clone();
923 + let comps = PathBar::new(path_components(&path));
924 + if let Some(i) = comps.index_at(ch.path, text, theme, p) {
925 + let target = segment_path(&path, i);
926 + app.navigate(app.active, target);
927 + }
928 + return;
929 + }
930 + }
931 +
932 + // 5. Panel boczny.
933 + if lay.sidebar.contains(p) {
934 + let entry = app
935 + .sidebar
936 + .click_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
937 + if entry {
938 + let idx = app.sidebar.selected();
939 + if let Some(action) = idx
940 + .and_then(|e| app.sidebar_entry_action.get(e).copied().flatten())
941 + .and_then(|ai| app.sidebar_actions.get(ai))
942 + {
943 + match action {
944 + SideAction::Go(path) => {
945 + let path = path.clone();
946 + app.navigate(app.active, path);
947 + }
948 + SideAction::Unmount(i) => {
949 + if let Some(m) = app.mounts.get(*i) {
950 + places::unmount_device(&m.dev);
951 + app.show_toast(libluna_i18n::tr("Unmounting…"));
952 + }
953 + }
954 + SideAction::Mount(i) => {
955 + if let Some(m) = app.mounts.get(*i) {
956 + places::mount_device(&m.dev);
957 + app.show_toast(libluna_i18n::tr("Mounting…"));
958 + }
959 + }
960 + }
961 + }
962 + }
963 + return;
964 + }
965 +
966 + // 6. Panele.
967 + let count = if app.split { 2 } else { 1 };
968 + for i in 0..count {
969 + if !lay.panes[i].contains(p) {
970 + continue;
971 + }
972 + app.active = i;
973 + let pr = pane_rects(app, lay, i, theme);
974 + // Klik w nagłówek kolumny sortuje (tak jak w Nautilusie).
975 + if app.panes[i].view.mode() == ViewMode::List
976 + && p.y < pr.items.y() + list::HEADER_H
977 + && pr.items.contains(p)
978 + {
979 + if let Some(key) = list::header_at(pr.items, p, theme) {
980 + let pane = &mut app.panes[i];
981 + if pane.dir.sort_key() == key {
982 + let asc = pane.dir.ascending();
983 + pane.dir.set_ascending(!asc);
984 + } else {
985 + pane.dir.set_sort_key(key);
986 + // Rozmiar i data naturalnie malejąco.
987 + pane.dir
988 + .set_ascending(!matches!(key, SortKey::Size | SortKey::Modified));
989 + }
990 + pane.sync_view();
991 + if crate::app::debug() {
992 + eprintln!(
993 + "luna-files: sort {:?} asc={}",
994 + pane.dir.sort_key(),
995 + pane.dir.ascending()
996 + );
997 + }
998 + }
999 + return;
1000 + }
1001 + let rows = file_rows(&app.panes[i], pr.items);
1002 + if rows.contains(p) {
1003 + let index = hit_index(&app.panes[i], rows, p, theme);
1004 + let Some(index) = index else {
1005 + app.panes[i].view.clear_selection();
1006 + app.panes[i].preview_cache = None;
1007 + return;
1008 + };
1009 + let now = Instant::now();
1010 + let double = app
1011 + .last_click
1012 + .map(|(last, t)| {
1013 + last == index && now.duration_since(t) < Duration::from_millis(400)
1014 + })
1015 + .unwrap_or(false);
1016 + if double {
1017 + app.last_click = None;
1018 + let entry = app.panes[i].dir.entry(index).cloned();
1019 + if let Some(entry) = entry {
1020 + if entry.is_dir {
1021 + app.navigate(i, entry.path);
1022 + } else {
1023 + let _ = std::process::Command::new("xdg-open")
1024 + .arg(&entry.path)
1025 + .spawn();
1026 + }
1027 + }
1028 + } else {
1029 + app.last_click = Some((index, now));
1030 + if mods.ctrl {
1031 + app.panes[i].view.toggle_selected(index);
1032 + } else if mods.shift {
1033 + app.panes[i].view.select_range(index);
1034 + } else {
1035 + app.panes[i].view.set_selected(Some(index));
1036 + }
1037 + app.panes[i].preview_cache = None;
1038 + }
1039 + return;
1040 + }
1041 + // Klik poza listą czyści zaznaczenie.
1042 + app.panes[i].view.clear_selection();
1043 + app.panes[i].preview_cache = None;
1044 + return;
1045 + }
1046 +}
1047 +
1048 +fn on_right_press(
1049 + app: &mut App,
1050 + p: Point,
1051 + lay: &Layout,
1052 + size: Size,
1053 + theme: &Theme,
1054 + text: &TextEngine,
1055 +) {
1056 + if let Some(context) = &app.context {
1057 + if context.panel.contains(p) {
1058 + return;
1059 + }
1060 + app.context = None;
1061 + }
1062 +
1063 + // Panel boczny: menu urządzenia.
1064 + if lay.sidebar.contains(p) {
1065 + let entry = app
1066 + .sidebar
1067 + .index_at(lay.sidebar.deflate(Insets::splat(6.0)), p, theme);
1068 + if let Some(entry) = entry {
1069 + app.sidebar.set_selected(Some(entry));
1070 + if let Some(ai) = app.sidebar_entry_action.get(entry).copied().flatten() {
1071 + if let Some(SideAction::Go(path)) = app.sidebar_actions.get(ai) {
1072 + if let Some(mi) = app.mounts.iter().position(|m| &m.mount == path) {
1073 + open_menu(app, p, MenuTarget::Device(mi), size, theme, text);
1074 + return;
1075 + }
1076 + }
1077 + }
1078 + }
1079 + return;
1080 + }
1081 +
1082 + let count = if app.split { 2 } else { 1 };
1083 + for i in 0..count {
1084 + if !lay.panes[i].contains(p) {
1085 + continue;
1086 + }
1087 + app.active = i;
1088 + let pr = pane_rects(app, lay, i, theme);
1089 + let rows = file_rows(&app.panes[i], pr.items);
1090 + if let Some(index) = hit_index(&app.panes[i], rows, p, theme) {
1091 + if !app.panes[i].view.is_selected(index) {
1092 + app.panes[i].view.set_selected(Some(index));
1093 + }
1094 + if let Some(entry) = app.panes[i].dir.entry(index).cloned() {
1095 + let in_trash = app.panes[i].dir.path == trash::trash_root().join("files");
1096 + let target = if in_trash {
1097 + MenuTarget::TrashItem(entry.path)
1098 + } else {
1099 + MenuTarget::Item(entry.path, entry.is_dir)
1100 + };
1101 + open_menu(app, p, target, size, theme, text);
1102 + }
1103 + } else {
1104 + open_menu(app, p, MenuTarget::Background, size, theme, text);
1105 + }
1106 + return;
1107 + }
1108 +}
1109 +
1110 +fn on_key(
1111 + app: &mut App,
1112 + ke: &KeyEvent,
1113 + lay: &Layout,
1114 + ch: &Chrome,
1115 + _size: Size,
1116 + theme: &Theme,
1117 + text: &TextEngine,
1118 +) {
1119 + // Menu kontekstowe: Escape zamyka.
1120 + if app.context.is_some() {
1121 + if ke.keysym == 0xff1b {
1122 + app.context = None;
1123 + }
1124 + return;
1125 + }
1126 +
1127 + // Escape anuluje trwającą operację (gdy nie edytujemy tekstu).
1128 + if ke.keysym == 0xff1b
1129 + && app.ops.busy()
1130 + && app.pane().edit.is_none()
1131 + && !app.pane().search_active
1132 + && app.conflict.is_none()
1133 + {
1134 + app.cancel_op();
1135 + return;
1136 + }
1137 +
1138 + // Pole edycji (ścieżka / nazwa).
1139 + if app.pane().edit.is_some() {
1140 + if ke.keysym == 0xff1b {
1141 + app.pane_mut().edit = None;
1142 + return;
1143 + }
1144 + if ke.keysym == 0xff0d {
1145 + apply_edit(app);
1146 + return;
1147 + }
1148 + let handled = {
1149 + let pane = app.pane_mut();
1150 + let edit = pane.edit.as_mut().unwrap();
1151 + handle_text_key(&mut edit.entry, ke)
1152 + };
1153 + if handled {
1154 + return;
1155 + }
1156 + }
1157 +
1158 + // Wyszukiwanie.
1159 + if app.pane().search_active && app.pane().search.is_focused() {
1160 + if ke.keysym == 0xff1b || ke.keysym == 0xff0d {
1161 + let pane = app.pane_mut();
1162 + pane.search_active = false;
1163 + pane.search.set_focused(false);
1164 + pane.search.clear();
1165 + pane.dir.set_filter("");
1166 + pane.sync_view();
1167 + return;
1168 + }
1169 + let handled = {
1170 + let pane = app.pane_mut();
1171 + handle_text_key(&mut pane.search, ke)
1172 + };
1173 + if handled {
1174 + let query = app.pane().search.text().to_owned();
1175 + let pane = app.pane_mut();
1176 + pane.dir.set_filter(query);
1177 + pane.sync_view();
1178 + return;
1179 + }
1180 + }
1181 +
1182 + let ctrl = ke.modifiers.ctrl;
1183 + let shift = ke.modifiers.shift;
1184 + let alt = ke.modifiers.alt;
1185 +
1186 + if ctrl {
1187 + match ke.keysym {
1188 + 0x6c => {
1189 + // Ctrl+L — edycja ścieżki
1190 + let path = app.pane().dir.path.clone();
1191 + app.pane_mut().edit = Some(EditState::path(&path));
1192 + }
1193 + 0x66 => {
1194 + // Ctrl+F — szukanie
1195 + let pane = app.pane_mut();
1196 + pane.search_active = true;
1197 + pane.search.set_focused(true);
1198 + }
1199 + 0x74 => app.new_tab(), // Ctrl+T
1200 + 0x77 => app.close_tab(), // Ctrl+W
1201 + 0x63 => copy_selection(app, false), // Ctrl+C
1202 + 0x78 => copy_selection(app, true), // Ctrl+X
1203 + 0x76 => paste(app), // Ctrl+V
1204 + 0x61 => app.pane_mut().view.select_all(), // Ctrl+A
1205 + 0x68 => toggle_hidden(app), // Ctrl+H
1206 + 0x6e => new_folder_edit(app), // Ctrl+N
1207 + 0x72 => app.reload_pane(app.active), // Ctrl+R
1208 + _ => {}
1209 + }
1210 + return;
1211 + }
1212 +
1213 + match ke.keysym {
1214 + 0x72 => app.reload_pane(app.active), // r
1215 + 0xff0d => app.open_selected(), // Enter
1216 + 0xff08 => app.up(), // Backspace
1217 + 0xffff => {
1218 + // Delete — do kosza (Shift = trwale)
1219 + if shift {
1220 + delete_selection(app, true);
1221 + } else {
1222 + delete_selection(app, false);
1223 + }
1224 + }
1225 + 0xff1b => {
1226 + let pane = app.pane_mut();
1227 + pane.preview = false;
1228 + pane.preview_cache = None;
1229 + pane.view.clear_selection();
1230 + }
1231 + 0x20 => {
1232 + // Space — podgląd
1233 + let pane = app.pane_mut();
1234 + pane.preview = !pane.preview;
1235 + pane.preview_cache = None;
1236 + }
1237 + 0xffbe => {} // F1
1238 + 0xffbf => {
1239 + // F2 — zmiana nazwy
1240 + if let Some(entry) = app.pane().primary().cloned() {
1241 + app.pane_mut().edit = Some(EditState::rename(&entry.path));
1242 + }
1243 + }
1244 + 0xffc0 => {
1245 + // F3 — dwa panele
1246 + app.split = !app.split;
1247 + if !app.split {
1248 + app.active = 0;
1249 + }
1250 + }
1251 + 0xffc1 => app.open_terminal(), // F4
1252 + 0xffc2 => app.reload_pane(app.active), // F5
1253 + 0xffc6 => app.sidebar_visible = !app.sidebar_visible, // F9
1254 + 0xff51 => {
1255 + if alt {
1256 + app.back();
1257 + } else {
1258 + move_selection(app, -1, shift, lay, theme);
1259 + }
1260 + }
1261 + 0xff53 => {
1262 + if alt {
1263 + app.forward();
1264 + } else {
1265 + move_selection(app, 1, shift, lay, theme);
1266 + }
1267 + }
1268 + 0xff52 => move_selection(app, -1, shift, lay, theme),
1269 + 0xff54 => move_selection(app, 1, shift, lay, theme),
1270 + 0xff55 => move_selection(app, -10, shift, lay, theme),
1271 + 0xff56 => move_selection(app, 10, shift, lay, theme),
1272 + 0xff50 => select_boundary(app, false, shift, lay, theme),
1273 + 0xff57 => select_boundary(app, true, shift, lay, theme),
1274 + 0xff09 => {
1275 + // Tab — następna zakładka (bez Ctrl)
1276 + let next = {
1277 + let p = app.pane();
1278 + (p.tab + 1) % p.tabs.len().max(1)
1279 + };
1280 + app.select_tab(next);
1281 + }
1282 + _ => {
1283 + // Pisanie bez fokusu pola = szybkie wyszukiwanie.
1284 + if let Some(t) = &ke.text {
1285 + if !t.is_empty() && !t.chars().all(char::is_control) {
1286 + let pane = app.pane_mut();
1287 + pane.search_active = true;
1288 + pane.search.set_focused(true);
1289 + pane.search.insert_str(t);
1290 + let query = pane.search.text().to_owned();
1291 + pane.dir.set_filter(query);
1292 + pane.sync_view();
1293 + }
1294 + }
1295 + }
1296 + }
1297 +
1298 + // Pasek edycji ma priorytet nad resztą — nic więcej nie rób.
1299 + if app.pane().edit.is_some() {
1300 + let _ = ch;
1301 + let _ = text;
1302 + }
1303 +}
1304 +
1305 +fn handle_text_key<T: TextInput>(entry: &mut T, ke: &KeyEvent) -> bool {
1306 + let shift = ke.modifiers.shift;
1307 + let ctrl = ke.modifiers.ctrl;
1308 + if ctrl && ke.keysym == 0x61 {
1309 + entry.select_all();
1310 + return true;
1311 + }
1312 + if !ctrl {
1313 + if let Some(t) = &ke.text {
1314 + if !t.is_empty() && !t.chars().all(char::is_control) {
1315 + entry.insert_str(t);
1316 + return true;
1317 + }
1318 + }
1319 + }
1320 + match ke.keysym {
1321 + 0xff08 => entry.backspace(),
1322 + 0xffff => entry.delete_forward(),
1323 + 0xff51 => {
1324 + if shift {
1325 + entry.move_left_selecting(true)
1326 + } else {
1327 + entry.move_left()
1328 + }
1329 + }
1330 + 0xff53 => {
1331 + if shift {
1332 + entry.move_right_selecting(true)
1333 + } else {
1334 + entry.move_right()
1335 + }
1336 + }
1337 + 0xff50 => {
1338 + if shift {
1339 + entry.home_selecting(true)
1340 + } else {
1341 + entry.home()
1342 + }
1343 + }
1344 + 0xff57 => {
1345 + if shift {
1346 + entry.end_selecting(true)
1347 + } else {
1348 + entry.end()
1349 + }
1350 + }
1351 + _ => return false,
1352 + }
1353 + true
1354 +}
1355 +
1356 +fn apply_edit(app: &mut App) {
1357 + let dir = app.pane().dir.path.clone();
1358 + let edit = app.pane_mut().edit.take();
1359 + let Some(edit) = edit else { return };
1360 + let text = edit.entry.text().trim().to_owned();
1361 + if text.is_empty() {
1362 + return;
1363 + }
1364 + if let Some(from) = edit.rename {
1365 + let to = from
1366 + .parent()
1367 + .map(|p| p.join(&text))
1368 + .unwrap_or_else(|| dir.join(&text));
1369 + if to != from {
1370 + app.submit(Job::Rename { from, to });
1371 + }
1372 + } else if edit.new_folder {
1373 + app.submit(Job::Mkdir {
1374 + path: dir.join(&text),
1375 + });
1376 + } else {
1377 + let target = if text.starts_with('/') {
1378 + PathBuf::from(&text)
1379 + } else if text == "~" {
1380 + places::home()
1381 + } else if let Some(rest) = text.strip_prefix("~/") {
1382 + places::home().join(rest)
1383 + } else {
1384 + dir.join(&text)
1385 + };
1386 + app.navigate(app.active, target);
1387 + }
1388 +}
1389 +
1390 +fn move_selection(app: &mut App, delta: i32, extend: bool, lay: &Layout, theme: &Theme) {
1391 + let pr = pane_rects(app, lay, app.active, theme);
1392 + let rows = file_rows(&app.panes[app.active], pr.items);
1393 + let p = &mut app.panes[app.active];
1394 + let len = p.view.len();
1395 + if len == 0 {
1396 + return;
1397 + }
1398 + let current = p.view.selected().unwrap_or(0) as i32;
1399 + let next = (current + delta).clamp(0, len as i32 - 1) as usize;
1400 + if extend {
1401 + p.view.select_range(next);
1402 + } else {
1403 + p.view.set_selected(Some(next));
1404 + }
1405 + let slot = p.view.slot_rect(rows, next, theme);
1406 + if slot.y() < rows.y() {
1407 + let d = rows.y() - slot.y();
1408 + p.view.set_scroll(p.view.scroll() - d);
1409 + } else if slot.bottom() > rows.bottom() {
1410 + let d = slot.bottom() - rows.bottom();
1411 + p.view.set_scroll(p.view.scroll() + d);
1412 + }
1413 + p.view.clamp_scroll(rows, theme);
1414 + p.preview_cache = None;
1415 +}
1416 +
1417 +fn select_boundary(app: &mut App, end: bool, extend: bool, lay: &Layout, theme: &Theme) {
1418 + let pr = pane_rects(app, lay, app.active, theme);
1419 + let rows = file_rows(&app.panes[app.active], pr.items);
1420 + let p = &mut app.panes[app.active];
1421 + if p.view.len() == 0 {
1422 + return;
1423 + }
1424 + let index = if end { p.view.len() - 1 } else { 0 };
1425 + if extend {
1426 + p.view.select_range(index);
1427 + } else {
1428 + p.view.set_selected(Some(index));
1429 + }
1430 + let slot = p.view.slot_rect(rows, index, theme);
1431 + if slot.y() < rows.y() {
1432 + let d = rows.y() - slot.y();
1433 + p.view.set_scroll(p.view.scroll() - d);
1434 + } else if slot.bottom() > rows.bottom() {
1435 + let d = slot.bottom() - rows.bottom();
1436 + p.view.set_scroll(p.view.scroll() + d);
1437 + }
1438 + p.view.clamp_scroll(rows, theme);
1439 + p.preview_cache = None;
1440 +}
1441 +
1442 +fn toggle_hidden(app: &mut App) {
1443 + let pane = app.pane_mut();
1444 + let show = !pane.dir.show_hidden();
1445 + pane.dir.set_show_hidden(show);
1446 + pane.sync_view();
1447 +}
1448 +
1449 +fn new_folder_edit(app: &mut App) {
1450 + let dir = app.pane().dir.path.clone();
1451 + let mut edit = EditState::path(&dir);
1452 + let mut entry = TextEntry::new(libluna_i18n::tr("Name"));
1453 + entry.set_text(libluna_i18n::tr("New Folder"));
1454 + entry.set_focused(true);
1455 + entry.select_all();
1456 + edit.entry = entry;
1457 + edit.rename = None;
1458 + edit.new_folder = true;
1459 + app.pane_mut().edit = Some(edit);
1460 +}
1461 +
1462 +fn copy_selection(app: &mut App, cut: bool) {
1463 + let paths = app.pane().selection();
1464 + if paths.is_empty() {
1465 + return;
1466 + }
1467 + let n = paths.len();
1468 + app.clipboard = Some(crate::app::Clipboard { paths, cut });
1469 + app.show_toast(if cut {
1470 + libluna_i18n::tr_args("{n} items cut", &[("n", &n.to_string())])
1471 + } else {
1472 + libluna_i18n::tr_args("{n} items copied", &[("n", &n.to_string())])
1473 + });
1474 +}
1475 +
1476 +fn paste(app: &mut App) {
1477 + let Some(clip) = app.clipboard.clone() else {
1478 + return;
1479 + };
1480 + let dst = app.pane().dir.path.clone();
1481 + if clip.cut {
1482 + app.submit(Job::Move {
1483 + srcs: clip.paths.clone(),
1484 + dst,
1485 + });
1486 + app.clipboard = None;
1487 + } else {
1488 + app.submit(Job::Copy {
1489 + srcs: clip.paths,
1490 + dst,
1491 + });
1492 + }
1493 +}
1494 +
1495 +fn delete_selection(app: &mut App, permanent: bool) {
1496 + let paths = app.pane().selection();
1497 + if paths.is_empty() {
1498 + return;
1499 + }
1500 + if permanent {
1501 + app.submit(Job::Delete { paths });
1502 + } else {
1503 + app.submit(Job::Trash { paths });
1504 + }
1505 +}
1506 +
1507 +// ---------------------------------------------------------------------------
1508 +// Menu
1509 +// ---------------------------------------------------------------------------
1510 +
1511 +fn open_menu(
1512 + app: &mut App,
1513 + at: Point,
1514 + target: MenuTarget,
1515 + size: Size,
1516 + theme: &Theme,
1517 + text: &TextEngine,
1518 +) {
1519 + let (entries, cmds) = menu_entries(app, &target);
1520 + let menu = Menu::new(entries);
1521 + let width = menu.width(text, theme);
1522 + let anchor = Rect::from_xywh(at.x, at.y, 1.0, 1.0);
1523 + let panel = menu.place(anchor, width, Rect::from_size(size), theme);
1524 + app.context = Some(ContextMenu {
1525 + menu,
1526 + panel,
1527 + target,
1528 + cmds,
1529 + });
1530 +}
1531 +
1532 +struct MenuBuild {
1533 + entries: Vec<MenuEntry>,
1534 + cmds: Vec<Option<MenuCmd>>,
1535 +}
1536 +
1537 +impl MenuBuild {
1538 + fn new() -> Self {
1539 + Self {
1540 + entries: Vec::new(),
1541 + cmds: Vec::new(),
1542 + }
1543 + }
1544 +
1545 + fn action(&mut self, label: impl Into<String>, shortcut: Option<&str>, cmd: MenuCmd) {
1546 + let mut entry = MenuEntry::action(label);
1547 + if let Some(shortcut) = shortcut {
1548 + entry = entry.shortcut(shortcut);
1549 + }
1550 + self.entries.push(entry);
1551 + self.cmds.push(Some(cmd));
1552 + }
1553 +
1554 + fn disabled(&mut self, label: impl Into<String>) {
1555 + self.entries.push(MenuEntry::action(label).disabled());
1556 + self.cmds.push(None);
1557 + }
1558 +
1559 + fn sep(&mut self) {
1560 + self.entries.push(MenuEntry::separator());
1561 + self.cmds.push(None);
1562 + }
1563 +}
1564 +
1565 +fn menu_entries(app: &App, target: &MenuTarget) -> (Vec<MenuEntry>, Vec<Option<MenuCmd>>) {
1566 + let tr = libluna_i18n::tr;
1567 + let mut m = MenuBuild::new();
1568 + match target {
1569 + MenuTarget::Item(..) => {
1570 + m.action(tr("Open"), Some("Enter"), MenuCmd::Open);
1571 + m.action(tr("Open in Terminal"), Some("F4"), MenuCmd::OpenTerminal);
1572 + m.sep();
1573 + m.action(tr("Copy"), Some("Ctrl+C"), MenuCmd::Copy);
1574 + m.action(tr("Cut"), Some("Ctrl+X"), MenuCmd::Cut);
1575 + m.sep();
1576 + m.action(tr("Rename"), Some("F2"), MenuCmd::Rename);
1577 + m.action(tr("Move to Trash"), Some("Delete"), MenuCmd::Trash);
1578 + m.action(
1579 + tr("Delete Permanently"),
1580 + Some("Shift+Delete"),
1581 + MenuCmd::Delete,
1582 + );
1583 + m.sep();
1584 + m.action(tr("Properties"), None, MenuCmd::Properties);
1585 + }
1586 + MenuTarget::TrashItem(_) => {
1587 + m.action(tr("Restore"), None, MenuCmd::Restore);
1588 + m.action(
1589 + tr("Delete Permanently"),
1590 + Some("Shift+Delete"),
1591 + MenuCmd::Purge,
1592 + );
1593 + m.sep();
1594 + m.action(tr("Properties"), None, MenuCmd::Properties);
1595 + }
1596 + MenuTarget::Device(i) => {
1597 + m.action(tr("Unmount"), None, MenuCmd::Unmount(*i));
1598 + m.action(tr("Open"), None, MenuCmd::OpenDevice(*i));
1599 + }
1600 + MenuTarget::Background | MenuTarget::AppMenu => {
1601 + let pane = app.pane();
1602 + if matches!(target, MenuTarget::Background) {
1603 + if app.clipboard.is_some() {
1604 + m.action(tr("Paste"), Some("Ctrl+V"), MenuCmd::Paste);
1605 + } else {
1606 + m.disabled(tr("Paste"));
1607 + }
1608 + m.action(tr("Select All"), Some("Ctrl+A"), MenuCmd::SelectAll);
1609 + m.sep();
1610 + }
1611 + m.action(tr("New Folder"), Some("Ctrl+N"), MenuCmd::NewFolder);
1612 + m.action(tr("New Tab"), Some("Ctrl+T"), MenuCmd::NewTab);
1613 + m.action(
1614 + if app.split {
1615 + tr("Single Pane")
1616 + } else {
1617 + tr("Split View")
1618 + },
1619 + None,
1620 + MenuCmd::Split,
1621 + );
1622 + m.sep();
1623 + m.action(
1624 + if pane.dir.show_hidden() {
1625 + tr("Hide Hidden Files")
1626 + } else {
1627 + tr("Show Hidden Files")
1628 + },
1629 + Some("Ctrl+H"),
1630 + MenuCmd::ToggleHidden,
1631 + );
1632 + m.sep();
1633 + for key in [
1634 + SortKey::Name,
1635 + SortKey::Size,
1636 + SortKey::Modified,
1637 + SortKey::Kind,
1638 + ] {
1639 + let mark = if pane.dir.sort_key() == key {
1640 + "• "
1641 + } else {
1642 + " "
1643 + };
1644 + m.action(format!("{mark}{}", key.label()), None, MenuCmd::Sort(key));
1645 + }
1646 + m.action(
1647 + if pane.dir.ascending() {
1648 + tr("Descending")
1649 + } else {
1650 + tr("Ascending")
1651 + },
1652 + None,
1653 + MenuCmd::ToggleAsc,
1654 + );
1655 + m.sep();
1656 + m.action(tr("Refresh"), Some("F5"), MenuCmd::Refresh);
1657 + m.action(tr("Empty Trash"), None, MenuCmd::EmptyTrash);
1658 + }
1659 + }
1660 + (m.entries, m.cmds)
1661 +}
1662 +
1663 +fn run_menu(app: &mut App, index: usize) {
1664 + let Some(context) = app.context.take() else {
1665 + return;
1666 + };
1667 + let Some(cmd) = context.cmds.get(index).copied().flatten() else {
1668 + return;
1669 + };
1670 + if crate::app::debug() {
1671 + eprintln!("luna-files: menu {cmd:?}");
1672 + }
1673 + let target = match &context.target {
1674 + MenuTarget::Item(path, is_dir) => Some((path.clone(), *is_dir)),
1675 + MenuTarget::TrashItem(path) => Some((path.clone(), false)),
1676 + _ => None,
1677 + };
1678 + match cmd {
1679 + MenuCmd::Open => {
1680 + if let Some((path, is_dir)) = target {
1681 + if is_dir {
1682 + app.navigate(app.active, path);
1683 + } else {
1684 + let _ = std::process::Command::new("xdg-open").arg(&path).spawn();
1685 + }
1686 + }
1687 + }
1688 + MenuCmd::OpenTerminal => {
1689 + if let Some((path, is_dir)) = target {
1690 + let dir = if is_dir {
1691 + path
1692 + } else {
1693 + path.parent()
1694 + .map(|p| p.to_path_buf())
1695 + .unwrap_or_else(|| app.pane().dir.path.clone())
1696 + };
1697 + app.open_terminal_at(&dir);
1698 + }
1699 + }
1700 + MenuCmd::Copy | MenuCmd::Cut => {
1701 + if let Some((path, _)) = target {
1702 + app.clipboard = Some(crate::app::Clipboard {
1703 + paths: vec![path],
1704 + cut: matches!(cmd, MenuCmd::Cut),
1705 + });
1706 + }
1707 + }
1708 + MenuCmd::Rename => {
1709 + if let Some((path, _)) = target {
1710 + app.pane_mut().edit = Some(EditState::rename(&path));
1711 + }
1712 + }
1713 + MenuCmd::Trash => {
1714 + if let Some((path, _)) = target {
1715 + app.submit(Job::Trash { paths: vec![path] });
1716 + }
1717 + }
1718 + MenuCmd::Delete => {
1719 + if let Some((path, _)) = target {
1720 + app.submit(Job::Delete { paths: vec![path] });
1721 + }
1722 + }
1723 + MenuCmd::Restore => {
1724 + if let Some((path, _)) = target {
1725 + app.submit(Job::Restore { path });
1726 + }
1727 + }
1728 + MenuCmd::Purge => {
1729 + if let Some((path, _)) = target {
1730 + app.submit(Job::Purge { path });
1731 + }
1732 + }
1733 + MenuCmd::Properties => {
1734 + if let Some((path, _)) = target {
1735 + show_properties(app, &path);
1736 + }
1737 + }
1738 + MenuCmd::Paste => paste(app),
1739 + MenuCmd::SelectAll => app.pane_mut().view.select_all(),
1740 + MenuCmd::NewFolder => new_folder_edit(app),
1741 + MenuCmd::NewTab => app.new_tab(),
1742 + MenuCmd::Split => {
1743 + app.split = !app.split;
1744 + if !app.split {
1745 + app.active = 0;
1746 + }
1747 + }
1748 + MenuCmd::ToggleHidden => toggle_hidden(app),
1749 + MenuCmd::Sort(key) => set_sort(app, key),
1750 + MenuCmd::ToggleAsc => {
1751 + let asc = app.pane().dir.ascending();
1752 + app.pane_mut().dir.set_ascending(!asc);
1753 + app.pane_mut().sync_view();
1754 + }
1755 + MenuCmd::Refresh => app.reload_pane(app.active),
1756 + MenuCmd::EmptyTrash => {
1757 + app.confirm_empty = Some(
1758 + AlertDialog::new(
1759 + libluna_i18n::tr("Empty Trash"),
1760 + libluna_i18n::tr("Permanently delete all items in the Trash?"),
1761 + )
1762 + .button(libluna_i18n::tr("Empty Trash"))
1763 + .button(libluna_i18n::tr("Cancel")),
1764 + );
1765 + }
1766 + MenuCmd::Unmount(i) => {
1767 + if let Some(m) = app.mounts.get(i) {
1768 + places::unmount_device(&m.dev);
1769 + app.show_toast(libluna_i18n::tr("Unmounting…"));
1770 + }
1771 + }
1772 + MenuCmd::OpenDevice(i) => {
1773 + if let Some(m) = app.mounts.get(i) {
1774 + let mount = m.mount.clone();
1775 + app.navigate(app.active, mount);
1776 + }
1777 + }
1778 + }
1779 +}
1780 +
1781 +fn set_sort(app: &mut App, key: SortKey) {
1782 + let pane = app.pane_mut();
1783 + pane.dir.set_sort_key(key);
1784 + pane.sync_view();
1785 +}
1786 +
1787 +fn show_properties(app: &mut App, path: &Path) {
1788 + let Ok(meta) = std::fs::metadata(path) else {
1789 + return;
1790 + };
1791 + let kind = if meta.is_dir() {
1792 + Kind::Folder.label()
1793 + } else {
1794 + libluna_i18n::tr("File")
1795 + };
1796 + let modified = meta
1797 + .modified()
1798 + .ok()
1799 + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1800 + .map(|d| d.as_secs() as i64);
1801 + let msg = libluna_i18n::tr_args(
1802 + "{name} · {kind} · {size} · {date}",
1803 + &[
1804 + (
1805 + "name",
1806 + &path
1807 + .file_name()
1808 + .map(|n| n.to_string_lossy().into_owned())
1809 + .unwrap_or_default(),
1810 + ),
1811 + ("kind", &kind),
1812 + (
1813 + "size",
1814 + &human_size(if meta.is_dir() { 0 } else { meta.len() }),
1815 + ),
1816 + ("date", &format_mtime(modified)),
1817 + ],
1818 + );
1819 + app.show_toast(msg);
1820 +}
@@ -66,3 +66,85 @@ Show = Anzeigen
66 66 Hide = Ausblenden
67 67 Close = Programm schließen
68 68 Move to workspace {n} = In Arbeitsfläche {n} verschieben
69 +
70 +# --- Luna Files (Dateimanager) ---
71 +Folder = Ordner
72 +Image = Bild
73 +Audio = Audio
74 +Video = Video
75 +Archive = Archiv
76 +Document = Dokument
77 +Code = Code
78 +Text file = Textdatei
79 +File = Datei
80 +Modified = Geändert
81 +Type = Typ
82 +Desktop = Schreibtisch
83 +Downloads = Downloads
84 +Pictures = Bilder
85 +Music = Musik
86 +Videos = Videos
87 +File System = Dateisystem
88 +Places = Orte
89 +Devices = Geräte
90 +Trash = Papierkorb
91 +Path = Pfad
92 +free = frei
93 +Name = Name
94 +Size = Größe
95 +{n} items = {n} Elemente
96 +{n} selected = {n} ausgewählt
97 +{n} selected ({size}) = {n} ausgewählt ({size})
98 +{label}: {n} done = {label}: {n} erledigt
99 +Another operation is still running = Eine andere Operation läuft noch
100 +No terminal emulator found = Kein Terminalemulator gefunden
101 +No matching files = Keine passenden Dateien
102 +Folder is empty = Ordner ist leer
103 +No preview = Keine Vorschau
104 +Unmounting… = Wird ausgehängt…
105 +Mounting… = Wird eingehängt…
106 +Open = Öffnen
107 +Open in Terminal = Im Terminal öffnen
108 +Copy = Kopieren
109 +Cut = Ausschneiden
110 +Rename = Umbenennen
111 +Move to Trash = In den Papierkorb
112 +Delete Permanently = Endgültig löschen
113 +Properties = Eigenschaften
114 +Unmount = Aushängen
115 +Paste = Einfügen
116 +Select All = Alles auswählen
117 +New Folder = Neuer Ordner
118 +New Tab = Neuer Tab
119 +Single Pane = Ein Bereich
120 +Split View = Geteilte Ansicht
121 +Hide Hidden Files = Versteckte Dateien ausblenden
122 +Show Hidden Files = Versteckte Dateien anzeigen
123 +Descending = Absteigend
124 +Ascending = Aufsteigend
125 +Refresh = Aktualisieren
126 +Empty Trash = Papierkorb leeren
127 +Trash emptied = Papierkorb geleert
128 +{n} items cut = {n} ausgeschnitten
129 +{n} items copied = {n} kopiert
130 +{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
131 +{name} — {free} free = {name} — {free} frei
132 +Copying = Kopieren
133 +Moving = Verschieben
134 +Moving to Trash = In den Papierkorb
135 +Deleting = Löschen
136 +Renaming = Umbenennen
137 +Creating folder = Ordner wird erstellt
138 +Cancelled = Abgebrochen
139 +A file with that name already exists = Eine Datei mit diesem Namen existiert bereits
140 +Restore = Wiederherstellen
141 +Restoring = Wiederherstellen
142 +Cancel = Abbrechen
143 +File exists = Datei existiert bereits
144 +{name} already exists in the destination. = {name} existiert bereits am Zielort.
145 +Replace = Ersetzen
146 +Skip = Überspringen
147 +Replace All = Alle ersetzen
148 +{label}: {n} done, {skipped} skipped = {label}: {n} erledigt, {skipped} übersprungen
149 +{time} left = noch {time}
150 +Permanently delete all items in the Trash? = Alle Elemente im Papierkorb endgültig löschen?
@@ -67,3 +67,85 @@ Show = Show
67 67 Hide = Hide
68 68 Close = Close
69 69 Move to workspace {n} = Move to workspace {n}
70 +
71 +# --- Luna Files (file manager) ---
72 +Folder = Folder
73 +Image = Image
74 +Audio = Audio
75 +Video = Video
76 +Archive = Archive
77 +Document = Document
78 +Code = Code
79 +Text file = Text file
80 +File = File
81 +Modified = Modified
82 +Type = Type
83 +Desktop = Desktop
84 +Downloads = Downloads
85 +Pictures = Pictures
86 +Music = Music
87 +Videos = Videos
88 +File System = File System
89 +Places = Places
90 +Devices = Devices
91 +Trash = Trash
92 +Path = Path
93 +free = free
94 +Name = Name
95 +Size = Size
96 +{n} items = {n} items
97 +{n} selected = {n} selected
98 +{n} selected ({size}) = {n} selected ({size})
99 +{label}: {n} done = {label}: {n} done
100 +Another operation is still running = Another operation is still running
101 +No terminal emulator found = No terminal emulator found
102 +No matching files = No matching files
103 +Folder is empty = Folder is empty
104 +No preview = No preview
105 +Unmounting… = Unmounting…
106 +Mounting… = Mounting…
107 +Open = Open
108 +Open in Terminal = Open in Terminal
109 +Copy = Copy
110 +Cut = Cut
111 +Rename = Rename
112 +Move to Trash = Move to Trash
113 +Delete Permanently = Delete Permanently
114 +Properties = Properties
115 +Unmount = Unmount
116 +Paste = Paste
117 +Select All = Select All
118 +New Folder = New Folder
119 +New Tab = New Tab
120 +Single Pane = Single Pane
121 +Split View = Split View
122 +Hide Hidden Files = Hide Hidden Files
123 +Show Hidden Files = Show Hidden Files
124 +Descending = Descending
125 +Ascending = Ascending
126 +Refresh = Refresh
127 +Empty Trash = Empty Trash
128 +Trash emptied = Trash emptied
129 +{n} items cut = {n} items cut
130 +{n} items copied = {n} items copied
131 +{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
132 +{name} — {free} free = {name} — {free} free
133 +Copying = Copying
134 +Moving = Moving
135 +Moving to Trash = Moving to Trash
136 +Deleting = Deleting
137 +Renaming = Renaming
138 +Creating folder = Creating folder
139 +Cancelled = Cancelled
140 +A file with that name already exists = A file with that name already exists
141 +Restore = Restore
142 +Restoring = Restoring
143 +Cancel = Cancel
144 +File exists = File exists
145 +{name} already exists in the destination. = {name} already exists in the destination.
146 +Replace = Replace
147 +Skip = Skip
148 +Replace All = Replace All
149 +{label}: {n} done, {skipped} skipped = {label}: {n} done, {skipped} skipped
150 +{time} left = {time} left
151 +Permanently delete all items in the Trash? = Permanently delete all items in the Trash?
@@ -66,3 +66,85 @@ Show = Pokaż
66 66 Hide = Schowaj
67 67 Close = Zamknij program
68 68 Move to workspace {n} = Przenieś na przestrzeń {n}
69 +
70 +# --- Luna Files (menedżer plików) ---
71 +Folder = Folder
72 +Image = Obraz
73 +Audio = Dźwięk
74 +Video = Wideo
75 +Archive = Archiwum
76 +Document = Dokument
77 +Code = Kod
78 +Text file = Plik tekstowy
79 +File = Plik
80 +Modified = Modyfikacja
81 +Type = Typ
82 +Desktop = Pulpit
83 +Downloads = Pobrane
84 +Pictures = Obrazy
85 +Music = Muzyka
86 +Videos = Wideo
87 +File System = System plików
88 +Places = Miejsca
89 +Devices = Urządzenia
90 +Trash = Kosz
91 +Path = Ścieżka
92 +free = wolne
93 +Name = Nazwa
94 +Size = Rozmiar
95 +{n} items = {n} elementów
96 +{n} selected = zaznaczono {n}
97 +{n} selected ({size}) = zaznaczono {n} ({size})
98 +{label}: {n} done = {label}: {n} — gotowe
99 +Another operation is still running = Inna operacja jeszcze trwa
100 +No terminal emulator found = Nie znaleziono emulatora terminala
101 +No matching files = Brak pasujących plików
102 +Folder is empty = Folder jest pusty
103 +No preview = Brak podglądu
104 +Unmounting… = Odmontowywanie…
105 +Mounting… = Montowanie…
106 +Open = Otwórz
107 +Open in Terminal = Otwórz w terminalu
108 +Copy = Kopiuj
109 +Cut = Wytnij
110 +Rename = Zmień nazwę
111 +Move to Trash = Przenieś do kosza
112 +Delete Permanently = Usuń trwale
113 +Properties = Właściwości
114 +Unmount = Odmontuj
115 +Paste = Wklej
116 +Select All = Zaznacz wszystko
117 +New Folder = Nowy folder
118 +New Tab = Nowa karta
119 +Single Pane = Jeden panel
120 +Split View = Widok dzielony
121 +Hide Hidden Files = Ukryj pliki ukryte
122 +Show Hidden Files = Pokaż ukryte pliki
123 +Descending = Malejąco
124 +Ascending = Rosnąco
125 +Refresh = Odśwież
126 +Empty Trash = Opróżnij kosz
127 +Trash emptied = Kosz opróżniony
128 +{n} items cut = Wycięto {n}
129 +{n} items copied = Skopiowano {n}
130 +{name} · {kind} · {size} · {date} = {name} · {kind} · {size} · {date}
131 +{name} — {free} free = {name} — {free} wolnego
132 +Copying = Kopiowanie
133 +Moving = Przenoszenie
134 +Moving to Trash = Przenoszenie do kosza
135 +Deleting = Usuwanie
136 +Renaming = Zmiana nazwy
137 +Creating folder = Tworzenie folderu
138 +Cancelled = Anulowano
139 +A file with that name already exists = Plik o tej nazwie już istnieje
140 +Restore = Przywróć
141 +Restoring = Przywracanie
142 +Cancel = Anuluj
143 +File exists = Plik już istnieje
144 +{name} already exists in the destination. = {name} już istnieje w miejscu docelowym.
145 +Replace = Zastąp
146 +Skip = Pomiń
147 +Replace All = Zastąp wszystkie
148 +{label}: {n} done, {skipped} skipped = {label}: {n} — gotowe, pominięto {skipped}
149 +{time} left = pozostało {time}
150 +Permanently delete all items in the Trash? = Trwale usunąć wszystkie elementy z kosza?
@@ -156,6 +156,12 @@ impl AlertDialog {
156 156 self
157 157 }
158 158
159 + /// Szerokość okna (np. gdy jest więcej przycisków).
160 + pub fn width(mut self, width: f64) -> Self {
161 + self.dialog.width = width;
162 + self
163 + }
164 +
159 165 fn height(&self, theme: &Theme) -> f64 {
160 166 let pad = theme.spacing.lg;
161 167 let header = theme.metrics.sheet.header;
@@ -9,7 +9,7 @@ use libluna_core::style::weight;
9 9 use libluna_core::{Color, Point, Rect, Theme};
10 10
11 11 use crate::ctx::PaintCtx;
12 -use crate::icon;
12 +use crate::icon::{self, IconName};
13 13
14 14 /// Rodzaj elementu (wpływa na ikonę).
15 15 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -17,6 +17,13 @@ pub enum ItemKind {
17 17 Folder,
18 18 #[default]
19 19 File,
20 + Image,
21 + Audio,
22 + Video,
23 + Archive,
24 + Document,
25 + Code,
26 + Text,
20 27 }
21 28
22 29 /// Pojedynczy element (plik/katalog).
@@ -69,7 +76,10 @@ pub enum ViewMode {
69 76 pub struct ItemView {
70 77 items: Vec<Item>,
71 78 mode: ViewMode,
79 + /// Zaznaczenie „główne” (kotwica dla Shift+klik i klawiatury).
72 80 selected: Option<usize>,
81 + /// Wszystkie zaznaczone elementy (rosnąco, bez duplikatów).
82 + selected_set: Vec<usize>,
73 83 hovered: Option<usize>,
74 84 scroll: f64,
75 85 }
@@ -89,6 +99,7 @@ impl ItemView {
89 99 pub fn set_items(&mut self, items: Vec<Item>) {
90 100 self.items = items;
91 101 self.selected = None;
102 + self.selected_set.clear();
92 103 self.scroll = 0.0;
93 104 }
94 105
@@ -119,14 +130,60 @@ impl ItemView {
119 130 self.selected
120 131 }
121 132
133 + /// Wszystkie zaznaczone indeksy (rosnąco).
134 + pub fn selected_indices(&self) -> &[usize] {
135 + &self.selected_set
136 + }
137 +
138 + pub fn is_selected(&self, i: usize) -> bool {
139 + self.selected_set.binary_search(&i).is_ok()
140 + }
141 +
122 142 pub fn set_selected(&mut self, selected: Option<usize>) {
123 143 self.selected = selected;
144 + self.selected_set.clear();
145 + if let Some(i) = selected {
146 + self.selected_set.push(i);
147 + }
148 + }
149 +
150 + /// Dodaje/usuwa element z zaznaczenia (Ctrl+klik); staje się kotwicą.
151 + pub fn toggle_selected(&mut self, i: usize) {
152 + match self.selected_set.binary_search(&i) {
153 + Ok(pos) => {
154 + self.selected_set.remove(pos);
155 + }
156 + Err(pos) => self.selected_set.insert(pos, i),
157 + }
158 + self.selected = Some(i);
159 + }
160 +
161 + /// Zaznacza zakres od kotwicy do `to` (Shift+klik).
162 + pub fn select_range(&mut self, to: usize) {
163 + let from = self.selected.unwrap_or(to);
164 + let (a, b) = if from <= to { (from, to) } else { (to, from) };
165 + self.selected_set = (a..=b).collect();
166 + self.selected = Some(to);
167 + }
168 +
169 + pub fn select_all(&mut self) {
170 + self.selected_set = (0..self.items.len()).collect();
171 + self.selected = self.items.len().checked_sub(1);
172 + }
173 +
174 + pub fn clear_selection(&mut self) {
175 + self.selected = None;
176 + self.selected_set.clear();
124 177 }
125 178
126 179 pub fn set_hovered(&mut self, hovered: Option<usize>) {
127 180 self.hovered = hovered;
128 181 }
129 182
183 + pub fn hovered(&self) -> Option<usize> {
184 + self.hovered
185 + }
186 +
130 187 pub fn scroll(&self) -> f64 {
131 188 self.scroll
132 189 }
@@ -205,7 +262,7 @@ impl ItemView {
205 262 /// Zaznacza element pod punktem `p`; zwraca jego indeks.
206 263 pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
207 264 let i = self.index_at(rect, p, theme)?;
208 - self.selected = Some(i);
265 + self.set_selected(Some(i));
209 266 Some(i)
210 267 }
211 268
@@ -219,7 +276,12 @@ impl ItemView {
219 276 ctx.scene.push_clip(rect);
220 277 for (i, item) in self.items.iter().enumerate() {
221 278 let s = self.slot_rect(rect, i, &theme);
222 - let selected = self.selected == Some(i);
279 + // Wirtualizacja: elementy poza widokiem nie generują komend rysowania.
280 + // Bez tego katalog z tysiącami plików zamulał każdą klatkę.
281 + if s.bottom() <= rect.y() || s.y() >= rect.bottom() {
282 + continue;
283 + }
284 + let selected = self.is_selected(i);
223 285 if selected {
224 286 ctx.scene
225 287 .fill_rounded_rect(s, theme.radius.small, colors.shade.scale_alpha(1.8));
@@ -286,6 +348,13 @@ fn draw_icon(ctx: &mut PaintCtx, kind: ItemKind, box_: Rect, folder: Color, file
286 348 match kind {
287 349 ItemKind::Folder => icon::folder(ctx, folder, box_),
288 350 ItemKind::File => icon::file(ctx, file, box_),
351 + ItemKind::Image => icon::paint_icon(ctx, IconName::Image, box_, file),
352 + ItemKind::Audio => icon::paint_icon(ctx, IconName::Music, box_, file),
353 + ItemKind::Video => icon::paint_icon(ctx, IconName::Video, box_, file),
354 + ItemKind::Archive => icon::paint_icon(ctx, IconName::Archive, box_, file),
355 + ItemKind::Document => icon::paint_icon(ctx, IconName::Document, box_, file),
356 + ItemKind::Code => icon::paint_icon(ctx, IconName::Code, box_, file),
357 + ItemKind::Text => icon::paint_icon(ctx, IconName::Document, box_, file),
289 358 }
290 359 }
291 360
@@ -320,6 +389,24 @@ mod tests {
320 389 assert!(d.y() > a.y());
321 390 }
322 391
392 + #[test]
393 + fn multi_selection_toggle_range_and_all() {
394 + let mut v = ItemView::new(items());
395 + v.set_selected(Some(1));
396 + assert!(v.is_selected(1));
397 + v.toggle_selected(3);
398 + assert_eq!(v.selected_indices(), &[1, 3]);
399 + v.toggle_selected(1);
400 + assert_eq!(v.selected_indices(), &[3]);
401 + v.set_selected(Some(1));
402 + v.select_range(3);
403 + assert_eq!(v.selected_indices(), &[1, 2, 3]);
404 + v.select_all();
405 + assert_eq!(v.selected_indices().len(), 4);
406 + v.clear_selection();
407 + assert!(v.selected_indices().is_empty());
408 + }
409 +
323 410 #[test]
324 411 fn list_slots_stack_vertically() {
325 412 let theme = Theme::light();
326 413 new file mode 100644
@@ -0,0 +1,15 @@
1 +[Desktop Entry]
2 +Type=Application
3 +Name=Files
4 +Name[pl]=Pliki
5 +Name[de]=Dateien
6 +Comment=Browse and manage files
7 +Comment[pl]=Przeglądaj i zarządzaj plikami
8 +Comment[de]=Dateien durchsuchen und verwalten
9 +Exec=luna-files
10 +Icon=system-file-manager
11 +Terminal=false
12 +Categories=System;FileManager;
13 +Keywords=files;folders;manager;
14 +Keywords[pl]=pliki;foldery;menedżer;
15 +Keywords[de]=Dateien;Ordner;Dateimanager;
@@ -92,6 +92,14 @@ files() {
92 92 printf '%s|%s|%s\n' "$lib/luna-keyring" "$dest/bin/luna-keyring" "755"
93 93 printf '%s|%s|%s\n' "$lib/luna-polkit" "$dest/bin/luna-polkit" "755"
94 94 printf '%s|%s|%s\n' "$lib/luna-demo" "$dest/bin/luna-demo" "755"
95 + # Menedżer plików Luna Files (+ alias `gaminedes`).
96 + if [ -f "$lib/luna-files" ]; then
97 + printf '%s|%s|%s\n' "$lib/luna-files" "$dest/bin/luna-files" "755"
98 + printf '%s|%s|%s\n' "$root/de/session/eu.luna.Files.desktop" "$dest/share/applications/eu.luna.Files.desktop" "644"
99 + fi
100 + if [ -f "$lib/gaminedes" ]; then
101 + printf '%s|%s|%s\n' "$lib/gaminedes" "$dest/bin/gaminedes" "755"
102 + fi
95 103 if [ -f "$lib/gallery" ]; then
96 104 printf '%s|%s|%s\n' "$lib/gallery" "$dest/bin/luna-gallery" "755"
97 105 fi