Update the App ID creation prompt
Dadoum

Dadoum commited on 2026-07-10 00:39:34
Showing 5 changed files, with 566 additions and 61 deletions.

... ...
@@ -1,7 +1,4 @@
1
-use crate::app::entitlements::{
2
-    effective_bundle_identifier, effective_bundle_identifier_for_app,
3
-    effective_nested_bundle_identifier,
4
-};
1
+use crate::app::entitlements::{effective_bundle_identifier, effective_nested_bundle_identifier};
5 2
 use crate::app::models::{
6 3
     AccountOption, AdiBackendKind, AdiBackendOption, AppIdOption, AppOption, DeveloperDeviceOption,
7 4
     DeviceOption, MachineIdentity, PatchOption, TeamOption,
... ...
@@ -71,6 +68,20 @@ impl AppIdProvisioningPlan {
71 68
     }
72 69
 }
73 70
 
71
+#[derive(Clone, Debug)]
72
+struct SigningAppIdTarget {
73
+    registration_identifier: String,
74
+    bundle_identifier: String,
75
+    name: String,
76
+    existing: Option<AppIdOption>,
77
+}
78
+
79
+#[derive(Clone, Debug)]
80
+struct SigningAppIdTargets {
81
+    root: SigningAppIdTarget,
82
+    nested: Vec<SigningAppIdTarget>,
83
+}
84
+
74 85
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
75 86
 pub(crate) enum SignIpaProgress {
76 87
     Preparing,
... ...
@@ -409,14 +420,20 @@ pub(crate) async fn sign_ipa(
409 420
     .map_err(AppError::from)?;
410 421
 
411 422
     progress(SignIpaProgress::ResolvingAppId);
412
-    let root_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id());
423
+    let targets = signing_app_id_targets(
424
+        &request.team_id,
425
+        &request.team_app_ids,
426
+        request.auto_app_id,
427
+        request.selected_app_id.as_ref(),
428
+        &request.app,
429
+    )?;
413 430
     let (app_id, mut updated_account) = resolve_signing_app_id(
414 431
         request.developer_context.clone(),
415 432
         &request.team_id,
416 433
         &request.team_app_ids,
417 434
         request.auto_app_id,
418
-        request.selected_app_id,
419
-        &root_identifier,
435
+        request.selected_app_id.clone(),
436
+        &targets.root.registration_identifier,
420 437
         request.app.name(),
421 438
     )
422 439
     .await?;
... ...
@@ -425,8 +442,7 @@ pub(crate) async fn sign_ipa(
425 442
             "The selected App ID is missing its Xcode identifier. Refresh Developer Settings, then try again.",
426 443
         ));
427 444
     }
