Skip to main content

max / audiofiles

Tag tree sidebar, Rust patterns audit fixes, remove Linux drag-out Sidebar tags now render dot-separated names as collapsible folder tree (e.g. drums.kicks becomes a drums folder containing kicks). Remove Wayland drag-out backend (macOS/Windows only). Apply Rust patterns audit: eliminate unnecessary clones in decode, bulk_ops, import_workflow, toolbar breadcrumb, and rename dedup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-21 23:49 UTC
Commit: 2da535ad221277c4b1764c23085e560eb07afe6a
Parent: 0ee23a3
16 files changed, +174 insertions, -657 deletions
M Cargo.lock -4
@@ -391,7 +391,6 @@ dependencies = [
391 391 "gtk",
392 392 "open",
393 393 "parking_lot",
394 - "raw-window-handle",
395 394 "reqwest",
396 395 "semver",
397 396 "serde",
@@ -418,7 +417,6 @@ dependencies = [
418 417 "objc2-app-kit 0.3.2",
419 418 "objc2-foundation 0.3.2",
420 419 "parking_lot",
421 - "raw-window-handle",
422 420 "rfd",
423 421 "rusqlite",
424 422 "serde",
@@ -428,8 +426,6 @@ dependencies = [
428 426 "thiserror 2.0.18",
429 427 "toml",
430 428 "tracing",
431 - "wayland-backend",
432 - "wayland-client",
433 429 "windows 0.62.2",
434 430 "windows-core 0.62.2",
435 431 ]
M Cargo.toml +1 -1
@@ -39,7 +39,7 @@ uuid = { version = "1", features = ["v4"] }
39 39 base64 = "0.22"
40 40 chrono = "0.4"
41 41 rand = "0.8"
42 - smallvec = "1.13"
43 42 reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
44 43 semver = "1"
45 44 open = "5"
45 + docengine = { path = "../docengine" }
@@ -25,4 +25,3 @@ open = { workspace = true }
25 25 [target.'cfg(target_os = "linux")'.dependencies]
26 26 eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
27 27 gtk = "0.18"
28 - raw-window-handle = "0.6"
@@ -14,8 +14,6 @@ use audiofiles_browser::state::{BrowserState, SharedState, SyncSetupAction, Sync
14 14 use audiofiles_sync::{SyncKitConfig, SyncManager};
15 15 use eframe::egui;
16 16 use eframe::egui::ViewportCommand;
17 - #[cfg(target_os = "linux")]
18 - use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
19 17 use parking_lot::Mutex;
20 18 use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
21 19
@@ -171,8 +169,6 @@ struct AudioFilesApp {
171 169 sync_test_result: Arc<Mutex<Option<Result<String, String>>>>,
172 170 #[allow(dead_code)]
173 171 gtk_ok: bool,
174 - #[cfg(target_os = "linux")]
175 - wayland_handles_set: bool,
176 172 _runtime: tokio::runtime::Runtime,
177 173 }
178 174
@@ -206,8 +202,6 @@ impl AudioFilesApp {
206 202 update_checker,
207 203 sync_test_result: Arc::new(Mutex::new(None)),
208 204 gtk_ok,
209 - #[cfg(target_os = "linux")]
210 - wayland_handles_set: false,
211 205 _runtime: runtime,
212 206 }
213 207 }
@@ -222,8 +216,6 @@ impl AudioFilesApp {
222 216 update_checker,
223 217 sync_test_result: Arc::new(Mutex::new(None)),
224 218 gtk_ok,
225 - #[cfg(target_os = "linux")]
226 - wayland_handles_set: false,
227 219 _runtime: runtime,
228 220 }
229 221 }
@@ -340,34 +332,6 @@ impl eframe::App for AudioFilesApp {
340 332 browser.sync_mirror_if_dirty();
341 333 }
342 334
343 - // ── Wayland DnD: extract handles once, poll drops each frame ──
344 - #[cfg(target_os = "linux")]
345 - if !self.wayland_handles_set {
346 - if let Ok(display_handle) = frame.display_handle() {
347 - if let raw_window_handle::RawDisplayHandle::Wayland(wl) = display_handle.as_raw() {
348 - if let Some(display_ptr) = std::ptr::NonNull::new(wl.display.as_ptr()) {
349 - if let Ok(window_handle) = frame.window_handle() {
350 - if let raw_window_handle::RawWindowHandle::Wayland(wl_win) = window_handle.as_raw() {
351 - if let Some(surface_ptr) = std::ptr::NonNull::new(wl_win.surface.as_ptr()) {
352 - audiofiles_browser::drag_out::set_wayland_handles(display_ptr, surface_ptr);
353 - self.wayland_handles_set = true;
354 - tracing::debug!("Wayland DnD handles set");
355 - }
356 - }
357 - }
358 - }
359 - }
360 - }
361 - }
362 -
363 - // Poll Wayland drops (supplements eframe's built-in drop handling).
364 - #[cfg(target_os = "linux")]
365 - let wayland_drops = if self.wayland_handles_set {
366 - audiofiles_browser::drag_out::poll_wayland_drops()
367 - } else {
368 - Vec::new()
369 - };
370 -
371 335 // Handle dropped files (drag-and-drop import)
372 336 let (hovered_count, dropped): (usize, Vec<PathBuf>) = ctx.input(|i| {
373 337 let hovered = i.raw.hovered_files.len();
@@ -387,13 +351,7 @@ impl eframe::App for AudioFilesApp {
387 351 }
388 352
389 353 if let Some(ref mut browser) = self.browser {
390 - // Merge eframe drops + Wayland drops into a single iterator.
391 - #[cfg(target_os = "linux")]
392 - let all_drops = dropped.into_iter().chain(wayland_drops);
393 - #[cfg(not(target_os = "linux"))]
394 - let all_drops = dropped.into_iter();
395 -
396 - for path in all_drops {
354 + for path in dropped {
397 355 if path.is_dir() {
398 356 let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
399 357 vfs_id: browser.current_vfs_id(),
@@ -416,7 +374,10 @@ impl eframe::App for AudioFilesApp {
416 374
417 375 // Show update notification overlay (bottom-right) — user must consent
418 376 if self.update_checker.should_show() {
419 - let status = self.update_checker.status.lock().clone();
377 + let (version, notes, download_url) = {
378 + let s = self.update_checker.status.lock();
379 + (s.version.clone(), s.notes.clone(), s.download_url.clone())
380 + };
420 381 egui::Area::new(egui::Id::new("update-banner"))
421 382 .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0))
422 383 .order(egui::Order::Foreground)
@@ -425,14 +386,14 @@ impl eframe::App for AudioFilesApp {
425 386 .inner_margin(12.0)
426 387 .show(ui, |ui| {
427 388 ui.set_max_width(280.0);
428 - ui.strong(format!("Update Available: v{}", status.version));
429 - if !status.notes.is_empty() {
430 - ui.label(&status.notes);
389 + ui.strong(format!("Update Available: v{}", version));
390 + if !notes.is_empty() {
391 + ui.label(&notes);
431 392 }
432 393 ui.add_space(4.0);
433 394 ui.horizontal(|ui| {
434 - if ui.button("Download").clicked() && !status.download_url.is_empty() {
435 - let _ = open::that(&status.download_url);
395 + if ui.button("Download").clicked() && !download_url.is_empty() {
396 + let _ = open::that(&download_url);
436 397 }
437 398 if ui.button("Not Now").clicked() {
438 399 self.update_checker.dismiss();
@@ -36,8 +36,3 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSWindow", "NSV
36 36 [target.'cfg(target_os = "windows")'.dependencies]
37 37 windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Ole", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_Graphics_Gdi"] }
38 38 windows-core = "0.62"
39 -
40 - [target.'cfg(target_os = "linux")'.dependencies]
41 - wayland-client = { version = "0.31", default-features = false }
42 - wayland-backend = { version = "0.3", features = ["client_system"] }
43 - raw-window-handle = "0.6"
@@ -1,527 +0,0 @@
1 - //! Wayland drag-and-drop backend for Linux.
2 - //!
3 - //! Implements both drag-out (files FROM app → file manager/DAW) and drag-in
4 - //! (files FROM file manager → app) using `wayland-client` directly.
5 - //!
6 - //! Shares eframe's Wayland display connection via `Backend::from_foreign_display()`.
7 - //! All Wayland objects live in a `thread_local!` because they aren't `Send`.
8 -
9 - use std::cell::RefCell;
10 - use std::ffi::c_void;
11 - use std::io::{Read, Write};
12 - use std::os::fd::{AsFd, AsRawFd};
13 - use std::path::PathBuf;
14 - use std::ptr::NonNull;
15 - use std::sync::atomic::Ordering;
16 -
17 - use tracing::{debug, warn};
18 - use wayland_backend::client::Backend;
19 - use wayland_client::protocol::{
20 - wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_pointer,
21 - wl_registry, wl_seat, wl_surface,
22 - };
23 - use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, globals};
24 - use wayland_client::globals::GlobalListContents;
25 -
26 - /// MIME type for file URI lists (standard Wayland DnD for files).
27 - const MIME_URI_LIST: &str = "text/uri-list";
28 -
29 - /// State for the Wayland DnD subsystem.
30 - struct DragDispatcher {
31 - /// Wayland seat proxy.
32 - seat: Option<wl_seat::WlSeat>,
33 - /// Our pointer listener — only used to capture button serials.
34 - pointer: Option<wl_pointer::WlPointer>,
35 - /// Data device manager (factory for sources/devices).
36 - ddm: Option<wl_data_device_manager::WlDataDeviceManager>,
37 - /// Our data device (receives DnD events).
38 - data_device: Option<wl_data_device::WlDataDevice>,
39 - /// Serial from the most recent pointer button press (needed by `start_drag`).
40 - last_button_serial: u32,
41 - /// Files queued for the current outbound drag (consumed by the `Send` callback).
42 - pending_paths: Vec<PathBuf>,
43 - /// Incoming drops (drained by the app each frame via `poll_wayland_drops`).
44 - dropped_files: Vec<PathBuf>,
45 - /// Current inbound data offer (if any).
46 - current_offer: Option<wl_data_offer::WlDataOffer>,
47 - /// Whether the current offer advertises `text/uri-list`.
48 - offer_has_uri_list: bool,
49 - }
50 -
51 - /// Top-level wrapper holding the Wayland connection, event queue, and state.
52 - struct WaylandDragState {
53 - conn: Connection,
54 - queue: EventQueue<DragDispatcher>,
55 - state: DragDispatcher,
56 - surface_id: wayland_backend::client::ObjectId,
57 - }
58 -
59 - thread_local! {
60 - static WAYLAND_STATE: RefCell<Option<WaylandDragState>> = const { RefCell::new(None) };
61 - }
62 -
63 - // ── Wayland dispatch implementations ──
64 -
65 - impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for DragDispatcher {
66 - fn event(
67 - _state: &mut Self,
68 - _proxy: &wl_registry::WlRegistry,
69 - _event: wl_registry::Event,
70 - _data: &GlobalListContents,
71 - _conn: &Connection,
72 - _qh: &QueueHandle<Self>,
73 - ) {
74 - // Handled by registry_queue_init — nothing to do here.
75 - }
76 - }
77 -
78 - impl Dispatch<wl_seat::WlSeat, ()> for DragDispatcher {
79 - fn event(
80 - _state: &mut Self,
81 - _proxy: &wl_seat::WlSeat,
82 - event: wl_seat::Event,
83 - _data: &(),
84 - _conn: &Connection,
85 - _qh: &QueueHandle<Self>,
86 - ) {
87 - if let wl_seat::Event::Capabilities { capabilities } = event {
88 - debug!(?capabilities, "wl_seat capabilities");
89 - }
90 - }
91 - }
92 -
93 - impl Dispatch<wl_pointer::WlPointer, ()> for DragDispatcher {
94 - fn event(
95 - state: &mut Self,
96 - _proxy: &wl_pointer::WlPointer,
97 - event: wl_pointer::Event,
98 - _data: &(),
99 - _conn: &Connection,
100 - _qh: &QueueHandle<Self>,
101 - ) {
102 - // We only care about button events for their serial.
103 - if let wl_pointer::Event::Button { serial, .. } = event {
104 - state.last_button_serial = serial;
105 - }
106 - }
107 - }
108 -
109 - impl Dispatch<wl_data_device_manager::WlDataDeviceManager, ()> for DragDispatcher {
110 - fn event(
111 - _state: &mut Self,
112 - _proxy: &wl_data_device_manager::WlDataDeviceManager,
113 - _event: wl_data_device_manager::Event,
114 - _data: &(),
115 - _conn: &Connection,
116 - _qh: &QueueHandle<Self>,
117 - ) {
118 - // WlDataDeviceManager emits no events.
119 - }
120 - }
121 -
122 - impl Dispatch<wl_data_device::WlDataDevice, ()> for DragDispatcher {
123 - fn event(
124 - state: &mut Self,
125 - _proxy: &wl_data_device::WlDataDevice,
126 - event: wl_data_device::Event,
127 - _data: &(),
128 - conn: &Connection,
129 - _qh: &QueueHandle<Self>,
130 - ) {
131 - match event {
132 - wl_data_device::Event::DataOffer { id } => {
133 - // New offer — replace any previous one.
134 - state.current_offer = Some(id);
135 - state.offer_has_uri_list = false;
136 - }
137 - wl_data_device::Event::Enter {
138 - serial, surface, ..
139 - } => {
140 - let _ = (serial, surface);
141 - if let Some(ref offer) = state.current_offer {
142 - if state.offer_has_uri_list {
143 - offer.accept(serial, Some(MIME_URI_LIST.to_string()));
144 - }
145 - }
146 - }
147 - wl_data_device::Event::Drop => {
148 - if let Some(ref offer) = state.current_offer {
149 - if state.offer_has_uri_list {
150 - // Create a pipe, ask the source to write URIs into it.
151 - if let Some(paths) = receive_uri_list(offer, conn) {
152 - state.dropped_files.extend(paths);
153 - }
154 - offer.finish();
155 - }
156 - }
157 - state.current_offer = None;
158 - state.offer_has_uri_list = false;
159 - }
160 - wl_data_device::Event::Leave => {
161 - state.current_offer = None;
162 - state.offer_has_uri_list = false;
163 - }
164 - _ => {}
165 - }
166 - }
167 -
168 - wayland_client::event_created_child!(DragDispatcher, wl_data_device::WlDataDevice, [
169 - wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ()),
170 - ]);
171 - }
172 -
173 - impl Dispatch<wl_data_offer::WlDataOffer, ()> for DragDispatcher {
174 - fn event(
175 - state: &mut Self,
176 - _proxy: &wl_data_offer::WlDataOffer,
177 - event: wl_data_offer::Event,
178 - _data: &(),
179 - _conn: &Connection,
180 - _qh: &QueueHandle<Self>,
181 - ) {
182 - if let wl_data_offer::Event::Offer { mime_type } = event {
183 - if mime_type == MIME_URI_LIST {
184 - state.offer_has_uri_list = true;
185 - }
186 - }
187 - }
188 - }
189 -
190 - impl Dispatch<wl_data_source::WlDataSource, ()> for DragDispatcher {
191 - fn event(
192 - state: &mut Self,
193 - _proxy: &wl_data_source::WlDataSource,
194 - event: wl_data_source::Event,
195 - _data: &(),
196 - _conn: &Connection,
197 - _qh: &QueueHandle<Self>,
198 - ) {
199 - match event {
200 - wl_data_source::Event::Send { mime_type, fd } => {
201 - if mime_type == MIME_URI_LIST {
202 - let uri_list = paths_to_uri_list(&state.pending_paths);
203 - // Wrap the OwnedFd in a File for writing. We must forget
204 - // the File afterwards since `fd` (OwnedFd) owns the fd
205 - // and will close it when this event handler returns.
206 - use std::os::fd::FromRawFd;
207 - let mut file = unsafe { std::fs::File::from_raw_fd(fd.as_raw_fd()) };
208 - let _ = file.write_all(uri_list.as_bytes());
209 - std::mem::forget(file);
210 - }
211 - }
212 - wl_data_source::Event::DndFinished => {
213 - debug!("Drag finished (accepted)");
214 - state.pending_paths.clear();
215 - super::DRAG_ACTIVE.store(false, Ordering::Release);
216 - }
217 - wl_data_source::Event::Cancelled => {
218 - debug!("Drag cancelled");
219 - state.pending_paths.clear();
220 - super::DRAG_ACTIVE.store(false, Ordering::Release);
221 - }
222 - _ => {}
223 - }
224 - }
225 - }
226 -
227 - impl Dispatch<wl_surface::WlSurface, ()> for DragDispatcher {
228 - fn event(
229 - _state: &mut Self,
230 - _proxy: &wl_surface::WlSurface,
231 - _event: wl_surface::Event,
232 - _data: &(),
233 - _conn: &Connection,
234 - _qh: &QueueHandle<Self>,
235 - ) {
236 - // We don't own this surface — eframe does. No-op.
237 - }
238 - }
239 -
240 - // ── URI encoding/decoding ──
241 -
242 - /// Convert paths to a `text/uri-list` string (`file:///path\r\n` per entry).
243 - fn paths_to_uri_list(paths: &[PathBuf]) -> String {
244 - let mut out = String::new();
245 - for path in paths {
246 - out.push_str("file://");
247 - // Percent-encode the path (spaces, special chars).
248 - for byte in path.to_string_lossy().as_bytes() {
249 - match byte {
250 - // Safe characters that don't need encoding.
251 - b'A'..=b'Z'
252 - | b'a'..=b'z'
253 - | b'0'..=b'9'
254 - | b'/'
255 - | b'.'
256 - | b'-'
257 - | b'_'
258 - | b'~' => out.push(*byte as char),
259 - _ => {
260 - out.push('%');
261 - out.push_str(&format!("{byte:02X}"));
262 - }
263 - }
264 - }
265 - out.push_str("\r\n");
266 - }
267 - out
268 - }
269 -
270 - /// Parse a `text/uri-list` string into paths.
271 - fn parse_uri_list(data: &str) -> Vec<PathBuf> {
272 - data.lines()
273 - .filter(|line| !line.starts_with('#') && !line.is_empty())
274 - .filter_map(|line| {
275 - let line = line.trim();
276 - let path_str = line.strip_prefix("file://")?;
277 - Some(PathBuf::from(percent_decode(path_str)))
278 - })
279 - .filter(|p| p.exists())
280 - .collect()
281 - }
282 -
283 - /// Decode `%XX` sequences in a URI path.
284 - fn percent_decode(input: &str) -> String {
285 - let mut out = String::with_capacity(input.len());
286 - let mut bytes = input.bytes();
287 - while let Some(b) = bytes.next() {
288 - if b == b'%' {
289 - let hi = bytes.next().and_then(|c| char::from(c).to_digit(16));
290 - let lo = bytes.next().and_then(|c| char::from(c).to_digit(16));
291 - if let (Some(h), Some(l)) = (hi, lo) {
292 - out.push((h * 16 + l) as u8 as char);
293 - }
294 - } else {
295 - out.push(b as char);
296 - }
297 - }
298 - out
299 - }
300 -
301 - /// Read a `text/uri-list` from a data offer via a socket pair.
302 - fn receive_uri_list(offer: &wl_data_offer::WlDataOffer, conn: &Connection) -> Option<Vec<PathBuf>> {
303 - let (reader, writer) = std::os::unix::net::UnixStream::pair().ok()?;
304 -
305 - offer.receive(MIME_URI_LIST.to_string(), writer.as_fd());
306 - let _ = conn.flush();
307 -
308 - // Close write end so read sees EOF after the source writes.
309 - drop(writer);
310 -
311 - let mut buf = String::new();
312 - let mut reader = std::io::BufReader::new(reader);
313 - if let Err(e) = reader.read_to_string(&mut buf) {
314 - warn!("Failed to read DnD URI list: {e}");
315 - return None;
316 - }
317 -
318 - let paths = parse_uri_list(&buf);
319 - if paths.is_empty() {
320 - None
321 - } else {
322 - debug!(count = paths.len(), "Received drag-in files");
323 - Some(paths)
324 - }
325 - }
326 -
327 - // ── Initialization ──
328 -
329 - /// Initialize the Wayland DnD subsystem, sharing eframe's display connection.
330 - ///
331 - /// Called once from `main.rs` when Wayland display/surface handles are available.
332 - /// Safe to call multiple times — subsequent calls are no-ops.
333 - pub fn init_wayland(display: NonNull<c_void>, surface: NonNull<c_void>) {
334 - WAYLAND_STATE.with(|cell| {
335 - if cell.borrow().is_some() {
336 - return; // Already initialized.
337 - }
338 -
339 - match try_init(display, surface) {
340 - Some(ws) => {
341 - debug!("Wayland DnD subsystem initialized");
342 - *cell.borrow_mut() = Some(ws);
343 - }
344 - None => {
345 - warn!("Failed to initialize Wayland DnD subsystem");
346 - }
347 - }
348 - });
349 - }
350 -
351 - fn try_init(display: NonNull<c_void>, surface: NonNull<c_void>) -> Option<WaylandDragState> {
352 - // Create a guest backend that shares eframe's Wayland connection.
353 - let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) };
354 - let conn = Connection::from_backend(backend);
355 -
356 - // Create our own event queue and enumerate globals via registry roundtrip.
357 - let (global_list, queue) =
358 - globals::registry_queue_init::<DragDispatcher>(&conn).ok()?;
359 -
360 - let qh = queue.handle();
361 -
362 - let mut state = DragDispatcher {
363 - seat: None,
364 - pointer: None,
365 - ddm: None,
366 - data_device: None,
367 - last_button_serial: 0,
368 - pending_paths: Vec::new(),
369 - dropped_files: Vec::new(),
370 - current_offer: None,
371 - offer_has_uri_list: false,
372 - };
373 -
374 - // Bind WlSeat (version 1 is enough for pointer + data device).
375 - let seat: wl_seat::WlSeat = global_list.bind(&qh, 1..=9, ()).ok()?;
376 - let pointer = seat.get_pointer(&qh, ());
377 - let ddm: wl_data_device_manager::WlDataDeviceManager =
378 - global_list.bind(&qh, 1..=3, ()).ok()?;
379 - let data_device = ddm.get_data_device(&seat, &qh, ());
380 -
381 - state.seat = Some(seat);
382 - state.pointer = Some(pointer);
383 - state.ddm = Some(ddm);
384 - state.data_device = Some(data_device);
385 -
386 - // Create an ObjectId for the surface so we can wrap it later for start_drag.
387 - let surface_id = unsafe {
388 - wayland_backend::client::ObjectId::from_ptr(
389 - wl_surface::WlSurface::interface(),
390 - surface.as_ptr().cast(),
391 - )
392 - }
393 - .ok()?;
394 -
395 - Some(WaylandDragState {
396 - conn,
397 - queue,
398 - state,
399 - surface_id,
400 - })
401 - }
402 -
403 - // ── Drag-out ──
404 -
405 - /// Start an outbound drag session for the given file paths.
406 - ///
407 - /// Returns `true` if the drag was initiated (async — `DRAG_ACTIVE` is cleared
408 - /// by the `DndFinished`/`Cancelled` callback).
409 - pub fn begin_drag_session(paths: &[PathBuf]) -> bool {
410 - WAYLAND_STATE.with(|cell| {
411 - let mut borrow = cell.borrow_mut();
412 - let Some(ws) = borrow.as_mut() else {
413 - warn!("Wayland DnD not initialized — cannot drag");
414 - return false;
415 - };
416 -
417 - // Roundtrip to ensure the compositor has flushed all pending events
418 - // (including the button press serial). dispatch_pending alone is racy
419 - // because eframe's own pointer listener may consume events first.
420 - let _ = ws.queue.roundtrip(&mut ws.state);
421 -
422 - debug!(serial = ws.state.last_button_serial, "Serial after roundtrip");
423 -
424 - if ws.state.last_button_serial == 0 {
425 - warn!("No pointer button serial available — cannot start drag");
426 - return false;
427 - }
428 -
429 - let qh = ws.queue.handle();
430 -
431 - // Create a data source offering text/uri-list.
432 - let Some(ref ddm) = ws.state.ddm else {
433 - return false;
434 - };
435 - let source = ddm.create_data_source(&qh, ());
436 - source.offer(MIME_URI_LIST.to_string());
437 -
438 - // Store paths for the Send callback.
439 - ws.state.pending_paths = paths.to_vec();
440 -
441 - // Wrap the surface ObjectId into a WlSurface proxy.
442 - let surface = match wl_surface::WlSurface::from_id(&ws.conn, ws.surface_id.clone()) {
443 - Ok(s) => s,
444 - Err(e) => {
445 - warn!("Failed to create surface proxy: {e}");
446 - return false;
447 - }
448 - };
449 -
450 - let Some(ref dd) = ws.state.data_device else {
451 - return false;
452 - };
453 -
454 - let serial = ws.state.last_button_serial;
455 - debug!(serial, count = paths.len(), "Starting Wayland drag");
456 -
457 - dd.start_drag(Some(&source), &surface, None, serial);
458 - let _ = ws.conn.flush();
459 -
460 - true
461 - })
462 - }
463 -
464 - // ── Drag-in ──
465 -
466 - /// Dispatch pending Wayland events and drain any files that were dropped.
467 - ///
468 - /// Called each frame from `main.rs`.
469 - pub fn poll_wayland_drops() -> Vec<PathBuf> {
470 - WAYLAND_STATE.with(|cell| {
471 - let mut borrow = cell.borrow_mut();
472 - let Some(ws) = borrow.as_mut() else {
473 - return Vec::new();
474 - };
475 -
476 - // Process any pending events on our queue.
477 - let _ = ws.queue.dispatch_pending(&mut ws.state);
478 -
479 - // Also read from the connection (non-blocking).
480 - if let Some(guard) = ws.conn.prepare_read() {
481 - let _ = guard.read();
482 - let _ = ws.queue.dispatch_pending(&mut ws.state);
483 - }
484 -
485 - // Drain dropped files.
486 - std::mem::take(&mut ws.state.dropped_files)
487 - })
488 - }
489 -
490 - #[cfg(test)]
491 - mod tests {
492 - use super::*;
493 -
494 - #[test]
495 - fn uri_roundtrip() {
496 - let paths = vec![
497 - PathBuf::from("/home/user/samples/kick.wav"),
498 - PathBuf::from("/tmp/my file (1).wav"),
499 - ];
500 - let uri_list = paths_to_uri_list(&paths);
Lines truncated
@@ -7,7 +7,6 @@
7 7 //! Platform backends:
8 8 //! - macOS: `NSDraggingSession` via objc2
9 9 //! - Windows: `DoDragDrop` + COM `IDataObject`/`IDropSource` with `CF_HDROP`
10 - //! - Linux: Wayland `wl_data_device` via `wayland-client`
11 10
12 11 use std::path::{Path, PathBuf};
13 12 use std::sync::atomic::{AtomicBool, Ordering};
@@ -21,8 +20,6 @@ static DRAG_ACTIVE: AtomicBool = AtomicBool::new(false);
21 20 mod macos;
22 21 #[cfg(target_os = "windows")]
23 22 mod windows;
24 - #[cfg(target_os = "linux")]
25 - mod linux;
26 23
27 24 /// A file to be dragged out of the application.
28 25 pub struct DragFile {
@@ -141,42 +138,4 @@ pub fn begin_drag(files: &[DragFile]) -> bool {
141 138 DRAG_ACTIVE.store(false, Ordering::Release);
142 139 result
143 140 }
144 - #[cfg(target_os = "linux")]
145 - {
146 - let result = linux::begin_drag_session(&paths);
147 - // Async like macOS — DRAG_ACTIVE cleared by DndFinished/Cancelled callback.
148 - if !result {
149 - DRAG_ACTIVE.store(false, Ordering::Release);
150 - }
151 - result
152 - }
153 - }
154 -
155 - /// Store Wayland display/surface handles for the Linux DnD backend.
156 - ///
157 - /// No-op on non-Linux platforms.
158 - #[cfg(target_os = "linux")]
159 - pub fn set_wayland_handles(
160 - display: std::ptr::NonNull<std::ffi::c_void>,
161 - surface: std::ptr::NonNull<std::ffi::c_void>,
162 - ) {
163 - linux::init_wayland(display, surface);
164 - }
165 -
166 - /// Drain any files dropped onto the window via Wayland DnD.
167 - ///
168 - /// Returns an empty Vec on non-Linux platforms or when nothing was dropped.
169 - #[cfg(target_os = "linux")]
170 - pub fn poll_wayland_drops() -> Vec<PathBuf> {
171 - linux::poll_wayland_drops()
172 - }
173 -
174 - /// No-op stub for non-Linux platforms.
175 - #[cfg(not(target_os = "linux"))]
176 - pub fn set_wayland_handles(_display: std::ptr::NonNull<std::ffi::c_void>, _surface: std::ptr::NonNull<std::ffi::c_void>) {}
177 -
178 - /// No-op stub for non-Linux platforms.
179 - #[cfg(not(target_os = "linux"))]
180 - pub fn poll_wayland_drops() -> Vec<PathBuf> {
181 - Vec::new()
182 141 }
@@ -82,11 +82,10 @@ pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> {
82 82 .ok_or(PreviewError::NoTrack)?;
83 83
84 84 let track_id = track.id;
85 - let codec_params = track.codec_params.clone();
86 - let source_sample_rate = codec_params.sample_rate.unwrap_or(44100);
85 + let source_sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
87 86
88 87 let mut decoder = symphonia::default::get_codecs()
89 - .make(&codec_params, &DecoderOptions::default())
88 + .make(&track.codec_params, &DecoderOptions::default())
90 89 .map_err(|e| PreviewError::Decoder(e.to_string()))?;
91 90
92 91 let mut all_samples: Vec<f32> = Vec::new();
@@ -247,7 +247,7 @@ impl BrowserState {
247 247 Ok(count) => {
248 248 self.push_undo(UndoOp::BulkTagAdd {
249 249 tag: tag.to_string(),
250 - hashes: hashes.clone(),
250 + hashes,
251 251 });
252 252 self.status = format!("Tagged {count} samples with \"{tag}\"");
253 253 }
@@ -260,7 +260,7 @@ impl BrowserState {
260 260 Ok(count) => {
261 261 self.push_undo(UndoOp::BulkTagRemove {
262 262 tag: tag.to_string(),
263 - hashes: hashes.clone(),
263 + hashes,
264 264 });
265 265 self.status = format!("Removed tag \"{tag}\" from {count} samples");
266 266 }
@@ -339,11 +339,12 @@ impl BrowserState {
339 339 let result = *result;
340 340 let _ = self.backend.save_analysis(&result);
341 341
342 - let name = self.backend.sample_original_name(&result.hash)
343 - .unwrap_or_else(|_| result.hash.clone());
342 + let hash = audiofiles_core::SampleHash::new(result.hash.clone());
343 + let name = self.backend.sample_original_name(&hash)
344 + .unwrap_or_else(|_| hash.to_string());
344 345
345 346 self.pending_review_items.push(ReviewItem {
346 - hash: audiofiles_core::SampleHash::new(result.hash.clone()),
347 + hash,
347 348 name,
348 349 suggestions: suggestions
349 350 .into_iter()
@@ -1,10 +1,131 @@
1 1 //! Left sidebar: VFS roots and tag tree.
2 2
3 + use std::collections::BTreeMap;
4 +
3 5 use egui;
4 6
5 7 use crate::state::BrowserState;
6 8 use super::theme;
7 9
10 + /// A node in the tag tree built from dot-separated tag names.
11 + struct TagNode {
12 + children: BTreeMap<String, TagNode>,
13 + is_leaf: bool,
14 + }
15 +
16 + impl TagNode {
17 + fn new() -> Self {
18 + Self {
19 + children: BTreeMap::new(),
20 + is_leaf: false,
21 + }
22 + }
23 +
24 + /// Insert a tag into the tree by splitting on `.`.
25 + fn insert(&mut self, tag: &str) {
26 + let mut current = self;
27 + let segments: Vec<&str> = tag.split('.').collect();
28 + for (i, seg) in segments.iter().enumerate() {
29 + current = current.children.entry((*seg).to_string()).or_insert_with(TagNode::new);
30 + if i == segments.len() - 1 {
31 + current.is_leaf = true;
32 + }
33 + }
34 + }
35 + }
36 +
37 + /// Build a tag tree from a sorted list of dotted tag strings.
38 + fn build_tag_tree(tags: &[String]) -> BTreeMap<String, TagNode> {
39 + let mut root = TagNode::new();
40 + for tag in tags {
41 + root.insert(tag);
42 + }
43 + root.children
44 + }
45 +
46 + /// Check if this path or any descendant is active in required_tags.
47 + fn any_descendant_active(prefix: &str, required_tags: &[String]) -> bool {
48 + required_tags.iter().any(|t| t == prefix || t.starts_with(&format!("{prefix}.")))
49 + }
50 +
51 + /// Draw a single tag tree node recursively.
52 + fn draw_tag_node(
53 + ui: &mut egui::Ui,
54 + prefix: &str,
55 + segment: &str,
56 + node: &TagNode,
57 + state: &mut BrowserState,
58 + ) {
59 + let full_path = if prefix.is_empty() {
60 + segment.to_string()
61 + } else {
62 + format!("{prefix}.{segment}")
63 + };
64 +
65 + let is_active = state.search_filter.required_tags.contains(&full_path);
66 + let has_active_descendant = any_descendant_active(&full_path, &state.search_filter.required_tags);
67 +
68 + if node.children.is_empty() {
69 + // Leaf node — flat selectable label
70 + let label = if is_active {
71 + egui::RichText::new(segment).color(theme::accent_blue())
72 + } else {
73 + egui::RichText::new(segment).color(theme::text_secondary())
74 + };
75 + let hover = if is_active {
76 + format!("Remove \"{full_path}\" filter")
77 + } else {
78 + format!("Filter by \"{full_path}\"")
79 + };
80 + if ui.selectable_label(is_active, label).on_hover_text(hover).clicked() {
81 + if is_active {
82 + state.search_filter.required_tags.retain(|t| t != &full_path);
83 + } else {
84 + state.search_filter.required_tags.push(full_path);
85 + }
86 + state.apply_search();
87 + }
88 + } else {
89 + // Folder node — collapsing header
90 + let color = if is_active || has_active_descendant {
91 + theme::accent_blue()
92 + } else {
93 + theme::text_secondary()
94 + };
95 + let header_text = egui::RichText::new(segment).color(color);
96 +
97 + egui::CollapsingHeader::new(header_text)
98 + .id_salt(&full_path)
99 + .show(ui, |ui| {
100 + // If this folder path is itself a tag, show a clickable "all" entry
101 + if node.is_leaf {
102 + let leaf_label = if is_active {
103 + egui::RichText::new(segment).color(theme::accent_blue())
104 + } else {
105 + egui::RichText::new(segment).color(theme::text_secondary())
106 + };
107 + let hover = if is_active {
108 + format!("Remove \"{full_path}\" filter")
109 + } else {
110 + format!("Filter by \"{full_path}\" (exact)")
111 + };
112 + if ui.selectable_label(is_active, leaf_label).on_hover_text(hover).clicked() {
113 + if is_active {
114 + state.search_filter.required_tags.retain(|t| t != &full_path);
115 + } else {
116 + state.search_filter.required_tags.push(full_path.clone());
117 + }
118 + state.apply_search();
119 + }
120 + }
121 + // Recurse into children
122 + for (child_seg, child_node) in &node.children {
123 + draw_tag_node(ui, &full_path, child_seg, child_node, state);
124 + }
125 + });
126 + }
127 + }
128 +
8 129 /// Draw the sidebar panel content: VFS list, tags section.
9 130 pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
10 131 ui.label(egui::RichText::new("Libraries").strong().color(theme::text_secondary()));
@@ -178,31 +299,14 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
178 299
179 300 ui.add_space(4.0);
180 301
181 - // Tags section
302 + // Tags section — tree view for dot-separated tags
182 303 ui.collapsing("Tags", |ui| {
183 304 if state.all_tags.is_empty() {
184 305 ui.label(egui::RichText::new("No tags yet").color(theme::text_muted()));
185 306 } else {
186 - for tag in state.all_tags.clone().iter() {
187 - let is_active = state.search_filter.required_tags.contains(tag);
188 - let label = if is_active {
189 - egui::RichText::new(tag).color(theme::accent_blue())
190 - } else {
191 - egui::RichText::new(tag).color(theme::text_secondary())
192 - };
193 - let hover = if is_active {
194 - format!("Remove \"{}\" filter", tag)
195 - } else {
196 - format!("Filter by \"{}\"", tag)
197 - };
198 - if ui.selectable_label(is_active, label).on_hover_text(hover).clicked() {
199 - if is_active {
200 - state.search_filter.required_tags.retain(|t| t != tag);
201 - } else {
202 - state.search_filter.required_tags.push(tag.clone());
203 - }
204 - state.apply_search();
205 - }
307 + let tree = build_tag_tree(&state.all_tags);
308 + for (segment, node) in &tree {
309 + draw_tag_node(ui, "", segment, node, state);
206 310 }
207 311 }
208 312 });
@@ -185,22 +185,27 @@ fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) {
185 185 state.refresh_contents();
186 186 }
187 187
188 - // Breadcrumb path segments
189 - for (i, crumb) in state.breadcrumb.clone().iter().enumerate() {
188 + // Breadcrumb path segments — iterate by reference, defer mutation
189 + let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None;
190 + let breadcrumb_len = state.breadcrumb.len();
191 + for (i, crumb) in state.breadcrumb.iter().enumerate() {
190 192 ui.label("/");
191 - let is_last = i == state.breadcrumb.len() - 1;
193 + let is_last = i == breadcrumb_len - 1;
192 194 let crumb_hover = if is_last {
193 195 format!("Current directory: {}", crumb.name)
194 196 } else {
195 197 format!("Navigate to {}", crumb.name)
196 198 };
197 199 if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last {
198 - state.current_dir = Some(crumb.id);
199 - state.breadcrumb.truncate(i + 1);
200 - state.selection.clear();
201 - state.refresh_contents();
200 + nav_to = Some((crumb.id, i + 1));
202 201 }
203 202 }
203 + if let Some((dir_id, truncate_at)) = nav_to {
204 + state.current_dir = Some(dir_id);
205 + state.breadcrumb.truncate(truncate_at);
206 + state.selection.clear();
207 + state.refresh_contents();
208 + }
204 209
205 210 // Import + Export buttons + Sync + theme selector (right-aligned)
206 211 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -58,17 +58,18 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> {
58 58 .ok_or(AnalysisError::NoAudioTrack)?;
59 59
60 60 let track_id = track.id;
61 - let codec_params = track.codec_params.clone();
62 - let source_sample_rate = codec_params
61 + let source_sample_rate = track
62 + .codec_params
63 63 .sample_rate
64 64 .ok_or(AnalysisError::ProbeFailed("missing sample rate".to_string()))?;
65 - let source_channels = codec_params
65 + let source_channels = track
66 + .codec_params
66 67 .channels
67 68 .map(|c| c.count() as u16)
68 69 .ok_or(AnalysisError::ProbeFailed("missing channel count".to_string()))?;
69 70
70 71 let mut decoder = symphonia::default::get_codecs()
71 - .make(&codec_params, &DecoderOptions::default())
72 + .make(&track.codec_params, &DecoderOptions::default())
72 73 .map_err(|e| AnalysisError::DecoderFailed(e.to_string()))?;
73 74
74 75 let mut mono_samples: Vec<f32> = Vec::new();
@@ -53,17 +53,18 @@ pub fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError
53 53 .ok_or(AnalysisError::NoAudioTrack)?;
54 54
55 55 let track_id = track.id;
56 - let codec_params = track.codec_params.clone();
57 - let source_sample_rate = codec_params
56 + let source_sample_rate = track
57 + .codec_params
58 58 .sample_rate
59 59 .ok_or(AnalysisError::ProbeFailed("missing sample rate".to_string()))?;
60 - let source_channels = codec_params
60 + let source_channels = track
61 + .codec_params
61 62 .channels
62 63 .map(|c| c.count() as u16)
63 64 .ok_or(AnalysisError::ProbeFailed("missing channel count".to_string()))?;
64 65
65 66 let mut decoder = symphonia::default::get_codecs()
66 - .make(&codec_params, &DecoderOptions::default())
67 + .make(&track.codec_params, &DecoderOptions::default())
67 68 .map_err(|e| AnalysisError::DecoderFailed(e.to_string()))?;
68 69
69 70 let mut all_samples: Vec<f32> = Vec::new();
@@ -147,27 +147,23 @@ fn collapse_separators(s: &str) -> String {
147 147
148 148 /// Append ` (2)`, ` (3)`, etc. to duplicate stems.
149 149 fn deduplicate(stems: Vec<String>) -> Vec<String> {
150 + // First pass: count occurrences using owned keys
150 151 let mut counts: HashMap<String, usize> = HashMap::new();
151 - let mut result = Vec::with_capacity(stems.len());
152 -
153 - // First pass: count occurrences
154 152 for stem in &stems {
155 153 *counts.entry(stem.clone()).or_insert(0) += 1;
156 154 }
157 155
158 156 // Second pass: assign suffixes where needed
159 157 let mut seen: HashMap<String, usize> = HashMap::new();
158 + let mut result = Vec::with_capacity(stems.len());
160 159 for stem in stems {
161 - let total = counts[&stem];
162 - if total > 1 {
163 - let n = seen.entry(stem.clone()).or_insert(0);
160 + if counts[&stem] <= 1 {
161 + result.push(stem);
162 + } else if let Some(n) = seen.get_mut(&stem) {
164 163 *n += 1;
165 - if *n == 1 {
166 - result.push(stem);
167 - } else {
168 - result.push(format!("{stem} ({n})"));
169 - }
164 + result.push(format!("{stem} ({n})"));
170 165 } else {
166 + seen.insert(stem.clone(), 1);
171 167 result.push(stem);
172 168 }
173 169 }
@@ -328,7 +328,7 @@ mod tests {
328 328
329 329 assert!(stats.entries_removed > 0);
330 330 // The symlink should be gone.
331 - assert!(!mirror_dir.path().join("Library/kick.wav").symlink_metadata().is_ok());
331 + assert!(mirror_dir.path().join("Library/kick.wav").symlink_metadata().is_err());
332 332 }
333 333
334 334 #[cfg(unix)]