1use crate::context::GlobalRouter;
8use crate::nested::resolve_child_route;
9#[cfg(feature = "transition")]
10use crate::transition::{SlideDirection, Transition};
11use crate::{debug_log, error_log, trace_log, warn_log};
12use gpui::{div, AnyElement, App, Div, IntoElement, ParentElement, SharedString, Styled, Window};
13
14#[cfg(feature = "transition")]
15use gpui::{relative, Animation, AnimationExt};
16
17#[cfg(feature = "transition")]
18use gpui::prelude::FluentBuilder;
19
20#[cfg(feature = "transition")]
21use std::time::Duration;
22
23#[derive(Clone)]
51pub struct RouterOutlet {
52 name: Option<String>,
55}
56
57impl RouterOutlet {
58 pub fn new() -> Self {
60 Self { name: None }
61 }
62
63 pub fn named(name: impl Into<String>) -> Self {
82 Self {
83 name: Some(name.into()),
84 }
85 }
86}
87
88impl Default for RouterOutlet {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94use gpui::{Context, Render};
95
96#[derive(Clone)]
98struct OutletState {
99 current_path: String,
100 animation_counter: u32,
101 current_params: crate::RouteParams,
103 current_builder: Option<crate::route::RouteBuilder>,
104 #[cfg(feature = "transition")]
105 current_transition: crate::transition::Transition,
106 previous_route: Option<PreviousRoute>,
108}
109
110#[derive(Clone)]
111struct PreviousRoute {
112 path: String,
113 params: crate::RouteParams,
114 builder: Option<crate::route::RouteBuilder>,
115}
116
117impl Default for OutletState {
118 fn default() -> Self {
119 Self {
120 current_path: String::new(),
121 animation_counter: 0,
122 current_params: crate::RouteParams::new(),
123 current_builder: None,
124 #[cfg(feature = "transition")]
125 current_transition: crate::transition::Transition::None,
126 previous_route: None,
127 }
128 }
129}
130
131impl Render for RouterOutlet {
132 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
133 trace_log!("🔄 RouterOutlet::render() called");
134
135 let state_key = SharedString::from(format!("outlet_{:?}", self.name));
137 let state = window.use_keyed_state(state_key.clone(), cx, |_, _| OutletState::default());
138
139 let (prev_path, animation_counter) = {
140 let guard = state.read(cx);
141 (guard.current_path.clone(), guard.animation_counter)
142 };
143
144 #[cfg(feature = "transition")]
146 let (router_path, route_params, route_transition, builder_opt) = cx
147 .try_global::<crate::context::GlobalRouter>()
148 .map(|router| {
149 let path = router.current_path().to_string();
150
151 let params = router
152 .current_match_immutable()
153 .map(|m| {
154 let mut rp = crate::RouteParams::new();
155 for (k, v) in m.params {
156 rp.insert(k, v);
157 }
158 rp
159 })
160 .unwrap_or_else(crate::RouteParams::new);
161
162 let transition = router
163 .current_route()
164 .map(|route| route.transition.default.clone())
165 .unwrap_or(Transition::None);
166
167 let builder = router
168 .current_route()
169 .and_then(|route| route.builder.clone());
170
171 (path, params, transition, builder)
172 })
173 .unwrap_or_else(|| {
174 (
175 "/".to_string(),
176 crate::RouteParams::new(),
177 Transition::None,
178 None,
179 )
180 });
181
182 #[cfg(not(feature = "transition"))]
183 let (router_path, route_params, builder_opt) = cx
184 .try_global::<crate::context::GlobalRouter>()
185 .map(|router| {
186 let path = router.current_path().to_string();
187
188 let params = router
189 .current_match_immutable()
190 .map(|m| {
191 let mut rp = crate::RouteParams::new();
192 for (k, v) in m.params {
193 rp.insert(k, v);
194 }
195 rp
196 })
197 .unwrap_or_else(crate::RouteParams::new);
198
199 let builder = router
200 .current_route()
201 .and_then(|route| route.builder.clone());
202
203 (path, params, builder)
204 })
205 .unwrap_or_else(|| ("/".to_string(), crate::RouteParams::new(), None));
206
207 let path_changed = router_path != prev_path;
209
210 #[cfg_attr(not(feature = "transition"), allow(unused_variables))]
212 let animation_counter = if path_changed {
213 #[cfg_attr(not(feature = "transition"), allow(unused_variables))]
214 let is_initial = prev_path.is_empty();
215
216 #[cfg(feature = "transition")]
217 let new_counter = if is_initial {
218 debug_log!("Initial route: '{}', no animation", router_path);
219 animation_counter
220 } else {
221 let counter = animation_counter.wrapping_add(1);
222 debug_log!(
223 "Route changed: '{}' -> '{}', transition={:?}, animation_counter={}",
224 prev_path,
225 router_path,
226 route_transition,
227 counter
228 );
229 counter
230 };
231
232 #[cfg(not(feature = "transition"))]
233 let new_counter = {
234 debug_log!(
235 "Route changed: '{}' -> '{}' (no transition)",
236 prev_path,
237 router_path
238 );
239 animation_counter
240 };
241
242 state.update(cx, |s, _| {
244 if !is_initial {
248 s.previous_route = Some(PreviousRoute {
249 path: s.current_path.clone(),
250 params: s.current_params.clone(),
251 builder: s.current_builder.clone(),
252 });
253 } else {
254 s.previous_route = None;
256 }
257 s.current_path = router_path.clone();
259 s.current_params = route_params.clone();
260 s.current_builder = builder_opt.clone();
261 #[cfg(feature = "transition")]
262 {
263 s.current_transition = route_transition.clone();
264 }
265 s.animation_counter = new_counter;
266 });
267
268 new_counter
269 } else {
270 trace_log!("Route unchanged: '{}'", router_path);
271 animation_counter
272 };
273
274 #[cfg(feature = "transition")]
275 {
276 let duration_ms = match &route_transition {
279 Transition::Fade { duration_ms, .. } => *duration_ms,
280 Transition::Slide { duration_ms, .. } => *duration_ms,
281 Transition::None => 0,
282 };
283
284 debug_log!(
285 "Rendering route '{}' with animation_counter={}, duration={}ms",
286 router_path,
287 animation_counter,
288 duration_ms
289 );
290
291 let previous_route = state
295 .read(cx)
296 .previous_route
297 .as_ref()
298 .filter(|prev| prev.path != router_path)
299 .cloned();
300
301 debug_log!(
302 "Previous route exists: {}, path: {:?}",
303 previous_route.is_some(),
304 previous_route.as_ref().map(|p| &p.path)
305 );
306
307 match &route_transition {
310 Transition::Slide { direction, .. } => {
311 let old_content = previous_route.map(|prev| {
313 if let Some(builder) = prev.builder.as_ref() {
314 builder(cx, &prev.params)
315 } else {
316 not_found_page().into_any_element()
317 }
318 });
319
320 let new_content = if let Some(builder) = builder_opt.as_ref() {
322 builder(cx, &route_params)
323 } else {
324 not_found_page().into_any_element()
325 };
326
327 let animation_id = SharedString::from(format!(
329 "outlet_slide_{:?}_{}",
330 self.name, animation_counter
331 ));
332
333 match direction {
334 SlideDirection::Left | SlideDirection::Right => {
335 let is_left = matches!(direction, SlideDirection::Left);
337
338 div()
339 .relative()
340 .w_full()
341 .h_full()
342 .overflow_hidden()
343 .when_some(old_content, |container, old| {
345 container.child(
346 div()
347 .absolute()
348 .w_full()
349 .h_full()
350 .child(old)
351 .left(relative(0.0)) .with_animation(
353 animation_id.clone(),
354 Animation::new(Duration::from_millis(duration_ms)),
355 move |this, delta| {
356 let progress = delta.clamp(0.0, 1.0);
357 let offset = if is_left {
359 -progress } else {
361 progress };
363 this.left(relative(offset))
364 },
365 ),
366 )
367 })
368 .child(
370 div()
371 .absolute()
372 .w_full()
373 .h_full()
374 .child(new_content)
375 .left(relative(if is_left { 1.0 } else { -1.0 })) .with_animation(
377 animation_id.clone(),
378 Animation::new(Duration::from_millis(duration_ms)),
379 move |this, delta| {
380 let progress = delta.clamp(0.0, 1.0);
381 let start = if is_left { 1.0 } else { -1.0 };
383 let offset = start * (1.0 - progress);
384 this.left(relative(offset))
385 },
386 ),
387 )
388 .into_any_element()
389 }
390 SlideDirection::Up | SlideDirection::Down => {
391 let is_up = matches!(direction, SlideDirection::Up);
393
394 div()
395 .relative()
396 .w_full()
397 .h_full()
398 .overflow_hidden()
399 .when_some(old_content, |container, old| {
401 container.child(
402 div()
403 .absolute()
404 .w_full()
405 .h_full()
406 .child(old)
407 .top(relative(0.0)) .with_animation(
409 animation_id.clone(),
410 Animation::new(Duration::from_millis(duration_ms)),
411 move |this, delta| {
412 let progress = delta.clamp(0.0, 1.0);
413 let offset = if is_up {
415 -progress } else {
417 progress };
419 this.top(relative(offset))
420 },
421 ),
422 )
423 })
424 .child(
426 div()
427 .absolute()
428 .w_full()
429 .h_full()
430 .child(new_content)
431 .top(relative(if is_up { 1.0 } else { -1.0 })) .with_animation(
433 animation_id.clone(),
434 Animation::new(Duration::from_millis(duration_ms)),
435 move |this, delta| {
436 let progress = delta.clamp(0.0, 1.0);
437 let start = if is_up { 1.0 } else { -1.0 };
439 let offset = start * (1.0 - progress);
440 this.top(relative(offset))
441 },
442 ),
443 )
444 .into_any_element()
445 }
446 }
447 }
448 Transition::Fade { .. } => {
449 let old_content = previous_route.map(|prev| {
451 if let Some(builder) = prev.builder.as_ref() {
452 builder(cx, &prev.params)
453 } else {
454 not_found_page().into_any_element()
455 }
456 });
457
458 let new_content = if let Some(builder) = builder_opt.as_ref() {
460 builder(cx, &route_params)
461 } else {
462 not_found_page().into_any_element()
463 };
464
465 div()
466 .relative()
467 .w_full()
468 .h_full()
469 .overflow_hidden()
470 .when_some(old_content, |container, old| {
472 container.child(
473 div()
474 .absolute()
475 .w_full()
476 .h_full()
477 .child(old)
478 .with_animation(
479 SharedString::from(format!(
480 "outlet_fade_exit_{:?}_{}",
481 self.name, animation_counter
482 )),
483 Animation::new(Duration::from_millis(duration_ms)),
484 |this, delta| {
485 let progress = delta.clamp(0.0, 1.0);
486 this.opacity(1.0 - progress)
487 },
488 ),
489 )
490 })
491 .child(
493 div()
494 .absolute()
495 .w_full()
496 .h_full()
497 .child(new_content)
498 .opacity(0.0)
499 .with_animation(
500 SharedString::from(format!(
501 "outlet_fade_enter_{:?}_{}",
502 self.name, animation_counter
503 )),
504 Animation::new(Duration::from_millis(duration_ms)),
505 |this, delta| {
506 let progress = delta.clamp(0.0, 1.0);
507 this.opacity(progress)
508 },
509 ),
510 )
511 .into_any_element()
512 }
513 _ => {
514 let new_content = if let Some(builder) = builder_opt.as_ref() {
516 builder(cx, &route_params)
517 } else {
518 not_found_page().into_any_element()
519 };
520
521 div()
522 .relative()
523 .w_full()
524 .h_full()
525 .child(new_content)
526 .into_any_element()
527 }
528 }
529 }
530
531 #[cfg(not(feature = "transition"))]
532 {
533 let animation_id = SharedString::from(format!("outlet_{:?}", self.name));
535 build_animated_route_content(
536 cx,
537 window,
538 builder_opt.as_ref(),
539 &route_params,
540 animation_id,
541 &(),
542 0,
543 )
544 }
545 }
546}
547
548pub fn router_outlet(cx: &mut App) -> impl IntoElement {
564 render_router_outlet(cx, None)
565}
566
567pub fn router_outlet_named(cx: &mut App, name: impl Into<String>) -> impl IntoElement {
583 render_router_outlet(cx, Some(&name.into()))
584}
585
586pub fn render_router_outlet(cx: &mut App, name: Option<&str>) -> AnyElement {
605 trace_log!("render_router_outlet called with name: {:?}", name);
606
607 let router = cx.try_global::<GlobalRouter>();
609
610 let Some(router) = router else {
611 error_log!("No global router found - call init_router() first");
612 return div()
613 .child("RouterOutlet: No global router found. Call init_router() first.")
614 .into_any_element();
615 };
616
617 let current_path = router.current_path();
618 trace_log!("Current path: '{}'", current_path);
619
620 let parent_route = find_parent_route_for_path(router.state().routes(), current_path);
623
624 let Some(parent_route) = parent_route else {
625 warn_log!(
626 "No parent route with children found for path '{}'",
627 current_path
628 );
629 return div()
630 .child(format!(
631 "RouterOutlet: No parent route with children found for path '{}'",
632 current_path
633 ))
634 .into_any_element();
635 };
636
637 trace_log!(
638 "Found parent route: '{}' with {} children",
639 parent_route.config.path,
640 parent_route.get_children().len()
641 );
642
643 if parent_route.get_children().is_empty() {
645 return div()
646 .child(format!(
647 "RouterOutlet: Route '{}' has no child routes",
648 parent_route.config.path
649 ))
650 .into_any_element();
651 }
652
653 let route_params = crate::RouteParams::new();
656
657 let resolved = resolve_child_route(parent_route, current_path, &route_params, name);
658
659 let Some((child_route, child_params)) = resolved else {
660 warn_log!("No child route matched for path '{}'", current_path);
661 return div()
662 .child(format!(
663 "RouterOutlet: No child route matched for path '{}'",
664 current_path
665 ))
666 .into_any_element();
667 };
668
669 trace_log!("Matched child route: '{}'", child_route.config.path);
670
671 if let Some(builder) = &child_route.builder {
673 builder(cx, &child_params)
675 } else {
676 div()
677 .child(format!(
678 "RouterOutlet: Child route '{}' has no builder",
679 child_route.config.path
680 ))
681 .into_any_element()
682 }
683}
684
685fn find_parent_route_for_path<'a>(
717 routes: &'a [std::sync::Arc<crate::route::Route>],
718 current_path: &str,
719) -> Option<&'a std::sync::Arc<crate::route::Route>> {
720 find_parent_route_internal(routes, current_path, "")
721}
722
723fn find_parent_route_internal<'a>(
724 routes: &'a [std::sync::Arc<crate::route::Route>],
725 current_path: &str,
726 accumulated_path: &str,
727) -> Option<&'a std::sync::Arc<crate::route::Route>> {
728 let current_normalized = current_path.trim_start_matches('/').trim_end_matches('/');
729
730 for route in routes {
731 if route.get_children().is_empty() {
733 continue;
734 }
735
736 let route_segment = route
737 .config
738 .path
739 .trim_start_matches('/')
740 .trim_end_matches('/');
741
742 let full_route_path = if accumulated_path.is_empty() {
744 if route_segment.is_empty() || route_segment == "/" {
745 String::new()
746 } else {
747 route_segment.to_string()
748 }
749 } else if route_segment.is_empty() || route_segment == "/" {
750 accumulated_path.to_string()
751 } else {
752 format!("{}/{}", accumulated_path, route_segment)
753 };
754
755 let is_under = if full_route_path.is_empty() {
757 !current_normalized.is_empty()
758 } else {
759 current_normalized.starts_with(&full_route_path)
760 && (current_normalized.len() == full_route_path.len()
761 || current_normalized[full_route_path.len()..].starts_with('/'))
762 };
763
764 if is_under {
765 if let Some(deeper) =
767 find_parent_route_internal(route.get_children(), current_path, &full_route_path)
768 {
769 return Some(deeper);
770 }
771
772 for child in route.get_children() {
775 let child_segment = child
776 .config
777 .path
778 .trim_start_matches('/')
779 .trim_end_matches('/');
780 let child_full_path = if full_route_path.is_empty() {
781 child_segment.to_string()
782 } else {
783 format!("{}/{}", full_route_path, child_segment)
784 };
785
786 if current_normalized == child_full_path
788 || current_normalized.starts_with(&format!("{}/", child_full_path))
789 {
790 return Some(route);
791 }
792 }
793
794 if current_normalized == full_route_path && accumulated_path.is_empty() {
798 return Some(route);
799 }
800 }
801 }
802
803 None
804}
805
806use crate::Navigator;
814use gpui::*;
815
816pub struct RouterLink {
828 path: SharedString,
830 active_class: Option<Box<dyn Fn(Div) -> Div>>,
832 children: Vec<AnyElement>,
834}
835
836impl RouterLink {
837 pub fn new(path: impl Into<SharedString>) -> Self {
839 Self {
840 path: path.into(),
841 active_class: None,
842 children: Vec::new(),
843 }
844 }
845
846 pub fn child(mut self, child: impl IntoElement) -> Self {
848 self.children.push(child.into_any_element());
849 self
850 }
851
852 pub fn active_class(mut self, style: impl Fn(Div) -> Div + 'static) -> Self {
854 self.active_class = Some(Box::new(style));
855 self
856 }
857
858 pub fn build<V: 'static>(self, cx: &mut Context<'_, V>) -> Div {
860 let path = self.path.clone();
861 let current_path = Navigator::current_path(cx);
862 let is_active = current_path == path.as_ref();
863
864 let mut link = div().cursor_pointer().on_mouse_down(
865 MouseButton::Left,
866 cx.listener(move |_view, _event, _window, cx| {
867 Navigator::push(cx, path.to_string());
868 cx.notify();
869 }),
870 );
871
872 if is_active {
874 if let Some(active_fn) = self.active_class {
875 link = active_fn(link);
876 }
877 }
878
879 for child in self.children {
881 link = link.child(child);
882 }
883
884 link
885 }
886}
887
888pub fn router_link<V: 'static>(
890 cx: &mut Context<'_, V>,
891 path: impl Into<SharedString>,
892 label: impl Into<SharedString>,
893) -> Div {
894 let path_str: SharedString = path.into();
895 let label_str: SharedString = label.into();
896 let current_path = Navigator::current_path(cx);
897 let is_active = current_path == path_str.as_ref();
898
899 div()
900 .cursor_pointer()
901 .text_color(if is_active {
902 rgb(0x2196f3)
903 } else {
904 rgb(0x333333)
905 })
906 .hover(|this| this.text_color(rgb(0x2196f3)))
907 .child(label_str)
908 .on_mouse_down(
909 MouseButton::Left,
910 cx.listener(move |_view, _event, _window, cx| {
911 Navigator::push(cx, path_str.to_string());
912 cx.notify();
913 }),
914 )
915}
916
917pub struct DefaultPages {
923 pub not_found: Option<Box<dyn Fn() -> AnyElement + Send + Sync>>,
925 pub loading: Option<Box<dyn Fn() -> AnyElement + Send + Sync>>,
927 pub error: Option<Box<dyn Fn(&str) -> AnyElement + Send + Sync>>,
929}
930
931impl DefaultPages {
932 pub fn new() -> Self {
934 Self {
935 not_found: None,
936 loading: None,
937 error: None,
938 }
939 }
940
941 pub fn with_not_found<F>(mut self, builder: F) -> Self
943 where
944 F: Fn() -> AnyElement + Send + Sync + 'static,
945 {
946 self.not_found = Some(Box::new(builder));
947 self
948 }
949
950 pub fn with_loading<F>(mut self, builder: F) -> Self
952 where
953 F: Fn() -> AnyElement + Send + Sync + 'static,
954 {
955 self.loading = Some(Box::new(builder));
956 self
957 }
958
959 pub fn with_error<F>(mut self, builder: F) -> Self
961 where
962 F: Fn(&str) -> AnyElement + Send + Sync + 'static,
963 {
964 self.error = Some(Box::new(builder));
965 self
966 }
967
968 pub fn render_not_found(&self) -> AnyElement {
970 if let Some(builder) = &self.not_found {
971 builder()
972 } else {
973 default_not_found_page().into_any_element()
974 }
975 }
976
977 pub fn render_loading(&self) -> AnyElement {
979 if let Some(builder) = &self.loading {
980 builder()
981 } else {
982 default_loading_page().into_any_element()
983 }
984 }
985
986 pub fn render_error(&self, message: &str) -> AnyElement {
988 if let Some(builder) = &self.error {
989 builder(message)
990 } else {
991 default_error_page(message).into_any_element()
992 }
993 }
994}
995
996impl Default for DefaultPages {
997 fn default() -> Self {
998 Self::new()
999 }
1000}
1001
1002fn not_found_page() -> impl IntoElement {
1008 default_not_found_page()
1011}
1012
1013fn default_not_found_page() -> impl IntoElement {
1015 use gpui::{div, relative, rgb, ParentElement, Styled};
1016
1017 div()
1018 .flex()
1019 .flex_col()
1020 .items_center()
1021 .justify_center()
1022 .size_full()
1023 .bg(rgb(0x1e1e1e))
1024 .p_8()
1025 .gap_6()
1026 .child(
1027 div()
1028 .flex()
1029 .items_center()
1030 .justify_center()
1031 .w(px(140.))
1032 .h(px(140.))
1033 .rounded(px(24.))
1034 .bg(rgb(0xf44336))
1035 .shadow_lg()
1036 .child(
1037 div()
1038 .text_color(rgb(0xffffff))
1039 .text_size(px(64.))
1040 .child("404"),
1041 ),
1042 )
1043 .child(
1044 div()
1045 .text_3xl()
1046 .font_weight(FontWeight::BOLD)
1047 .text_color(rgb(0xffffff))
1048 .child("Page Not Found"),
1049 )
1050 .child(
1051 div()
1052 .text_base()
1053 .text_color(rgb(0xcccccc))
1054 .text_center()
1055 .max_w(px(500.))
1056 .line_height(relative(1.6))
1057 .child("The page you're looking for doesn't exist or has been moved."),
1058 )
1059 .child(
1060 div()
1061 .mt_4()
1062 .p_6()
1063 .bg(rgb(0x252526))
1064 .rounded(px(12.))
1065 .border_1()
1066 .border_color(rgb(0x3e3e3e))
1067 .max_w(px(600.))
1068 .child(
1069 div()
1070 .flex()
1071 .flex_col()
1072 .gap_3()
1073 .child(
1074 div()
1075 .text_sm()
1076 .font_weight(FontWeight::BOLD)
1077 .text_color(rgb(0xf44336))
1078 .mb_2()
1079 .child("What happened?"),
1080 )
1081 .child(not_found_item("•", "The route doesn't exist in the router"))
1082 .child(not_found_item("•", "The URL might be mistyped"))
1083 .child(not_found_item("•", "The page may have been removed")),
1084 ),
1085 )
1086}
1087
1088fn not_found_item(bullet: &str, text: &str) -> impl IntoElement {
1089 use gpui::{div, rgb, ParentElement, Styled};
1090
1091 div()
1092 .flex()
1093 .items_start()
1094 .gap_3()
1095 .child(
1096 div()
1097 .text_sm()
1098 .text_color(rgb(0xf44336))
1099 .child(bullet.to_string()),
1100 )
1101 .child(
1102 div()
1103 .text_sm()
1104 .text_color(rgb(0xcccccc))
1105 .line_height(relative(1.5))
1106 .child(text.to_string()),
1107 )
1108}
1109
1110fn default_loading_page() -> impl IntoElement {
1112 use gpui::{div, rgb, ParentElement, Styled};
1113
1114 div()
1115 .flex()
1116 .flex_col()
1117 .items_center()
1118 .justify_center()
1119 .size_full()
1120 .bg(rgb(0x1e1e1e))
1121 .gap_4()
1122 .child(
1123 div()
1124 .flex()
1125 .items_center()
1126 .justify_center()
1127 .w(px(80.))
1128 .h(px(80.))
1129 .rounded(px(16.))
1130 .bg(rgb(0x2196f3))
1131 .shadow_lg()
1132 .child(
1133 div()
1134 .text_color(rgb(0xffffff))
1135 .text_size(px(36.))
1136 .child("⏳"),
1137 ),
1138 )
1139 .child(
1140 div()
1141 .text_xl()
1142 .font_weight(FontWeight::MEDIUM)
1143 .text_color(rgb(0xffffff))
1144 .child("Loading..."),
1145 )
1146 .child(
1147 div()
1148 .text_sm()
1149 .text_color(rgb(0x888888))
1150 .child("Please wait"),
1151 )
1152}
1153
1154fn default_error_page(message: &str) -> impl IntoElement {
1156 use gpui::{div, relative, rgb, ParentElement, Styled};
1157
1158 div()
1159 .flex()
1160 .flex_col()
1161 .items_center()
1162 .justify_center()
1163 .size_full()
1164 .bg(rgb(0x1e1e1e))
1165 .p_8()
1166 .gap_6()
1167 .child(
1168 div()
1169 .flex()
1170 .items_center()
1171 .justify_center()
1172 .w(px(120.))
1173 .h(px(120.))
1174 .rounded(px(20.))
1175 .bg(rgb(0xff9800))
1176 .shadow_lg()
1177 .child(
1178 div()
1179 .text_color(rgb(0xffffff))
1180 .text_size(px(48.))
1181 .child("⚠️"),
1182 ),
1183 )
1184 .child(
1185 div()
1186 .text_2xl()
1187 .font_weight(FontWeight::BOLD)
1188 .text_color(rgb(0xffffff))
1189 .child("Something Went Wrong"),
1190 )
1191 .child(
1192 div()
1193 .text_base()
1194 .text_color(rgb(0xcccccc))
1195 .text_center()
1196 .max_w(px(500.))
1197 .line_height(relative(1.6))
1198 .child(message.to_string()),
1199 )
1200 .child(
1201 div()
1202 .mt_2()
1203 .px_6()
1204 .py_3()
1205 .bg(rgb(0x252526))
1206 .rounded(px(8.))
1207 .border_1()
1208 .border_color(rgb(0x3e3e3e))
1209 .child(
1210 div()
1211 .text_sm()
1212 .text_color(rgb(0x888888))
1213 .child("Try refreshing the page or contact support"),
1214 ),
1215 )
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 use super::{find_parent_route_for_path, RouterOutlet};
1221 use crate::route::Route;
1222 use gpui::{div, IntoElement, ParentElement};
1223 use std::sync::Arc;
1224
1225 #[test]
1226 fn test_outlet_creation() {
1227 let outlet = RouterOutlet::default();
1228 assert!(outlet.name.is_none());
1229
1230 let named = RouterOutlet::named("sidebar");
1231 assert_eq!(named.name.as_deref(), Some("sidebar"));
1232 }
1233
1234 #[test]
1235 fn test_outlet_name() {
1236 let outlet = RouterOutlet::new();
1237 assert!(outlet.name.is_none());
1238
1239 let named = RouterOutlet::named("main");
1240 assert_eq!(named.name, Some("main".to_string()));
1241 }
1242
1243 fn dummy_builder(_cx: &mut gpui::App, _params: &crate::RouteParams) -> gpui::AnyElement {
1245 div().child("test").into_any_element()
1246 }
1247
1248 #[test]
1249 fn test_find_parent_route_simple() {
1250 let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1255 vec![
1256 Arc::new(Route::new("overview", dummy_builder)),
1257 Arc::new(Route::new("analytics", dummy_builder)),
1258 ],
1259 ))];
1260
1261 let result = find_parent_route_for_path(&routes, "/dashboard/analytics");
1263 assert!(result.is_some());
1264 assert_eq!(result.unwrap().config.path, "/dashboard");
1265 }
1266
1267 #[test]
1268 fn test_find_parent_route_exact_match() {
1269 let routes = vec![Arc::new(
1270 Route::new("/dashboard", dummy_builder)
1271 .children(vec![Arc::new(Route::new("settings", dummy_builder))]),
1272 )];
1273
1274 let result = find_parent_route_for_path(&routes, "/dashboard");
1276 assert!(result.is_some());
1277 assert_eq!(result.unwrap().config.path, "/dashboard");
1278 }
1279
1280 #[test]
1281 fn test_find_parent_route_no_children() {
1282 let routes = vec![Arc::new(Route::new("/about", dummy_builder))];
1284
1285 let result = find_parent_route_for_path(&routes, "/about");
1287 assert!(result.is_none());
1288 }
1289
1290 #[test]
1291 fn test_find_parent_route_nested_parents() {
1292 let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1297 vec![Arc::new(Route::new("settings", dummy_builder).children(
1298 vec![Arc::new(Route::new("profile", dummy_builder))],
1299 ))],
1300 ))];
1301
1302 let result = find_parent_route_for_path(&routes, "/dashboard/settings/profile");
1304 assert!(result.is_some());
1305 assert_eq!(result.unwrap().config.path, "settings");
1306 }
1307
1308 #[test]
1309 fn test_find_parent_route_root() {
1310 let routes = vec![Arc::new(Route::new("/", dummy_builder).children(vec![
1312 Arc::new(Route::new("home", dummy_builder)),
1313 Arc::new(Route::new("about", dummy_builder)),
1314 ]))];
1315
1316 let result = find_parent_route_for_path(&routes, "/home");
1318 assert!(result.is_some());
1319 assert_eq!(result.unwrap().config.path, "/");
1320
1321 let result = find_parent_route_for_path(&routes, "/about");
1322 assert!(result.is_some());
1323 assert_eq!(result.unwrap().config.path, "/");
1324 }
1325
1326 #[test]
1327 fn test_find_parent_route_multiple_top_level() {
1328 let routes = vec![
1330 Arc::new(
1331 Route::new("/dashboard", dummy_builder)
1332 .children(vec![Arc::new(Route::new("overview", dummy_builder))]),
1333 ),
1334 Arc::new(
1335 Route::new("/settings", dummy_builder)
1336 .children(vec![Arc::new(Route::new("profile", dummy_builder))]),
1337 ),
1338 ];
1339
1340 let result = find_parent_route_for_path(&routes, "/dashboard/overview");
1342 assert!(result.is_some());
1343 assert_eq!(result.unwrap().config.path, "/dashboard");
1344
1345 let result = find_parent_route_for_path(&routes, "/settings/profile");
1346 assert!(result.is_some());
1347 assert_eq!(result.unwrap().config.path, "/settings");
1348 }
1349
1350 #[test]
1351 fn test_find_parent_route_no_match() {
1352 let routes = vec![Arc::new(
1353 Route::new("/dashboard", dummy_builder)
1354 .children(vec![Arc::new(Route::new("overview", dummy_builder))]),
1355 )];
1356
1357 let result = find_parent_route_for_path(&routes, "/nonexistent/path");
1359 assert!(result.is_none());
1360 }
1361
1362 #[test]
1363 fn test_find_parent_route_trailing_slash() {
1364 let routes = vec![Arc::new(
1365 Route::new("/dashboard/", dummy_builder)
1366 .children(vec![Arc::new(Route::new("settings", dummy_builder))]),
1367 )];
1368
1369 let result = find_parent_route_for_path(&routes, "/dashboard/settings");
1371 assert!(result.is_some());
1372 }
1373
1374 #[test]
1375 fn test_find_parent_route_empty_child_path() {
1376 let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1378 vec![
1379 Arc::new(Route::new("", dummy_builder)), Arc::new(Route::new("settings", dummy_builder)),
1381 ],
1382 ))];
1383
1384 let result = find_parent_route_for_path(&routes, "/dashboard");
1386 assert!(result.is_some());
1387 assert_eq!(result.unwrap().config.path, "/dashboard");
1388 }
1389
1390 #[test]
1391 fn test_find_parent_prefers_deepest() {
1392 let routes = vec![Arc::new(Route::new("/", dummy_builder).children(vec![
1398 Arc::new(
1399 Route::new("dashboard", dummy_builder).children(vec![Arc::new(
1400 Route::new("settings", dummy_builder)
1401 .children(vec![Arc::new(Route::new("profile", dummy_builder))]),
1402 )]),
1403 ),
1404 ]))];
1405
1406 let result = find_parent_route_for_path(&routes, "/dashboard/settings/profile");
1408 assert!(result.is_some());
1409 assert_eq!(result.unwrap().config.path, "settings");
1410
1411 let result = find_parent_route_for_path(&routes, "/dashboard/settings");
1413 assert!(result.is_some());
1414 assert_eq!(result.unwrap().config.path, "dashboard");
1415 }
1416}