fs_router/adapters/
gpui.rs

1use crate::core::{ParamKind, RouteSpec, RouteTable};
2use gpui::{AnyView, Context, IntoElement, Render, SharedString, Window, div, prelude::*, px, rgb};
3
4#[derive(Debug, Clone)]
5pub struct RouteMatch {
6    pub spec: RouteSpec,
7    pub params: Vec<(String, String)>,
8}
9
10pub struct RouterView {
11    table: RouteTable<AnyView>,
12    current_route: SharedString,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum NavigateError {
17    RouteIdNotFound(u64),
18    MissingParam { name: String },
19    UnsupportedWildcard,
20}
21
22impl RouterView {
23    pub fn new(table: RouteTable<AnyView>, initial_route: impl Into<SharedString>) -> Self {
24        Self {
25            table,
26            current_route: initial_route.into(),
27        }
28    }
29
30    pub fn route(&self) -> &str {
31        &self.current_route
32    }
33
34    pub fn navigate(&mut self, route: impl Into<SharedString>, cx: &mut Context<Self>) {
35        self.current_route = route.into();
36        cx.notify();
37    }
38
39    pub fn set_route(&mut self, route: impl Into<SharedString>, cx: &mut Context<Self>) {
40        self.navigate(route, cx);
41    }
42
43    pub fn navigate_by_id(
44        &mut self,
45        route_id: u64,
46        params: &[(&str, &str)],
47        cx: &mut Context<Self>,
48    ) -> Result<(), NavigateError> {
49        let (spec, _) = self
50            .table
51            .routes
52            .iter()
53            .find(|(spec, _)| spec.id == route_id)
54            .ok_or(NavigateError::RouteIdNotFound(route_id))?;
55
56        let route = build_path(spec, params)?;
57        self.navigate(route, cx);
58        Ok(())
59    }
60
61    pub fn table(&self) -> &RouteTable<AnyView> {
62        &self.table
63    }
64
65    pub fn table_mut(&mut self) -> &mut RouteTable<AnyView> {
66        &mut self.table
67    }
68}
69
70impl Render for RouterView {
71    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
72        let resolved = resolve_any_view(&self.table, &self.current_route);
73
74        let (matched_view, matched_meta) = match resolved {
75            Some(resolved) => {
76                let view = Some(resolved.view);
77                let meta = Some(resolved.matched);
78                (view, meta)
79            }
80            None => (self.table.fallback.clone(), None),
81        };
82
83        let header = render_debug_header(&self.current_route, matched_meta.as_ref(), &self.table);
84
85        div()
86            .flex()
87            .flex_col()
88            .size_full()
89            .child(header)
90            .child(
91                div()
92                    .flex_1()
93                    .size_full()
94                    .bg(rgb(0xffffff))
95                    .when_some(matched_view, |d, view| d.child(view)),
96            )
97    }
98}
99
100struct ResolvedAnyView {
101    matched: RouteMatch,
102    view: AnyView,
103}
104
105fn resolve_any_view(table: &RouteTable<AnyView>, path: &str) -> Option<ResolvedAnyView> {
106    let mut best: Option<(usize, RouteMatch, AnyView)> = None;
107
108    for (spec, view) in &table.routes {
109        let Some(matched) = match_route(spec, path) else {
110            continue;
111        };
112
113        let score = score_spec(spec);
114        match &best {
115            Some((best_score, _, _)) if *best_score >= score => {}
116            _ => best = Some((score, matched, view.clone())),
117        }
118    }
119
120    best.map(|(_, matched, view)| ResolvedAnyView { matched, view })
121}
122
123fn score_spec(spec: &RouteSpec) -> usize {
124    let tokens = tokenize_pattern(&spec.path, &spec.params);
125    tokens
126        .iter()
127        .map(|t| match t {
128            Token::Static(_) => 100,
129            Token::ParamSingle(_) => 10,
130            Token::ParamCatchAll(_) => 1,
131            Token::Wildcard => 0,
132        })
133        .sum::<usize>()
134        + tokens.len()
135}
136
137pub fn build_path(spec: &RouteSpec, params: &[(&str, &str)]) -> Result<String, NavigateError> {
138    let tokens = tokenize_pattern(&spec.path, &spec.params);
139
140    let mut result = String::new();
141    for token in tokens {
142        match token {
143            Token::Static(s) => {
144                if s != "/" {
145                    result.push('/');
146                    result.push_str(&s);
147                }
148            }
149            Token::ParamSingle(name) => {
150                let value = params
151                    .iter()
152                    .find(|(k, _)| *k == name)
153                    .map(|(_, v)| *v)
154                    .ok_or_else(|| NavigateError::MissingParam { name })?;
155
156                result.push('/');
157                result.push_str(value);
158            }
159            Token::ParamCatchAll(name) => {
160                let value = params
161                    .iter()
162                    .find(|(k, _)| *k == name)
163                    .map(|(_, v)| *v)
164                    .ok_or_else(|| NavigateError::MissingParam { name })?;
165
166                let value = value.trim_matches('/');
167                if !value.is_empty() {
168                    result.push('/');
169                    result.push_str(value);
170                }
171            }
172            Token::Wildcard => return Err(NavigateError::UnsupportedWildcard),
173        }
174    }
175
176    if result.is_empty() {
177        result.push('/');
178    }
179
180    Ok(result)
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184enum Token {
185    Static(String),
186    ParamSingle(String),
187    ParamCatchAll(String),
188    Wildcard,
189}
190
191fn tokenize_pattern(pattern: &str, params: &[crate::core::ParamSpec]) -> Vec<Token> {
192    let catchall_params: std::collections::HashSet<&str> = params
193        .iter()
194        .filter(|p| p.kind == ParamKind::CatchAll)
195        .map(|p| p.name.as_str())
196        .collect();
197
198    let mut segments: Vec<&str> = pattern
199        .split('/')
200        .filter(|s| !s.is_empty())
201        .collect();
202
203    if segments.is_empty() {
204        segments.push("/");
205    }
206
207    let mut tokens = Vec::new();
208    let mut i = 0;
209    while i < segments.len() {
210        let seg = segments[i];
211
212        if seg == "*" {
213            tokens.push(Token::Wildcard);
214            i += 1;
215            continue;
216        }
217
218        if let Some(name) = seg.strip_prefix(':') {
219            let is_catchall = catchall_params.contains(name);
220
221            if is_catchall && segments.get(i + 1).copied() == Some("*") {
222                tokens.push(Token::ParamCatchAll(name.to_string()));
223                i += 2;
224                continue;
225            }
226
227            tokens.push(Token::ParamSingle(name.to_string()));
228            i += 1;
229            continue;
230        }
231
232        tokens.push(Token::Static(seg.to_string()));
233        i += 1;
234    }
235
236    tokens
237}
238
239pub fn match_route(spec: &RouteSpec, path: &str) -> Option<RouteMatch> {
240    let tokens = tokenize_pattern(&spec.path, &spec.params);
241
242    let path_segments: Vec<&str> = path
243        .split('/')
244        .filter(|s| !s.is_empty())
245        .collect();
246
247    let mut params: Vec<(String, String)> = Vec::new();
248
249    if match_tokens(&tokens, &path_segments, 0, 0, &mut params) {
250        Some(RouteMatch {
251            spec: spec.clone(),
252            params,
253        })
254    } else {
255        None
256    }
257}
258
259fn match_tokens(
260    tokens: &[Token],
261    path_segments: &[&str],
262    token_index: usize,
263    path_index: usize,
264    params: &mut Vec<(String, String)>,
265) -> bool {
266    if token_index == tokens.len() {
267        return path_index == path_segments.len();
268    }
269
270    match &tokens[token_index] {
271        Token::Static(expected) => {
272            let Some(actual) = path_segments.get(path_index).copied() else {
273                return false;
274            };
275            if actual != expected {
276                return false;
277            }
278            match_tokens(tokens, path_segments, token_index + 1, path_index + 1, params)
279        }
280        Token::ParamSingle(name) => {
281            let Some(value) = path_segments.get(path_index).copied() else {
282                return false;
283            };
284            params.push((name.clone(), value.to_string()));
285            let ok = match_tokens(tokens, path_segments, token_index + 1, path_index + 1, params);
286            if !ok {
287                params.pop();
288            }
289            ok
290        }
291        Token::ParamCatchAll(name) => {
292            for end in (path_index + 1..=path_segments.len()).rev() {
293                let captured = path_segments[path_index..end].join("/");
294                params.push((name.clone(), captured));
295                let ok = match_tokens(tokens, path_segments, token_index + 1, end, params);
296                if ok {
297                    return true;
298                }
299                params.pop();
300            }
301            false
302        }
303        Token::Wildcard => {
304            if token_index + 1 == tokens.len() {
305                return true;
306            }
307
308            for end in path_index..=path_segments.len() {
309                if match_tokens(tokens, path_segments, token_index + 1, end, params) {
310                    return true;
311                }
312            }
313            false
314        }
315    }
316}
317
318fn render_debug_header(
319    current_route: &str,
320    matched: Option<&RouteMatch>,
321    table: &RouteTable<AnyView>,
322) -> impl IntoElement {
323    let matched_path = matched.map(|m| m.spec.path.as_str()).unwrap_or("(none)");
324    let matched_source = matched
325        .map(|m| m.spec.source.as_str())
326        .unwrap_or("(none)");
327
328    let matched_kind = matched
329        .map(|m| format!("{:?}", m.spec.kind))
330        .unwrap_or_else(|| "(none)".to_string());
331
332    let params_string = matched
333        .map(|m| {
334            if m.params.is_empty() {
335                "(none)".to_string()
336            } else {
337                m.params
338                    .iter()
339                    .map(|(k, v)| format!("{k}={v}"))
340                    .collect::<Vec<_>>()
341                    .join(", ")
342            }
343        })
344        .unwrap_or_else(|| "(none)".to_string());
345
346    div()
347        .flex()
348        .flex_col()
349        .gap_1()
350        .p_2()
351        .bg(rgb(0x161616))
352        .text_color(rgb(0xf0f0f0))
353        .text_sm()
354        .child(format!("route: {current_route}"))
355        .child(format!("matched: {matched_path}  kind: {matched_kind}"))
356        .child(format!("params: {params_string}"))
357        .child(format!("source: {matched_source}"))
358        .child(format!(
359            "routes: {}  fallback: {}",
360            table.routes.len(),
361            if table.fallback.is_some() { "yes" } else { "no" }
362        ))
363        .child(div().h(px(1.0)).bg(rgb(0x2a2a2a)))
364}