Skip to main content

max / audiofiles

Flip the four name modals to the described screens New vault, rename vault, new folder and rename folder all serve from `quasi::naming`. `handle_name_modal_outcome` and the four `draw_*_modal` functions are gone from `ui/overlays.rs`; which of the four is showing is still the app's own `vfs_modal` flag, so the address is built at the call site. The flip needed one thing the port had left as a known finding. `naming`'s header said its exits navigate rather than dismiss "because there is no described way to close what is on top", and that was survivable while the screen sat beside a working modal. Serving, it is not: what keeps one of these on screen is the host's flag, not the runtime's layer stack, so a route that navigates away leaves the window up with the main screen inside it. Every exit now goes through `POST /naming/done` first, which is `integrity`'s dismiss in a second consumer. The vocabulary gap is untouched -- an `Outcome` meaning "this overlay is finished" still does not exist -- and this is the app's answer to it, not the vocabulary's. Parity was asserted for all four before the deletion, against one fixture each. The tests that replace them compare the description against what the host draws for it, which outlives the flip. Two allowances, both the same finding from opposite sides. `makeover_immediate::field` draws `Field::label` as a label above the control and never associates the two, so the box reaches the widget tree unnamed and a prefilled one is announced as its own contents. The shipped `name_modal` had the identical gap, so parity held either way; it is worse in the description layer only because the label is right there and `Response::labelled_by` is the one call that would attach it. Filed against makeover-immediate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 17:18 UTC
Signed with PGP, not checked
Commit: 76a7a1f2acb64840f60e589682f325d623231c4a
Parent: 3d1f0e3
7 files changed, +237 insertions, -203 deletions
@@ -141,17 +141,22 @@
141 141 if state.overlay.show_help {
142 142 overlays::draw_help_overlay(ctx, state);
143 143 }
144 + // The four name modals, all of them `quasi::naming`. Which one is showing is
145 + // still the app's own flag, so the address is built here rather than read
146 + // off anything a route knows.
144 147 if state.vfs_modal.show_vfs_create {
145 - overlays::draw_vfs_create_modal(ctx, state);
148 + crate::quasi::panel::draw_naming(ctx, state, "New Vault", "/vaults/new");
146 149 }
147 - if state.vfs_modal.vfs_rename_target.is_some() {
148 - overlays::draw_vfs_rename_modal(ctx, state);
150 + if let Some((id, _)) = &state.vfs_modal.vfs_rename_target {
151 + let home = format!("/vaults/{}/rename", id.as_i64());
152 + crate::quasi::panel::draw_naming(ctx, state, "Rename Vault", &home);
149 153 }
150 154 if state.vfs_modal.show_dir_create {
151 - overlays::draw_dir_create_modal(ctx, state);
155 + crate::quasi::panel::draw_naming(ctx, state, "New Folder", "/folders/new");
152 156 }
153 - if state.vfs_modal.dir_rename_target.is_some() {
154 - overlays::draw_dir_rename_modal(ctx, state);
157 + if let Some((id, _)) = &state.vfs_modal.dir_rename_target {
158 + let home = format!("/folders/{}/rename", id.as_i64());
159 + crate::quasi::panel::draw_naming(ctx, state, "Rename", &home);
155 160 }
156 161 if state.loose_files.show_loose_files_warning {
157 162 crate::quasi::panel::draw_integrity(ctx, state);
@@ -748,6 +748,8 @@
748 748 BulkMove(Option<i64>),
749 749 /// Rename everything chosen by this pattern.
750 750 BulkRename(String),
751 + /// A name modal is finished with, whichever of the four it was.
752 + NamingDone,
751 753 /// Re-read the vault list, and say this about why.
752 754 VaultsChanged(String),
753 755 /// Re-read the current folder, and say this about why.
@@ -2764,6 +2766,12 @@
2764 2766 /// the app does about a write decides where the write goes*, and here what the
2765 2767 /// app does about it is two things with different lifetimes.
2766 2768 pub trait Naming {
2769 + /// The modal is finished with: put it away.
2770 + ///
2771 + /// The host is what keeps one of these on screen, so leaving the address is
2772 + /// not leaving the screen. See `naming`'s `DONE`.
2773 + fn done(&self);
2774 +
2767 2775 /// What this vault is called, if it is one.
2768 2776 fn vault(&self, id: i64) -> Option<String>;
2769 2777
@@ -2837,6 +2845,10 @@
2837 2845 }
2838 2846
2839 2847 impl Naming for FromNaming<'_> {
2848 + fn done(&self) {
2849 + self.intents.borrow_mut().push(Intent::NamingDone);
2850 + }
2851 +
2840 2852 fn vault(&self, id: i64) -> Option<String> {
2841 2853 self.state
2842 2854 .nav
@@ -82,8 +82,21 @@
82 82 /// The main window, which is what all four of these are drawn over. Same finding
83 83 /// as [`bulk`](super::bulk)'s first: this navigates rather than dismisses,
84 84 /// because there is no described way to close what is on top.
85 + ///
86 + /// Navigating is not enough on its own, which the flip found (2026-08-22).
87 + /// **What keeps one of these on screen is the host's own flag**, not the
88 + /// runtime's layer stack: `vfs_modal`'s two bools and two targets are what the
89 + /// app checks before drawing anything, so a route that navigates away and says
90 + /// nothing else leaves the window up with the main screen inside it. So every
91 + /// exit goes through [`DONE`] first, which is `integrity`'s `dismiss` in a
92 + /// second consumer: a route whose whole job is to tell the host the screen is
93 + /// finished with. That is the app's answer to the finding above, not the
94 + /// vocabulary's -- an `Outcome` meaning "this overlay is done" is still missing.
85 95 const BACK: &str = "/";
86 96
97 + /// The route that says a modal is finished with, whichever of the four it was.
98 + const DONE: &str = "/naming/done";
99 +
87 100 /// Register the four modals' routes.
88 101 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
89 102 router
@@ -95,6 +108,7 @@
95 108 .post("/folders/new", new_folder)
96 109 .get("/folders/{id}/rename", rename_folder_screen)
97 110 .post("/folders/{id}/rename", rename_folder)
111 + .post(DONE, done)
98 112 }
99 113
100 114 /// What one of these modals asks.
@@ -124,7 +138,7 @@
124 138
125 139 /// `POST /vaults/new`
126 140 fn new_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
127 - submitted(&request, &asking_new_vault(), |name| {
141 + submitted(state, &request, &asking_new_vault(), |name| {
128 142 state.naming.create_vault(name)
129 143 })
130 144 }
@@ -160,7 +174,7 @@
160 174 /// `POST /vaults/{id}/rename`
161 175 fn rename_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
162 176 let id = numbered(&request)?;
163 - submitted(&request, &asking_rename_vault(id), |name| {
177 + submitted(state, &request, &asking_rename_vault(id), |name| {
164 178 state.naming.rename_vault(id, name)
165 179 })
166 180 }
@@ -185,7 +199,7 @@
185 199
186 200 /// `POST /folders/new`
187 201 fn new_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
188 - submitted(&request, &asking_new_folder(), |name| {
202 + submitted(state, &request, &asking_new_folder(), |name| {
189 203 state.naming.create_folder(name)
190 204 })
191 205 }
@@ -216,7 +230,7 @@
216 230 /// `POST /folders/{id}/rename`
217 231 fn rename_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
218 232 let id = numbered(&request)?;
219 - submitted(&request, &asking_rename_folder(id), |name| {
233 + submitted(state, &request, &asking_rename_folder(id), |name| {
220 234 state.naming.rename_folder(id, name)
221 235 })
222 236 }
@@ -233,6 +247,14 @@
233 247 }
234 248 }
235 249
250 + /// `POST /naming/done`
251 + ///
252 + /// Tell the host the modal is finished with, then leave. See [`DONE`].
253 + fn done(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
254 + state.naming.done();
255 + Ok(Response::from(leaving()))
256 + }
257 +
236 258 /// The id a request names.
237 259 fn numbered(request: &Request) -> Result<i64, RouteError> {
238 260 request
@@ -253,16 +275,21 @@
253 275 /// and the reason none of these fields is `required`: the marker would claim a
254 276 /// refusal that never happens.
255 277 fn submitted(
278 + state: &Panels<'_>,
256 279 request: &Request,
257 280 asking: &Asking,
258 281 commit: impl FnOnce(&str) -> Result<String, String>,
259 282 ) -> Result<Response, RouteError> {
260 283 let typed = request.payload.get(NAME).unwrap_or_default().trim();
261 284 if typed.is_empty() {
285 + state.naming.done();
262 286 return Ok(Response::from(leaving()));
263 287 }
264 288 match commit(typed) {
265 - Ok(say) => Ok(Response::from(leaving()).toast(Tone::Success, say)),
289 + Ok(say) => {
290 + state.naming.done();
291 + Ok(Response::from(leaving()).toast(Tone::Success, say))
292 + }
266 293 // The modal stays up with the name still in it. See the module header:
267 294 // a fragment rather than a second `Over`.
268 295 Err(why) => Ok(Response::from(Outcome::Fragment {
@@ -280,7 +307,7 @@
280 307 }
281 308 let body = body
282 309 .with(form(asking, value, error))
283 - .with(Node::Act(Act::new("Cancel", Action::get(BACK)).key("esc")));
310 + .with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc")));
284 311
285 312 Response::from(Outcome::Over(
286 313 Screen::sidebar_content(asking.title).with(body),
@@ -60,6 +60,7 @@
60 60 sweep: Option<Runtime>,
61 61 filters: Option<Runtime>,
62 62 integrity: Option<Runtime>,
63 + naming: Option<Runtime>,
63 64 /// Whether the described main window is open.
64 65 pub show_shell: bool,
65 66 /// Whether the described detail panel is open.
@@ -489,6 +490,50 @@
489 490 }
490 491 }
491 492
493 + /// Draw one of the four name modals, and act on whatever was pressed.
494 + ///
495 + /// One function for four screens, because they are one screen four times: a
496 + /// field, a submit and a cancel, differing only in what they are called and in
497 + /// what the submit does. The shipped side already knew that, and said it by
498 + /// sharing `handle_name_modal_outcome` between four functions rather than by
499 + /// sharing an address.
500 + ///
501 + /// `title` and `home` come from the caller because the app is what knows which
502 + /// of the four is showing: `vfs_modal`'s two flags and two targets still decide,
503 + /// and those are the host's own state rather than anything a route reads.
504 + ///
505 + /// Refreshed unconditionally. A refusal replaces the form region and the field
506 + /// keeps what was typed, which is `naming`'s `Fragment`, and a screen that is
507 + /// not re-asked shows the answer before it.
508 + pub fn draw_naming(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) {
509 + let intents = RefCell::new(Vec::new());
510 + let mut runtime = state.described.naming.take();
511 + let host = Host {
512 + state,
513 + sync: None,
514 + themes: themes(),
515 + intents: &intents,
516 + };
517 + let closed = window(ctx, title, &mut runtime, &host, home, true);
518 + state.described.naming = runtime;
519 + let finished = intents
520 + .borrow()
521 + .iter()
522 + .any(|i| matches!(i, Intent::NamingDone));
523 + apply(ctx, state, None, intents.into_inner());
524 + // The window's own X is the fourth way out, beside Cancel, an empty submit
525 + // and a successful one. It raises no intent, so it is answered here with
526 + // what `Intent::NamingDone` would have done.
527 + if closed || finished {
528 + state.described.naming = None;
529 + state.vfs_modal.show_vfs_create = false;
530 + state.vfs_modal.show_dir_create = false;
531 + state.vfs_modal.vfs_rename_target = None;
532 + state.vfs_modal.dir_rename_target = None;
533 + state.vfs_modal.name_modal_error = None;
534 + }
535 + }
536 +
492 537 /// Do what a described screen asked the app to do to itself.
493 538 ///
494 539 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -919,6 +964,15 @@
919 964 // is the one place this port does that and `naming`'s header is
920 965 // why: the store's refusal has to reach the field it was typed
921 966 // into. What is left is the half a route cannot do.
967 + // Whichever of the four was up, and the error with it: the modal is
968 + // finished with, so a refusal it was showing is finished with too.
969 + Intent::NamingDone => {
970 + state.vfs_modal.show_vfs_create = false;
971 + state.vfs_modal.show_dir_create = false;
972 + state.vfs_modal.vfs_rename_target = None;
973 + state.vfs_modal.dir_rename_target = None;
974 + state.vfs_modal.name_modal_error = None;
975 + }
922 976 Intent::VaultsChanged(say) => {
923 977 state.refresh_vfs_list();
924 978 state.status = say;
@@ -545,6 +545,24 @@
545 545 .dropping("Close window")
546 546 }
547 547
548 + /// A field the description names and `makeover-immediate` draws unnamed.
549 + ///
550 + /// `makeover_immediate::field` draws `Field::label` as a `ui.label` above
551 + /// the control and never associates the two, so what reaches the widget tree
552 + /// is an unnamed box with some text near it. A screen reader announces a
553 + /// text field with no question, and this reader -- which drops a control
554 + /// with nothing to say -- sees no control at all.
555 + ///
556 + /// The same gap the shipped panels have, inherited. It is worse here only
557 + /// because the description **knows** the label: `Field::label` is right
558 + /// there, and `egui::Response::labelled_by` is the one call that would
559 + /// attach it. Filed against makeover-immediate; until it is fixed and
560 + /// published, every described field is an allowance at a call site.
561 + #[must_use]
562 + pub(super) fn unnamed_field(self, label: &str) -> Self {
563 + self.gaining(label)
564 + }
565 +
548 566 /// Assert the two sides offer the same thing, panicking with a diff if not.
549 567 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
550 568 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
@@ -769,3 +787,92 @@
769 787 .in_a_window("Loose-files mode warning")
770 788 .assert(&described, &drawn);
771 789 }
790 +
791 + /// The four name modals: what each is called, and where it is served from.
792 + ///
793 + /// A table rather than four tests, because they are one screen four times and
794 + /// the flip's claim is exactly that.
795 + #[test]
796 + fn the_four_name_modals_serve_what_they_describe() {
797 + type Show = fn(&mut crate::state::BrowserState);
798 +
799 + let modals: [(&str, &str, Show); 4] = [
800 + ("New Vault", "/vaults/new", |state| {
801 + state.vfs_modal.show_vfs_create = true;
802 + }),
803 + ("Rename Vault", "/vaults/{id}/rename", |state| {
804 + let vault = state.nav.vfs_list[0].clone();
805 + state.vfs_modal.vfs_rename_target = Some((vault.id, vault.name));
806 + }),
807 + ("New Folder", "/folders/new", |state| {
808 + state.vfs_modal.show_dir_create = true;
809 + }),
810 + ("Rename", "/folders/{id}/rename", |state| {
811 + let folder = state.nav.contents[0].node.clone();
812 + state.vfs_modal.dir_rename_target = Some((folder.id, folder.name));
813 + }),
814 + ];
815 +
816 + // What each modal asks, and what the renderer does not attach to the box.
817 + // See `Parity::unnamed_field`.
818 + let asked = ["Vault name", "New name", "Folder name", "New name"];
819 +
820 + for ((title, address, show), asked) in modals.into_iter().zip(asked) {
821 + let (mut state, _dir) = fixture();
822 + // A folder to rename, which the sample-only fixture does not have.
823 + let vault = state.current_vfs_id().unwrap();
824 + let parent = state.nav.current_dir;
825 + state
826 + .backend
827 + .create_directory(vault, parent, "drums")
828 + .unwrap();
829 + state.refresh_contents();
830 + show(&mut state);
831 +
832 + // What a prefilled unnamed box is announced as: its own contents. The
833 + // other half of `unnamed_field` -- with no label attached, a rename
834 + // modal's field reports the name it was seeded with.
835 + let prefilled = state
836 + .vfs_modal
837 + .vfs_rename_target
838 + .as_ref()
839 + .map(|(_, name)| name.clone())
840 + .or_else(|| {
841 + state
842 + .vfs_modal
843 + .dir_rename_target
844 + .as_ref()
845 + .map(|(_, name)| name.clone())
846 + });
847 +
848 + let address = address.replace("{id}", &real_id(&state, address).to_string());
849 + let described = described(&super::panel::described_screen(&state, &address));
850 + let drawn = shipped(|ui| {
851 + super::panel::draw_naming(ui.ctx(), &mut state, title, &address);
852 + });
853 +
854 + described.addresses_resolve();
855 + let mut parity = Parity::strict().in_a_window(title).unnamed_field(asked);
856 + if let Some(prefilled) = &prefilled {
857 + parity = parity.dropping(prefilled);
858 + }
859 + parity.assert(&described, &drawn);
860 + }
861 + }
862 +
863 + /// The id the rename addresses need, read off the fixture.
864 + fn real_id(state: &crate::state::BrowserState, address: &str) -> i64 {
865 + if address.starts_with("/vaults/") {
866 + state.nav.vfs_list[0].id.as_i64()
867 + } else {
868 + state
869 + .nav
870 + .contents
871 + .iter()
872 + .map(|node| &node.node)
873 + .find(|node| node.sample_hash.is_none())
874 + .expect("the fixture makes a folder")
875 + .id
876 + .as_i64()
877 + }
878 + }
@@ -5006,6 +5006,8 @@
5006 5006 struct Unnamed;
5007 5007
5008 5008 impl Naming for Unnamed {
5009 + fn done(&self) {}
5010 +
5009 5011 fn vault(&self, _id: i64) -> Option<String> {
5010 5012 None
5011 5013 }
@@ -5101,6 +5103,12 @@
5101 5103 folders: Vec<(i64, String)>,
5102 5104 refusing: Option<String>,
5103 5105 asked: RefCell<Vec<String>>,
5106 + /// Whether the modal was told it is finished with.
5107 + ///
5108 + /// Its own field rather than a row in `asked`, because it is not a naming
5109 + /// operation: `asked` answers "what did this screen do to the tree", and
5110 + /// telling the host to put a window away does nothing to the tree.
5111 + finished: std::cell::Cell<bool>,
5104 5112 }
5105 5113
5106 5114 impl FakeNaming {
@@ -5123,6 +5131,10 @@
5123 5131 self.asked.borrow().clone()
5124 5132 }
5125 5133
5134 + fn finished(&self) -> bool {
5135 + self.finished.get()
5136 + }
5137 +
5126 5138 fn did(&self, what: String) -> Result<String, String> {
5127 5139 self.asked.borrow_mut().push(what.clone());
5128 5140 match &self.refusing {
@@ -5133,6 +5145,10 @@
5133 5145 }
5134 5146
5135 5147 impl Naming for FakeNaming {
5148 + fn done(&self) {
5149 + self.finished.set(true);
5150 + }
5151 +
5136 5152 fn vault(&self, id: i64) -> Option<String> {
5137 5153 self.vaults
5138 5154 .iter()
@@ -5317,6 +5333,9 @@
5317 5333
5318 5334 assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}");
5319 5335 assert!(namer.asked().is_empty());
5336 + // Leaving the address is not leaving the screen: the host's own flag is
5337 + // what keeps a name modal up, so every exit says so. See `naming`'s `DONE`.
5338 + assert!(namer.finished());
5320 5339 }
5321 5340
5322 5341 #[test]
@@ -1,7 +1,7 @@
1 1 //! Overlay windows: help dialog, delete confirmation, and bulk operation modals.
2 2
3 3 use super::theme;
4 - use super::widgets::{self, ConfirmOutcome, ConfirmSpec, NameModalOutcome};
4 + use super::widgets::{self, ConfirmOutcome, ConfirmSpec};
5 5 use egui;
6 6
7 7 use crate::state::{BrowserState, BulkModal, ConfirmAction};
@@ -845,196 +845,6 @@
845 845 }
846 846 }
847 847
848 - /// Shared outcome handling for the four name modals (create/rename × vault/folder).
849 - ///
850 - /// Each modal draws the same `name_modal` widget and then treats its outcome the
851 - /// same way: an empty or cancelled submit closes the modal; a non-empty submit
852 - /// runs the create/rename op, showing its status line on success or surfacing the
853 - /// error inline (C-3) while leaving the modal open with the typed name intact.
854 - /// Only the static labels, the name buffer, `close`, and `commit` differ per modal.
855 - ///
856 - /// `close` resets the modal (hides it, or clears its rename target); `commit`
857 - /// performs the op and returns the status line to show, or an error string to
858 - /// surface inline.
859 - fn handle_name_modal_outcome(
860 - outcome: NameModalOutcome,
861 - state: &mut BrowserState,
862 - close: impl FnOnce(&mut BrowserState),
863 - commit: impl FnOnce(&mut BrowserState, String) -> Result<String, String>,
864 - ) {
865 - match outcome {
866 - NameModalOutcome::Submitted(name) => {
867 - if name.is_empty() {
868 - // Empty submit = no-op cancel. Close without erroring.
869 - close(state);
870 - state.vfs_modal.name_modal_error = None;
871 - } else {
872 - match commit(state, name) {
873 - Ok(status) => {
874 - state.status = status;
875 - close(state);
876 - state.vfs_modal.name_modal_error = None;
877 - }
878 - // C-3: keep the modal open; surface the error inline so the
879 - // user can edit and retry without re-typing the name.
880 - Err(msg) => state.vfs_modal.name_modal_error = Some(msg),
881 - }
882 - }
883 - }
884 - NameModalOutcome::Cancelled => {
885 - close(state);
886 - state.vfs_modal.name_modal_error = None;
887 - }
888 - NameModalOutcome::None => {}
889 - }
890 - }
891 -
892 - /// Draw the "New Vault" modal: text input for vault name.
893 - pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
894 - // What a vault is, and how to nest one, are facts about the modal rather
895 - // than about the name being typed, so they stay a lead-in above the field.
896 - let lead_in = "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.";
897 - // C-3: clone the error into a local so the &mut input borrow doesn't
898 - // conflict with the immutable error borrow into name_modal.
899 - let error_owned = state.vfs_modal.name_modal_error.clone();
900 - let outcome = widgets::name_modal(
901 - ctx,
902 - &widgets::NameModalSpec {
903 - title: "New Vault",
904 - lead_in: Some(lead_in),
905 - label: "Vault name",
906 - hint: None,
907 - placeholder: None,
908 - submit_label: "Create",
909 - error: error_owned.as_deref(),
910 - },
911 - &mut state.vfs_modal.vfs_create_input,
912 - );
913 - handle_name_modal_outcome(
914 - outcome,
915 - state,
916 - |s| s.vfs_modal.show_vfs_create = false,
917 - |s, name| {
918 - s.backend.create_vfs(&name).map_err(|e| format!("{e}"))?;
919 - s.refresh_vfs_list();
920 - Ok(format!("Created vault: {name}"))
921 - },
922 - );
923 - }
924 -
925 - /// Draw the "Rename Vault" modal: text input pre-filled with current name.
926 - pub fn draw_vfs_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
927 - let error_owned = state.vfs_modal.name_modal_error.clone();
928 - let (outcome, vfs_id) = if let Some((id, ref mut name_buf)) = state.vfs_modal.vfs_rename_target
929 - {
930 - (
931 - widgets::name_modal(
932 - ctx,
933 - &widgets::NameModalSpec {
934 - title: "Rename Vault",
935 - lead_in: None,
936 - label: "New name",
937 - // About the answer, so it is the field's hint.
938 - hint: Some("Vault names can contain spaces."),
939 - placeholder: None,
940 - submit_label: "Save",
941 - error: error_owned.as_deref(),
942 - },
943 - name_buf,
944 - ),
945 - id,
946 - )
947 - } else {
948 - return;
949 - };
950 - handle_name_modal_outcome(
951 - outcome,
952 - state,
953 - |s| s.vfs_modal.vfs_rename_target = None,
954 - move |s, new_name| {
955 - s.backend
956 - .rename_vfs(vfs_id, &new_name)
957 - .map_err(|e| format!("{e}"))?;
958 - s.refresh_vfs_list();
959 - Ok(format!("Renamed vault to: {new_name}"))
960 - },
961 - );
962 - }
963 -
964 - /// Draw the "New Folder" modal: text input for folder name.
965 - pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
966 - let error_owned = state.vfs_modal.name_modal_error.clone();
967 - let outcome = widgets::name_modal(
968 - ctx,
969 - &widgets::NameModalSpec {
970 - title: "New Folder",
971 - lead_in: None,
972 - label: "Folder name",
973 - // A constraint on the answer, so it is the field's hint.
974 - hint: Some("Folder names cannot contain /"),
975 - placeholder: None,
976 - submit_label: "Create",
977 - error: error_owned.as_deref(),
978 - },
979 - &mut state.vfs_modal.dir_create_input,
980 - );
981 - let vfs_id = state.current_vfs_id();
982 - let current_dir = state.nav.current_dir;
983 - handle_name_modal_outcome(
984 - outcome,
985 - state,
986 - |s| s.vfs_modal.show_dir_create = false,
987 - move |s, name| {
988 - // The New Folder modal is only reachable inside a vault, so this is a
989 - // defensive guard rather than a real path.
990 - let vfs_id = vfs_id.ok_or_else(|| "No vault selected".to_string())?;
991 - s.backend
992 - .create_directory(vfs_id, current_dir, &name)
993 - .map_err(|e| format!("{e}"))?;
994 - s.refresh_contents();
995 - Ok(format!("Created folder: {name}"))
996 - },
997 - );
998 - }
999 -
1000 - /// Draw the "Rename Folder" modal: text input pre-filled with current name.
1001 - pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
1002 - let error_owned = state.vfs_modal.name_modal_error.clone();
1003 - let (outcome, node_id) = if let Some((id, ref mut name_buf)) = state.vfs_modal.dir_rename_target
1004 - {
1005 - (
1006 - widgets::name_modal(
1007 - ctx,
1008 - &widgets::NameModalSpec {
1009 - title: "Rename",
1010 - lead_in: None,
1011 - label: "New name",
1012 - hint: None,
1013 - placeholder: None,
1014 - submit_label: "Save",
1015 - error: error_owned.as_deref(),
1016 - },
1017 - name_buf,
1018 - ),
1019 - id,
1020 - )
1021 - } else {
1022 - return;
1023 - };
1024 - handle_name_modal_outcome(
1025 - outcome,
1026 - state,
1027 - |s| s.vfs_modal.dir_rename_target = None,
1028 - move |s, new_name| {
1029 - s.backend
1030 - .rename_node(node_id, &new_name)
1031 - .map_err(|e| format!("{e}"))?;
1032 - s.refresh_contents();
1033 - Ok(format!("Renamed to: {new_name}"))
1034 - },
1035 - );
1036 - }
1037 -
1038 848 #[cfg(test)]
1039 849 mod tests {
1040 850 use super::*;