Skip to main content

max / audiofiles

Describe three more forms, and give group() its first callers The import strategy, the classifier rule name, and the encryption password form become described fields. All three were labelled questions already; what they gained is the thing the description carries and the hand-rolled versions could not: an error that sits on the field it is about. - import strategy is a Radio, not a Select. It decides where every imported file lands and cannot be revised without re-importing, so the three answers have to be readable without opening anything. The vault name and the merge target follow it through group(), which had no caller until now: the gap between a question and its follow-up is the field style's group_gap rather than a number chosen per screen. - the strategy is now re-derived from the answers every frame instead of rebuilt at each edit site. Editing the vault name was the only site that remembered to. - "Enter a name for the new vault" and "No existing vaults to merge into" moved off the disabled Import button's hover, which only a pointer could find, onto the fields. - the rule name had no well at all, alone among the app's text inputs. Its error is gated on RuleDraft::save_refused rather than on being empty, so a freshly opened editor is not already complaining, and the refusal reaches the field instead of only the status line at the far end of the window. - the encryption pair are Secret, so the renderer masks them because of what the question is rather than because each call site passed a flag. encryption_submit_state returns the two messages apart: a short password is wrong with the password, a mismatch is only ever wrong with the confirmation, and one hint under both could not say so. Deliberately not converted, so they are not reopened as oversights: the per-tag threshold sliders and the storage cap (no FieldKind names a bounded number, filed separately), the condition and action rows (a row of controls composing one value, and group() is a column), the export name and the add-a-tag input (inline affordances, not questions), and the All/Any match mode (a control inside a sentence). 1279 tests pass, clippy clean, fmt clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 00:16 UTC
Signed with PGP, not checked
Commit: 5a68342c93b056ee0971e018112a4ed78ee4ec2c
Parent: 081bbb9
6 files changed, +325 insertions, -158 deletions
@@ -60,6 +60,7 @@
60 60 priority: 0,
61 61 created_at: 0,
62 62 match_count: None,
63 + save_refused: false,
63 64 });
64 65 }
65 66
@@ -75,6 +76,7 @@
75 76 priority: rule.priority,
76 77 created_at: rule.created_at,
77 78 match_count: None,
79 + save_refused: false,
78 80 });
79 81 }
80 82
@@ -122,6 +124,12 @@
122 124 };
123 125 if draft.name.trim().is_empty() {
124 126 self.status = "Name the rule before saving.".to_string();
127 + // Also on the field, which is where the user is looking. The status
128 + // line is at the other end of the window and says nothing about
129 + // which of the form's inputs it means.
130 + if let Some(draft) = self.classifier.editing.as_mut() {
131 + draft.save_refused = true;
132 + }
125 133 return;
126 134 }
127 135 let result = match draft.id {
@@ -401,6 +401,14 @@
401 401 pub created_at: i64,
402 402 /// Cached dry-run match count (recomputed when the draft changes).
403 403 pub match_count: Option<usize>,
404 + /// Whether Save has been pressed on this draft and refused.
405 + ///
406 + /// What the name field's error is gated on. A form that shows "this is
407 + /// required" before the user has done anything is scolding an empty form;
408 + /// one that shows it after Save declined is answering the question the user
409 + /// just asked. The refusal itself lives in `classifier_save_draft`, which
410 + /// sets this rather than only writing to the status line.
411 + pub save_refused: bool,
404 412 }
405 413
406 414 /// VFS navigation: the vault list, current location, folder contents, row
@@ -3,6 +3,8 @@
3 3 //! reconciliation go through the `classifier_*` methods on `BrowserState`.
4 4
5 5 use egui;
6 + use makeover_immediate;
7 + use makeover_layout;
6 8
7 9 use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp};
8 10
@@ -345,10 +347,32 @@
345 347 return;
346 348 };
347 349
348 - ui.horizontal(|ui| {
349 - ui.label("Name");
350 - ui.text_edit_singleline(&mut draft.name);
351 - });
350 + // A described field: a labelled question the form refuses to submit
351 + // without, and the one input here that had no well at all — a bare
352 + // `text_edit_singleline` next to every other text input in the app
353 + // being a well. The error is `save_refused` rather than "empty" so a
354 + // freshly opened editor is not already complaining.
355 + let name_field = makeover_layout::Field {
356 + required: true,
357 + error: (draft.save_refused && draft.name.trim().is_empty())
358 + .then_some("Name the rule before saving."),
359 + placeholder: Some("Kick drums"),
360 + ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "rule_name", "Name")
361 + };
362 + if widgets::field(
363 + ui,
364 + &name_field,
365 + makeover_immediate::Filling::Text(&mut draft.name),
366 + None,
367 + )
368 + .is_some_and(|response| response.changed())
369 + {
370 + draft.save_refused = false;
371 + }
372 + ui.add_space(theme::space::bound());
373 + // A bare toggle, not a field: no help, no validation, no options, and
374 + // under `field()` it would be a checkbox plus a required marker at
375 + // three times the source.
352 376 ui.checkbox(&mut draft.enabled, "Enabled");
353 377
354 378 ui.horizontal(|ui| {
@@ -1,6 +1,8 @@
1 1 //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2 2
3 3 use egui;
4 + use makeover_immediate;
5 + use makeover_layout;
4 6 use tracing::{error, warn};
5 7
6 8 use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
@@ -30,28 +32,60 @@
30 32 }
31 33 }
32 34
33 - /// Decide whether the encryption form can submit, and the inline hint to show.
35 + /// Whether the encryption form can submit, and what is wrong with which field.
36 + ///
37 + /// The two messages are held apart rather than returned as one hint because
38 + /// they belong to different questions: a password too short is wrong with the
39 + /// password, and a mismatch is only ever wrong with the confirmation. A
40 + /// described field carries its own error, so a single message would have to be
41 + /// drawn detached from both fields to stay truthful — which is what this form
42 + /// did before it was described.
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 + struct EncryptionForm {
45 + can_submit: bool,
46 + password_error: Option<&'static str>,
47 + confirm_error: Option<&'static str>,
48 + }
49 +
50 + impl EncryptionForm {
51 + const SILENT: Self = Self {
52 + can_submit: false,
53 + password_error: None,
54 + confirm_error: None,
55 + };
56 + }
57 +
58 + /// Decide whether the encryption form can submit, and which field is wrong.
34 59 /// Unlock (`has_server_key`) only needs a non-empty password. First-time setup
35 - /// requires a >=8-char password confirmed by a matching second entry; the hint
36 - /// is `None` while the user is still mid-entry (so no error flashes prematurely).
37 - fn encryption_submit_state(
38 - has_server_key: bool,
39 - pw: &str,
40 - confirm: &str,
41 - ) -> (bool, Option<&'static str>) {
60 + /// requires a >=8-char password confirmed by a matching second entry; both
61 + /// errors stay `None` while the user is still mid-entry (so no error flashes
62 + /// prematurely).
63 + fn encryption_submit_state(has_server_key: bool, pw: &str, confirm: &str) -> EncryptionForm {
42 64 if has_server_key {
43 - return (!pw.is_empty(), None);
65 + return EncryptionForm {
66 + can_submit: !pw.is_empty(),
67 + ..EncryptionForm::SILENT
68 + };
44 69 }
45 70 if pw.is_empty() {
46 - (false, None)
71 + EncryptionForm::SILENT
47 72 } else if pw.len() < 8 {
48 - (false, Some("Password must be at least 8 characters."))
73 + EncryptionForm {
74 + password_error: Some("Password must be at least 8 characters."),
75 + ..EncryptionForm::SILENT
76 + }
49 77 } else if confirm.is_empty() {
50 - (false, None)
78 + EncryptionForm::SILENT
51 79 } else if pw != confirm {
52 - (false, Some("Passwords don't match."))
80 + EncryptionForm {
81 + confirm_error: Some("Passwords don't match."),
82 + ..EncryptionForm::SILENT
83 + }
53 84 } else {
54 - (true, None)
85 + EncryptionForm {
86 + can_submit: true,
87 + ..EncryptionForm::SILENT
88 + }
55 89 }
56 90 }
57 91
@@ -446,46 +480,50 @@
446 480 }
447 481
448 482 ui.add_space(theme::space::group());
449 - ui.horizontal(|ui| {
450 - ui.label("Password:");
451 - widgets::text_field(
452 - ui,
453 - egui::TextEdit::singleline(&mut state.sync.encryption_input)
454 - .password(true)
455 - .desired_width(200.0),
456 - );
457 - });
458 483
459 - // First-time setup: confirm field + length gate. The unlock path doesn't
460 - // need confirmation, a typo there is recoverable (just re-enter).
461 - let (can_submit, hint): (bool, Option<&str>) = if has_server_key {
462 - encryption_submit_state(true, &state.sync.encryption_input, "")
463 - } else {
464 - ui.add_space(theme::space::bound());
465 - ui.horizontal(|ui| {
466 - ui.label("Confirm: ");
467 - widgets::text_field(
468 - ui,
469 - egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input)
470 - .password(true)
471 - .desired_width(200.0),
472 - );
473 - });
474 - encryption_submit_state(
475 - false,
476 - &state.sync.encryption_input,
477 - &state.sync.encryption_confirm_input,
484 + // A described form, and the app's clearest case for one: two labelled
485 + // questions the form refuses to submit without, each with its own rule
486 + // about what a wrong answer is. `Secret` rather than `Text` for both --
487 + // the renderer masks a confidential kind without being asked, which is
488 + // what makes the masking a property of the question here rather than a
489 + // flag every call site has to remember.
490 + //
491 + // First-time setup gets the confirmation; the unlock path does not, because
492 + // a typo there is recoverable by re-entering.
493 + let form = encryption_submit_state(
494 + has_server_key,
495 + &state.sync.encryption_input,
496 + &state.sync.encryption_confirm_input,
497 + );
498 + let mut fields = vec![makeover_layout::Field {
499 + required: true,
500 + error: form.password_error,
501 + ..makeover_layout::Field::new(
502 + makeover_layout::FieldKind::Secret,
503 + "encryption_password",
504 + "Password",
478 505 )
479 - };
480 -
481 - if let Some(msg) = hint {
482 - ui.add_space(theme::space::hair());
483 - ui.label(
484 - egui::RichText::new(msg)
485 - .small()
486 - .color(theme::content_muted()),
487 - );
506 + }];
507 + if !has_server_key {
508 + fields.push(makeover_layout::Field {
509 + required: true,
510 + error: form.confirm_error,
511 + ..makeover_layout::Field::new(
512 + makeover_layout::FieldKind::Secret,
513 + "encryption_confirm",
514 + "Confirm password",
515 + )
516 + });
488 517 }
518 + widgets::group(ui, &fields, false, |ui, field| {
519 + let filling = if field.name == "encryption_password" {
520 + makeover_immediate::Filling::Text(&mut state.sync.encryption_input)
521 + } else {
522 + makeover_immediate::Filling::Text(&mut state.sync.encryption_confirm_input)
523 + };
524 + widgets::field(ui, field, filling, None);
525 + });
526 + let can_submit = form.can_submit;
489 527
490 528 ui.add_space(theme::space::peer());
491 529 let button_label = if has_server_key {
@@ -784,17 +822,29 @@
784 822
785 823 #[test]
786 824 fn unlock_needs_only_a_nonempty_password() {
787 - assert_eq!(encryption_submit_state(true, "", ""), (false, None));
788 - assert_eq!(encryption_submit_state(true, "x", ""), (true, None));
825 + assert_eq!(
826 + encryption_submit_state(true, "", ""),
827 + EncryptionForm::SILENT
828 + );
829 + assert_eq!(
830 + encryption_submit_state(true, "x", ""),
831 + EncryptionForm {
832 + can_submit: true,
833 + ..EncryptionForm::SILENT
834 + }
835 + );
789 836 }
790 837
791 838 #[test]
792 839 fn setup_holds_hint_silent_until_a_rule_is_broken() {
793 840 // Empty password and empty confirm are mid-entry states, not errors.
794 - assert_eq!(encryption_submit_state(false, "", ""), (false, None));
841 + assert_eq!(
842 + encryption_submit_state(false, "", ""),
843 + EncryptionForm::SILENT
844 + );
795 845 assert_eq!(
796 846 encryption_submit_state(false, "longenough", ""),
797 - (false, None)
847 + EncryptionForm::SILENT
798 848 );
799 849 }
800 850
@@ -802,15 +852,37 @@
802 852 fn setup_enforces_length_and_match() {
803 853 assert_eq!(
804 854 encryption_submit_state(false, "short", "short"),
805 - (false, Some("Password must be at least 8 characters.")),
855 + EncryptionForm {
856 + password_error: Some("Password must be at least 8 characters."),
857 + ..EncryptionForm::SILENT
858 + },
806 859 );
807 860 assert_eq!(
808 861 encryption_submit_state(false, "longenough", "different"),
809 - (false, Some("Passwords don't match.")),
862 + EncryptionForm {
863 + confirm_error: Some("Passwords don't match."),
864 + ..EncryptionForm::SILENT
865 + },
810 866 );
811 867 assert_eq!(
812 868 encryption_submit_state(false, "longenough", "longenough"),
813 - (true, None)
869 + EncryptionForm {
870 + can_submit: true,
871 + ..EncryptionForm::SILENT
872 + }
814 873 );
815 874 }
875 +
876 + /// Each message belongs to the field it is about. A single hint could only
877 + /// sit under both, which is what the described form replaced.
878 + #[test]
879 + fn a_mismatch_is_wrong_with_the_confirmation_not_the_password() {
880 + let form = encryption_submit_state(false, "longenough", "different");
881 + assert_eq!(form.password_error, None);
882 + assert!(form.confirm_error.is_some());
883 +
884 + let form = encryption_submit_state(false, "short", "short");
885 + assert!(form.password_error.is_some());
886 + assert_eq!(form.confirm_error, None);
887 + }
816 888 }
@@ -451,6 +451,25 @@
451 451 makeover_immediate::field(ui, field, filling, state, &theme::palette(), &field_style())
452 452 }
453 453
454 + /// A set of described fields laid down a column, at this app's field geometry.
455 + ///
456 + /// [`field`]'s wrapper for the same reason, and one more: the gap between one
457 + /// field and the next is `field_style().group_gap`, so a form that lays its own
458 + /// fields out with `add_space` picks a number instead of using the one the
459 + /// style already names. Drawing the set through here is what keeps the spacing
460 + /// inside a form and the spacing between forms from drifting apart.
461 + ///
462 + /// `draw` is called once per visible field, in order; `show_extended` is the
463 + /// form's disclosure, which the app owns rather than any one field.
464 + pub fn group<'a>(
465 + ui: &mut egui::Ui,
466 + fields: &'a [makeover_layout::Field<'a>],
467 + show_extended: bool,
468 + draw: impl FnMut(&mut egui::Ui, &'a makeover_layout::Field<'a>),
469 + ) {
470 + makeover_immediate::group(ui, fields, show_extended, &field_style(), draw);
471 + }
472 +
454 473 /// Inline informational banner: raised card, body text in `content_secondary`.
455 474 /// Used for one-time tips and unobtrusive panel notices.
456 475 pub fn info_banner(ui: &mut egui::Ui, body: &str) {
@@ -2,6 +2,8 @@
2 2
3 3 use super::super::{theme, widgets};
4 4 use egui;
5 + use makeover_immediate;
6 + use makeover_layout;
5 7
6 8 use crate::import::ImportStrategy;
7 9 use crate::state::{BrowserState, ImportMode};
@@ -58,110 +60,144 @@
58 60 );
59 61 ui.add_space(theme::space::group());
60 62
61 - ui.label("Import strategy:");
62 - ui.add_space(theme::space::bound());
63 -
64 - let is_flat = matches!(
65 - &state.import_wf.import_mode,
66 - ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. }
67 - );
68 - let is_new_vfs = matches!(
69 - &state.import_wf.import_mode,
70 - ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. }
71 - );
72 - let is_merge = matches!(
73 - &state.import_wf.import_mode,
74 - ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. }
75 - );
63 + // The strategy and the one input it opens are a described form, drawn
64 + // as a `group` so the spacing between the question and its follow-up is
65 + // the field style's own `group_gap` rather than a number chosen here.
66 + //
67 + // A `Radio` and not a `Select`, for the reason the kind exists: this
68 + // decides where every imported file lands and it cannot be revised
69 + // afterwards without re-importing, so the three answers have to be
70 + // readable without opening anything.
71 + const STRATEGIES: [makeover_layout::Choice<'static>; 3] = [
72 + makeover_layout::Choice {
73 + value: "flat",
74 + label: "Flat (all files in current directory)",
75 + },
76 + makeover_layout::Choice {
77 + value: "new",
78 + label: "New vault (preserve directory structure)",
79 + },
80 + makeover_layout::Choice {
81 + value: "merge",
82 + label: "Merge into existing vault",
83 + },
84 + ];
76 85
86 + // The description names its answers by string and this app holds a
87 + // strategy enum, a name and an index, so the two are marshalled here
88 + // rather than by reshaping the state. Same division as the storage-style
89 + // choice in `settings_panel`: the question is described, the app's
90 + // encoding of the answer stays private.
77 91 let current_vfs_id = state.current_vfs_id();
78 92 let current_dir = state.nav.current_dir;
79 - if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat
80 - && let (ImportMode::ConfigureImport { strategy, .. }, Some(vfs_id)) =
81 - (&mut state.import_wf.import_mode, current_vfs_id)
82 - {
83 - *strategy = ImportStrategy::Flat {
84 - vfs_id,
85 - parent_id: current_dir,
86 - };
87 - }
93 + let ImportMode::ConfigureImport {
94 + strategy,
95 + new_vfs_name,
96 + available_vfs,
97 + selected_merge_vfs_idx,
98 + ..
99 + } = &state.import_wf.import_mode
100 + else {
101 + return;
102 + };
103 + let mut strategy_value = String::from(match strategy {
104 + ImportStrategy::Flat { .. } => "flat",
105 + ImportStrategy::NewVfs { .. } => "new",
106 + ImportStrategy::MergeIntoVfs { .. } => "merge",
107 + });
108 + let mut vault_name = new_vfs_name.clone();
109 + let mut merge_value = selected_merge_vfs_idx.to_string();
110 + let no_vaults = available_vfs.is_empty();
111 + // Owned first, borrowed second: `Choice` holds `&str`, and the vault
112 + // names live behind the same borrow of `state` the fillings need
113 + // mutably.
114 + let vault_options: Vec<(String, String)> = available_vfs
115 + .iter()
116 + .enumerate()
117 + .map(|(i, vfs)| (i.to_string(), vfs.name.clone()))
118 + .collect();
119 + let vault_choices: Vec<makeover_layout::Choice<'_>> = vault_options
120 + .iter()
121 + .map(|(value, label)| makeover_layout::Choice { value, label })
122 + .collect();
88 123
89 - if ui.radio(is_new_vfs, "New vault (preserve directory structure)").clicked() && !is_new_vfs
90 - && let ImportMode::ConfigureImport {
91 - ref mut strategy,
92 - ref new_vfs_name,
93 - ..
94 - } = state.import_wf.import_mode
95 - {
96 - *strategy = ImportStrategy::NewVfs {
97 - vfs_name: new_vfs_name.clone(),
98 - };
99 - }
100 -
101 - if is_new_vfs {
102 - ui.indent("new_vfs_indent", |ui| {
103 - ui.horizontal(|ui| {
104 - ui.label("Vault name:");
105 - if let ImportMode::ConfigureImport {
106 - ref mut new_vfs_name,
107 - ref mut strategy,
108 - ..
109 - } = state.import_wf.import_mode
110 - && ui.text_edit_singleline(new_vfs_name).changed() {
111 - *strategy = ImportStrategy::NewVfs {
112 - vfs_name: new_vfs_name.clone(),
113 - };
114 - }
115 - });
116 - });
124 + let mut fields = vec![makeover_layout::Field::radio(
125 + "import_strategy",
126 + "Import strategy",
127 + &STRATEGIES,
128 + )];
129 + match strategy_value.as_str() {
130 + // The error is the state the old screen left unexplained: a new
131 + // vault chosen and no name, where Import disables itself and says
132 + // why only to a pointer that hovers it.
133 + "new" => fields.push(makeover_layout::Field {
134 + required: true,
135 + placeholder: Some("Drum Kits"),
136 + error: vault_name
137 + .trim()
138 + .is_empty()
139 + .then_some("Enter a name for the new vault."),
140 + ..makeover_layout::Field::new(
141 + makeover_layout::FieldKind::Text,
142 + "new_vault_name",
143 + "Vault name",
144 + )
145 + }),
146 + "merge" => fields.push(makeover_layout::Field {
147 + error: no_vaults.then_some("No existing vaults to merge into."),
148 + ..makeover_layout::Field::select("merge_vault", "Merge into", &vault_choices)
149 + }),
150 + _ => {}
117 151 }
118 152
119 - if ui.radio(is_merge, "Merge into existing vault").clicked() && !is_merge
120 - && let ImportMode::ConfigureImport {
121 - ref mut strategy,
122 - ref available_vfs,
123 - selected_merge_vfs_idx,
124 - ..
125 - } = state.import_wf.import_mode
126 - && let Some(vfs) = available_vfs.get(selected_merge_vfs_idx)
127 - {
128 - *strategy = ImportStrategy::MergeIntoVfs {
153 + widgets::group(ui, &fields, false, |ui, field| {
154 + let filling = match field.name {
155 + "import_strategy" => makeover_immediate::Filling::Text(&mut strategy_value),
156 + "new_vault_name" => makeover_immediate::Filling::Text(&mut vault_name),
157 + _ => makeover_immediate::Filling::Text(&mut merge_value),
158 + };
159 + widgets::field(ui, field, filling, None);
160 + });
161 +
162 + // Back the other way. Unconditional rather than gated on `.changed()`:
163 + // the strategy is derived from the three answers, so re-deriving it
164 + // every frame keeps it correct without every edit site having to
165 + // remember to rebuild it — which the vault-name edit was the only site
166 + // that did.
167 + let ImportMode::ConfigureImport {
168 + strategy,
169 + new_vfs_name,
170 + available_vfs,
171 + selected_merge_vfs_idx,
172 + ..
173 + } = &mut state.import_wf.import_mode
174 + else {
175 + return;
176 + };
177 + new_vfs_name.clone_from(&vault_name);
178 + if let Ok(i) = merge_value.parse::<usize>() {
179 + *selected_merge_vfs_idx = i;
180 + }
181 + // A strategy the app cannot form yet (no current vault to import flat
182 + // into, no vault to merge with) leaves the previous one standing, and
183 + // the radio reads back from it on the next frame.
184 + let next = match strategy_value.as_str() {
185 + "flat" => current_vfs_id.map(|vfs_id| ImportStrategy::Flat {
186 + vfs_id,
187 + parent_id: current_dir,
188 + }),
189 + "new" => Some(ImportStrategy::NewVfs {
190 + vfs_name: new_vfs_name.clone(),
191 + }),
192 + _ => available_vfs
193 + .get(*selected_merge_vfs_idx)
194 + .map(|vfs| ImportStrategy::MergeIntoVfs {
129 195 vfs_id: vfs.id,
130 196 parent_id: None,
131 - };
132 - }
133 -
134 - if is_merge {
135 - ui.indent("merge_vfs_indent", |ui| {
136 - if let ImportMode::ConfigureImport {
137 - ref mut strategy,
138 - ref available_vfs,
139 - ref mut selected_merge_vfs_idx,
140 - ..
141 - } = state.import_wf.import_mode
142 - {
143 - let current_name = available_vfs
144 - .get(*selected_merge_vfs_idx)
145 - .map_or("(none)", |v| v.name.as_str());
146 -
147 - egui::ComboBox::from_id_salt("merge_vfs_select")
148 - .selected_text(current_name)
149 - .show_ui(ui, |ui| {
150 - for (i, vfs) in available_vfs.iter().enumerate() {
151 - if ui
152 - .selectable_label(i == *selected_merge_vfs_idx, &vfs.name)
153 - .clicked()
154 - {
155 - *selected_merge_vfs_idx = i;
156 - *strategy = ImportStrategy::MergeIntoVfs {
157 - vfs_id: vfs.id,
158 - parent_id: None,
159 - };
160 - }
161 - }
162 - });
163 - }
164 - });
197 + }),
198 + };
199 + if let Some(next) = next {
200 + *strategy = next;
165 201 }
166 202
167 203 ui.add_space(theme::space::section());