1use gpui_kit::{
5 App, AppContext as _, Context, ElementId, Entity, EventEmitter, Focusable as _,
6 InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _,
7 Subscription, Task, TestSupportExt as _, Window,
8 base::{h_flex, v_flex},
9 div,
10 prelude::FluentBuilder as _,
11};
12use std::sync::Arc;
13
14use smol::channel::{Sender, unbounded};
15
16use super::{WebFrame, WebView};
17use crate::{
18 ActiveTheme as _, Button, Input, InputEvent, InputState, Tab, Tabs, TabsEvent, TabsState,
19 gpui_kit::assets::IconName,
20};
21
22const ID: &str = "browser";
24
25const MAX_TABS: usize = 32;
28
29const SEARCH: &str = "https://duckduckgo.com/?q=";
32
33enum Notice {
36 Url(SharedString, String),
37 Title(SharedString, String),
38 OpenTab(String),
39}
40
41struct Page {
42 id: SharedString,
43 view: Entity<WebView>,
44 url: SharedString,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum BrowserEvent {
51 Opened(SharedString),
53 Closed(SharedString),
55 Navigated {
57 tab: SharedString,
59 url: SharedString,
61 },
62 Failed(SharedString),
65}
66
67pub struct BrowserState {
76 tabs: Entity<TabsState>,
77 address: Entity<InputState>,
78 pages: Vec<Page>,
79 home: SharedString,
80 next: u64,
81 notices: Sender<Notice>,
82 _pump: Task<()>,
83 _subscriptions: Vec<Subscription>,
84}
85
86impl EventEmitter<BrowserEvent> for BrowserState {}
87
88impl BrowserState {
89 pub fn open(
96 home: impl Into<SharedString>,
97 window: &mut Window,
98 cx: &mut App,
99 ) -> wry::Result<Entity<Self>> {
100 let home = home.into();
101 let state = cx.new(|cx| Self::create(home.clone(), window, cx));
102 state.update(cx, |this, cx| this.open_tab(home.to_string(), window, cx))?;
103 Ok(state)
104 }
105
106 fn create(home: SharedString, window: &mut Window, cx: &mut Context<Self>) -> Self {
107 let tabs = cx.new(|_| TabsState::new([]));
108 let address =
109 cx.new(|cx| InputState::new(window, cx).placeholder("Search or enter address"));
110 let (notices, inbox) = unbounded();
111 let pump = cx.spawn_in(window, async move |this, cx| {
112 while let Ok(notice) = inbox.recv().await {
113 if this
114 .update_in(cx, |this, window, cx| this.apply(notice, window, cx))
115 .is_err()
116 {
117 break;
118 }
119 }
120 });
121 let subscriptions = vec![
122 cx.subscribe_in(&tabs, window, |this, _, event, window, cx| match event {
123 TabsEvent::Selected(_) => this.show_selected(window, cx),
124 TabsEvent::Closed(id) => this.drop_page(id, window, cx),
125 TabsEvent::AddRequested => {
126 this.open_home(window, cx);
127 }
128 _ => {}
129 }),
130 cx.subscribe_in(&address, window, |this, input, event, _, cx| {
131 if matches!(event, InputEvent::PressEnter { .. }) {
132 let text = input.read(cx).value().to_string();
133 this.navigate(text, cx);
134 }
135 }),
136 ];
137 Self {
138 tabs,
139 address,
140 pages: Vec::new(),
141 home,
142 next: 0,
143 notices,
144 _pump: pump,
145 _subscriptions: subscriptions,
146 }
147 }
148
149 fn open_home(&mut self, window: &mut Window, cx: &mut Context<Self>) {
151 let home = self.home.to_string();
152 self.open_reporting(home, window, cx);
153 }
154
155 fn open_reporting(&mut self, url: String, window: &mut Window, cx: &mut Context<Self>) {
157 if let Err(error) = self.open_tab(url, window, cx) {
158 cx.emit(BrowserEvent::Failed(error.to_string().into()));
159 }
160 }
161
162 pub fn open_tab(
169 &mut self,
170 url: impl Into<String>,
171 window: &mut Window,
172 cx: &mut Context<Self>,
173 ) -> wry::Result<SharedString> {
174 let url = url.into();
175 if self.pages.len() >= MAX_TABS {
176 return Err(wry::Error::Io(std::io::Error::other("too many tabs")));
177 }
178 let id = SharedString::from(format!("tab-{}", self.next));
179 self.next += 1;
180 let navigation = self.notices.clone();
181 let titles = self.notices.clone();
182 let windows = self.notices.clone();
183 let tab = id.clone();
184 let named = id.clone();
185 let builder = wry::WebViewBuilder::new()
186 .with_url(url.clone())
187 .with_navigation_handler(move |url| {
188 if !is_browsable(&url) {
189 return false;
190 }
191 navigation.try_send(Notice::Url(tab.clone(), url)).ok();
192 true
193 })
194 .with_document_title_changed_handler(move |title| {
195 titles.try_send(Notice::Title(named.clone(), title)).ok();
196 })
197 .with_new_window_req_handler(move |url, _| {
198 windows.try_send(Notice::OpenTab(url)).ok();
199 wry::NewWindowResponse::Deny
200 });
201 let view = WebView::build(builder, window, cx)?;
202 let view = cx.new(|_| view);
203 self.pages.push(Page {
204 id: id.clone(),
205 view,
206 url: url.into(),
207 });
208 self.tabs.update(cx, |tabs, cx| {
209 tabs.push(
210 Tab::new(id.clone(), "New tab").with_icon(IconName::Globe),
211 cx,
212 );
213 });
214 self.show_selected(window, cx);
215 cx.emit(BrowserEvent::Opened(id.clone()));
216 Ok(id)
217 }
218
219 pub fn navigate(&mut self, text: impl AsRef<str>, cx: &mut Context<Self>) {
223 let url = address_to_url(text.as_ref());
224 let Some(page) = self.selected_page(cx) else {
225 return;
226 };
227 page.view.update(cx, |view, _| view.load_url(&url));
228 let tab = page.id.clone();
229 cx.emit(BrowserEvent::Navigated {
230 tab,
231 url: url.into(),
232 });
233 }
234
235 pub fn back(&mut self, cx: &mut Context<Self>) {
237 if let Some(page) = self.selected_page(cx) {
238 page.view.update(cx, |view, _| view.back().ok());
239 }
240 }
241
242 pub fn forward(&mut self, cx: &mut Context<Self>) {
244 if let Some(page) = self.selected_page(cx) {
245 page.view.update(cx, |view, _| view.forward().ok());
246 }
247 }
248
249 pub fn reload(&mut self, cx: &mut Context<Self>) {
251 if let Some(page) = self.selected_page(cx) {
252 page.view.read(cx).raw().reload().ok();
253 }
254 }
255
256 pub fn close_tab(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
258 self.tabs.update(cx, |tabs, cx| tabs.remove(id, cx));
259 }
260
261 pub fn tabs(&self) -> &Entity<TabsState> {
263 &self.tabs
264 }
265
266 pub fn url(&self, cx: &App) -> Option<SharedString> {
268 let selected = self.tabs.read(cx).selected()?;
269 self.pages
270 .iter()
271 .find(|page| &page.id == selected)
272 .map(|page| page.url.clone())
273 }
274
275 fn selected_page(&self, cx: &App) -> Option<&Page> {
276 let selected = self.tabs.read(cx).selected()?;
277 self.pages.iter().find(|page| &page.id == selected)
278 }
279
280 fn show_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
283 let selected = self.tabs.read(cx).selected().cloned();
284 for page in &self.pages {
285 let shown = selected.as_ref() == Some(&page.id);
286 page.view.read(cx).set_visible(shown).ok();
287 }
288 let url = self.url(cx).unwrap_or_default();
289 self.address
290 .update(cx, |input, cx| input.set_value(url.to_string(), window, cx));
291 cx.notify();
292 }
293
294 fn drop_page(&mut self, id: &SharedString, window: &mut Window, cx: &mut Context<Self>) {
295 let Some(index) = self.pages.iter().position(|page| &page.id == id) else {
296 return;
297 };
298 self.pages.remove(index);
299 cx.emit(BrowserEvent::Closed(id.clone()));
300 if self.pages.is_empty() {
301 self.open_home(window, cx);
302 }
303 }
304
305 fn apply(&mut self, notice: Notice, window: &mut Window, cx: &mut Context<Self>) {
306 match notice {
307 Notice::Url(id, url) => {
308 let selected = self.tabs.read(cx).selected() == Some(&id);
309 let Some(page) = self.pages.iter_mut().find(|page| page.id == id) else {
310 return;
311 };
312 page.url = url.clone().into();
313 let editing = self.address.read(cx).focus_handle(cx).is_focused(window);
314 if selected && !editing {
315 self.address
316 .update(cx, |input, cx| input.set_value(url, window, cx));
317 }
318 }
319 Notice::Title(id, title) => {
320 let label = if title.trim().is_empty() {
321 SharedString::from("New tab")
322 } else {
323 title.into()
324 };
325 self.tabs.update(cx, |tabs, cx| tabs.rename(id, label, cx));
326 }
327 Notice::OpenTab(url) => {
328 if is_browsable(&url) {
329 self.open_reporting(url, window, cx);
330 }
331 }
332 }
333 cx.notify();
334 }
335}
336
337fn is_browsable(url: &str) -> bool {
341 let lower = url.trim().to_ascii_lowercase();
342 lower.starts_with("https://") || lower.starts_with("http://") || lower == "about:blank"
343}
344
345fn is_local_host(text: &str) -> bool {
348 let host = text.split(['/', '?', '#']).next().unwrap_or_default();
349 let name = host.rsplit_once(':').map_or(host, |(name, _)| name);
350 name == "localhost" || name.parse::<std::net::Ipv4Addr>().is_ok()
351}
352
353fn address_to_url(text: &str) -> String {
355 let text = text.trim();
356 if is_browsable(text) {
357 text.to_owned()
358 } else if !text.contains(char::is_whitespace) && is_local_host(text) {
359 format!("http://{text}")
360 } else if !text.contains(char::is_whitespace) && !text.contains(':') && text.contains('.') {
361 format!("https://{text}")
362 } else {
363 let query: String = text
364 .bytes()
365 .map(|byte| match byte {
366 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
367 char::from(byte).to_string()
368 }
369 b' ' => "+".to_owned(),
370 _ => format!("%{byte:02X}"),
371 })
372 .collect();
373 format!("{SEARCH}{query}")
374 }
375}
376
377impl Render for BrowserState {
378 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
379 let page = self.selected_page(cx).map(|page| page.view.clone());
380 let (fill, bar, hairline) = {
381 let theme = cx.theme();
382 (theme.background(), theme.metrics.title_bar, theme.border())
383 };
384 let button = |name: &'static str, icon: IconName, label: &'static str| {
385 Button::new(ElementId::NamedChild(
386 Arc::new(ElementId::Name(ID.into())),
387 name.into(),
388 ))
389 .ghost()
390 .icon(icon)
391 .accessibility_label(label)
392 .tooltip(label)
393 };
394 v_flex()
395 .size_full()
396 .id(ID)
397 .test_support()
398 .bg(fill)
399 .child(div().h(bar).w_full().flex_shrink_0().child(Tabs::new(
400 ElementId::NamedChild(Arc::new(ElementId::Name(ID.into())), "tabs".into()),
401 &self.tabs,
402 )))
403 .child(
404 h_flex()
405 .gap_1()
406 .p_2()
407 .flex_shrink_0()
408 .border_b_1()
409 .border_color(hairline)
410 .items_center()
411 .child(
412 button("back", IconName::ArrowLeft, "Back")
413 .on_click(cx.listener(|this, _, _, cx| this.back(cx))),
414 )
415 .child(
416 button("forward", IconName::ArrowRight, "Forward")
417 .on_click(cx.listener(|this, _, _, cx| this.forward(cx))),
418 )
419 .child(
420 button("reload", IconName::RotateCw, "Reload")
421 .on_click(cx.listener(|this, _, _, cx| this.reload(cx))),
422 )
423 .child(
424 Input::new(&self.address)
425 .id(ElementId::NamedChild(
426 Arc::new(ElementId::Name(ID.into())),
427 "address".into(),
428 ))
429 .accessibility_label("Address")
430 .flex_1(),
431 ),
432 )
433 .when_some(page, |column, page| {
434 column.child(
435 div().flex_1().min_h_0().child(
436 WebFrame::new(ElementId::NamedChild(
437 Arc::new(ElementId::Name(ID.into())),
438 "page".into(),
439 ))
440 .webview(page)
441 .size_full()
442 .rounded_none()
443 .border_0(),
444 ),
445 )
446 })
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::{address_to_url, is_browsable};
453
454 #[test]
455 fn a_web_address_keeps_its_scheme() {
456 assert_eq!(address_to_url(" https://a.dev/x "), "https://a.dev/x");
457 assert_eq!(address_to_url("about:blank"), "about:blank");
458 }
459
460 #[test]
461 fn a_host_gets_https() {
462 assert_eq!(address_to_url("example.com/a"), "https://example.com/a");
463 }
464
465 #[test]
466 fn a_local_host_gets_http() {
467 assert_eq!(address_to_url("localhost:3000"), "http://localhost:3000");
468 assert_eq!(
469 address_to_url("127.0.0.1:8080/x"),
470 "http://127.0.0.1:8080/x"
471 );
472 }
473
474 #[test]
475 fn other_text_is_a_search() {
476 assert_eq!(
477 address_to_url("rust gpui?"),
478 "https://duckduckgo.com/?q=rust+gpui%3F"
479 );
480 }
481
482 #[test]
483 fn files_scripts_and_data_are_never_loaded() {
484 for text in [
485 "file:///etc/passwd",
486 "javascript:alert(1)",
487 "data:text/html,x",
488 ] {
489 assert!(!is_browsable(text), "{text}");
490 assert!(
491 address_to_url(text).starts_with("https://duckduckgo.com/?q="),
492 "{text}"
493 );
494 }
495 }
496}