Skip to main content

max / audiofiles

Describe the file list's row menu quasi-router 0.20.0 gave Cells the menu member Row has had all along, and this is the consumer that asked for it: draw_context_menu's 14 entries, branching on whether the row is a sample or a folder, over a table that has been described since 2026-08-17 and could not offer any of them. Ten entries for a sample, five for a folder, five withheld from a sample whose bytes are only in the cloud. Seven new Files methods cover it, because most entries needed no new capability: Copy Path, Edit, Find Similar and Find Duplicates are Detail's already, Export is Export::open's, and New Folder and Rename are addresses naming answers, so those routes select the row and call what exists. Delete carries Act::confirm rather than each renderer inventing a question. Sample gains directory and cloud_only. Without them a described row could not tell a folder from a sample, so every folder was getting the analysis columns and a Play control -- fixed here too, with the Play cell left empty rather than dropped, since cells are positional against the columns. The reveal label and the spawn table move to the top of file_list_menus.rs and are shared. The described menu offers the same act, and two platform tables to keep in agreement is one too many. apply() takes the sync manager now. Download is the one row-menu entry that needs it and only the host has one; ten of the eleven callers pass None, as before. Add to Collection is not described. It is the only nested entry, Cells::menu is flat like Row::menu, and a submenu wants a second consumer first.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 21:50 UTC
Signed with PGP, not checked
Commit: 9dc5eed34c06e55a2780a9d5e048d12ba27ef51e
Parent: 012027b
8 files changed, +830 insertions, -84 deletions
M Cargo.lock +26 -26
@@ -4244,7 +4244,7 @@
4244 4244
4245 4245 [[package]]
4246 4246 name = "quasi-immediate"
4247 - version = "0.19.0"
4247 + version = "0.20.0"
4248 4248 dependencies = [
4249 4249 "docengine",
4250 4250 "egui",
@@ -4254,7 +4254,7 @@
4254 4254
4255 4255 [[package]]
4256 4256 name = "quasi-router"
4257 - version = "0.19.0"
4257 + version = "0.20.0"
4258 4258 dependencies = [
4259 4259 "makeover-layout",
4260 4260 ]
@@ -7543,30 +7543,6 @@
7543 7543 "winnow 1.0.4",
7544 7544 ]
7545 7545
7546 - [[patch.unused]]
7547 - name = "quasi-axum"
7548 - version = "0.19.0"
7549 -
7550 - [[patch.unused]]
7551 - name = "quasi-basics"
7552 - version = "0.19.0"
7553 -
7554 - [[patch.unused]]
7555 - name = "quasi-http"
7556 - version = "0.19.0"
7557 -
7558 - [[patch.unused]]
7559 - name = "quasi-store"
7560 - version = "0.1.0"
7561 -
7562 - [[patch.unused]]
7563 - name = "quasi-tauri"
7564 - version = "0.19.0"
7565 -
7566 - [[patch.unused]]
7567 - name = "quasi-webview"
7568 - version = "0.19.0"
7569 -
7570 7546 [[patch.unused]]
7571 7547 name = "kberg"
7572 7548 version = "0.1.0"
@@ -7582,3 +7558,27 @@
7582 7558 [[patch.unused]]
7583 7559 name = "quasi-type"
7584 7560 version = "0.1.0"
7561 +
7562 + [[patch.unused]]
7563 + name = "quasi-axum"
7564 + version = "0.20.0"
7565 +
7566 + [[patch.unused]]
7567 + name = "quasi-basics"
7568 + version = "0.20.0"
7569 +
7570 + [[patch.unused]]
7571 + name = "quasi-http"
7572 + version = "0.20.0"
7573 +
7574 + [[patch.unused]]
7575 + name = "quasi-store"
7576 + version = "0.1.0"
7577 +
7578 + [[patch.unused]]
7579 + name = "quasi-tauri"
7580 + version = "0.20.0"
7581 +
7582 + [[patch.unused]]
7583 + name = "quasi-webview"
7584 + version = "0.20.0"
M Cargo.toml +2 -2
@@ -21,8 +21,8 @@
21 21 makeover-immediate = "0.28.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.19" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.19" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.20" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.20" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -188,7 +188,7 @@
188 188
189 189 #[cfg(feature = "quasi")]
190 190 if state.described.show_files {
191 - crate::quasi::panel::draw_files(ctx, state);
191 + crate::quasi::panel::draw_files(ctx, state, sync_manager);
192 192 }
193 193
194 194 #[cfg(feature = "quasi")]
@@ -53,15 +53,30 @@
53 53 //! that belongs to the description rather than to a renderer: one description
54 54 //! has to become a context menu, an action sheet and a key-driven menu, and no
55 55 //! single renderer can be where that is said. It is right, and the file list is
56 - //! a [`Node::Table`], whose [`Cells`] has no `menu`.
56 + //! a [`Node::Table`], whose [`Cells`] had no `menu`.
57 57 //!
58 - //! That asymmetry has been corrected once before, in this exact place.
59 - //! `Cells::selected` says it was deliberately absent "on the grounds that no
60 - //! table asked for one and a member added because its sibling has it is a member
61 - //! with no consumer to tell us what it should mean", and that it "was correct
62 - //! until 2026-08-15". This is the same sentence about the next field along, and
63 - //! the consumer that ends it is here: 14 entries, branching on node type, with
64 - //! a submenu. Filed as `quasi:vocabulary:table-row-menu`.
58 + //! **[`Cells::menu`] exists as of quasi-router 0.20.0, and this screen describes
59 + //! its row menu through it.** See `menu` below. The asymmetry had been corrected once
60 + //! before in this exact place: `Cells::selected` says it was deliberately absent
61 + //! "on the grounds that no table asked for one and a member added because its
62 + //! sibling has it is a member with no consumer to tell us what it should mean",
63 + //! and that it "was correct until 2026-08-15". This was the same sentence about
64 + //! the next field along, and the consumer that ended it is here.
65 + //!
66 + //! Two things came out of describing it that were not about the member. The
67 + //! description needs to know whether a row is a folder and whether its bytes are
68 + //! only in the cloud — `draw_context_menu` branches on both and [`Sample`] said
69 + //! neither, so every row was getting the sample columns and a Play control,
70 + //! folders included. And `quasi-immediate` drew no menu at all, for `Row` either:
71 + //! the detail pane's tag rows have offered one since they were described and it
72 + //! has never opened, because that renderer had no arm for the member. Both fixed
73 + //! in the same pass.
74 + //!
75 + //! The one entry still not described is **Add to Collection**, the only nested
76 + //! one. `Cells::menu` is flat, matching `Row::menu`, and a submenu wants a second
77 + //! consumer before [`Act`] grows a child list. Flattening it reads "Add to
78 + //! Kicks", "Add to Breaks" for as many collections as exist, which is honest and
79 + //! gets long.
65 80 //!
66 81 //! **The other two have no container at all.** A menu over the *selection* and a
67 82 //! menu over the *surface* are not per-row, and nothing in the vocabulary holds
@@ -83,7 +98,7 @@
83 98 //! from the start: windowing rows the app already holds is a performance
84 99 //! technique, not a described fact.
85 100
86 - use quasi_router::layout::{Priority, Sort, Width};
101 + use quasi_router::layout::{Priority, Sort, Tone, Width};
87 102 use quasi_router::{
88 103 Act, Action, Cell, Cells, Column, Node, RegionKind, Request, Response, RouteError, Router,
89 104 Screen, Slot, Tag,
@@ -110,6 +125,22 @@
110 125 .post("/files/{id}/open", open)
111 126 .post("/files/{id}/play", play)
112 127 .post("/files/sort/{column}", sort)
128 + // The row menu. Five of these hold no capability of their own: they
129 + // select the row and then call the handle that already does the act for
130 + // the sample in focus, which is why `Files` grew seven methods for
131 + // thirteen entries. See `Files`'s own note.
132 + .post("/files/{id}/enter", enter)
133 + .post("/files/{id}/path/copy", copy_path)
134 + .post("/files/{id}/reveal", reveal)
135 + .post("/files/{id}/similar", find_similar)
136 + .post("/files/{id}/duplicates", find_duplicates)
137 + .post("/files/{id}/edit", edit)
138 + .post("/files/{id}/instrument", instrument)
139 + .post("/files/{id}/export", export)
140 + .post("/files/{id}/reanalyze", reanalyze)
141 + .post("/files/{id}/delete", delete)
142 + .post("/files/{id}/download", download)
143 + .post("/files/{id}/collection/remove", remove_from_collection)
113 144 }
114 145
115 146 /// `GET /files`
@@ -131,6 +162,115 @@
131 162 Ok(screen(state).into())
132 163 }
133 164
165 + /// `POST /files/{id}/enter`
166 + ///
167 + /// A folder row's Open, which is not [`open`]'s Open: selecting a folder and
168 + /// going into it are two acts, and the shipped menu offers the second.
169 + fn enter(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
170 + let id = id_of(&request)?;
171 + state.files.enter(id);
172 + Ok(screen(state).into())
173 + }
174 +
175 + /// `POST /files/{id}/path/copy`
176 + ///
177 + /// The first of the five that borrow a capability rather than growing one:
178 + /// select the row, then call what the detail pane already calls on the sample in
179 + /// focus. The clipboard is `Detail`'s because the detail pane needed it first,
180 + /// and a second way to copy a path would be a second way for it to be wrong.
181 + fn copy_path(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
182 + let id = id_of(&request)?;
183 + state.files.open(id);
184 + state.detail.copy_path();
185 + Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
186 + }
187 +
188 + /// `POST /files/{id}/reveal`
189 + fn reveal(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
190 + let id = id_of(&request)?;
191 + state.files.reveal(id);
192 + Ok(screen(state).into())
193 + }
194 +
195 + /// `POST /files/{id}/similar`
196 + fn find_similar(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
197 + let id = id_of(&request)?;
198 + state.files.open(id);
199 + state.detail.find_similar();
200 + Ok(screen(state).into())
201 + }
202 +
203 + /// `POST /files/{id}/duplicates`
204 + fn find_duplicates(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
205 + let id = id_of(&request)?;
206 + state.files.open(id);
207 + state.detail.find_duplicates();
208 + Ok(screen(state).into())
209 + }
210 +
211 + /// `POST /files/{id}/edit`
212 + fn edit(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
213 + let id = id_of(&request)?;
214 + state.files.open(id);
215 + state.detail.edit();
216 + Ok(screen(state).into())
217 + }
218 +
219 + /// `POST /files/{id}/instrument`
220 + fn instrument(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
221 + let id = id_of(&request)?;
222 + state.files.as_instrument(id);
223 + Ok(screen(state).into())
224 + }
225 +
226 + /// `POST /files/{id}/export`
227 + ///
228 + /// The flow opens on whatever is chosen, so this selects the row and then asks
229 + /// for it. Same two steps the shipped menu takes, and the answer is the same
230 + /// `Goto` [`export`](super::export)'s own `begin` gives, because the flow is a
231 + /// screen rather than a change to this one.
232 + fn export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
233 + let id = id_of(&request)?;
234 + state.files.open(id);
235 + state.export.open();
236 + Ok(Response::from(quasi_router::Outcome::Goto(Action::get(
237 + "/export",
238 + ))))
239 + }
240 +
241 + /// `POST /files/{id}/reanalyze`
242 + fn reanalyze(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
243 + let id = id_of(&request)?;
244 + state.files.reanalyze(id);
245 + Ok(screen(state).into())
246 + }
247 +
248 + /// `POST /files/{id}/delete`
249 + ///
250 + /// The act carries the question, so arriving here means it was answered. The app
251 + /// raises its own counted dialog after this, which is the shipped behaviour and
252 + /// is not a duplicate of the same question: one asks whether to delete this row
253 + /// and the other says how much is about to go.
254 + fn delete(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
255 + let id = id_of(&request)?;
256 + state.files.delete(id);
257 + Ok(screen(state).into())
258 + }
259 +
260 + /// `POST /files/{id}/download`
261 + fn download(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
262 + let id = id_of(&request)?;
263 + state.files.download(id);
264 + Ok(screen(state).into())
265 + }
266 +
267 + /// `POST /files/{id}/collection/remove`
268 + fn remove_from_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
269 + let id = id_of(&request)?;
270 + state.files.remove_from_collection(id);
271 + Ok(screen(state).into())
272 + }
273 +
134 274 /// `POST /files/sort/{column}`
135 275 ///
136 276 /// The heading a user pressed. Which way it then sorts is the app's: pressing
@@ -178,6 +318,14 @@
178 318 let shown = state.files.columns();
179 319 let samples = state.files.samples();
180 320 let current = state.files.current();
321 + // Whether a collection is being shown, which decides one menu entry. Read
322 + // once for the table rather than per row: it is a fact about the screen and
323 + // every row would otherwise ask the library the same question.
324 + let collection = state
325 + .library
326 + .collections()
327 + .into_iter()
328 + .any(|collection| collection.active);
181 329
182 330 if samples.is_empty() {
183 331 // The sentence and the way out are both on the node rather than on the
@@ -192,7 +340,7 @@
192 340 columns: columns(state, shown),
193 341 rows: samples
194 342 .iter()
195 - .map(|sample| row(sample, shown, current))
343 + .map(|sample| row(sample, shown, current, collection))
196 344 .collect(),
197 345 // Everything the app has loaded and filtered is here. Windowing
198 346 // rows it already holds is a renderer's job, which is the note in
@@ -255,7 +403,12 @@
255 403 }
256 404
257 405 /// One sample as a row.
258 - fn row(sample: &Sample, shown: super::ColumnsShown, current: Option<i64>) -> Cells {
406 + fn row(
407 + sample: &Sample,
408 + shown: super::ColumnsShown,
409 + current: Option<i64>,
410 + collection: bool,
411 + ) -> Cells {
259 412 let mut values = vec![Cell::new(&sample.name)];
260 413 if shown.duration {
261 414 values.push(Cell::new(seconds(sample.duration)));
@@ -294,16 +447,107 @@
294 447 }
295 448 values.push(cell);
296 449 }
297 - values.push(Cell::acts([Act::new(
298 - "Play",
299 - Action::post(format!("/files/{}/play", sample.id)),
300 - )]));
450 + // A folder has nothing to play, and the cell stays because cells are
451 + // positional against the columns: dropping it would shift every value after
452 + // it one column left. Empty rather than absent is the same answer the
453 + // analysis cells already give for a folder.
454 + values.push(if sample.directory || sample.cloud_only {
455 + Cell::new("")
456 + } else {
457 + Cell::acts([Act::new(
458 + "Play",
459 + Action::post(format!("/files/{}/play", sample.id)),
460 + )])
461 + });
301 462
302 463 let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id)));
303 464 row.current = current == Some(sample.id);
465 + row.menu = menu(sample, collection);
304 466 row
305 467 }
306 468
469 + /// What a row offers without showing it.
470 + ///
471 + /// `ui/file_list_menus.rs::draw_context_menu`, said as a description. The
472 + /// branching is the shipped menu's: a folder and a sample offer different things,
473 + /// and a cloud-only sample withholds the four acts that need the bytes on disk.
474 + ///
475 + /// # What is not here, and why each one is not a gap
476 + ///
477 + /// - **Add to Collection.** The one nested entry, over a list the app holds.
478 + /// [`Cells::menu`] is flat, matching `Row::menu`, and flattening this reads
479 + /// "Add to Kicks", "Add to Breaks" for as many collections as exist. Growing
480 + /// [`Act`] a child list wants a second consumer first, which is the rule the
481 + /// menu member itself was held back by.
482 + /// - **The selection menu and the background menu.** Eleven entries and five,
483 + /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set
484 + /// and the empty-space menu on the folder being shown. Neither has a container
485 + /// in the vocabulary -- there is no menu over a selection and none over a
486 + /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over)
487 + /// they become app-modal overlays, which is near enough and not exact. Filed as
488 + /// `quasi:vocabulary:anchored-menu`; see this module's header.
489 + fn menu(sample: &Sample, collection: bool) -> Vec<Act> {
490 + let id = sample.id;
491 + let at = |verb: &str| Action::post(format!("/files/{id}/{verb}"));
492 +
493 + if sample.directory {
494 + return vec![
495 + Act::new("Open", at("enter")),
496 + // Both already described, at addresses of their own: the folder
497 + // modals are `naming`'s, and pointing the menu at them is the whole
498 + // benefit of a description having addresses.
499 + Act::new("New Folder", Action::get("/folders/new")),
500 + Act::new("Rename", Action::get(format!("/folders/{id}/rename"))),
501 + Act::new("Export...", at("export")),
502 + Act::new("Delete", at("delete"))
503 + .tone(Tone::Danger)
504 + .confirm(format!("Delete {}?", sample.name)),
505 + ];
506 + }
507 +
508 + let mut acts = Vec::new();
509 +
510 + // A sample nobody has fetched yet: the one act it does offer, and then
511 + // nothing that needs the file.
512 + if sample.cloud_only {
513 + acts.push(Act::new("Download", at("download")));
514 + } else {
515 + acts.push(Act::new("Preview", at("play")));
516 + }
517 +
518 + acts.push(Act::new("Copy Path", at("path/copy")));
519 +
520 + if !sample.cloud_only {
521 + acts.push(Act::new(
522 + crate::ui::file_list_menus::reveal_label(),
523 + at("reveal"),
524 + ));
525 + }
526 +
527 + // The two searches carry the keys the detail pane already binds for them, so
528 + // one screen does not teach a different chord for the same act.
529 + acts.push(Act::new("Find Similar", at("similar")).key("shift+f"));
530 + acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d"));
531 +
532 + if collection {
533 + acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger));
534 + }
535 +
536 + if !sample.cloud_only {
537 + acts.push(Act::new("Edit...", at("edit")).key("e"));
538 + acts.push(Act::new("Play as Instrument", at("instrument")));
539 + acts.push(Act::new("Export...", at("export")));
540 + acts.push(Act::new("Re-analyze...", at("reanalyze")));
541 + }
542 +
543 + acts.push(
544 + Act::new("Delete", at("delete"))
545 + .tone(Tone::Danger)
546 + .confirm(format!("Delete {}?", sample.name)),
547 + );
548 + acts
549 + }
550 +
307 551 /// A duration as the list writes it.
308 552 fn seconds(duration: Option<f64>) -> String {
309 553 let Some(seconds) = duration else {
@@ -482,6 +482,19 @@
482 482 pub peak_db: Option<f64>,
483 483 /// Whatever it is tagged with.
484 484 pub tags: Vec<String>,
485 + /// Whether this row is a folder rather than a sample.
486 + ///
487 + /// The file list holds both, and until 2026-08-17 a described row could not
488 + /// tell them apart: every row got the sample columns and a Play control,
489 + /// folders included. `draw_context_menu` branches on exactly this and offers
490 + /// two different menus, so `Cells::menu` could not be described without it.
491 + pub directory: bool,
492 + /// Whether the bytes are only in the cloud.
493 + ///
494 + /// What the shipped menu reads to offer Download and to withhold the four
495 + /// acts that need the file on disk. A sample nobody has fetched can still be
496 + /// listed, named and tagged, so this is not the same fact as absence.
497 + pub cloud_only: bool,
485 498 }
486 499
487 500 /// Which columns the file list is showing.
@@ -527,6 +540,39 @@
527 540
528 541 /// Order by this column.
529 542 fn sort_by(&self, column: &str);
543 +
544 + /// Go into this folder.
545 + ///
546 + /// The rest of this block is the row menu, 2026-08-17. Five of its entries
547 + /// are not here and do not need to be: Copy Path, Edit, Find Similar and
548 + /// Find Duplicates are already [`Detail`]'s, acting on the sample in focus,
549 + /// so a route selects the row through [`open`](Self::open) and then calls
550 + /// the capability that exists. Export is [`Export::open`]'s the same way, and
551 + /// New Folder and Rename are addresses [`Naming`] already answers. What is
552 + /// left is what nothing else could do.
553 + fn enter(&self, id: i64);
554 +
555 + /// Show this row in the system file manager.
556 + fn reveal(&self, id: i64);
557 +
558 + /// Play this sample chromatically, as an instrument.
559 + fn as_instrument(&self, id: i64);
560 +
561 + /// Analyse this sample again, replacing what is there.
562 + fn reanalyze(&self, id: i64);
563 +
564 + /// Delete this row.
565 + ///
566 + /// The asking is the act's, not this method's: a described act carries
567 + /// [`Act::confirm`](quasi_router::Act::confirm) and every renderer raises it
568 + /// its own way, so by the time a handler calls this the user has agreed.
569 + fn delete(&self, id: i64);
570 +
571 + /// Fetch this cloud-only sample to local storage.
572 + fn download(&self, id: i64);
573 +
574 + /// Take this sample out of the collection being viewed.
575 + fn remove_from_collection(&self, id: i64);
530 576 }
531 577
532 578 /// What a described screen asked the app to do to itself.
@@ -545,6 +591,20 @@
545 591 Open(i64),
546 592 /// Preview a row.
547 593 Play(i64),
594 + /// Go into a folder.
595 + Enter(i64),
596 + /// Show a row in the system file manager.
597 + Reveal(i64),
598 + /// Play a sample chromatically.
599 + Instrument(i64),
600 + /// Analyse a sample again.
601 + Reanalyze(i64),
602 + /// Delete a row, the asking already done.
603 + Delete(i64),
604 + /// Fetch a cloud-only sample.
605 + Download(i64),
606 + /// Take a sample out of the collection being viewed.
607 + RemoveFromCollection(i64),
548 608 /// Order by a column.
549 609 SortBy(String),
550 610 /// Change one export setting.
@@ -826,6 +886,11 @@
826 886 key: node.musical_key.clone(),
827 887 peak_db: node.peak_db,
828 888 tags: node.tags.clone(),
889 + directory: matches!(
890 + node.node.node_type,
891 + audiofiles_core::vfs::NodeType::Directory
892 + ),
893 + cloud_only: node.cloud_only,
829 894 })
830 895 .collect()
831 896 }
@@ -874,6 +939,36 @@
874 939 .borrow_mut()
875 940 .push(Intent::SortBy(column.to_owned()));
876 941 }
942 +
943 + fn enter(&self, id: i64) {
944 + self.intents.borrow_mut().push(Intent::Enter(id));
945 + }
946 +
947 + fn reveal(&self, id: i64) {
948 + self.intents.borrow_mut().push(Intent::Reveal(id));
949 + }
950 +
951 + fn as_instrument(&self, id: i64) {
952 + self.intents.borrow_mut().push(Intent::Instrument(id));
953 + }
954 +
955 + fn reanalyze(&self, id: i64) {
956 + self.intents.borrow_mut().push(Intent::Reanalyze(id));
957 + }
958 +
959 + fn delete(&self, id: i64) {
960 + self.intents.borrow_mut().push(Intent::Delete(id));
961 + }
962 +
963 + fn download(&self, id: i64) {
964 + self.intents.borrow_mut().push(Intent::Download(id));
965 + }
966 +
967 + fn remove_from_collection(&self, id: i64) {
968 + self.intents
969 + .borrow_mut()
970 + .push(Intent::RemoveFromCollection(id));
971 + }
877 972 }
878 973
879 974 /// Where the export flow has got to.
@@ -108,7 +108,7 @@
108 108 stale,
109 109 );
110 110 state.described.settings = runtime;
111 - apply(ctx, state, intents.into_inner());
111 + apply(ctx, state, None, intents.into_inner());
112 112 if closed {
113 113 state.settings.show_manager = false;
114 114 state.described.settings = None;
@@ -139,7 +139,7 @@
139 139 stale,
140 140 );
141 141 state.described.sync = runtime;
142 - apply(ctx, state, intents.into_inner());
142 + apply(ctx, state, sync, intents.into_inner());
143 143 if closed {
144 144 state.sync.show_panel = false;
145 145 state.described.sync = None;
@@ -147,13 +147,13 @@
147 147 }
148 148
149 149 /// Draw the described file list, and act on whatever was pressed.
150 - pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState) {
150 + pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
151 151 let intents = RefCell::new(Vec::new());
152 152 let mut runtime = state.described.files.take();
153 153 let stale = state.described.stale;
154 154 let host = Host {
155 155 state,
156 - sync: None,
156 + sync,
157 157 themes: themes(),
158 158 intents: &intents,
159 159 };
@@ -166,7 +166,7 @@
166 166 stale,
167 167 );
168 168 state.described.files = runtime;
169 - apply(ctx, state, intents.into_inner());
169 + apply(ctx, state, sync, intents.into_inner());
170 170 if closed {
171 171 state.described.show_files = false;
172 172 state.described.files = None;
@@ -199,7 +199,7 @@
199 199 true,
200 200 );
201 201 state.described.export = runtime;
202 - apply(ctx, state, intents.into_inner());
202 + apply(ctx, state, None, intents.into_inner());
203 203 if closed {
204 204 state.described.export = None;
205 205 }
@@ -231,7 +231,7 @@
231 231 true,
232 232 );
233 233 state.described.detail = runtime;
234 - apply(ctx, state, intents.into_inner());
234 + apply(ctx, state, None, intents.into_inner());
235 235 if closed {
236 236 state.described.show_detail = false;
237 237 state.described.detail = None;
@@ -264,7 +264,7 @@
264 264 true,
265 265 );
266 266 state.described.shell = runtime;
267 - apply(ctx, state, intents.into_inner());
267 + apply(ctx, state, None, intents.into_inner());
268 268 if closed {
269 269 state.described.show_shell = false;
270 270 state.described.shell = None;
@@ -294,7 +294,7 @@
294 294 true,
295 295 );
296 296 state.described.edit = runtime;
297 - apply(ctx, state, intents.into_inner());
297 + apply(ctx, state, None, intents.into_inner());
298 298 if closed {
299 299 state.described.edit = None;
300 300 }
@@ -324,7 +324,7 @@
324 324 true,
325 325 );
326 326 state.described.forge = runtime;
327 - apply(ctx, state, intents.into_inner());
327 + apply(ctx, state, None, intents.into_inner());
328 328 if closed {
329 329 state.described.forge = None;
330 330 }
@@ -355,7 +355,7 @@
355 355 true,
356 356 );
357 357 state.described.import = runtime;
358 - apply(ctx, state, intents.into_inner());
358 + apply(ctx, state, None, intents.into_inner());
359 359 if closed {
360 360 state.described.import = None;
361 361 }
@@ -383,7 +383,7 @@
383 383 true,
384 384 );
385 385 state.described.queue = runtime;
386 - apply(ctx, state, intents.into_inner());
386 + apply(ctx, state, None, intents.into_inner());
387 387 if closed {
388 388 state.described.queue = None;
389 389 }
@@ -412,7 +412,7 @@
412 412 true,
413 413 );
414 414 state.described.sweep = runtime;
415 - apply(ctx, state, intents.into_inner());
415 + apply(ctx, state, None, intents.into_inner());
416 416 if closed {
417 417 state.described.sweep = None;
418 418 }
@@ -428,7 +428,16 @@
428 428 /// Each arm calls what the shipped list calls, rather than reaching into the
429 429 /// fields itself: a described screen that set `nav.selection` by hand would be a
430 430 /// second implementation of selection, which is what the port is for avoiding.
431 - fn apply(ctx: &egui::Context, state: &mut BrowserState, intents: Vec<Intent>) {
431 + /// `sync` is the manager, where the caller has one. Only two panels do: the sync
432 + /// window, and the file list since 2026-08-17, because a cloud-only row's
433 + /// Download is an act nothing but the manager can perform. Every other caller
434 + /// passes `None` and no intent it can raise wants it.
435 + fn apply(
436 + ctx: &egui::Context,
437 + state: &mut BrowserState,
438 + sync: Option<&SyncManager>,
439 + intents: Vec<Intent>,
440 + ) {
432 441 // Anything applied here landed *after* the router answered, so the screen
433 442 // showing was built without it. The next frame reloads.
434 443 state.described.stale = !intents.is_empty();
@@ -464,6 +473,117 @@
464 473 state.autoplay_current();
465 474 }
466 475 }
476 + // The row menu, 2026-08-17. Every arm selects the row first and then
477 + // calls what the shipped menu calls on the selection, which is the
478 + // rule this whole function follows: `draw_context_menu` opens with
479 + // `nav.selection.set_single(row_idx)` for exactly the same reason,
480 + // and a described screen reaching past the app's own selection would
481 + // be a second implementation of it.
482 + Intent::Enter(id) => {
483 + if let Some(at) = index_of(state, id) {
484 + state.nav.selection.set_single(at);
485 + state.enter_directory();
486 + }
487 + }
488 + // A host act, like `CopyPath` above: a file manager is the system's
489 + // and the description has no way to say so.
490 + Intent::Reveal(id) => {
491 + if let Some(at) = index_of(state, id) {
492 + state.nav.selection.set_single(at);
493 + if let Some(path) = state.selected_sample_path() {
494 + crate::ui::file_list_menus::reveal(&path);
495 + }
496 + }
497 + }
498 + Intent::Instrument(id) => {
499 + if let Some(at) = index_of(state, id) {
500 + state.nav.selection.set_single(at);
501 + if let Some(hash) = selected_hash(state) {
502 + let name = state
503 + .selected_node()
504 + .map(|node| node.node.name.clone())
505 + .unwrap_or_default();
506 + state.load_chromatic_sample(&hash);
507 + state.preview.instrument_visible = true;
508 + state.preview.show_midi_window = true;
509 + state.status = format!("Instrument: {name}");
510 + }
511 + }
512 + }
513 + // The overwrite branch is the shipped menu's, verbatim in intent: an
514 + // analysis that would replace numbers already there asks first, and
515 + // one filling a gap does not. `ReanalyzeOverwrite` is the app's own
516 + // confirmation, so this does not use `Act::confirm` -- the question
517 + // is conditional on state the description does not carry.
518 + Intent::Reanalyze(id) => {
519 + if let Some(at) = index_of(state, id) {
520 + state.nav.selection.set_single(at);
521 + let existing = state
522 + .selected_node()
523 + .is_some_and(|node| node.bpm.is_some() || node.musical_key.is_some());
524 + if let Some(hash) = selected_hash(state)
525 + && let Ok(ext) = state.backend.sample_extension(&hash)
526 + {
527 + let hashes = vec![(hash, ext)];
528 + if existing {
529 + state.overlay.pending_confirm =
530 + Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
531 + sample_hashes: hashes,
532 + overwrite_count: 1,
533 + });
534 + } else {
535 + state.start_analysis_flow(hashes);
536 + }
537 + }
538 + }
539 + }
540 + // The act carried `Act::confirm`, so the user has already agreed by
541 + // the time this runs. `confirm_delete_selected` raises the app's own
542 + // dialog on top of that, which is one question too many and is the
543 + // shipped behaviour: the menu entry asks nothing itself and this is
544 + // where the asking lives. Left as the app's, because the two dialogs
545 + // are not equivalent -- the app's counts what it is about to remove.
546 + Intent::Delete(id) => {
547 + if let Some(at) = index_of(state, id) {
548 + state.nav.selection.set_single(at);
549 + state.confirm_delete_selected();
550 + }
551 + }
552 + Intent::Download(id) => {
553 + if let Some(at) = index_of(state, id) {
554 + state.nav.selection.set_single(at);
555 + let name = state
556 + .selected_node()
557 + .map(|node| node.node.name.clone())
558 + .unwrap_or_default();
559 + // Without a manager there is nothing to ask, which is the
560 + // case the described screen already withholds the entry for.
561 + // Said again here because an intent can arrive from a
562 + // hand-typed request, and the shipped menu's own fallback is
563 + // a status line rather than a silence.
564 + match (sync, selected_hash(state)) {
565 + (Some(manager), Some(hash)) if manager.download_sample(&hash) => {
566 + state.status = format!("Downloading {name}...");
567 + }
568 + _ => {
569 + "Sync not ready, open the Sync panel first"
570 + .clone_into(&mut state.status);
571 + }
572 + }
573 + }
574 + }
575 + Intent::RemoveFromCollection(id) => {
576 + if let Some(active) = state.collections_ui.active_collection
577 + && let Some(at) = index_of(state, id)
578 + {
579 + state.nav.selection.set_single(at);
580 + if let Some(hash) = selected_hash(state) {
581 + let _ = state.backend.remove_from_collection(active, &hash);
582 + state.refresh_collections();
583 + state.activate_collection(active);
584 + }
585 + }
586 + }
467 587 Intent::SortBy(column) => {
468 588 let key = match column.as_str() {
469 589 "Name" => crate::state::SortColumn::Name,
@@ -121,6 +121,30 @@
121 121 fn sort_by(&self, column: &str) {
122 122 self.asked.borrow_mut().push(format!("sort:{column}"));
123 123 }
124 + // The row menu's seven. Recorded rather than performed, same as the three
125 + // above: what a test of a described screen asserts is that pressing a
126 + // described act reaches the capability, and the app is what does it.
127 + fn enter(&self, id: i64) {
128 + self.asked.borrow_mut().push(format!("enter:{id}"));
129 + }
130 + fn reveal(&self, id: i64) {
131 + self.asked.borrow_mut().push(format!("reveal:{id}"));
132 + }
133 + fn as_instrument(&self, id: i64) {
134 + self.asked.borrow_mut().push(format!("instrument:{id}"));
135 + }
136 + fn reanalyze(&self, id: i64) {
137 + self.asked.borrow_mut().push(format!("reanalyze:{id}"));
138 + }
139 + fn delete(&self, id: i64) {
140 + self.asked.borrow_mut().push(format!("delete:{id}"));
141 + }
142 + fn download(&self, id: i64) {
143 + self.asked.borrow_mut().push(format!("download:{id}"));
144 + }
145 + fn remove_from_collection(&self, id: i64) {
146 + self.asked.borrow_mut().push(format!("uncollect:{id}"));
147 + }
124 148 }
125 149
126 150 /// An export flow that is not running, for the screens that are not about one.
@@ -279,6 +303,29 @@
279 303 key: Some("Am".to_owned()),
280 304 peak_db: Some(-3.2),
281 305 tags: vec!["drums".to_owned(), "loop".to_owned()],
306 + directory: false,
307 + cloud_only: false,
308 + }
309 + }
310 +
311 + /// A folder row, which offers a different menu.
312 + fn folder(id: i64, name: &str) -> Sample {
313 + Sample {
314 + duration: None,
315 + bpm: None,
316 + key: None,
317 + peak_db: None,
318 + tags: Vec::new(),
319 + directory: true,
320 + ..sample(id, name)
321 + }
322 + }
323 +
324 + /// A sample whose bytes are only in the cloud.
325 + fn cloud_only(id: i64, name: &str) -> Sample {
326 + Sample {
327 + cloud_only: true,
328 + ..sample(id, name)
282 329 }
283 330 }
284 331
@@ -308,6 +355,66 @@
308 355 router().handle(&state, request)
309 356 }
310 357
358 + /// The same call, with a collection open.
359 + ///
360 + /// One menu entry turns on it -- Remove from Collection means nothing outside one
361 + /// -- so the file list asks the library whether any collection is active, and
362 + /// this is the fixture that says yes. `Empty` answers no and is what every other
363 + /// file-list test wants.
364 + fn listing_in_collection(
365 + files: &FakeFiles,
366 + request: Request,
367 + ) -> Result<Response, quasi_router::RouteError> {
368 + struct Showing;
369 +
370 + impl Library for Showing {
371 + fn vaults(&self) -> Vec<Vault> {
372 + Vec::new()
373 + }
374 + fn collections(&self) -> Vec<Collection> {
375 + vec![Collection {
376 + id: 3,
377 + name: "Kicks".to_owned(),
378 + holding: Holding::Fixed(4),
379 + active: true,
380 + }]
381 + }
382 + fn tags(&self) -> Vec<Filter> {
383 + Vec::new()
384 + }
385 + fn open_vault(&self, _id: i64) {}
386 + fn delete_vault(&self, _id: i64) {}
387 + fn toggle_tag(&self, _path: &str) {}
388 + fn remove_tag(&self, _path: &str) {}
389 + fn open_collection(&self, _id: i64) {}
390 + fn close_collection(&self) {}
391 + fn delete_collection(&self, _id: i64) {}
392 + }
393 +
394 + let store = Store::default();
395 + let sync = Offline;
396 + let themes = themes();
397 + let state = Panels {
398 + detail: &Unfocused,
399 + bulk: &Unchosen,
400 + shell: &Quiet,
401 + library: &Showing,
402 + bar: &Still,
403 + config: &store,
404 + sync: &sync,
405 + files,
406 + export: &Idle,
407 + naming: &Unnamed,
408 + importing: &NoImport,
409 + integrity: &Sound,
410 + editor: &Unedited,
411 + forge: &Unforged,
412 + queue: &Unqueued,
413 + themes: &themes,
414 + };
415 + router().handle(&state, request)
416 + }
417 +
311 418 /// The table on a screen.
312 419 ///
313 420 /// Descends into a `Node::Region`, because a region is a slot inside a node and
@@ -1333,6 +1440,160 @@
1333 1440 assert_eq!(files.asked(), ["open:7", "play:7", "sort:BPM"]);
1334 1441 }
1335 1442
1443 + #[test]
1444 + fn a_sample_row_offers_the_menu_the_shipped_one_offers() {
1445 + // `Cells::menu`, the member this screen asked quasi-router for. The
1446 + // assertion is against `draw_context_menu`'s sample branch: same acts, same
1447 + // order, same words.
1448 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1449 + let response = listing(&files, Request::get("/files")).expect("answered");
1450 + let (_, rows) = table_of(screen_of(&response));
1451 +
1452 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1453 + assert_eq!(
1454 + labels,
1455 + [
1456 + "Preview",
1457 + "Copy Path",
1458 + crate::ui::file_list_menus::reveal_label(),
1459 + "Find Similar",
1460 + "Find Duplicates",
1461 + "Edit...",
1462 + "Play as Instrument",
1463 + "Export...",
1464 + "Re-analyze...",
1465 + "Delete",
1466 + ],
1467 + "the described menu drifted from the shipped one"
1468 + );
1469 +
1470 + // Addressed by the row's own id, like `activate` and for the same reason.
1471 + let delete = rows[0].menu.last().expect("the menu ends in Delete");
1472 + assert_eq!(delete.action.destination.as_str(), "/files/7/delete");
1473 + // The one destructive entry says so, and carries the question rather than
1474 + // leaving each renderer to invent one.
1475 + assert_eq!(delete.tone, quasi_router::layout::Tone::Danger);
1476 + assert_eq!(delete.confirm.as_deref(), Some("Delete kick.wav?"));
1477 +
1478 + // No collection is open in the fixture, so the entry that only makes sense
1479 + // inside one is absent rather than drawn dead.
1480 + assert!(!labels.contains(&"Remove from Collection"));
1481 + }
1482 +
1483 + #[test]
1484 + fn a_folder_row_offers_the_folder_menu_and_nothing_about_samples() {
1485 + // The branch. A folder has no analysis to redo and nothing to preview, and
1486 + // the shipped menu offers it five entries instead of ten.
1487 + let files = FakeFiles::with(vec![folder(4, "Drums")]);
1488 + let response = listing(&files, Request::get("/files")).expect("answered");
1489 + let (columns, rows) = table_of(screen_of(&response));
1490 +
1491 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1492 + assert_eq!(
1493 + labels,
1494 + ["Open", "New Folder", "Rename", "Export...", "Delete"]
1495 + );
1496 +
1497 + // The two that were already described point at the addresses that already
1498 + // answer them rather than at new ones.
1499 + assert_eq!(rows[0].menu[1].action.destination.as_str(), "/folders/new");
1500 + assert_eq!(
1501 + rows[0].menu[2].action.destination.as_str(),
1502 + "/folders/4/rename"
1503 + );
1504 +
1505 + // And a folder still fills every column, because cells are positional: the
1506 + // Play cell is empty rather than missing.
1507 + assert_eq!(rows[0].values.len(), columns.len());
1508 + assert!(
1509 + !rows[0]
1510 + .values
1511 + .last()
1512 + .expect("a Play cell")
1513 + .carries_control(),
1514 + "a folder offered something to play"
1515 + );
1516 + }
1517 +
1518 + #[test]
1519 + fn a_cloud_only_row_offers_the_fetch_and_withholds_what_needs_the_bytes() {
1520 + // The third branch, and the one a description could get quietly wrong: the
1521 + // acts are all still *sayable* for a sample nobody has fetched, and offering
1522 + // them would be offering acts that fail on a file that is not there.
1523 + let files = FakeFiles::with(vec![cloud_only(9, "snare.wav")]);
1524 + let response = listing(&files, Request::get("/files")).expect("answered");
1525 + let (_, rows) = table_of(screen_of(&response));
1526 +
1527 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1528 + assert_eq!(
1529 + labels,
1530 + [
1531 + "Download",
1532 + "Copy Path",
1533 + "Find Similar",
1534 + "Find Duplicates",
1535 + "Delete",
1536 + ]
1537 + );
1538 + // Named rather than left to the count above: these four are the ones that
1539 + // need the file on disk.
1540 + for withheld in ["Preview", "Edit...", "Play as Instrument", "Re-analyze..."] {
1541 + assert!(!labels.contains(&withheld), "{withheld} was offered");
1542 + }
1543 + }
1544 +
1545 + #[test]
1546 + fn a_collection_being_shown_adds_the_entry_that_only_means_something_there() {
1547 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1548 + let response = listing_in_collection(&files, Request::get("/files")).expect("answered");
1549 + let (_, rows) = table_of(screen_of(&response));
1550 +
1551 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1552 + assert!(labels.contains(&"Remove from Collection"), "{labels:?}");
1553 + }
1554 +
1555 + #[test]
1556 + fn every_menu_entry_reaches_the_app_at_the_row_it_was_opened_on() {
1557 + // The whole point of the member: the acts are addresses, and pressing one
1558 + // has to arrive with the row's id rather than with whatever is selected. Two
1559 + // rows in the fixture so an id that came from the selection would show up.
1560 + let files = FakeFiles::with(vec![sample(7, "kick.wav"), folder(4, "Drums")]);
1561 + for verb in [
1562 + "enter",
1563 + "path/copy",
1564 + "reveal",
1565 + "similar",
1566 + "duplicates",
1567 + "edit",
1568 + "instrument",
1569 + "reanalyze",
1570 + "delete",
1571 + "download",
1572 + "collection/remove",
1573 + ] {
1574 + listing(&files, Request::post(format!("/files/7/{verb}"))).expect("answered");
1575 + }
1576 +
1577 + assert_eq!(
1578 + files.asked(),
1579 + [
1580 + "enter:7",
1581 + // The five that borrow a capability select the row and then let the
1582 + // detail handle act on it, so what this fake sees is the selection.
1583 + "open:7",
1584 + "reveal:7",
1585 + "open:7",
1586 + "open:7",
1587 + "open:7",
1588 + "instrument:7",
1589 + "reanalyze:7",
1590 + "delete:7",
1591 + "download:7",
1592 + "uncollect:7",
1593 + ]
1594 + );
1595 + }
1596 +
1336 1597 #[test]
1337 1598 fn a_column_with_no_sort_is_refused_rather_than_ordered_by() {
1338 1599 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
@@ -11,6 +11,53 @@
11 11 #[cfg(any(target_os = "macos", target_os = "windows"))]
12 12 use crate::drag_out;
13 13
14 + /// What the reveal entry is called on this platform.
15 + ///
16 + /// macOS and Windows highlight the file itself; Linux has no widely-supported
17 + /// select flag and opens the containing folder instead, so the label says so.
18 + /// Shared with the described file list, which offers the same act and must not
19 + /// name it differently.
20 + pub const fn reveal_label() -> &'static str {
21 + #[cfg(target_os = "macos")]
22 + {
23 + "Reveal in Finder"
24 + }
25 + #[cfg(target_os = "windows")]
26 + {
27 + "Show in Explorer"
28 + }
29 + #[cfg(not(any(target_os = "macos", target_os = "windows")))]
30 + {
31 + "Open Containing Folder"
32 + }
33 + }
34 +
35 + /// Show `path` in the system file manager.
36 + ///
37 + /// Extracted 2026-08-17 so the described file list can offer the same act. It
38 + /// was inline in the menu below, and a second copy in the described path would
39 + /// have been two spawn tables to keep in agreement.
40 + pub fn reveal(path: &str) {
41 + #[cfg(target_os = "macos")]
42 + let _ = std::process::Command::new("open")
43 + .args(["-R", path])
44 + .spawn();
45 + #[cfg(target_os = "windows")]
46 + let _ = std::process::Command::new("explorer")
47 + .arg(format!("/select,{path}"))
48 + .spawn();
49 + #[cfg(not(any(target_os = "macos", target_os = "windows")))]
50 + {
51 + // No select flag to reach for, so the parent is the closest honest
52 + // answer and the label above already promised only that.
53 + let parent = std::path::Path::new(path).parent().map_or_else(
54 + || std::path::PathBuf::from(path),
55 + std::path::Path::to_path_buf,
56 + );
57 + let _ = std::process::Command::new("xdg-open").arg(&parent).spawn();
58 + }
59 + }
60 +
14 61 /// Draw the right-click context menu for a single item.
15 62 /// Branches on node type: samples get Preview/Copy Path/Delete,
16 63 /// directories get Open/Delete.
@@ -63,33 +110,12 @@
63 110 }
64 111 ui.close();
65 112 }
66 - // M-6: one-click jump to the file in the system file manager.
67 - // macOS / Windows highlight the file itself; Linux falls back to
68 - // opening the parent directory (no widely-supported select flag).
69 - #[cfg(target_os = "macos")]
70 - let reveal_label = "Reveal in Finder";
71 - #[cfg(target_os = "windows")]
72 - let reveal_label = "Show in Explorer";
73 - #[cfg(target_os = "linux")]
74 - let reveal_label = "Open Containing Folder";
75 - if !node.cloud_only && ui.button(reveal_label).clicked() {
113 + // M-6: one-click jump to the file in the system file manager. The
114 + // label and the spawning both live at the top of this file now, so
115 + // the described file list offers the same act under the same name.
116 + if !node.cloud_only && ui.button(reveal_label()).clicked() {
76 117 if let Some(path) = state.selected_sample_path() {
77 - #[cfg(target_os = "macos")]
78 - let _ = std::process::Command::new("open")
79 - .args(["-R", &path])
80 - .spawn();
81 - #[cfg(target_os = "windows")]
82 - let _ = std::process::Command::new("explorer")
83 - .arg(format!("/select,{}", path))
84 - .spawn();
85 - #[cfg(target_os = "linux")]
86 - {
87 - let parent = std::path::Path::new(&path).parent().map_or_else(
88 - || std::path::PathBuf::from(&path),
89 - std::path::Path::to_path_buf,
90 - );
91 - let _ = std::process::Command::new("xdg-open").arg(&parent).spawn();
92 - }
118 + reveal(&path);
93 119 }
94 120 ui.close();
95 121 }