428
-    let expected_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id());
429
-    if !app_id_matches_identifier(&app_id.identifier, &expected_identifier) {
445
+    if !app_id_matches_identifier(&app_id.identifier, &targets.root.bundle_identifier) {
430 446
         return Err(AppError::from(format!(
431 447
             "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
432 448
             app_id.identifier,
... ...
@@ -438,32 +454,25 @@ pub(crate) async fn sign_ipa(
438 454
         .as_ref()
439 455
         .and_then(|account| team_app_ids_for_account(account, &request.team_id))
440 456
         .unwrap_or_else(|| request.team_app_ids.clone());
441
-    let effective_bundle_id = effective_bundle_identifier_for_app(&request.app, &request.team_id);
457
+    let effective_bundle_id = targets.root.bundle_identifier;
442 458
     let mut bundle_id_replacements = BTreeMap::from([(
443 459
         request.app.bundle_id().to_string(),
444 460
         effective_bundle_id.clone(),
445 461
     )]);
446 462
     let mut profile_requests = vec![(effective_bundle_id.clone(), app_id.developer_id)];
447 463
 
448
-    for nested_bundle in &request.app.nested_bundles {
449
-        let effective_nested_bundle_id = effective_nested_bundle_identifier(
450
-            request.app.original_bundle_id(),
451
-            request.app.bundle_id(),
452
-            &nested_bundle.bundle_id,
453
-            &request.team_id,
454
-        );
464
+    for (nested_bundle, target) in request.app.nested_bundles.iter().zip(targets.nested) {
455 465
         let (nested_app_id, account_update) = resolve_signing_app_id(
456 466
             request.developer_context.clone(),
457 467
             &request.team_id,
458 468
             &team_app_ids,
459 469
             true,
460 470
             None,
461
-            &effective_nested_bundle_id,
471
+            &target.registration_identifier,
462 472
             &nested_bundle.name,
463 473
         )
464 474
         .await?;
465
-        let expected_identifier = effective_nested_bundle_id.clone();
466
-        if !app_id_matches_identifier(&nested_app_id.identifier, &expected_identifier) {
475
+        if !app_id_matches_identifier(&nested_app_id.identifier, &target.bundle_identifier) {
467 476
             return Err(AppError::from(format!(
468 477
                 "App ID {} does not match nested bundle identifier {}.",
469 478
                 nested_app_id.identifier, nested_bundle.bundle_id
... ...
@@ -482,9 +491,9 @@ pub(crate) async fn sign_ipa(
482 491
         }
483 492
         bundle_id_replacements.insert(
484 493
             nested_bundle.bundle_id.clone(),
485
-            effective_nested_bundle_id.clone(),
494
+            target.bundle_identifier.clone(),
486 495
         );
487
-        profile_requests.push((effective_nested_bundle_id, nested_app_id.developer_id));
496
+        profile_requests.push((target.bundle_identifier, nested_app_id.developer_id));
488 497
     }
489 498
 
490 499
     progress(SignIpaProgress::DownloadingProfile);
... ...
@@ -538,41 +547,24 @@ pub(crate) fn app_id_provisioning_plan(
538 547
     selected_app_id: Option<AppIdOption>,
539 548
     app: &AppOption,
540 549
 ) -> AppResult<AppIdProvisioningPlan> {
550
+    let targets = signing_app_id_targets(
551
+        &team.identifier,
552
+        &team.app_ids,
553
+        auto_app_id,
554
+        selected_app_id.as_ref(),
555
+        app,
556
+    )?;
541 557
     let mut missing = BTreeMap::<String, String>::new();
542
-    let root_identifier = developer_app_identifier(&team.identifier, app.bundle_id());
543 558
 
544
-    if auto_app_id {
545
-        add_missing_app_id(
546
-            &mut missing,
547
-            &team.app_ids,
548
-            root_identifier,
549
-            app.name().to_string(),
550
-        );
551
-    } else {
552
-        let selected_app_id = selected_app_id.ok_or_else(|| {
553
-            AppError::from("Select an App ID in Developer Settings before signing.")
554
-        })?;
555
-        if !app_id_matches_identifier(&selected_app_id.identifier, &root_identifier) {
556
-            return Err(AppError::from(format!(
557
-                "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
558
-                selected_app_id.identifier,
559
-                app.bundle_id()
560
-            )));
559
+    if targets.root.existing.is_none() {
560
+        missing.insert(targets.root.registration_identifier, targets.root.name);
561 561
     }
562
+    for target in targets.nested {
563
+        if target.existing.is_none() {
564
+            missing
565
+                .entry(target.registration_identifier)
566
+                .or_insert(target.name);
562 567
         }
563
-
564
-    for nested_bundle in &app.nested_bundles {
565
-        add_missing_app_id(
566
-            &mut missing,
567
-            &team.app_ids,
568
-            effective_nested_bundle_identifier(
569
-                app.original_bundle_id(),
570
-                app.bundle_id(),
571
-                &nested_bundle.bundle_id,
572
-                &team.identifier,
573
-            ),
574
-            nested_bundle.name.clone(),
575
-        );
576 568
     }
577 569
 
578 570
     let app_ids = missing
... ...
@@ -595,18 +587,196 @@ pub(crate) fn app_id_provisioning_plan(
595 587
     })
596 588
 }
597 589
 
598
-fn add_missing_app_id(
599
-    missing: &mut BTreeMap<String, String>,
600
-    existing: &[AppIdOption],
601
-    identifier: String,
602
-    name: String,
590
+fn signing_app_id_targets(
591
+    team_id: &str,
592
+    team_app_ids: &[AppIdOption],
593
+    auto_app_id: bool,
594
+    selected_app_id: Option<&AppIdOption>,
595
+    app: &AppOption,
596
+) -> AppResult<SigningAppIdTargets> {
597
+    let fallback_identifier = developer_app_identifier(team_id, app.bundle_id());
598
+    if !auto_app_id {
599
+        let selected = selected_app_id.ok_or_else(|| {
600
+            AppError::from("Select an App ID in Developer Settings before signing.")
601
+        })?;
602
+        if !app_id_matches_bundle(
603
+            &selected.identifier,
604
+            app.bundle_id(),
605
+            &fallback_identifier,
606
+            team_id,
603 607
         ) {
604
-    if !existing
608
+            return Err(AppError::from(format!(
609
+                "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
610
+                selected.identifier,
611
+                app.bundle_id()
612
+            )));
613
+        }
614
+        let bundle_identifier = if selected.identifier.ends_with('*') {
615
+            fallback_identifier
616
+        } else {
617
+            selected.identifier.clone()
618
+        };
619
+        let root = SigningAppIdTarget {
620
+            registration_identifier: selected.identifier.clone(),
621
+            bundle_identifier: bundle_identifier.clone(),
622
+            name: app.name().to_string(),
623
+            existing: Some(selected.clone()),
624
+        };
625
+        return Ok(SigningAppIdTargets {
626
+            nested: nested_app_id_targets(team_id, team_app_ids, app, &bundle_identifier),
627
+            root,
628
+        });
629
+    }
630
+
631
+    let canonical_bundle_id = identifier_without_team_component(app.bundle_id(), team_id);
632
+    let mut candidates = team_app_ids
605 633
         .iter()
606
-        .any(|app_id| app_id.identifier == identifier)
634
+        .filter(|app_id| {
635
+            !app_id.identifier.contains('*')
636
+                && identifier_without_team_component(&app_id.identifier, team_id)
637
+                    == canonical_bundle_id
638
+        })
639
+        .map(|app_id| SigningAppIdTarget {
640
+            registration_identifier: app_id.identifier.clone(),
641
+            bundle_identifier: app_id.identifier.clone(),
642
+            name: app.name().to_string(),
643
+            existing: Some(app_id.clone()),
644
+        })
645
+        .collect::<Vec<_>>();
646
+    if !candidates
647
+        .iter()
648
+        .any(|target| target.registration_identifier == fallback_identifier)
607 649
     {
608
-        missing.entry(identifier).or_insert(name);
650
+        candidates.push(SigningAppIdTarget {
651
+            registration_identifier: fallback_identifier.clone(),
652
+            bundle_identifier: fallback_identifier.clone(),
653
+            name: app.name().to_string(),
654
+            existing: team_app_ids
655
+                .iter()
656
+                .find(|app_id| app_id.identifier == fallback_identifier)
657
+                .cloned(),
658
+        });
609 659
     }
660
+
661
+    candidates
662
+        .into_iter()
663
+        .map(|root| {
664
+            let nested = nested_app_id_targets(team_id, team_app_ids, app, &root.bundle_identifier);
665
+            let missing = usize::from(root.existing.is_none())
666
+                + nested
667
+                    .iter()
668
+                    .filter(|target| target.existing.is_none())
669
+                    .count();
670
+            let preference =
671
+                if root.existing.is_some() && root.bundle_identifier == app.bundle_id().trim() {
672
+                    0
673
+                } else if root.existing.is_some() && root.bundle_identifier == fallback_identifier {
674
+                    1
675
+                } else if root.existing.is_some() {
676
+                    2
677
+                } else {
678
+                    3
679
+                };
680
+            (missing, preference, SigningAppIdTargets { root, nested })
681
+        })
682
+        .min_by_key(|(missing, preference, _)| (*missing, *preference))
683
+        .map(|(_, _, targets)| targets)
684
+        .ok_or_else(|| AppError::from("Could not determine the App IDs required for signing."))
685
+}
686
+
687
+fn nested_app_id_targets(
688
+    team_id: &str,
689
+    team_app_ids: &[AppIdOption],
690
+    app: &AppOption,
691
+    root_bundle_identifier: &str,
692
+) -> Vec<SigningAppIdTarget> {
693
+    app.nested_bundles
694
+        .iter()
695
+        .map(|nested_bundle| {
696
+            let desired_identifier = rebased_nested_bundle_identifier(
697
+                app.original_bundle_id(),
698
+                app.bundle_id(),
699
+                root_bundle_identifier,
700
+                &nested_bundle.bundle_id,
701
+                team_id,
702
+            );
703
+            let canonical_desired = identifier_without_team_component(&desired_identifier, team_id);
704
+            let root_prefix = format!("{root_bundle_identifier}.");
705
+            let existing = team_app_ids
706
+                .iter()
707
+                .find(|app_id| app_id.identifier == desired_identifier)
708
+                .or_else(|| {
709
+                    team_app_ids.iter().find(|app_id| {
710
+                        !app_id.identifier.contains('*')
711
+                            && app_id.identifier.starts_with(&root_prefix)
712
+                            && identifier_without_team_component(&app_id.identifier, team_id)
713
+                                == canonical_desired
714
+                    })
715
+                })
716
+                .cloned();
717
+            let identifier = existing
718
+                .as_ref()
719
+                .map(|app_id| app_id.identifier.clone())
720
+                .unwrap_or(desired_identifier);
721
+            SigningAppIdTarget {
722
+                registration_identifier: identifier.clone(),
723
+                bundle_identifier: identifier,
724
+                name: nested_bundle.name.clone(),
725
+                existing,
726
+            }
727
+        })
728
+        .collect()
729
+}
730
+
731
+fn rebased_nested_bundle_identifier(
732
+    original_root_bundle_id: &str,
733
+    current_root_bundle_id: &str,
734
+    effective_root_bundle_id: &str,
735
+    nested_bundle_id: &str,
736
+    team_id: &str,
737
+) -> String {
738
+    let original_root = identifier_without_team_component(original_root_bundle_id, team_id);
739
+    let current_root = identifier_without_team_component(current_root_bundle_id, team_id);
740
+    let nested = identifier_without_team_component(nested_bundle_id, team_id);
741
+    nested
742
+        .strip_prefix(&original_root)
743
+        .or_else(|| nested.strip_prefix(&current_root))
744
+        .filter(|suffix| suffix.starts_with('.'))
745
+        .map(|suffix| format!("{effective_root_bundle_id}{suffix}"))
746
+        .unwrap_or_else(|| {
747
+            effective_nested_bundle_identifier(
748
+                original_root_bundle_id,
749
+                current_root_bundle_id,
750
+                nested_bundle_id,
751
+                team_id,
752
+            )
753
+        })
754
+}
755
+
756
+fn identifier_without_team_component(identifier: &str, team_id: &str) -> String {
757
+    let team_id = team_id.trim();
758
+    if team_id.is_empty() {
759
+        return identifier.trim().to_string();
760
+    }
761
+    identifier
762
+        .trim()
763
+        .split('.')
764
+        .filter(|component| *component != team_id)
765
+        .collect::<Vec<_>>()
766
+        .join(".")
767
+}
768
+
769
+fn app_id_matches_bundle(
770
+    app_id: &str,
771
+    bundle_id: &str,
772
+    fallback_identifier: &str,
773
+    team_id: &str,
774
+) -> bool {
775
+    app_id_matches_identifier(app_id, bundle_id)
776
+        || app_id_matches_identifier(app_id, fallback_identifier)
777
+        || (!app_id.contains('*')
778
+            && identifier_without_team_component(app_id, team_id)
779
+                == identifier_without_team_component(bundle_id, team_id))
610 780
 }
611 781
 
612 782
 fn team_app_ids_for_account(account: &AccountOption, team_id: &str) -> Option<Vec<AppIdOption>> {
... ...
@@ -1016,6 +1186,91 @@ mod tests {
1016 1186
         assert_eq!(plan.remaining_after_signing(), Some(3));
1017 1187
     }
1018 1188
 
1189
+    #[test]
1190
+    fn provisioning_plan_reuses_unchanged_registered_app_ids() {
1191
+        let app = app("com.example.app", &["com.example.app.widget"]);
1192
+        let team = team(
1193
+            vec![app_id("com.example.app"), app_id("com.example.app.widget")],
1194
+            Some(5),
1195
+        );
1196
+
1197
+        let targets =
1198
+            signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap();
1199
+        let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap();
1200
+
1201
+        assert!(plan.app_ids.is_empty());
1202
+        assert_eq!(targets.root.bundle_identifier, "com.example.app");
1203
+        assert_eq!(
1204
+            targets.nested[0].bundle_identifier,
1205
+            "com.example.app.widget"
1206
+        );
1207
+    }
1208
+
1209
+    #[test]
1210
+    fn provisioning_plan_reuses_team_prefixed_registered_app_ids() {
1211
+        let app = app("com.example.app", &["com.example.app.widget"]);
1212
+        let team = team(
1213
+            vec![
1214
+                app_id("TEAM.com.example.app"),
1215
+                app_id("TEAM.com.example.app.widget"),
1216
+            ],
1217
+            Some(5),
1218
+        );
1219
+
1220
+        let targets =
1221
+            signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap();
1222
+        let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap();
1223
+
1224
+        assert!(plan.app_ids.is_empty());
1225
+        assert_eq!(targets.root.bundle_identifier, "TEAM.com.example.app");
1226
+        assert_eq!(
1227
+            targets.nested[0].bundle_identifier,
1228
+            "TEAM.com.example.app.widget"
1229
+        );
1230
+    }
1231
+
1232
+    #[test]
1233
+    fn provisioning_plan_reuses_registered_ids_after_a_root_bundle_override() {
1234
+        let mut app = app(
1235
+            "com.google.ios.youtube",
1236
+            &["com.google.ios.youtube.OpenYouTube.Extension"],
1237
+        );
1238
+        app.metadata
1239
+            .bundle_id
1240
+            .set_override("ddcom.google.ios.youtube".to_string());
1241
+        let team = team(
1242
+            vec![
1243
+                app_id("ddcom.google.ios.youtube.TEAM"),
1244
+                app_id("ddcom.google.ios.youtube.TEAM.OpenYouTube.Extension"),
1245
+            ],
1246
+            Some(5),
1247
+        );
1248
+
1249
+        let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap();
1250
+
1251
+        assert!(plan.app_ids.is_empty());
1252
+    }
1253
+
1254
+    #[test]
1255
+    fn provisioning_plan_chooses_the_existing_layout_with_fewer_missing_ids() {
1256
+        let app = app("com.example.app", &["com.example.app.widget"]);
1257
+        let team = team(
1258
+            vec![
1259
+                app_id("com.example.app.TEAM"),
1260
+                app_id("TEAM.com.example.app"),
1261
+                app_id("TEAM.com.example.app.widget"),
1262
+            ],
1263
+            Some(5),
1264
+        );
1265
+
1266
+        let targets =
1267
+            signing_app_id_targets(&team.identifier, &team.app_ids, true, None, &app).unwrap();
1268
+        let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap();
1269
+
1270
+        assert!(plan.app_ids.is_empty());
1271
+        assert_eq!(targets.root.bundle_identifier, "TEAM.com.example.app");
1272
+    }
1273
+
1019 1274
     #[test]
1020 1275
     fn provisioning_plan_rebases_nested_ids_when_root_bundle_id_is_overridden() {
1021 1276
         let mut app = app("com.example.app", &["com.example.app.SubApp.Extension"]);
... ...
@@ -21,6 +21,9 @@ pub(crate) fn effective_bundle_identifier_for_app(app: &AppOption, team_id: &str
21 21
 pub(crate) fn effective_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
22 22
     let team_id = team_id.trim();
23 23
     if team_id.is_empty()
24
+        || bundle_id
25
+            .strip_prefix(team_id)
26
+            .is_some_and(|suffix| suffix.starts_with('.'))
24 27
         || bundle_id
25 28
             .strip_suffix(team_id)
26 29
             .is_some_and(|prefix| prefix.ends_with('.'))
... ...
@@ -246,6 +249,10 @@ mod tests {
246 249
             effective_bundle_identifier("com.example.app.TEAM123", "TEAM123"),
247 250
             "com.example.app.TEAM123"
248 251
         );
252
+        assert_eq!(
253
+            effective_bundle_identifier("TEAM123.com.example.app", "TEAM123"),
254
+            "TEAM123.com.example.app"
255
+        );
249 256
         assert_eq!(
250 257
             effective_nested_bundle_identifier(
251 258
                 "com.example.app",
... ...
@@ -22,6 +22,8 @@ fn main() {
22 22
         .with_quit_mode(QuitMode::LastWindowClosed)
23 23
         .run(|cx: &mut App| {
24 24
             gpui_component::init(cx);
25
+            #[cfg(target_os = "linux")]
26
+            ui::prompt::install(cx);
25 27
 
26 28
             let window_size = size(px(WINDOW_WIDTH), px(WINDOW_HEIGHT));
27 29
             let bounds = Bounds::centered(None, window_size, cx);
... ...
@@ -1,5 +1,7 @@
1 1
 pub(crate) mod assets;
2 2
 pub(crate) mod main_view;
3
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
4
+pub(crate) mod prompt;
3 5
 pub(crate) mod settings;
4 6
 pub(crate) mod theme;
5 7
 pub(crate) mod widgets;
... ...
@@ -0,0 +1,239 @@
1
+use crate::ui::theme::tokens;
2
+use gpui::{
3
+    div, hsla, opaque_grey, point, px, App, AppContext as _, BoxShadow, Context, EventEmitter,
4
+    FocusHandle, Focusable, FontWeight, InteractiveElement, IntoElement, KeyDownEvent,
5
+    ParentElement, PromptButton, PromptLevel, PromptResponse, Render, StatefulInteractiveElement,
6
+    Styled, Window,
7
+};
8
+use gpui_component::button::{Button, ButtonVariants as _};
9
+
10
+const PROMPT_WIDTH: f32 = 560.;
11
+const PROMPT_BODY_MAX_HEIGHT: f32 = 260.;
12
+
13
+pub(crate) fn install(cx: &mut App) {
14
+    cx.set_prompt_builder(|level, message, detail, actions, handle, window, cx| {
15
+        let prompt = cx.new(|cx| AppPrompt {
16
+            level,
17
+            message: message.to_string(),
18
+            detail: detail.map(ToString::to_string),
19
+            actions: actions.to_vec(),
20
+            focus_handle: cx.focus_handle(),
21
+        });
22
+
23
+        handle.with_view(prompt, window, cx)
24
+    });
25
+}
26
+
27
+struct AppPrompt {
28
+    level: PromptLevel,
29
+    message: String,
30
+    detail: Option<String>,
31
+    actions: Vec<PromptButton>,
32
+    focus_handle: FocusHandle,
33
+}
34
+
35
+impl AppPrompt {
36
+    fn respond(&mut self, index: usize, cx: &mut Context<Self>) {
37
+        cx.emit(PromptResponse(index));
38
+        cx.stop_propagation();
39
+    }
40
+
41
+    fn handle_key_down(
42
+        &mut self,
43
+        event: &KeyDownEvent,
44
+        window: &mut Window,
45
+        cx: &mut Context<Self>,
46
+    ) {
47
+        let response = match event.keystroke.key.as_str() {
48
+            "enter" => self.actions.iter().position(|action| !action.is_cancel()),
49
+            "escape" => self.actions.iter().position(PromptButton::is_cancel),
50
+            _ => None,
51
+        };
52
+
53
+        if let Some(index) = response {
54
+            window.prevent_default();
55
+            self.respond(index, cx);
56
+        }
57
+    }
58
+}
59
+
60
+impl Render for AppPrompt {
61
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
62
+        let colors = tokens();
63
+        let (level_background, level_foreground, level_mark) = match self.level {
64
+            PromptLevel::Info => (colors.accent_surface, colors.accent, "i"),
65
+            PromptLevel::Warning => (colors.warning_surface, colors.warning, "!"),
66
+            PromptLevel::Critical => (colors.danger_surface, colors.danger, "!"),
67
+        };
68
+
69
+        let header = div()
70
+            .w_full()
71
+            .flex()
72
+            .items_start()
73
+            .gap_3()
74
+            .child(
75
+                div()
76
+                    .flex_none()
77
+                    .size_10()
78
+                    .rounded_full()
79
+                    .bg(level_background)
80
+                    .text_color(level_foreground)
81
+                    .flex()
82
+                    .items_center()
83
+                    .justify_center()
84
+                    .text_lg()
85
+                    .font_weight(FontWeight::BOLD)
86
+                    .child(level_mark),
87
+            )
88
+            .child(
89
+                div()
90
+                    .min_w_0()
91
+                    .flex_1()
92
+                    .pt_1()
93
+                    .text_lg()
94
+                    .font_weight(FontWeight::SEMIBOLD)
95
+                    .text_color(colors.text_primary)
96
+                    .whitespace_normal()
97
+                    .child(self.message.clone()),
98
+            );
99
+
100
+        let body = self.detail.clone().map(|detail| {
101
+            div()
102
+                .id("app-prompt-detail")
103
+                .w_full()
104
+                .max_h(px(PROMPT_BODY_MAX_HEIGHT))
105
+                .overflow_hidden()
106
+                .overflow_y_scroll()
107
+                .scrollbar_width(px(8.))
108
+                .rounded_md()
109
+                .border_1()
110
+                .border_color(colors.border)
111
+                .bg(colors.surface_subtle)
112
+                .child(
113
+                    div()
114
+                        .w_full()
115
+                        .min_w_0()
116
+                        .p_3()
117
+                        .text_sm()
118
+                        .text_color(colors.text_secondary)
119
+                        .whitespace_normal()
120
+                        .child(detail),
121
+                )
122
+        });
123
+
124
+        let actions = self
125
+            .actions
126
+            .iter()
127
+            .enumerate()
128
+            .rev()
129
+            .map(|(index, action)| {
130
+                let button = Button::new(("app-prompt-action", index))
131
+                    .label(action.label().clone())
132
+                    .on_click(cx.listener(move |prompt, _, _, cx| {
133
+                        prompt.respond(index, cx);
134
+                    }));
135
+
136
+                if action.is_cancel() {
137
+                    button.outline()
138
+                } else if index == 0 {
139
+                    button.primary()
140
+                } else {
141
+                    button.secondary()
142
+                }
143
+            });
144
+
145
+        let dialog = div()
146
+            .id("app-prompt")
147
+            .track_focus(&self.focus_handle)
148
+            .capture_key_down(cx.listener(Self::handle_key_down))
149
+            .w(px(PROMPT_WIDTH))
150
+            .max_w_full()
151
+            .rounded_xl()
152
+            .border_1()
153
+            .border_color(colors.border)
154
+            .bg(colors.surface)
155
+            .text_color(colors.text_primary)
156
+            .shadow(vec![
157
+                BoxShadow {
158
+                    color: hsla(0., 0., 0., 0.18),
159
+                    offset: point(px(0.), px(18.)),
160
+                    blur_radius: px(40.),
161
+                    spread_radius: px(-12.),
162
+                    inset: false,
163
+                },
164
+                BoxShadow {
165
+                    color: hsla(0., 0., 0., 0.1),
166
+                    offset: point(px(0.), px(6.)),
167
+                    blur_radius: px(14.),
168
+                    spread_radius: px(-6.),
169
+                    inset: false,
170
+                },
171
+            ])
172
+            .overflow_hidden()
173
+            .child(
174
+                div()
175
+                    .w_full()
176
+                    .p_5()
177
+                    .pb_4()
178
+                    .flex()
179
+                    .flex_col()
180
+                    .gap_4()
181
+                    .child(header)
182
+                    .children(body),
183
+            )
184
+            .child(
185
+                div()
186
+                    .w_full()
187
+                    .px_5()
188
+                    .py_3()
189
+                    .border_t_1()
190
+                    .border_color(colors.border)
191
+                    .bg(colors.surface_subtle)
192
+                    .flex()
193
+                    .items_center()
194
+                    .justify_end()
195
+                    .gap_2()
196
+                    .children(actions),
197
+            );
198
+
199
+        div()
200
+            .size_full()
201
+            .absolute()
202
+            .top_0()
203
+            .left_0()
204
+            .p_6()
205
+            .bg(opaque_grey(0.05, 0.62))
206
+            .flex()
207
+            .items_center()
208
+            .justify_center()
209
+            .on_any_mouse_down(|_, _, cx| cx.stop_propagation())
210
+            .child(dialog)
211
+    }
212
+}
213
+
214
+impl EventEmitter<PromptResponse> for AppPrompt {}
215
+
216
+impl Focusable for AppPrompt {
217
+    fn focus_handle(&self, _: &App) -> FocusHandle {
218
+        self.focus_handle.clone()
219
+    }
220
+}
221
+
222
+#[cfg(test)]
223
+mod tests {
224
+    use super::*;
225
+
226
+    #[test]
227
+    fn prompt_keyboard_actions_use_confirm_and_cancel_semantics() {
228
+        let actions = [
229
+            PromptButton::new("Continue"),
230
+            PromptButton::Cancel("Cancel".into()),
231
+        ];
232
+
233
+        assert_eq!(
234
+            actions.iter().position(|action| !action.is_cancel()),
235
+            Some(0)
236
+        );
237
+        assert_eq!(actions.iter().position(PromptButton::is_cancel), Some(1));
238
+    }
239
+}
0 240