Skip to main content

oxide_browser/
ui.rs

1//! Desktop shell for Oxide using [GPUI](https://www.gpui.rs/) (Zed’s GPU-accelerated UI framework).
2//!
3//! Guest canvas commands are painted with [`Window::paint_quad`], [`Window::paint_path`],
4//! [`Window::paint_image`], and GPU text shaping — bitmaps (including video frames) are uploaded as
5//! [`RenderImage`] textures and composited on the GPU.
6//!
7//! ## Public API
8//!
9//! - [`run_browser`] — Start the GPUI [`Application`] and open the main browser window; pass
10//!   [`HostState`] and page status from [`crate::runtime::BrowserHost`].
11//! - [`OxideBrowserView`] — Root view: tabs, toolbar, canvas [`canvas`] element, console, and bookmarks.
12
13use std::collections::{HashMap, HashSet};
14use std::path::PathBuf;
15use std::sync::atomic::Ordering;
16use std::sync::mpsc::{self, TryRecvError};
17use std::sync::{Arc, Mutex};
18use std::time::{Instant, SystemTime, UNIX_EPOCH};
19
20use gpui::prelude::*;
21use gpui::{
22    canvas, div, font, img, point, px, size, Application, Bounds, ClickEvent, FocusHandle,
23    ImageSource, InteractiveElement, KeyDownEvent, KeyUpEvent, Keystroke, MouseButton,
24    MouseDownEvent, MouseUpEvent, PathBuilder, Pixels, Point, Render, RenderImage, Rgba,
25    ScrollDelta, ScrollWheelEvent, SharedString, TextRun, TitlebarOptions, Window, WindowBounds,
26    WindowKind, WindowOptions,
27};
28use image::Frame;
29use smallvec::smallvec;
30
31use crate::bookmarks::BookmarkStore;
32use crate::capabilities::{
33    ConsoleLevel, DrawCommand, GradientStop, HostState, WidgetCommand, WidgetValue,
34};
35use crate::download::{format_bytes, DownloadManager, DownloadState};
36use crate::engine::ModuleLoader;
37use crate::forge::{
38    ForgeChatMessage, ForgeCreationSummary, ForgeMessageRole, ForgePhase, ForgeSnapshot, ForgeState,
39};
40use crate::forge_config::{mask_api_key, ForgeProvider, ForgeUserConfig};
41use crate::history::HistoryStore;
42use crate::navigation::HistoryEntry;
43use crate::runtime::{LiveModule, PageStatus};
44
45enum RunRequest {
46    FetchAndRun { url: String },
47    LoadLocal(Vec<u8>),
48}
49
50struct RunResult {
51    error: Option<String>,
52    live_module: Option<LiveModule>,
53}
54
55// SAFETY: `LiveModule` contains a wasmtime `Store<HostState>` whose fields are
56// behind `Arc<Mutex<…>>`, making them safe to send across threads. The `error`
57// field is a plain `Option<String>`.
58unsafe impl Send for RunResult {}
59
60#[derive(Clone, PartialEq)]
61enum InternalPage {
62    Home,
63    History,
64    Bookmarks,
65    About,
66    Forge,
67}
68
69fn try_internal_page(url: &str) -> Option<InternalPage> {
70    match url {
71        "oxide://home" => Some(InternalPage::Home),
72        "oxide://history" => Some(InternalPage::History),
73        "oxide://bookmarks" => Some(InternalPage::Bookmarks),
74        "oxide://about" => Some(InternalPage::About),
75        "oxide://forge" => Some(InternalPage::Forge),
76        _ => None,
77    }
78}
79
80fn format_friendly_timestamp(timestamp_ms: u64) -> String {
81    let now_ms = SystemTime::now()
82        .duration_since(UNIX_EPOCH)
83        .unwrap_or_default()
84        .as_millis() as u64;
85    let diff_secs = now_ms.saturating_sub(timestamp_ms) / 1000;
86    if diff_secs < 60 {
87        "Just now".to_string()
88    } else if diff_secs < 3600 {
89        let m = diff_secs / 60;
90        if m == 1 {
91            "1 minute ago".to_string()
92        } else {
93            format!("{m} minutes ago")
94        }
95    } else if diff_secs < 86400 {
96        let h = diff_secs / 3600;
97        if h == 1 {
98            "1 hour ago".to_string()
99        } else {
100            format!("{h} hours ago")
101        }
102    } else {
103        let d = diff_secs / 86400;
104        if d == 1 {
105            "Yesterday".to_string()
106        } else {
107            format!("{d} days ago")
108        }
109    }
110}
111
112struct TabState {
113    id: u64,
114    url_input: String,
115    host_state: HostState,
116    status: Arc<Mutex<PageStatus>>,
117    show_console: bool,
118    run_tx: std::sync::mpsc::Sender<RunRequest>,
119    run_rx: Arc<Mutex<std::sync::mpsc::Receiver<RunResult>>>,
120    /// GPU texture cache for decoded canvas images (video frames use the same path).
121    image_textures: HashMap<usize, Arc<RenderImage>>,
122    pip_texture: Option<Arc<RenderImage>>,
123    pip_last_serial: u64,
124    canvas_generation: u64,
125    pending_history_url: Option<String>,
126    hovered_link_url: Option<String>,
127    live_module: Option<LiveModule>,
128    last_frame: Instant,
129    keys_held: HashSet<u32>,
130    /// Guest `TextInput` widget id with keyboard focus, if any.
131    text_input_focus: Option<u32>,
132    /// Cursor byte offset in `url_input`.
133    url_cursor: usize,
134    /// Selection anchor byte offset; when != `url_cursor`, the range between them is selected.
135    url_sel_start: usize,
136    /// True while the mouse button is held to drag-select in the URL bar.
137    url_selecting: bool,
138    /// Bounds of the URL text canvas element, for mouse hit-testing.
139    url_text_bounds: Arc<Mutex<Bounds<Pixels>>>,
140    internal_page: Option<InternalPage>,
141    /// Draft prompt for `oxide://forge`. Cleared when a session is started.
142    forge_prompt: String,
143    /// Forge session id being viewed / generated in this tab, if any.
144    forge_session_id: Option<u64>,
145}
146
147impl TabState {
148    fn new(id: u64, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
149        let (req_tx, req_rx) = std::sync::mpsc::channel::<RunRequest>();
150        let (res_tx, res_rx) = std::sync::mpsc::channel::<RunResult>();
151
152        let hs = host_state.clone();
153        let st = status.clone();
154
155        std::thread::spawn(move || {
156            let rt = tokio::runtime::Runtime::new().unwrap();
157            while let Ok(request) = req_rx.recv() {
158                let mut host = crate::runtime::BrowserHost::recreate(hs.clone(), st.clone());
159                let result = match request {
160                    RunRequest::FetchAndRun { url } => rt.block_on(host.fetch_and_run(&url)),
161                    RunRequest::LoadLocal(bytes) => host.run_bytes(&bytes),
162                };
163                let (error, live_module) = match result {
164                    Ok(live) => (None, live),
165                    Err(e) => (Some(e.to_string()), None),
166                };
167                let _ = res_tx.send(RunResult { error, live_module });
168            }
169        });
170
171        Self {
172            id,
173            url_input: String::from("oxide://home"),
174            host_state,
175            status,
176            show_console: false,
177            run_tx: req_tx,
178            run_rx: Arc::new(Mutex::new(res_rx)),
179            image_textures: HashMap::new(),
180            pip_texture: None,
181            pip_last_serial: 0,
182            canvas_generation: 0,
183            pending_history_url: None,
184            hovered_link_url: None,
185            live_module: None,
186            last_frame: Instant::now(),
187            keys_held: HashSet::new(),
188            text_input_focus: None,
189            url_cursor: 12,
190            url_sel_start: 12,
191            url_selecting: false,
192            url_text_bounds: Arc::new(Mutex::new(Bounds::default())),
193            internal_page: Some(InternalPage::Home),
194            forge_prompt: String::new(),
195            forge_session_id: None,
196        }
197        .with_home_status()
198    }
199
200    fn with_home_status(self) -> Self {
201        *self.status.lock().unwrap() = PageStatus::Running("oxide://home".to_string());
202        self
203    }
204
205    fn display_title(&self) -> String {
206        let status = self.status.lock().unwrap().clone();
207        match status {
208            PageStatus::Idle => "New Tab".to_string(),
209            PageStatus::Loading(_) => "Loading\u{2026}".to_string(),
210            PageStatus::Running(ref url) => url_to_title(url),
211            PageStatus::Error(_) => "Error".to_string(),
212        }
213    }
214
215    fn navigate(&mut self, dm: &DownloadManager) {
216        let mut url = self.url_input.trim().to_string();
217        if url.is_empty() {
218            return;
219        }
220        let is_search = (!url.contains('.')
221            && !url.starts_with("oxide://")
222            && !url.starts_with("http://")
223            && !url.starts_with("https://"))
224            || url.contains(' ');
225        if is_search {
226            self.forge_prompt = url;
227            url = "oxide://forge".to_string();
228            self.url_input = url.clone();
229            self.url_clamp_cursor();
230        }
231        if let Some(page) = try_internal_page(&url) {
232            self.internal_page = Some(page);
233            self.live_module = None;
234            *self.status.lock().unwrap() = PageStatus::Running(url.clone());
235            let mut nav = self.host_state.navigation.lock().unwrap();
236            nav.push(HistoryEntry::new(&url));
237            return;
238        }
239        if is_downloadable_url(&url) {
240            dm.start_download(url);
241            return;
242        }
243        self.internal_page = None;
244        self.pending_history_url = Some(url.clone());
245        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
246    }
247
248    fn navigate_to(&mut self, mut url: String, push_history: bool, dm: &DownloadManager) {
249        let is_search = (!url.contains('.')
250            && !url.starts_with("oxide://")
251            && !url.starts_with("http://")
252            && !url.starts_with("https://"))
253            || url.contains(' ');
254        if is_search {
255            self.forge_prompt = url;
256            url = "oxide://forge".to_string();
257        }
258        self.url_input = url.clone();
259        let len = self.url_input.len();
260        self.url_cursor = len;
261        self.url_sel_start = len;
262        if let Some(page) = try_internal_page(&url) {
263            self.internal_page = Some(page);
264            self.live_module = None;
265            *self.status.lock().unwrap() = PageStatus::Running(url.clone());
266            if push_history {
267                let mut nav = self.host_state.navigation.lock().unwrap();
268                nav.push(HistoryEntry::new(&url));
269            }
270            return;
271        }
272        if is_downloadable_url(&url) {
273            dm.start_download(url);
274            return;
275        }
276        self.internal_page = None;
277        if push_history {
278            self.pending_history_url = Some(url.clone());
279        }
280        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
281    }
282
283    fn reload(&mut self) {
284        *self.host_state.scroll_x.lock().unwrap() = 0.0;
285        *self.host_state.scroll_y.lock().unwrap() = 0.0;
286        let url = self.url_input.clone();
287        if !url.is_empty() {
288            if let Some(page) = try_internal_page(&url) {
289                self.internal_page = Some(page);
290                self.live_module = None;
291                *self.status.lock().unwrap() = PageStatus::Running(url.clone());
292            } else {
293                self.internal_page = None;
294                let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
295            }
296        }
297    }
298
299    fn go_back(&mut self) {
300        let entry = {
301            let mut nav = self.host_state.navigation.lock().unwrap();
302            nav.go_back().cloned()
303        };
304        if let Some(entry) = entry {
305            self.url_input = entry.url.clone();
306            self.url_clamp_cursor();
307            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
308            if let Some(page) = try_internal_page(&entry.url) {
309                self.internal_page = Some(page);
310                self.live_module = None;
311                *self.status.lock().unwrap() = PageStatus::Running(entry.url);
312            } else {
313                self.internal_page = None;
314                let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
315            }
316        }
317    }
318
319    fn go_forward(&mut self) {
320        let entry = {
321            let mut nav = self.host_state.navigation.lock().unwrap();
322            nav.go_forward().cloned()
323        };
324        if let Some(entry) = entry {
325            self.url_input = entry.url.clone();
326            self.url_clamp_cursor();
327            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
328            if let Some(page) = try_internal_page(&entry.url) {
329                self.internal_page = Some(page);
330                self.live_module = None;
331                *self.status.lock().unwrap() = PageStatus::Running(entry.url);
332            } else {
333                self.internal_page = None;
334                let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
335            }
336        }
337    }
338
339    fn drain_results(&mut self) {
340        if let Ok(rx) = self.run_rx.lock() {
341            while let Ok(result) = rx.try_recv() {
342                if let Some(err) = result.error {
343                    *self.status.lock().unwrap() = PageStatus::Error(err);
344                    self.pending_history_url = None;
345                    self.live_module = None;
346                } else {
347                    self.internal_page = None;
348                    if let Some(url) = self.pending_history_url.take() {
349                        let mut nav = self.host_state.navigation.lock().unwrap();
350                        nav.push(HistoryEntry::new(&url));
351                        drop(nav);
352                        if let Some(store) = self.host_state.history_store.lock().unwrap().as_ref()
353                        {
354                            let title = url_to_title(&url);
355                            let _ = store.record(&url, &title);
356                        }
357                    }
358                    self.host_state.widget_states.lock().unwrap().clear();
359                    self.host_state.widget_clicked.lock().unwrap().clear();
360                    self.host_state.widget_commands.lock().unwrap().clear();
361                    self.live_module = result.live_module;
362                    self.last_frame = Instant::now();
363                }
364            }
365        }
366    }
367
368    fn handle_pending_navigation(&mut self, dm: &DownloadManager) {
369        let pending = self.host_state.pending_navigation.lock().unwrap().take();
370        if let Some(url) = pending {
371            self.navigate_to(url, true, dm);
372        }
373    }
374
375    fn sync_url_bar(&mut self) {
376        let cur = self.host_state.current_url.lock().unwrap().clone();
377        if !cur.is_empty() && cur != self.url_input {
378            let status = self.status.lock().unwrap().clone();
379            if matches!(status, PageStatus::Running(_)) {
380                self.url_input = cur;
381                self.url_clamp_cursor();
382            }
383        }
384    }
385
386    fn url_clamp_cursor(&mut self) {
387        let len = self.url_input.len();
388        self.url_cursor = self.url_cursor.min(len);
389        self.url_sel_start = self.url_sel_start.min(len);
390    }
391
392    fn url_has_selection(&self) -> bool {
393        self.url_cursor != self.url_sel_start
394    }
395
396    fn url_sel_range(&self) -> std::ops::Range<usize> {
397        let lo = self.url_cursor.min(self.url_sel_start);
398        let hi = self.url_cursor.max(self.url_sel_start);
399        lo..hi
400    }
401
402    fn url_prev_boundary(&self) -> usize {
403        let text = &self.url_input;
404        if self.url_cursor == 0 {
405            return 0;
406        }
407        let mut i = self.url_cursor - 1;
408        while i > 0 && !text.is_char_boundary(i) {
409            i -= 1;
410        }
411        i
412    }
413
414    fn url_next_boundary(&self) -> usize {
415        let text = &self.url_input;
416        if self.url_cursor >= text.len() {
417            return text.len();
418        }
419        let mut i = self.url_cursor + 1;
420        while i < text.len() && !text.is_char_boundary(i) {
421            i += 1;
422        }
423        i
424    }
425
426    fn url_move_to(&mut self, offset: usize) {
427        let offset = offset.min(self.url_input.len());
428        self.url_cursor = offset;
429        self.url_sel_start = offset;
430    }
431
432    fn url_select_to(&mut self, offset: usize) {
433        self.url_cursor = offset.min(self.url_input.len());
434    }
435
436    fn url_select_all(&mut self) {
437        self.url_sel_start = 0;
438        self.url_cursor = self.url_input.len();
439    }
440
441    fn url_delete_selection(&mut self) {
442        if !self.url_has_selection() {
443            return;
444        }
445        let range = self.url_sel_range();
446        self.url_input.replace_range(range.clone(), "");
447        self.url_cursor = range.start;
448        self.url_sel_start = range.start;
449    }
450
451    fn url_insert_at_cursor(&mut self, text: &str) {
452        if self.url_has_selection() {
453            self.url_delete_selection();
454        }
455        self.url_input.insert_str(self.url_cursor, text);
456        self.url_cursor += text.len();
457        self.url_sel_start = self.url_cursor;
458    }
459
460    fn url_backspace(&mut self) {
461        if self.url_has_selection() {
462            self.url_delete_selection();
463        } else if self.url_cursor > 0 {
464            let prev = self.url_prev_boundary();
465            self.url_input.replace_range(prev..self.url_cursor, "");
466            self.url_cursor = prev;
467            self.url_sel_start = prev;
468        }
469    }
470
471    fn url_delete_forward(&mut self) {
472        if self.url_has_selection() {
473            self.url_delete_selection();
474        } else if self.url_cursor < self.url_input.len() {
475            let next = self.url_next_boundary();
476            self.url_input.replace_range(self.url_cursor..next, "");
477        }
478    }
479
480    fn url_selected_text(&self) -> String {
481        if self.url_has_selection() {
482            self.url_input[self.url_sel_range()].to_string()
483        } else {
484            String::new()
485        }
486    }
487
488    fn sync_keys_held_to_input(&self) {
489        let mut input = self.host_state.input_state.lock().unwrap();
490        input.keys_down.clear();
491        input.keys_down.extend(self.keys_held.iter().copied());
492    }
493
494    fn tick_frame(&mut self) {
495        if self.live_module.is_none() {
496            return;
497        }
498
499        let now = Instant::now();
500        let dt = now - self.last_frame;
501        self.last_frame = now;
502        let dt_ms = dt.as_millis().min(100) as u32;
503
504        self.host_state.widget_commands.lock().unwrap().clear();
505
506        if let Some(ref mut live) = self.live_module {
507            match live.tick(dt_ms) {
508                Ok(()) => {}
509                Err(e) => {
510                    let msg = if e.to_string().contains("fuel") {
511                        "on_frame halted: fuel limit exceeded".to_string()
512                    } else {
513                        format!("on_frame error: {e}")
514                    };
515                    crate::capabilities::console_log(
516                        &self.host_state.console,
517                        crate::capabilities::ConsoleLevel::Error,
518                        msg.clone(),
519                    );
520                    *self.status.lock().unwrap() = PageStatus::Error(msg);
521                    self.live_module = None;
522                }
523            }
524        }
525
526        self.host_state.widget_clicked.lock().unwrap().clear();
527    }
528
529    fn post_tick_clear_input(&mut self) {
530        let mut input = self.host_state.input_state.lock().unwrap();
531        input.keys_pressed.clear();
532        input.mouse_buttons_clicked = [false; 3];
533        input.scroll_x = 0.0;
534        input.scroll_y = 0.0;
535    }
536
537    fn update_texture_cache(&mut self, _window: &mut Window) {
538        let tab_id = self.id;
539        let canvas = self.host_state.canvas.lock().unwrap();
540        if canvas.generation != self.canvas_generation {
541            self.image_textures.clear();
542            self.canvas_generation = canvas.generation;
543        }
544        for (i, decoded) in canvas.images.iter().enumerate() {
545            self.image_textures.entry(i).or_insert_with(|| {
546                decoded_to_render_image(decoded, format!("oxide_img_{i}_tab{tab_id}"))
547            });
548        }
549    }
550
551    fn refresh_pip_texture(&mut self, _window: &mut Window) {
552        let pip = self.host_state.video.lock().unwrap().pip;
553        if !pip {
554            self.pip_texture = None;
555            self.pip_last_serial = 0;
556            return;
557        }
558
559        let serial = *self.host_state.video_pip_serial.lock().unwrap();
560        if serial != self.pip_last_serial {
561            self.pip_last_serial = serial;
562            self.pip_texture = None;
563            let frame = self.host_state.video_pip_frame.lock().unwrap().clone();
564            if let Some(decoded) = frame {
565                self.pip_texture = Some(decoded_to_render_image(
566                    &decoded,
567                    format!("oxide_pip_{}_{}", self.id, serial),
568                ));
569            }
570        }
571    }
572}
573
574/// Decode RGBA guest bytes into a GPU [`RenderImage`] (BGRA upload for the renderer).
575fn decoded_to_render_image(
576    decoded: &crate::capabilities::DecodedImage,
577    _debug_label: String,
578) -> Arc<RenderImage> {
579    let mut buf = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.pixels.clone())
580        .expect("decoded image dimensions");
581    for pixel in buf.chunks_exact_mut(4) {
582        pixel.swap(0, 2);
583    }
584    let frame = Frame::new(buf);
585    Arc::new(RenderImage::new(smallvec![frame]))
586}
587
588fn rgba8(r: u8, g: u8, b: u8, a: u8) -> gpui::Hsla {
589    gpui::Hsla::from(Rgba {
590        r: r as f32 / 255.0,
591        g: g as f32 / 255.0,
592        b: b as f32 / 255.0,
593        a: a as f32 / 255.0,
594    })
595}
596
597fn circle_polygon(cx: f32, cy: f32, radius: f32) -> Vec<Point<Pixels>> {
598    let n = 24;
599    (0..n)
600        .map(|i| {
601            let t = i as f32 / n as f32 * std::f32::consts::TAU;
602            point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
603        })
604        .collect()
605}
606
607/// Saved canvas state for the transform/clip/opacity stack.
608#[derive(Clone)]
609struct CanvasPaintState {
610    offset_x: f32,
611    offset_y: f32,
612    clip: Option<Bounds<Pixels>>,
613    opacity: f32,
614}
615
616fn paint_draw_commands(
617    window: &mut Window,
618    cx: &mut gpui::App,
619    bounds: Bounds<Pixels>,
620    cmds: &[DrawCommand],
621    textures: &HashMap<usize, Arc<RenderImage>>,
622) {
623    let rect = bounds;
624    let origin_x = f32::from(rect.origin.x);
625    let origin_y = f32::from(rect.origin.y);
626
627    let mut state_stack: Vec<CanvasPaintState> = Vec::new();
628    let mut off_x = origin_x;
629    let mut off_y = origin_y;
630    let mut clip: Option<Bounds<Pixels>> = None;
631    let mut opacity: f32 = 1.0;
632
633    for cmd in cmds {
634        match cmd {
635            DrawCommand::Save => {
636                state_stack.push(CanvasPaintState {
637                    offset_x: off_x,
638                    offset_y: off_y,
639                    clip,
640                    opacity,
641                });
642            }
643            DrawCommand::Restore => {
644                if let Some(prev) = state_stack.pop() {
645                    off_x = prev.offset_x;
646                    off_y = prev.offset_y;
647                    clip = prev.clip;
648                    opacity = prev.opacity;
649                }
650            }
651            DrawCommand::Transform {
652                a: _,
653                b: _,
654                c: _,
655                d: _,
656                tx,
657                ty,
658            } => {
659                off_x += *tx;
660                off_y += *ty;
661            }
662            DrawCommand::Clip { x, y, w, h } => {
663                let new_clip = Bounds::from_corners(
664                    point(px(off_x + *x), px(off_y + *y)),
665                    point(px(off_x + *x + *w), px(off_y + *y + *h)),
666                );
667                clip = Some(match clip {
668                    Some(existing) => intersect_bounds(existing, new_clip),
669                    None => new_clip,
670                });
671            }
672            DrawCommand::Opacity { alpha } => {
673                opacity *= *alpha;
674            }
675
676            DrawCommand::Clear { r, g, b, a } => {
677                let ca = apply_opacity(*a, opacity);
678                window.paint_quad(gpui::fill(rect, rgba8(*r, *g, *b, ca)));
679            }
680            DrawCommand::Rect {
681                x,
682                y,
683                w,
684                h,
685                r,
686                g,
687                b,
688                a,
689            } => {
690                let min = point(px(off_x + *x), px(off_y + *y));
691                let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
692                if !clipped_out(clip, cmd_bounds) {
693                    let ca = apply_opacity(*a, opacity);
694                    window.paint_quad(gpui::fill(cmd_bounds, rgba8(*r, *g, *b, ca)));
695                }
696            }
697            DrawCommand::Circle {
698                cx,
699                cy,
700                radius,
701                r,
702                g,
703                b,
704                a,
705            } => {
706                let pts = circle_polygon(off_x + *cx, off_y + *cy, *radius);
707                let mut pb = PathBuilder::fill();
708                pb.add_polygon(&pts, true);
709                if let Ok(path) = pb.build() {
710                    let ca = apply_opacity(*a, opacity);
711                    window.paint_path(path, rgba8(*r, *g, *b, ca));
712                }
713            }
714            DrawCommand::Text {
715                x,
716                y,
717                size,
718                r,
719                g,
720                b,
721                a,
722                text,
723            } => {
724                let origin = point(px(off_x + *x), px(off_y + *y));
725                let text_owned = text.clone();
726                let ca = apply_opacity(*a, opacity);
727                let run = TextRun {
728                    len: text_owned.len(),
729                    font: font(".SystemUIFont"),
730                    color: rgba8(*r, *g, *b, ca),
731                    background_color: None,
732                    underline: None,
733                    strikethrough: None,
734                };
735                let line = window.text_system().shape_line(
736                    SharedString::from(text_owned),
737                    px(*size),
738                    &[run],
739                    None,
740                );
741                let _ = line.paint(origin, px(*size * 1.2), window, cx);
742            }
743            DrawCommand::TextEx {
744                x,
745                y,
746                size,
747                r,
748                g,
749                b,
750                a,
751                family,
752                weight,
753                style,
754                align,
755                text,
756            } => {
757                let text_owned = text.clone();
758                let ca = apply_opacity(*a, opacity);
759                let run = TextRun {
760                    len: text_owned.len(),
761                    font: crate::capabilities::make_gpui_font(family, *weight, *style),
762                    color: rgba8(*r, *g, *b, ca),
763                    background_color: None,
764                    underline: None,
765                    strikethrough: None,
766                };
767                let line = window.text_system().shape_line(
768                    SharedString::from(text_owned),
769                    px(*size),
770                    &[run],
771                    None,
772                );
773                let line_x = match *align {
774                    1 => off_x + *x - f32::from(line.width) / 2.0,
775                    2 => off_x + *x - f32::from(line.width),
776                    _ => off_x + *x,
777                };
778                let origin = point(px(line_x), px(off_y + *y));
779                let _ = line.paint(origin, px(*size * 1.2), window, cx);
780            }
781            DrawCommand::Line {
782                x1,
783                y1,
784                x2,
785                y2,
786                r,
787                g,
788                b,
789                a,
790                thickness,
791            } => {
792                let p1 = point(px(off_x + *x1), px(off_y + *y1));
793                let p2 = point(px(off_x + *x2), px(off_y + *y2));
794                let mut pb = PathBuilder::stroke(px(*thickness));
795                pb.move_to(p1);
796                pb.line_to(p2);
797                if let Ok(path) = pb.build() {
798                    let ca = apply_opacity(*a, opacity);
799                    window.paint_path(path, rgba8(*r, *g, *b, ca));
800                }
801            }
802            DrawCommand::Image {
803                x,
804                y,
805                w,
806                h,
807                image_id,
808            } => {
809                if let Some(tex) = textures.get(image_id) {
810                    let min = point(px(off_x + *x), px(off_y + *y));
811                    let img_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
812                    let _ = window.paint_image(img_bounds, (0.).into(), tex.clone(), 0, false);
813                }
814            }
815            DrawCommand::RoundedRect {
816                x,
817                y,
818                w,
819                h,
820                radius,
821                r,
822                g,
823                b,
824                a,
825            } => {
826                let min = point(px(off_x + *x), px(off_y + *y));
827                let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
828                if !clipped_out(clip, cmd_bounds) {
829                    let ca = apply_opacity(*a, opacity);
830                    let pts = rounded_rect_polygon(off_x + *x, off_y + *y, *w, *h, *radius);
831                    let mut pb = PathBuilder::fill();
832                    pb.add_polygon(&pts, true);
833                    if let Ok(path) = pb.build() {
834                        window.paint_path(path, rgba8(*r, *g, *b, ca));
835                    }
836                }
837            }
838            DrawCommand::Arc {
839                cx,
840                cy,
841                radius,
842                start_angle,
843                end_angle,
844                r,
845                g,
846                b,
847                a,
848                thickness,
849            } => {
850                let pts = arc_polyline(off_x + *cx, off_y + *cy, *radius, *start_angle, *end_angle);
851                if pts.len() >= 2 {
852                    let mut pb = PathBuilder::stroke(px(*thickness));
853                    pb.move_to(pts[0]);
854                    for p in &pts[1..] {
855                        pb.line_to(*p);
856                    }
857                    if let Ok(path) = pb.build() {
858                        let ca = apply_opacity(*a, opacity);
859                        window.paint_path(path, rgba8(*r, *g, *b, ca));
860                    }
861                }
862            }
863            DrawCommand::Bezier {
864                x1,
865                y1,
866                cp1x,
867                cp1y,
868                cp2x,
869                cp2y,
870                x2,
871                y2,
872                r,
873                g,
874                b,
875                a,
876                thickness,
877            } => {
878                let p1 = point(px(off_x + *x1), px(off_y + *y1));
879                let p2 = point(px(off_x + *x2), px(off_y + *y2));
880                let c1 = point(px(off_x + *cp1x), px(off_y + *cp1y));
881                let c2 = point(px(off_x + *cp2x), px(off_y + *cp2y));
882                let mut pb = PathBuilder::stroke(px(*thickness));
883                pb.move_to(p1);
884                pb.cubic_bezier_to(p2, c1, c2);
885                if let Ok(path) = pb.build() {
886                    let ca = apply_opacity(*a, opacity);
887                    window.paint_path(path, rgba8(*r, *g, *b, ca));
888                }
889            }
890            DrawCommand::Gradient {
891                x,
892                y,
893                w,
894                h,
895                kind,
896                ax: _,
897                ay: _,
898                bx: _,
899                by: _,
900                stops,
901            } => {
902                paint_gradient(
903                    window,
904                    &GradientParams {
905                        x: off_x + *x,
906                        y: off_y + *y,
907                        w: *w,
908                        h: *h,
909                        kind: *kind,
910                        stops: stops.clone(),
911                        opacity,
912                    },
913                );
914            }
915        }
916    }
917}
918
919fn apply_opacity(a: u8, opacity: f32) -> u8 {
920    (a as f32 * opacity).round().clamp(0.0, 255.0) as u8
921}
922
923fn clipped_out(clip: Option<Bounds<Pixels>>, target: Bounds<Pixels>) -> bool {
924    if let Some(c) = clip {
925        let cl = f32::from(c.origin.x);
926        let ct = f32::from(c.origin.y);
927        let cr = cl + f32::from(c.size.width);
928        let cb = ct + f32::from(c.size.height);
929
930        let tl = f32::from(target.origin.x);
931        let tt = f32::from(target.origin.y);
932        let tr = tl + f32::from(target.size.width);
933        let tb = tt + f32::from(target.size.height);
934
935        tr <= cl || tl >= cr || tb <= ct || tt >= cb
936    } else {
937        false
938    }
939}
940
941fn intersect_bounds(a: Bounds<Pixels>, b: Bounds<Pixels>) -> Bounds<Pixels> {
942    let al = f32::from(a.origin.x);
943    let at = f32::from(a.origin.y);
944    let ar = al + f32::from(a.size.width);
945    let ab = at + f32::from(a.size.height);
946
947    let bl = f32::from(b.origin.x);
948    let bt = f32::from(b.origin.y);
949    let br = bl + f32::from(b.size.width);
950    let bb = bt + f32::from(b.size.height);
951
952    let il = al.max(bl);
953    let it = at.max(bt);
954    let ir = ar.min(br);
955    let ib = ab.min(bb);
956
957    Bounds::from_corners(point(px(il), px(it)), point(px(ir.max(il)), px(ib.max(it))))
958}
959
960fn rounded_rect_polygon(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Vec<Point<Pixels>> {
961    let r = radius.min(w / 2.0).min(h / 2.0);
962    let segs = 4;
963    let mut pts = Vec::with_capacity(segs * 4 + 4);
964    for corner in 0..4 {
965        let (corner_x, corner_y, angle_start) = match corner {
966            0 => (x + w - r, y + r, -std::f32::consts::FRAC_PI_2), // top-right
967            1 => (x + w - r, y + h - r, 0.0),                      // bottom-right
968            2 => (x + r, y + h - r, std::f32::consts::FRAC_PI_2),  // bottom-left
969            _ => (x + r, y + r, std::f32::consts::PI),             // top-left
970        };
971        for i in 0..=segs {
972            let t = angle_start + (i as f32 / segs as f32) * std::f32::consts::FRAC_PI_2;
973            pts.push(point(
974                px(corner_x + r * t.cos()),
975                px(corner_y + r * t.sin()),
976            ));
977        }
978    }
979    pts
980}
981
982fn arc_polyline(cx: f32, cy: f32, radius: f32, start: f32, end: f32) -> Vec<Point<Pixels>> {
983    let sweep = end - start;
984    let n = ((sweep.abs() / std::f32::consts::TAU) * 24.0)
985        .ceil()
986        .max(2.0) as usize;
987    (0..=n)
988        .map(|i| {
989            let t = start + (i as f32 / n as f32) * sweep;
990            point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
991        })
992        .collect()
993}
994
995struct GradientParams {
996    x: f32,
997    y: f32,
998    w: f32,
999    h: f32,
1000    kind: u8,
1001    stops: Vec<GradientStop>,
1002    opacity: f32,
1003}
1004
1005fn paint_gradient(window: &mut Window, p: &GradientParams) {
1006    if p.stops.is_empty() {
1007        return;
1008    }
1009
1010    // Keep band count low — GPUI's Metal scene buffer has per-frame limits and each band
1011    // is a separate quad.  8 bands gives a smooth-enough look without overwhelming the
1012    // renderer (64 bands was causing "scene too large" at >800 quads per frame).
1013    let bands: usize = 8;
1014    for i in 0..bands {
1015        let t = i as f32 / (bands - 1).max(1) as f32;
1016        let (sr, sg, sb, sa) = sample_gradient(&p.stops, t);
1017        let ca = apply_opacity(sa, p.opacity);
1018
1019        if p.kind == 1 {
1020            // Radial: concentric rectangles from outside in.
1021            let frac = 1.0 - t;
1022            let bx = p.x + p.w * 0.5 * t;
1023            let by = p.y + p.h * 0.5 * t;
1024            let bw = p.w * frac;
1025            let bh = p.h * frac;
1026            if bw > 0.0 && bh > 0.0 {
1027                let min = point(px(bx), px(by));
1028                let band_bounds = Bounds::from_corners(min, min + point(px(bw), px(bh)));
1029                window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1030            }
1031        } else {
1032            // Linear: vertical bands along the gradient axis.
1033            let band_h = p.h / bands as f32;
1034            let by = p.y + i as f32 * band_h;
1035            let min = point(px(p.x), px(by));
1036            let band_bounds = Bounds::from_corners(min, min + point(px(p.w), px(band_h.ceil())));
1037            window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1038        }
1039    }
1040}
1041
1042fn sample_gradient(stops: &[GradientStop], t: f32) -> (u8, u8, u8, u8) {
1043    if stops.len() == 1 {
1044        let s = &stops[0];
1045        return (s.r, s.g, s.b, s.a);
1046    }
1047    let t = t.clamp(0.0, 1.0);
1048    let mut lo = &stops[0];
1049    let mut hi = &stops[stops.len() - 1];
1050    for pair in stops.windows(2) {
1051        if t >= pair[0].offset && t <= pair[1].offset {
1052            lo = &pair[0];
1053            hi = &pair[1];
1054            break;
1055        }
1056    }
1057    let range = hi.offset - lo.offset;
1058    let frac = if range > 0.0 {
1059        (t - lo.offset) / range
1060    } else {
1061        0.0
1062    };
1063    let lerp = |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * frac).round() as u8 };
1064    (
1065        lerp(lo.r, hi.r),
1066        lerp(lo.g, hi.g),
1067        lerp(lo.b, hi.b),
1068        lerp(lo.a, hi.a),
1069    )
1070}
1071
1072/// Result of a background `rfd` file dialog (must not run inside GPUI `App::update` — modal + focus events re-enter and panic).
1073enum FilePickDone {
1074    Chosen { path: PathBuf, bytes: Vec<u8> },
1075    Directory(PathBuf),
1076    Cancelled,
1077}
1078
1079pub struct OxideBrowserView {
1080    tabs: Vec<TabState>,
1081    active_tab: usize,
1082    next_tab_id: u64,
1083    shared_kv_db: Option<Arc<sled::Db>>,
1084    shared_module_loader: Option<Arc<ModuleLoader>>,
1085    bookmark_store: Option<BookmarkStore>,
1086    history_store: Option<HistoryStore>,
1087    show_bookmarks: bool,
1088    show_menu: bool,
1089    /// Focus for the page (canvas + guest widgets); required for keyboard to reach `on_key_down` on the root.
1090    canvas_focus: FocusHandle,
1091    url_focus: FocusHandle,
1092    /// Keyboard focus for the `oxide://forge` chat composer.
1093    forge_focus: FocusHandle,
1094    /// Keyboard focus for API key field in Forge settings.
1095    forge_settings_key_focus: FocusHandle,
1096    /// Keyboard focus for model field in Forge settings.
1097    forge_settings_model_focus: FocusHandle,
1098    /// Persisted multi-provider Forge configuration.
1099    forge_config: ForgeUserConfig,
1100    /// Provider tab selected in the settings panel.
1101    forge_settings_provider: ForgeProvider,
1102    /// Draft API key while editing settings (cleared after save).
1103    forge_settings_key_draft: String,
1104    /// Draft model id while editing settings.
1105    forge_settings_model_draft: String,
1106    /// Receiver for [`FilePickDone`]; dialog runs on a background thread so the main thread never holds `App` during `NSOpenPanel`.
1107    file_pick_rx: Option<mpsc::Receiver<FilePickDone>>,
1108    download_manager: DownloadManager,
1109    show_downloads: bool,
1110    /// Lazily-initialised Claude-backed guest app factory for `oxide://forge`.
1111    forge: Arc<Mutex<Option<ForgeState>>>,
1112    /// Whether the user is currently dragging the scrollbar thumb.
1113    scroll_dragging: bool,
1114    /// The screen Y position where the scrollbar drag started.
1115    scroll_drag_start_y: f32,
1116    /// The absolute scroll Y offset when the scrollbar drag started.
1117    scroll_drag_start_scroll_y: f32,
1118}
1119
1120impl OxideBrowserView {
1121    fn new(cx: &mut Context<Self>, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
1122        let shared_kv_db = host_state.kv_db.clone();
1123        let shared_module_loader = host_state.module_loader.clone();
1124        let bookmark_store = host_state.bookmark_store.lock().unwrap().clone();
1125        let history_store = host_state.history_store.lock().unwrap().clone();
1126        let first_tab = TabState::new(0, host_state, status);
1127        Self {
1128            tabs: vec![first_tab],
1129            active_tab: 0,
1130            next_tab_id: 1,
1131            shared_kv_db,
1132            shared_module_loader,
1133            bookmark_store,
1134            history_store,
1135            show_bookmarks: false,
1136            show_menu: false,
1137            canvas_focus: cx.focus_handle(),
1138            url_focus: cx.focus_handle(),
1139            forge_focus: cx.focus_handle(),
1140            forge_settings_key_focus: cx.focus_handle(),
1141            forge_settings_model_focus: cx.focus_handle(),
1142            forge_config: ForgeUserConfig::load(),
1143            forge_settings_provider: ForgeProvider::Anthropic,
1144            forge_settings_key_draft: String::new(),
1145            forge_settings_model_draft: String::new(),
1146            file_pick_rx: None,
1147            download_manager: DownloadManager::new(),
1148            show_downloads: false,
1149            forge: Arc::new(Mutex::new(None)),
1150            scroll_dragging: false,
1151            scroll_drag_start_y: 0.0,
1152            scroll_drag_start_scroll_y: 0.0,
1153        }
1154    }
1155
1156    /// Ensure the Forge subsystem is initialised; returns `true` if available.
1157    fn ensure_forge(&mut self) -> bool {
1158        let mut g = self.forge.lock().unwrap();
1159        if g.is_none() {
1160            *g = ForgeState::from_config(&self.forge_config);
1161        }
1162        if let Some(forge) = g.as_mut() {
1163            forge.apply_config(&self.forge_config);
1164        }
1165        g.is_some()
1166    }
1167
1168    fn forge_sync_settings_drafts(&mut self) {
1169        let p = self.forge_settings_provider;
1170        let settings = self.forge_config.provider(p);
1171        self.forge_settings_model_draft = settings.model_or_default(p);
1172        self.forge_settings_key_draft.clear();
1173    }
1174
1175    fn forge_toggle_settings(&mut self) {
1176        self.forge_config.settings_open = !self.forge_config.settings_open;
1177        if self.forge_config.settings_open {
1178            self.forge_settings_provider = self.forge_config.active_provider;
1179            self.forge_sync_settings_drafts();
1180        }
1181    }
1182
1183    fn forge_select_provider(&mut self, provider: ForgeProvider) {
1184        self.forge_config.active_provider = provider;
1185        self.forge_settings_provider = provider;
1186        self.forge_sync_settings_drafts();
1187        let mut g = self.forge.lock().unwrap();
1188        if let Some(forge) = g.as_mut() {
1189            forge.apply_config(&self.forge_config);
1190        }
1191        let _ = self.forge_config.save();
1192    }
1193
1194    fn forge_save_provider_settings(&mut self) {
1195        let provider = self.forge_settings_provider;
1196        if !self.forge_settings_key_draft.trim().is_empty() {
1197            self.forge_config
1198                .set_api_key(provider, self.forge_settings_key_draft.clone());
1199        }
1200        self.forge_config
1201            .set_model(provider, self.forge_settings_model_draft.clone());
1202        let _ = self.forge_config.save();
1203        self.forge_settings_key_draft.clear();
1204
1205        let mut g = self.forge.lock().unwrap();
1206        if g.is_none() {
1207            *g = ForgeState::from_config(&self.forge_config);
1208        } else if let Some(forge) = g.as_mut() {
1209            forge.apply_config(&self.forge_config);
1210        }
1211    }
1212
1213    /// Snapshot the session active in the current tab, if any.
1214    fn forge_current_snapshot(&self) -> Option<ForgeSnapshot> {
1215        let id = self.tabs[self.active_tab].forge_session_id?;
1216        let g = self.forge.lock().ok()?;
1217        g.as_ref()?.snapshot(id)
1218    }
1219
1220    fn forge_creations(&self) -> Vec<ForgeCreationSummary> {
1221        let g = self.forge.lock().ok();
1222        g.and_then(|g| g.as_ref().map(|forge| forge.list_creations()))
1223            .unwrap_or_default()
1224    }
1225
1226    fn forge_output_dir(&self) -> Option<PathBuf> {
1227        let g = self.forge.lock().ok()?;
1228        Some(g.as_ref()?.output_dir())
1229    }
1230
1231    /// Submit the current tab's prompt. On success, the tab's
1232    /// `forge_session_id` is set and the prompt is cleared.
1233    fn forge_submit(&mut self) {
1234        let idx = self.active_tab;
1235        let prompt = self.tabs[idx].forge_prompt.trim().to_string();
1236        if prompt.is_empty() {
1237            return;
1238        }
1239        if !self.ensure_forge() {
1240            return;
1241        }
1242        let session_id = self.tabs[idx].forge_session_id;
1243        let result = {
1244            let mut g = self.forge.lock().unwrap();
1245            g.as_mut().map(|forge| match session_id {
1246                Some(id) => forge.revise(id, prompt.clone()).map(|_| id),
1247                None => forge.start(prompt.clone()),
1248            })
1249        };
1250        match result {
1251            Some(Ok(id)) => {
1252                self.tabs[idx].forge_session_id = Some(id);
1253                self.tabs[idx].forge_prompt.clear();
1254            }
1255            Some(Err(e)) => {
1256                let console = self.tabs[idx].host_state.console.clone();
1257                crate::capabilities::console_log(
1258                    &console,
1259                    ConsoleLevel::Error,
1260                    format!("[FORGE] start failed: {e}"),
1261                );
1262            }
1263            None => {}
1264        }
1265    }
1266
1267    fn forge_pick_output_dir(&mut self) {
1268        if self.file_pick_rx.is_some() {
1269            return;
1270        }
1271        let (tx, rx) = mpsc::channel();
1272        self.file_pick_rx = Some(rx);
1273        std::thread::spawn(move || {
1274            let msg = rfd::FileDialog::new()
1275                .set_title("Choose Oxide Forge Output Folder")
1276                .pick_folder()
1277                .map(FilePickDone::Directory)
1278                .unwrap_or(FilePickDone::Cancelled);
1279            let _ = tx.send(msg);
1280        });
1281    }
1282
1283    /// Kick off a `cargo build` for the current tab's session.
1284    fn forge_build(&self) {
1285        let id = match self.tabs[self.active_tab].forge_session_id {
1286            Some(id) => id,
1287            None => return,
1288        };
1289        let mut g = self.forge.lock().unwrap();
1290        if let Some(forge) = g.as_mut() {
1291            if let Err(e) = forge.build(id) {
1292                crate::capabilities::console_log(
1293                    &self.tabs[self.active_tab].host_state.console,
1294                    ConsoleLevel::Error,
1295                    format!("[FORGE] build failed: {e}"),
1296                );
1297            }
1298        }
1299    }
1300
1301    fn forge_delete_current_creation(&mut self) {
1302        let id = match self.tabs[self.active_tab].forge_session_id {
1303            Some(id) => id,
1304            None => return,
1305        };
1306        let result = {
1307            let mut g = self.forge.lock().unwrap();
1308            g.as_mut().map(|forge| forge.delete_creation(id))
1309        };
1310        match result {
1311            Some(Ok(())) => {
1312                for tab in &mut self.tabs {
1313                    if tab.forge_session_id == Some(id) {
1314                        tab.forge_session_id = None;
1315                        tab.forge_prompt.clear();
1316                    }
1317                }
1318            }
1319            Some(Err(e)) => {
1320                crate::capabilities::console_log(
1321                    &self.tabs[self.active_tab].host_state.console,
1322                    ConsoleLevel::Error,
1323                    format!("[FORGE] delete failed: {e}"),
1324                );
1325            }
1326            None => {}
1327        }
1328    }
1329
1330    /// Load the built `.wasm` for the current session into a new tab.
1331    fn forge_run_in_new_tab(&mut self) {
1332        let id = match self.tabs[self.active_tab].forge_session_id {
1333            Some(id) => id,
1334            None => return,
1335        };
1336        let bytes = {
1337            let g = self.forge.lock().unwrap();
1338            g.as_ref().and_then(|f| f.artifact_bytes(id))
1339        };
1340        let Some(bytes) = bytes else {
1341            return;
1342        };
1343        let slug = {
1344            let g = self.forge.lock().unwrap();
1345            g.as_ref()
1346                .and_then(|f| f.snapshot(id))
1347                .map(|s| s.slug)
1348                .unwrap_or_else(|| format!("session-{id}"))
1349        };
1350        let new_idx = self.create_tab();
1351        self.active_tab = new_idx;
1352        let tab = &mut self.tabs[new_idx];
1353        tab.url_input = format!("oxide://forge/run/{slug}");
1354        tab.url_cursor = tab.url_input.len();
1355        tab.url_sel_start = tab.url_input.len();
1356        tab.internal_page = None;
1357        let _ = tab.run_tx.send(RunRequest::LoadLocal(bytes));
1358    }
1359
1360    fn poll_file_pick(&mut self, cx: &mut Context<Self>) {
1361        let rx = match self.file_pick_rx.take() {
1362            Some(r) => r,
1363            None => return,
1364        };
1365        match rx.try_recv() {
1366            Ok(FilePickDone::Chosen { path, bytes }) => {
1367                let file_url = format!("file://{}", path.display());
1368                let tab = &mut self.tabs[self.active_tab];
1369                tab.url_input = file_url.clone();
1370                tab.pending_history_url = Some(file_url);
1371                tab.internal_page = None;
1372                let _ = tab.run_tx.send(RunRequest::LoadLocal(bytes));
1373                cx.notify();
1374            }
1375            Ok(FilePickDone::Directory(path)) => {
1376                if self.ensure_forge() {
1377                    let result = {
1378                        let mut g = self.forge.lock().unwrap();
1379                        g.as_mut().map(|forge| forge.set_output_dir(path.clone()))
1380                    };
1381                    if let Some(Err(e)) = result {
1382                        crate::capabilities::console_log(
1383                            &self.tabs[self.active_tab].host_state.console,
1384                            ConsoleLevel::Error,
1385                            format!("[FORGE] output folder failed: {e}"),
1386                        );
1387                    } else {
1388                        self.tabs[self.active_tab].forge_session_id = None;
1389                    }
1390                }
1391                cx.notify();
1392            }
1393            Ok(FilePickDone::Cancelled) => {}
1394            Err(TryRecvError::Empty) => {
1395                self.file_pick_rx = Some(rx);
1396            }
1397            Err(TryRecvError::Disconnected) => {}
1398        }
1399    }
1400
1401    fn create_tab(&mut self) -> usize {
1402        let bm_shared: crate::bookmarks::SharedBookmarkStore =
1403            Arc::new(Mutex::new(self.bookmark_store.clone()));
1404        let hist_shared: crate::history::SharedHistoryStore =
1405            Arc::new(Mutex::new(self.history_store.clone()));
1406        let host_state = HostState {
1407            kv_db: self.shared_kv_db.clone(),
1408            module_loader: self.shared_module_loader.clone(),
1409            bookmark_store: bm_shared,
1410            history_store: hist_shared,
1411            ..Default::default()
1412        };
1413        let status = Arc::new(Mutex::new(PageStatus::Idle));
1414        let tab = TabState::new(self.next_tab_id, host_state, status);
1415        self.next_tab_id += 1;
1416        self.tabs.push(tab);
1417        self.tabs.len() - 1
1418    }
1419
1420    /// Keep `active_tab` in range. Stale close handlers can fire with an old tab index after the strip shrinks.
1421    fn clamp_active_tab(&mut self) {
1422        if self.tabs.is_empty() {
1423            self.active_tab = 0;
1424            return;
1425        }
1426        self.active_tab = self.active_tab.min(self.tabs.len() - 1);
1427    }
1428
1429    fn close_tab(&mut self, idx: usize) {
1430        if self.tabs.len() <= 1 {
1431            return;
1432        }
1433        if idx >= self.tabs.len() {
1434            self.clamp_active_tab();
1435            return;
1436        }
1437        self.tabs.remove(idx);
1438        if self.active_tab > idx {
1439            self.active_tab -= 1;
1440        } else if self.active_tab == idx && self.active_tab >= self.tabs.len() {
1441            self.active_tab = self.tabs.len().saturating_sub(1);
1442        }
1443        self.clamp_active_tab();
1444    }
1445
1446    fn toggle_active_bookmark(&self) {
1447        let url = self.tabs[self.active_tab].url_input.trim().to_string();
1448        if url.is_empty() || url == "https://" {
1449            return;
1450        }
1451        if let Some(store) = &self.bookmark_store {
1452            if store.contains(&url) {
1453                let _ = store.remove(&url);
1454            } else {
1455                let title = url_to_title(&url);
1456                let _ = store.add(&url, &title);
1457            }
1458        }
1459    }
1460}
1461
1462impl Render for OxideBrowserView {
1463    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1464        self.clamp_active_tab();
1465        self.poll_file_pick(cx);
1466        let dm = self.download_manager.clone();
1467        for tab in &mut self.tabs {
1468            tab.drain_results();
1469            tab.handle_pending_navigation(&dm);
1470            tab.sync_url_bar();
1471        }
1472
1473        let active = self.active_tab;
1474        let canvas_focused = self.canvas_focus.is_focused(window);
1475        {
1476            let tab = &mut self.tabs[active];
1477            tab.host_state
1478                .focused
1479                .store(canvas_focused, Ordering::Relaxed);
1480            tab.sync_keys_held_to_input();
1481            // Expose the window's text system to the guest only for the
1482            // duration of `on_frame`, so `canvas_measure_text` can shape
1483            // synchronously. Cleared after tick to avoid leaking the handle.
1484            *tab.host_state.text_system.lock().unwrap() = Some(window.text_system().clone());
1485            tab.tick_frame();
1486            *tab.host_state.text_system.lock().unwrap() = None;
1487            tab.update_texture_cache(window);
1488            tab.refresh_pip_texture(window);
1489        }
1490
1491        let canvas_offset = self.tabs[active].host_state.canvas_offset.clone();
1492        let cmds = self.tabs[active]
1493            .host_state
1494            .canvas
1495            .lock()
1496            .unwrap()
1497            .commands
1498            .clone();
1499        let hyperlinks = self.tabs[active]
1500            .host_state
1501            .hyperlinks
1502            .lock()
1503            .unwrap()
1504            .clone();
1505        let hyperlinks_hover = hyperlinks.clone();
1506        let widget_commands = self.tabs[active]
1507            .host_state
1508            .widget_commands
1509            .lock()
1510            .unwrap()
1511            .clone();
1512        let widget_cmds_overlay = widget_commands.clone();
1513        let textures = self.tabs[active].image_textures.clone();
1514        let show_console = self.tabs[active].show_console;
1515        let pip_tex = self.tabs[active].pip_texture.clone();
1516
1517        self.tabs[active].post_tick_clear_input();
1518
1519        cx.on_next_frame(window, |_this, _window, cx| {
1520            cx.notify();
1521        });
1522
1523        let tab_titles: Vec<String> = self.tabs.iter().map(|t| t.display_title()).collect();
1524        let num_tabs = self.tabs.len();
1525        let active_tab = self.active_tab;
1526        let bm = self.bookmark_store.clone();
1527        let current_url = self.tabs[active].url_input.clone();
1528        let is_bookmarked = bm
1529            .as_ref()
1530            .map(|s| s.contains(&current_url))
1531            .unwrap_or(false);
1532        let url_focused = self.url_focus.is_focused(window);
1533        let caret_blink_on = SystemTime::now()
1534            .duration_since(UNIX_EPOCH)
1535            .map(|d| (d.as_millis() / 530) % 2 == 0)
1536            .unwrap_or(true);
1537        let can_back = self.tabs[active]
1538            .host_state
1539            .navigation
1540            .lock()
1541            .unwrap()
1542            .can_go_back();
1543        let can_fwd = self.tabs[active]
1544            .host_state
1545            .navigation
1546            .lock()
1547            .unwrap()
1548            .can_go_forward();
1549
1550        let mut root = div()
1551            .id("oxide_root")
1552            .track_focus(&self.canvas_focus)
1553            .focusable()
1554            .size_full()
1555            .flex()
1556            .flex_col()
1557            .bg(gpui::rgb(0x1a1a20))
1558            .on_key_down(cx.listener(
1559                |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
1560                    {
1561                        let tab = &this.tabs[this.active_tab];
1562                        let mut input = tab.host_state.input_state.lock().unwrap();
1563                        input.modifiers_shift = event.keystroke.modifiers.shift;
1564                        input.modifiers_ctrl =
1565                            event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1566                        input.modifiers_alt = event.keystroke.modifiers.alt;
1567                    }
1568                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "r" {
1569                        this.tabs[this.active_tab].reload();
1570                        cx.notify();
1571                        return;
1572                    }
1573                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "t" {
1574                        let i = this.create_tab();
1575                        this.active_tab = i;
1576                        cx.notify();
1577                        return;
1578                    }
1579                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "w" {
1580                        if this.tabs.len() > 1 {
1581                            let a = this.active_tab;
1582                            this.close_tab(a);
1583                        }
1584                        cx.notify();
1585                        return;
1586                    }
1587                    if event.keystroke.modifiers.control
1588                        && !event.keystroke.modifiers.shift
1589                        && event.keystroke.key == "tab"
1590                    {
1591                        if !this.tabs.is_empty() {
1592                            this.active_tab = (this.active_tab + 1) % this.tabs.len();
1593                        }
1594                        cx.notify();
1595                        return;
1596                    }
1597                    if event.keystroke.modifiers.control
1598                        && event.keystroke.modifiers.shift
1599                        && event.keystroke.key == "tab"
1600                    {
1601                        if !this.tabs.is_empty() {
1602                            if this.active_tab == 0 {
1603                                this.active_tab = this.tabs.len() - 1;
1604                            } else {
1605                                this.active_tab -= 1;
1606                            }
1607                        }
1608                        cx.notify();
1609                        return;
1610                    }
1611                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "d" {
1612                        this.toggle_active_bookmark();
1613                        cx.notify();
1614                        return;
1615                    }
1616                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "b" {
1617                        this.show_bookmarks = !this.show_bookmarks;
1618                        cx.notify();
1619                        return;
1620                    }
1621                    if this.forge_settings_key_focus.is_focused(window)
1622                        || this.forge_settings_model_focus.is_focused(window)
1623                    {
1624                        let editing_key = this.forge_settings_key_focus.is_focused(window);
1625                        let draft = if editing_key {
1626                            &mut this.forge_settings_key_draft
1627                        } else {
1628                            &mut this.forge_settings_model_draft
1629                        };
1630                        match event.keystroke.key.as_str() {
1631                            "enter" => {
1632                                this.forge_save_provider_settings();
1633                                cx.notify();
1634                                return;
1635                            }
1636                            "escape" => {
1637                                if editing_key {
1638                                    this.forge_settings_key_draft.clear();
1639                                } else {
1640                                    this.forge_sync_settings_drafts();
1641                                }
1642                                cx.notify();
1643                                return;
1644                            }
1645                            "backspace" => {
1646                                draft.pop();
1647                                cx.notify();
1648                                return;
1649                            }
1650                            _ => {}
1651                        }
1652                        if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1653                            if let Ok(mut cb) = arboard::Clipboard::new() {
1654                                if let Ok(pasted) = cb.get_text() {
1655                                    draft.push_str(pasted.trim());
1656                                    cx.notify();
1657                                }
1658                            }
1659                            return;
1660                        }
1661                        if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1662                            draft.push_str(&s);
1663                            cx.notify();
1664                        }
1665                        return;
1666                    }
1667                    if this.forge_focus.is_focused(window) {
1668                        let active = this.active_tab;
1669                        match event.keystroke.key.as_str() {
1670                            "enter" => {
1671                                if !event.keystroke.modifiers.shift {
1672                                    this.forge_submit();
1673                                    cx.notify();
1674                                    return;
1675                                }
1676                                // Shift+Enter → insert newline
1677                                this.tabs[active].forge_prompt.push('\n');
1678                                cx.notify();
1679                                return;
1680                            }
1681                            "escape" => {
1682                                this.tabs[active].forge_prompt.clear();
1683                                cx.notify();
1684                                return;
1685                            }
1686                            "backspace" => {
1687                                this.tabs[active].forge_prompt.pop();
1688                                cx.notify();
1689                                return;
1690                            }
1691                            _ => {}
1692                        }
1693                        if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1694                            if let Ok(mut cb) = arboard::Clipboard::new() {
1695                                if let Ok(pasted) = cb.get_text() {
1696                                    this.tabs[active].forge_prompt.push_str(&pasted);
1697                                    cx.notify();
1698                                }
1699                            }
1700                            return;
1701                        }
1702                        if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1703                            this.tabs[active].forge_prompt.push_str(&s);
1704                            cx.notify();
1705                        }
1706                        return;
1707                    }
1708                    if this.url_focus.is_focused(window) {
1709                        return;
1710                    }
1711                    if let Some(id) = this.tabs[this.active_tab].text_input_focus {
1712                        let mut states = this.tabs[this.active_tab]
1713                            .host_state
1714                            .widget_states
1715                            .lock()
1716                            .unwrap();
1717                        let mut text = match states.get(&id) {
1718                            Some(WidgetValue::Text(t)) => t.clone(),
1719                            _ => String::new(),
1720                        };
1721                        if event.keystroke.modifiers.secondary() {
1722                            match event.keystroke.key.as_str() {
1723                                "c" => {
1724                                    if let Ok(mut cb) = arboard::Clipboard::new() {
1725                                        let _ = cb.set_text(&text);
1726                                    }
1727                                }
1728                                "v" => {
1729                                    if let Ok(mut cb) = arboard::Clipboard::new() {
1730                                        if let Ok(pasted) = cb.get_text() {
1731                                            text.push_str(&pasted);
1732                                            states.insert(id, WidgetValue::Text(text));
1733                                        }
1734                                    }
1735                                }
1736                                "x" => {
1737                                    if let Ok(mut cb) = arboard::Clipboard::new() {
1738                                        let _ = cb.set_text(&text);
1739                                    }
1740                                    states.insert(id, WidgetValue::Text(String::new()));
1741                                }
1742                                "a" => {}
1743                                _ => {}
1744                            }
1745                            cx.notify();
1746                            return;
1747                        }
1748                        if event.keystroke.key == "backspace" {
1749                            text.pop();
1750                        } else if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1751                            text.push_str(&s);
1752                        }
1753                        states.insert(id, WidgetValue::Text(text));
1754                        cx.notify();
1755                        return;
1756                    }
1757                    if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1758                        let tab = &mut this.tabs[this.active_tab];
1759                        tab.keys_held.insert(code);
1760                        tab.host_state
1761                            .input_state
1762                            .lock()
1763                            .unwrap()
1764                            .keys_pressed
1765                            .push(code);
1766                        cx.notify();
1767                    }
1768                },
1769            ))
1770            .on_key_up(cx.listener(|this, event: &KeyUpEvent, _, _cx| {
1771                let tab = &mut this.tabs[this.active_tab];
1772                {
1773                    let mut input = tab.host_state.input_state.lock().unwrap();
1774                    input.modifiers_shift = event.keystroke.modifiers.shift;
1775                    input.modifiers_ctrl =
1776                        event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1777                    input.modifiers_alt = event.keystroke.modifiers.alt;
1778                }
1779                if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1780                    tab.keys_held.remove(&code);
1781                }
1782            }));
1783
1784        // Tab strip
1785        root = root.child(
1786            div()
1787                .h(px(40.0))
1788                .flex()
1789                .flex_row()
1790                .items_center()
1791                .px_1()
1792                .border_b_1()
1793                .border_color(gpui::rgb(0x2a2a32))
1794                .children((0..num_tabs).map(|i| {
1795                    let title = tab_titles[i].clone();
1796                    let display = truncate_tab_title(&title);
1797                    let is_active = i == active_tab;
1798                    div()
1799                        .id(("oxide_tab", i))
1800                        .flex()
1801                        .flex_row()
1802                        .items_center()
1803                        .gap_1()
1804                        .min_w(px(140.0))
1805                        .px_3()
1806                        .py_2()
1807                        .rounded_md()
1808                        .cursor_pointer()
1809                        .when(is_active, |d| d.bg(gpui::rgb(0x373741)))
1810                        .text_sm()
1811                        .text_color(if is_active {
1812                            gpui::rgb(0xdcdce6)
1813                        } else {
1814                            gpui::rgb(0x9696a0)
1815                        })
1816                        .child(
1817                            div()
1818                                .flex_1()
1819                                .min_w(px(0.0))
1820                                .overflow_hidden()
1821                                .child(display),
1822                        )
1823                        .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1824                            this.active_tab = i;
1825                            cx.notify();
1826                        }))
1827                        .when(num_tabs > 1, |d| {
1828                            d.child(
1829                                div()
1830                                    .id(("oxide_tab_close", i))
1831                                    .flex_shrink_0()
1832                                    .cursor_pointer()
1833                                    .text_color(gpui::rgb(0xa0a0aa))
1834                                    .child("×")
1835                                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1836                                        this.close_tab(i);
1837                                        cx.notify();
1838                                    })),
1839                            )
1840                        })
1841                }))
1842                .child(
1843                    div()
1844                        .id("oxide_new_tab")
1845                        .ml_1()
1846                        .cursor_pointer()
1847                        .text_color(gpui::rgb(0xc0c0cc))
1848                        .child("+")
1849                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1850                            let i = this.create_tab();
1851                            this.active_tab = i;
1852                            cx.notify();
1853                        })),
1854                ),
1855        );
1856
1857        // Toolbar
1858        let (status_icon, status_color) = {
1859            let status = self.tabs[active].status.lock().unwrap();
1860            let icon = match &*status {
1861                PageStatus::Idle => "○",
1862                PageStatus::Loading(_) => "↻",
1863                PageStatus::Running(_) => "●",
1864                PageStatus::Error(_) => "●",
1865            };
1866            let color = match &*status {
1867                PageStatus::Error(_) => gpui::rgb(0xf05050),
1868                PageStatus::Running(_) => gpui::rgb(0x50e070),
1869                _ => gpui::rgb(0xa0a0a8),
1870            };
1871            (icon, color)
1872        };
1873
1874        root = root.child(
1875            div()
1876                .h(px(44.0))
1877                .flex()
1878                .flex_row()
1879                .items_center()
1880                .gap_2()
1881                .px_2()
1882                .border_b_1()
1883                .border_color(gpui::rgb(0x2a2a32))
1884                .child(
1885                    div()
1886                        .id("oxide_back")
1887                        .when(can_back, |el| el.cursor_pointer())
1888                        .text_sm()
1889                        .text_color(if can_back {
1890                            gpui::rgb(0xb8b8c4)
1891                        } else {
1892                            gpui::rgb(0x50505a)
1893                        })
1894                        .child("◀")
1895                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1896                            this.tabs[this.active_tab].go_back();
1897                            cx.notify();
1898                        })),
1899                )
1900                .child(
1901                    div()
1902                        .id("oxide_forward")
1903                        .when(can_fwd, |el| el.cursor_pointer())
1904                        .text_sm()
1905                        .text_color(if can_fwd {
1906                            gpui::rgb(0xb8b8c4)
1907                        } else {
1908                            gpui::rgb(0x50505a)
1909                        })
1910                        .child("▶")
1911                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1912                            this.tabs[this.active_tab].go_forward();
1913                            cx.notify();
1914                        })),
1915                )
1916                .child(
1917                    div()
1918                        .id("oxide_reload")
1919                        .cursor_pointer()
1920                        .text_sm()
1921                        .text_color(gpui::rgb(0xb8b8c4))
1922                        .hover(|style| style.text_color(gpui::rgb(0xffffff)))
1923                        .child("↻")
1924                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1925                            this.tabs[this.active_tab].reload();
1926                            cx.notify();
1927                        })),
1928                )
1929                .child(
1930                    div()
1931                        .text_sm()
1932                        .text_color(status_color)
1933                        .child(status_icon.to_string()),
1934                )
1935                .child({
1936                    let url_text_for_canvas =
1937                        SharedString::from(self.tabs[active].url_input.clone());
1938                    let url_cursor = self.tabs[active].url_cursor;
1939                    let url_sel_start = self.tabs[active].url_sel_start;
1940                    let url_bounds_ref = self.tabs[active].url_text_bounds.clone();
1941                    div()
1942                        .id("oxide_url_bar")
1943                        .flex_1()
1944                        .flex()
1945                        .flex_row()
1946                        .items_center()
1947                        .h(px(32.0))
1948                        .px_2()
1949                        .rounded_md()
1950                        .bg(gpui::rgb(0x121218))
1951                        .border_1()
1952                        .border_color(if url_focused {
1953                            gpui::rgb(0x6a6aff)
1954                        } else {
1955                            gpui::rgb(0x121218)
1956                        })
1957                        .track_focus(&self.url_focus)
1958                        .overflow_hidden()
1959                        .on_key_down(cx.listener(
1960                            |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
1961                                if !this.url_focus.is_focused(window) {
1962                                    return;
1963                                }
1964                                let shift = event.keystroke.modifiers.shift;
1965                                if event.keystroke.modifiers.secondary() {
1966                                    let tab = &mut this.tabs[this.active_tab];
1967                                    match event.keystroke.key.as_str() {
1968                                        "a" => {
1969                                            tab.url_select_all();
1970                                            cx.notify();
1971                                            return;
1972                                        }
1973                                        "c" => {
1974                                            let text = tab.url_selected_text();
1975                                            if !text.is_empty() {
1976                                                if let Ok(mut cb) = arboard::Clipboard::new() {
1977                                                    let _ = cb.set_text(text);
1978                                                }
1979                                            }
1980                                            return;
1981                                        }
1982                                        "x" => {
1983                                            let text = tab.url_selected_text();
1984                                            if !text.is_empty() {
1985                                                if let Ok(mut cb) = arboard::Clipboard::new() {
1986                                                    let _ = cb.set_text(text);
1987                                                }
1988                                                tab.url_delete_selection();
1989                                                cx.notify();
1990                                            }
1991                                            return;
1992                                        }
1993                                        "v" => {
1994                                            if let Ok(mut cb) = arboard::Clipboard::new() {
1995                                                if let Ok(text) = cb.get_text() {
1996                                                    tab.url_insert_at_cursor(&text);
1997                                                    cx.notify();
1998                                                }
1999                                            }
2000                                            return;
2001                                        }
2002                                        _ => {}
2003                                    }
2004                                }
2005                                let tab = &mut this.tabs[this.active_tab];
2006                                match event.keystroke.key.as_str() {
2007                                    "left" => {
2008                                        if shift {
2009                                            tab.url_select_to(tab.url_prev_boundary());
2010                                        } else if tab.url_has_selection() {
2011                                            let lo = tab.url_sel_range().start;
2012                                            tab.url_move_to(lo);
2013                                        } else {
2014                                            let prev = tab.url_prev_boundary();
2015                                            tab.url_move_to(prev);
2016                                        }
2017                                        cx.notify();
2018                                        return;
2019                                    }
2020                                    "right" => {
2021                                        if shift {
2022                                            tab.url_select_to(tab.url_next_boundary());
2023                                        } else if tab.url_has_selection() {
2024                                            let hi = tab.url_sel_range().end;
2025                                            tab.url_move_to(hi);
2026                                        } else {
2027                                            let next = tab.url_next_boundary();
2028                                            tab.url_move_to(next);
2029                                        }
2030                                        cx.notify();
2031                                        return;
2032                                    }
2033                                    "home" => {
2034                                        if shift {
2035                                            tab.url_select_to(0);
2036                                        } else {
2037                                            tab.url_move_to(0);
2038                                        }
2039                                        cx.notify();
2040                                        return;
2041                                    }
2042                                    "end" => {
2043                                        let len = tab.url_input.len();
2044                                        if shift {
2045                                            tab.url_select_to(len);
2046                                        } else {
2047                                            tab.url_move_to(len);
2048                                        }
2049                                        cx.notify();
2050                                        return;
2051                                    }
2052                                    "backspace" => {
2053                                        tab.url_backspace();
2054                                        cx.notify();
2055                                        return;
2056                                    }
2057                                    "delete" => {
2058                                        tab.url_delete_forward();
2059                                        cx.notify();
2060                                        return;
2061                                    }
2062                                    "enter" => {
2063                                        tab.navigate(&this.download_manager);
2064                                        this.show_downloads = this.download_manager.has_active()
2065                                            || this.show_downloads;
2066                                        cx.notify();
2067                                        return;
2068                                    }
2069                                    _ => {}
2070                                }
2071                                if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
2072                                    tab.url_insert_at_cursor(&s);
2073                                    cx.notify();
2074                                }
2075                            },
2076                        ))
2077                        .on_mouse_down(
2078                            MouseButton::Left,
2079                            cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2080                                if !this.url_focus.is_focused(window) {
2081                                    this.tabs[this.active_tab].url_select_all();
2082                                    cx.notify();
2083                                    return;
2084                                }
2085                                let tab = &mut this.tabs[this.active_tab];
2086                                let bounds = *tab.url_text_bounds.lock().unwrap();
2087                                let rel_x =
2088                                    f32::from(event.position.x) - f32::from(bounds.origin.x);
2089                                let text = SharedString::from(tab.url_input.clone());
2090                                if text.is_empty() {
2091                                    tab.url_move_to(0);
2092                                } else {
2093                                    let run = TextRun {
2094                                        len: text.len(),
2095                                        font: font(".SystemUIFont"),
2096                                        color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2097                                        background_color: None,
2098                                        underline: None,
2099                                        strikethrough: None,
2100                                    };
2101                                    let line = window.text_system().shape_line(
2102                                        text,
2103                                        px(14.0),
2104                                        &[run],
2105                                        None,
2106                                    );
2107                                    let idx = line.closest_index_for_x(px(rel_x));
2108                                    if event.modifiers.shift {
2109                                        tab.url_select_to(idx);
2110                                    } else {
2111                                        tab.url_move_to(idx);
2112                                        tab.url_selecting = true;
2113                                    }
2114                                }
2115                                cx.notify();
2116                            }),
2117                        )
2118                        .on_mouse_up(
2119                            MouseButton::Left,
2120                            cx.listener(|this, _: &MouseUpEvent, _, _cx| {
2121                                this.tabs[this.active_tab].url_selecting = false;
2122                            }),
2123                        )
2124                        .on_mouse_move(cx.listener(
2125                            move |this, event: &gpui::MouseMoveEvent, window, _cx| {
2126                                let tab = &mut this.tabs[this.active_tab];
2127                                if !tab.url_selecting {
2128                                    return;
2129                                }
2130                                let bounds = *tab.url_text_bounds.lock().unwrap();
2131                                let rel_x =
2132                                    f32::from(event.position.x) - f32::from(bounds.origin.x);
2133                                let text = SharedString::from(tab.url_input.clone());
2134                                if text.is_empty() {
2135                                    return;
2136                                }
2137                                let run = TextRun {
2138                                    len: text.len(),
2139                                    font: font(".SystemUIFont"),
2140                                    color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2141                                    background_color: None,
2142                                    underline: None,
2143                                    strikethrough: None,
2144                                };
2145                                let line =
2146                                    window
2147                                        .text_system()
2148                                        .shape_line(text, px(14.0), &[run], None);
2149                                let idx = line.closest_index_for_x(px(rel_x));
2150                                tab.url_select_to(idx);
2151                                _cx.notify();
2152                            },
2153                        ))
2154                        .child({
2155                            let url_bounds_store = url_bounds_ref.clone();
2156                            canvas(
2157                                {
2158                                    let text = url_text_for_canvas.clone();
2159                                    let bounds_store = url_bounds_store.clone();
2160                                    move |bounds, window, _cx| {
2161                                        *bounds_store.lock().unwrap() = bounds;
2162                                        if text.is_empty() {
2163                                            return None;
2164                                        }
2165                                        let run = TextRun {
2166                                            len: text.len(),
2167                                            font: font(".SystemUIFont"),
2168                                            color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2169                                            background_color: None,
2170                                            underline: None,
2171                                            strikethrough: None,
2172                                        };
2173                                        Some(window.text_system().shape_line(
2174                                            text.clone(),
2175                                            px(14.0),
2176                                            &[run],
2177                                            None,
2178                                        ))
2179                                    }
2180                                },
2181                                {
2182                                    let focused = url_focused;
2183                                    let blink = caret_blink_on;
2184                                    move |bounds, line_opt: Option<gpui::ShapedLine>, window, cx| {
2185                                        let has_sel = url_cursor != url_sel_start;
2186                                        let sel_lo = url_cursor.min(url_sel_start);
2187                                        let sel_hi = url_cursor.max(url_sel_start);
2188
2189                                        if let Some(ref line) = line_opt {
2190                                            if has_sel {
2191                                                let sx = line.x_for_index(sel_lo);
2192                                                let ex = line.x_for_index(sel_hi);
2193                                                let sel_bounds = Bounds::from_corners(
2194                                                    point(bounds.origin.x + sx, bounds.origin.y),
2195                                                    point(
2196                                                        bounds.origin.x + ex,
2197                                                        bounds.origin.y + bounds.size.height,
2198                                                    ),
2199                                                );
2200                                                window.paint_quad(gpui::fill(
2201                                                    sel_bounds,
2202                                                    rgba8(0x44, 0x66, 0xcc, 0x70),
2203                                                ));
2204                                            }
2205
2206                                            let _ = line.paint(
2207                                                bounds.origin,
2208                                                bounds.size.height,
2209                                                window,
2210                                                cx,
2211                                            );
2212
2213                                            if focused && !has_sel && blink {
2214                                                let cx_pos = line.x_for_index(url_cursor);
2215                                                let cursor_bounds = Bounds::from_corners(
2216                                                    point(
2217                                                        bounds.origin.x + cx_pos,
2218                                                        bounds.origin.y,
2219                                                    ),
2220                                                    point(
2221                                                        bounds.origin.x + cx_pos + px(2.0),
2222                                                        bounds.origin.y + bounds.size.height,
2223                                                    ),
2224                                                );
2225                                                window.paint_quad(gpui::fill(
2226                                                    cursor_bounds,
2227                                                    rgba8(0xe8, 0xe8, 0xf0, 0xff),
2228                                                ));
2229                                            }
2230                                        } else if focused && blink {
2231                                            let cursor_bounds = Bounds::from_corners(
2232                                                bounds.origin,
2233                                                point(
2234                                                    bounds.origin.x + px(2.0),
2235                                                    bounds.origin.y + bounds.size.height,
2236                                                ),
2237                                            );
2238                                            window.paint_quad(gpui::fill(
2239                                                cursor_bounds,
2240                                                rgba8(0xe8, 0xe8, 0xf0, 0xff),
2241                                            ));
2242                                        }
2243                                    }
2244                                },
2245                            )
2246                            .flex_1()
2247                            .h(px(16.0))
2248                        })
2249                })
2250                .child(
2251                    div()
2252                        .id("oxide_bookmark")
2253                        .cursor_pointer()
2254                        .text_lg()
2255                        .text_color(if is_bookmarked {
2256                            gpui::rgb(0xffc832)
2257                        } else {
2258                            gpui::rgb(0xa0a0a8)
2259                        })
2260                        .child(if is_bookmarked { "★" } else { "☆" })
2261                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2262                            this.toggle_active_bookmark();
2263                            cx.notify();
2264                        })),
2265                )
2266                .child(
2267                    div()
2268                        .id("oxide_open_file")
2269                        .cursor_pointer()
2270                        .text_sm()
2271                        .text_color(gpui::rgb(0xc8c8d4))
2272                        .child("Open")
2273                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2274                            if this.file_pick_rx.is_some() {
2275                                return;
2276                            }
2277                            let (tx, rx) = mpsc::channel();
2278                            this.file_pick_rx = Some(rx);
2279                            std::thread::spawn(move || {
2280                                let path = rfd::FileDialog::new()
2281                                    .add_filter("WebAssembly", &["wasm"])
2282                                    .set_title("Open .wasm Application")
2283                                    .pick_file();
2284                                let msg = match path {
2285                                    Some(p) => match std::fs::read(&p) {
2286                                        Ok(bytes) => FilePickDone::Chosen { path: p, bytes },
2287                                        Err(_) => FilePickDone::Cancelled,
2288                                    },
2289                                    None => FilePickDone::Cancelled,
2290                                };
2291                                let _ = tx.send(msg);
2292                            });
2293                            cx.notify();
2294                        })),
2295                )
2296                .child({
2297                    let has_active_dl = self.download_manager.has_active();
2298                    let dl_count = self.download_manager.downloads().lock().unwrap().len();
2299                    div()
2300                        .id("oxide_downloads_btn")
2301                        .cursor_pointer()
2302                        .w(px(28.0))
2303                        .h(px(28.0))
2304                        .flex()
2305                        .items_center()
2306                        .justify_center()
2307                        .rounded_md()
2308                        .hover(|s| s.bg(gpui::rgb(0x373741)))
2309                        .text_color(if has_active_dl {
2310                            gpui::rgb(0x50b0e0)
2311                        } else if dl_count > 0 {
2312                            gpui::rgb(0xc8c8d4)
2313                        } else {
2314                            gpui::rgb(0x60606a)
2315                        })
2316                        .child("⬇")
2317                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2318                            this.show_downloads = !this.show_downloads;
2319                            cx.notify();
2320                        }))
2321                })
2322                .child(
2323                    div()
2324                        .id("oxide_menu_btn")
2325                        .relative()
2326                        .cursor_pointer()
2327                        .w(px(28.0))
2328                        .h(px(28.0))
2329                        .flex()
2330                        .items_center()
2331                        .justify_center()
2332                        .rounded_md()
2333                        .hover(|s| s.bg(gpui::rgb(0x373741)))
2334                        .text_color(gpui::rgb(0xc8c8d4))
2335                        .child("⋮")
2336                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2337                            this.show_menu = !this.show_menu;
2338                            cx.notify();
2339                        })),
2340                ),
2341        );
2342
2343        // Main row: optional bookmarks + content
2344        let mut main_row = div().flex_1().flex().flex_row().min_h_0();
2345
2346        if self.show_bookmarks {
2347            if let Some(store) = &self.bookmark_store {
2348                let items = store.list_all();
2349                main_row = main_row.child(
2350                    div()
2351                        .id("oxide_bookmarks_panel")
2352                        .w(px(260.0))
2353                        .h_full()
2354                        .overflow_scroll()
2355                        .border_r_1()
2356                        .border_color(gpui::rgb(0x2a2a32))
2357                        .p_2()
2358                        .children(items.iter().enumerate().map(|(bi, bm)| {
2359                            let url = bm.url.clone();
2360                            let label = if bm.title.is_empty() {
2361                                url_to_title(&bm.url)
2362                            } else {
2363                                bm.title.clone()
2364                            };
2365                            div()
2366                                .id(("oxide_bm", bi))
2367                                .py_1()
2368                                .cursor_pointer()
2369                                .text_sm()
2370                                .text_color(gpui::rgb(0xaab4ff))
2371                                .child(truncate_tab_title(&label))
2372                                .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2373                                    this.tabs[this.active_tab].navigate_to(
2374                                        url.clone(),
2375                                        true,
2376                                        &this.download_manager,
2377                                    );
2378                                    this.show_downloads =
2379                                        this.download_manager.has_active() || this.show_downloads;
2380                                    cx.notify();
2381                                }))
2382                        })),
2383                );
2384            }
2385        }
2386
2387        let mut content_col = div().flex_1().flex().flex_col().min_h_0();
2388
2389        if let Some(ref page) = self.tabs[active].internal_page {
2390            match page {
2391                InternalPage::Home => {
2392                    content_col = content_col.child(
2393                        div()
2394                            .id("oxide_home_page")
2395                            .flex_1()
2396                            .flex()
2397                            .items_center()
2398                            .justify_center()
2399                            .p_4()
2400                            .child(
2401                                div()
2402                                    .w(px(560.0))
2403                                    .p_5()
2404                                    .rounded_lg()
2405                                    .bg(gpui::rgb(0x222228))
2406                                    .border_1()
2407                                    .border_color(gpui::rgb(0x3a3a44))
2408                                    .child(
2409                                        div()
2410                                            .text_xl()
2411                                            .font_weight(gpui::FontWeight::BOLD)
2412                                            .text_color(gpui::rgb(0x80d8d0))
2413                                            .child("Oxide Browser"),
2414                                    )
2415                                    .child(
2416                                        div()
2417                                            .mt_2()
2418                                            .text_sm()
2419                                            .text_color(gpui::rgb(0xc8c8d4))
2420                                            .child("A binary-first browser for WebAssembly apps. Oxide loads .wasm modules directly, runs them in a capability-based Wasmtime sandbox, and gives them a native GPU-accelerated canvas instead of an HTML/JavaScript runtime."),
2421                                    )
2422                                    .child(
2423                                        div()
2424                                            .mt_4()
2425                                            .flex()
2426                                            .flex_col()
2427                                            .gap_2()
2428                                            .text_xs()
2429                                            .text_color(gpui::rgb(0xa6a6b8))
2430                                            .child("Open a local .wasm file, enter an HTTP(S) .wasm URL, or build a new app with Forge.")
2431                                            .child("Guest apps start with no filesystem, environment, or socket access. Every host interaction goes through explicit Oxide capabilities."),
2432                                    )
2433                                    .child(
2434                                        div()
2435                                            .mt_4()
2436                                            .h(px(1.0))
2437                                            .bg(gpui::rgb(0x3a3a44)),
2438                                    )
2439                                    .child(
2440                                        div()
2441                                            .mt_4()
2442                                            .flex()
2443                                            .flex_row()
2444                                            .items_center()
2445                                            .justify_between()
2446                                            .gap_3()
2447                                            .child(
2448                                                div()
2449                                                    .flex_1()
2450                                                    .min_w_0()
2451                                                    .child(
2452                                                        div()
2453                                                            .text_sm()
2454                                                            .font_weight(gpui::FontWeight::SEMIBOLD)
2455                                                            .text_color(gpui::rgb(0xe8e8f4))
2456                                                            .child("Create with Oxide Forge"),
2457                                                    )
2458                                                    .child(
2459                                                        div()
2460                                                            .mt_1()
2461                                                            .text_xs()
2462                                                            .text_color(gpui::rgb(0x8a8aa0))
2463                                                            .child("Describe an app and Forge will generate, build, and hot-load a sandboxed guest WASM module."),
2464                                                    ),
2465                                            )
2466                                            .child(
2467                                                div()
2468                                                    .id("oxide_home_forge")
2469                                                    .px_3()
2470                                                    .py_2()
2471                                                    .rounded_md()
2472                                                    .bg(gpui::rgb(0x2f6f68))
2473                                                    .text_sm()
2474                                                    .text_color(gpui::rgb(0xffffff))
2475                                                    .cursor_pointer()
2476                                                    .child("oxide://forge")
2477                                                    .on_click(cx.listener(
2478                                                        |this, _: &ClickEvent, _, cx| {
2479                                                            this.tabs[this.active_tab].navigate_to(
2480                                                                "oxide://forge".to_string(),
2481                                                                true,
2482                                                                &this.download_manager,
2483                                                            );
2484                                                            cx.notify();
2485                                                        },
2486                                                    )),
2487                                            ),
2488                                    ),
2489                            ),
2490                    );
2491                }
2492                InternalPage::History => {
2493                    let all_entries: Vec<(Vec<u8>, String, String, u64)> = self
2494                        .history_store
2495                        .as_ref()
2496                        .map(|store| {
2497                            store
2498                                .list_all()
2499                                .into_iter()
2500                                .map(|(key, item)| (key, item.url, item.title, item.visited_at_ms))
2501                                .collect()
2502                        })
2503                        .unwrap_or_default();
2504                    let has_entries = !all_entries.is_empty();
2505
2506                    content_col = content_col.child(
2507                        div()
2508                            .id("oxide_history_page")
2509                            .flex_1()
2510                            .overflow_scroll()
2511                            .p_4()
2512                            .child(
2513                                div()
2514                                    .flex()
2515                                    .flex_row()
2516                                    .items_center()
2517                                    .justify_between()
2518                                    .child(
2519                                        div()
2520                                            .child(
2521                                                div()
2522                                                    .text_lg()
2523                                                    .font_weight(gpui::FontWeight::BOLD)
2524                                                    .text_color(gpui::rgb(0xb478ff))
2525                                                    .child("History"),
2526                                            )
2527                                            .child(
2528                                                div()
2529                                                    .mt_1()
2530                                                    .text_xs()
2531                                                    .text_color(gpui::rgb(0x7a7a90))
2532                                                    .child(format!(
2533                                                        "{} visited page{}",
2534                                                        all_entries.len(),
2535                                                        if all_entries.len() == 1 {
2536                                                            ""
2537                                                        } else {
2538                                                            "s"
2539                                                        }
2540                                                    )),
2541                                            ),
2542                                    )
2543                                    .when(has_entries, |d| {
2544                                        d.child(
2545                                            div()
2546                                                .id("oxide_hist_clear_all")
2547                                                .flex()
2548                                                .flex_row()
2549                                                .items_center()
2550                                                .gap_1()
2551                                                .px_3()
2552                                                .py(px(6.0))
2553                                                .rounded_md()
2554                                                .cursor_pointer()
2555                                                .bg(gpui::rgb(0x2a2a34))
2556                                                .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2557                                                .text_xs()
2558                                                .text_color(gpui::rgb(0xf05050))
2559                                                .child("🗑")
2560                                                .child("Clear All")
2561                                                .on_click(cx.listener(
2562                                                    |this, _: &ClickEvent, _, cx| {
2563                                                        if let Some(store) = &this.history_store {
2564                                                            let _ = store.clear();
2565                                                        }
2566                                                        cx.notify();
2567                                                    },
2568                                                )),
2569                                        )
2570                                    }),
2571                            )
2572                            .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
2573                            .when(!has_entries, |d| {
2574                                d.child(
2575                                    div()
2576                                        .mt_4()
2577                                        .text_sm()
2578                                        .text_color(gpui::rgb(0x7a7a90))
2579                                        .child(
2580                                            "No history yet. Navigate to a page to see it here.",
2581                                        ),
2582                                )
2583                            })
2584                            .children(all_entries.into_iter().enumerate().map(
2585                                |(i, (key, url, title, ts))| {
2586                                    let url_nav = url.clone();
2587                                    let key_for_delete = key.clone();
2588                                    let display_title = if title.is_empty() {
2589                                        url_to_title(&url)
2590                                    } else {
2591                                        title
2592                                    };
2593                                    let friendly = format_friendly_timestamp(ts);
2594                                    div()
2595                                        .id(("oxide_hist", i))
2596                                        .flex()
2597                                        .flex_row()
2598                                        .items_center()
2599                                        .justify_between()
2600                                        .py_2()
2601                                        .px_2()
2602                                        .rounded_md()
2603                                        .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2604                                        .border_b_1()
2605                                        .border_color(gpui::rgb(0x222230))
2606                                        .child(
2607                                            div()
2608                                                .id(("oxide_hist_link", i))
2609                                                .flex_1()
2610                                                .min_w_0()
2611                                                .overflow_hidden()
2612                                                .cursor_pointer()
2613                                                .child(
2614                                                    div()
2615                                                        .text_sm()
2616                                                        .text_color(gpui::rgb(0xaab4ff))
2617                                                        .child(display_title),
2618                                                )
2619                                                .child(
2620                                                    div()
2621                                                        .text_xs()
2622                                                        .text_color(gpui::rgb(0x6a6a80))
2623                                                        .mt(px(2.0))
2624                                                        .child(url.clone()),
2625                                                )
2626                                                .on_click(cx.listener(
2627                                                    move |this, _: &ClickEvent, _, cx| {
2628                                                        this.tabs[this.active_tab].navigate_to(
2629                                                            url_nav.clone(),
2630                                                            true,
2631                                                            &this.download_manager,
2632                                                        );
2633                                                        this.show_downloads =
2634                                                            this.download_manager.has_active()
2635                                                                || this.show_downloads;
2636                                                        cx.notify();
2637                                                    },
2638                                                )),
2639                                        )
2640                                        .child(
2641                                            div()
2642                                                .flex_shrink_0()
2643                                                .ml_3()
2644                                                .text_xs()
2645                                                .text_color(gpui::rgb(0x7a7a90))
2646                                                .child(friendly),
2647                                        )
2648                                        .child(
2649                                            div()
2650                                                .id(("oxide_hist_del", i))
2651                                                .flex_shrink_0()
2652                                                .ml_2()
2653                                                .w(px(24.0))
2654                                                .h(px(24.0))
2655                                                .flex()
2656                                                .items_center()
2657                                                .justify_center()
2658                                                .rounded_sm()
2659                                                .cursor_pointer()
2660                                                .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2661                                                .text_xs()
2662                                                .text_color(gpui::rgb(0x9696a0))
2663                                                .child("🗑")
2664                                                .on_click(cx.listener(
2665                                                    move |this, _: &ClickEvent, _, cx| {
2666                                                        if let Some(store) = &this.history_store {
2667                                                            let _ = store
2668                                                                .remove_by_key(&key_for_delete);
2669                                                        }
2670                                                        cx.notify();
2671                                                    },
2672                                                )),
2673                                        )
2674                                },
2675                            )),
2676                    );
2677                }
2678                InternalPage::Bookmarks => {
2679                    let items = self
2680                        .bookmark_store
2681                        .as_ref()
2682                        .map(|s| s.list_all())
2683                        .unwrap_or_default();
2684
2685                    content_col = content_col.child(
2686                        div()
2687                            .id("oxide_bookmarks_page")
2688                            .flex_1()
2689                            .overflow_scroll()
2690                            .p_4()
2691                            .child(
2692                                div()
2693                                    .text_lg()
2694                                    .font_weight(gpui::FontWeight::BOLD)
2695                                    .text_color(gpui::rgb(0xb478ff))
2696                                    .child("Bookmarks"),
2697                            )
2698                            .child(
2699                                div()
2700                                    .mt_1()
2701                                    .text_xs()
2702                                    .text_color(gpui::rgb(0x7a7a90))
2703                                    .child(format!(
2704                                        "{} bookmark{}",
2705                                        items.len(),
2706                                        if items.len() == 1 { "" } else { "s" }
2707                                    )),
2708                            )
2709                            .child(
2710                                div()
2711                                    .mt_3()
2712                                    .h(px(1.0))
2713                                    .bg(gpui::rgb(0x2a2a32)),
2714                            )
2715                            .when(items.is_empty(), |d| {
2716                                d.child(
2717                                    div()
2718                                        .mt_4()
2719                                        .text_sm()
2720                                        .text_color(gpui::rgb(0x7a7a90))
2721                                        .child("No bookmarks yet. Press ☆ in the toolbar to bookmark a page."),
2722                                )
2723                            })
2724                            .children(items.into_iter().enumerate().map(|(i, bm)| {
2725                                let url = bm.url.clone();
2726                                let url_nav = bm.url.clone();
2727                                let label = if bm.title.is_empty() {
2728                                    url_to_title(&bm.url)
2729                                } else {
2730                                    bm.title.clone()
2731                                };
2732                                div()
2733                                    .id(("oxide_bmp", i))
2734                                    .flex()
2735                                    .flex_row()
2736                                    .items_center()
2737                                    .py_2()
2738                                    .px_2()
2739                                    .rounded_md()
2740                                    .cursor_pointer()
2741                                    .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2742                                    .border_b_1()
2743                                    .border_color(gpui::rgb(0x222230))
2744                                    .child(
2745                                        div()
2746                                            .flex_1()
2747                                            .min_w_0()
2748                                            .overflow_hidden()
2749                                            .child(
2750                                                div()
2751                                                    .text_sm()
2752                                                    .text_color(gpui::rgb(0xaab4ff))
2753                                                    .child(label),
2754                                            )
2755                                            .child(
2756                                                div()
2757                                                    .text_xs()
2758                                                    .text_color(gpui::rgb(0x6a6a80))
2759                                                    .mt(px(2.0))
2760                                                    .child(url),
2761                                            ),
2762                                    )
2763                                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2764                                        this.tabs[this.active_tab]
2765                                            .navigate_to(url_nav.clone(), true, &this.download_manager);
2766                                        this.show_downloads = this.download_manager.has_active() || this.show_downloads;
2767                                        cx.notify();
2768                                    }))
2769                            })),
2770                    );
2771                }
2772                InternalPage::About => {
2773                    content_col = content_col.child(
2774                        div()
2775                            .flex_1()
2776                            .flex()
2777                            .items_center()
2778                            .justify_center()
2779                            .p_4()
2780                            .child(
2781                                div()
2782                                    .w(px(480.0))
2783                                    .p_5()
2784                                    .rounded_lg()
2785                                    .bg(gpui::rgb(0x222228))
2786                                    .border_1()
2787                                    .border_color(gpui::rgb(0x3a3a44))
2788                                    .child(
2789                                        div()
2790                                            .text_xl()
2791                                            .font_weight(gpui::FontWeight::BOLD)
2792                                            .text_color(gpui::rgb(0xb478ff))
2793                                            .child("Oxide Browser"),
2794                                    )
2795                                    .child(
2796                                        div()
2797                                            .mt_1()
2798                                            .text_sm()
2799                                            .text_color(gpui::rgb(0x8888a0))
2800                                            .child(format!(
2801                                                "Version {}",
2802                                                env!("CARGO_PKG_VERSION")
2803                                            )),
2804                                    )
2805                                    .child(
2806                                        div()
2807                                            .mt_3()
2808                                            .h(px(1.0))
2809                                            .bg(gpui::rgb(0x3a3a44)),
2810                                    )
2811                                    .child(
2812                                        div()
2813                                            .mt_3()
2814                                            .text_sm()
2815                                            .text_color(gpui::rgb(0xc0c0cc))
2816                                            .child("A binary-first browser that fetches and runs .wasm modules in a secure sandbox, powered by a GPU-accelerated native UI."),
2817                                    )
2818                                    .child(
2819                                        div()
2820                                            .mt_3()
2821                                            .flex()
2822                                            .flex_col()
2823                                            .gap_1()
2824                                            .text_xs()
2825                                            .text_color(gpui::rgb(0x9696a0))
2826                                            .child(
2827                                                div().flex().flex_row().gap_2()
2828                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Engine"))
2829                                                    .child(div().child("Wasmtime sandbox")),
2830                                            )
2831                                            .child(
2832                                                div().flex().flex_row().gap_2()
2833                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("UI"))
2834                                                    .child(div().child("GPUI (Zed's GPU-accelerated framework)")),
2835                                            )
2836                                            .child(
2837                                                div().flex().flex_row().gap_2()
2838                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Graphics"))
2839                                                    .child(div().child("Metal / wgpu")),
2840                                            )
2841                                            .child(
2842                                                div().flex().flex_row().gap_2()
2843                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("License"))
2844                                                    .child(div().child("MIT")),
2845                                            ),
2846                                    )
2847                                    .child(
2848                                        div()
2849                                            .mt_3()
2850                                            .text_xs()
2851                                            .text_color(gpui::rgb(0x6a6a80))
2852                                            .child("github.com/niklabh/oxide"),
2853                                    ),
2854                            ),
2855                    );
2856                }
2857                InternalPage::Forge => {
2858                    let forge_ready = self.ensure_forge();
2859                    let snapshot = self.forge_current_snapshot();
2860                    let creations = self.forge_creations();
2861                    let output_dir = self
2862                        .forge_output_dir()
2863                        .map(|p| p.display().to_string())
2864                        .unwrap_or_else(|| "(not configured)".to_string());
2865                    let prompt_draft = self.tabs[active].forge_prompt.clone();
2866                    let prompt_focused = self.forge_focus.is_focused(window);
2867                    let settings_open = self.forge_config.settings_open;
2868                    let active_provider = self.forge_config.active_provider;
2869                    let active_model = self.forge_config.active_model();
2870                    let settings_provider = self.forge_settings_provider;
2871                    let settings_key_focused = self.forge_settings_key_focus.is_focused(window);
2872                    let settings_model_focused = self.forge_settings_model_focus.is_focused(window);
2873                    let settings_key_draft = self.forge_settings_key_draft.clone();
2874                    let settings_model_draft = self.forge_settings_model_draft.clone();
2875                    let saved_key = self.forge_config.provider(settings_provider).api_key;
2876                    let settings_key_hint = if settings_key_draft.is_empty() {
2877                        if saved_key.is_empty() {
2878                            format!("Paste {} API key", settings_provider.label())
2879                        } else {
2880                            mask_api_key(&saved_key)
2881                        }
2882                    } else {
2883                        "•".repeat(settings_key_draft.chars().count().min(28))
2884                    };
2885                    let settings_save_enabled = !settings_key_draft.trim().is_empty()
2886                        || !settings_model_draft.trim().is_empty()
2887                        || !saved_key.is_empty();
2888
2889                    let (status_word, status_color, status_hint) = if !forge_ready {
2890                        (
2891                            "Configure AI".to_string(),
2892                            gpui::rgb(0xf08050),
2893                            "Open Settings and add an API key for your preferred provider."
2894                                .to_string(),
2895                        )
2896                    } else {
2897                        (
2898                            "ready".to_string(),
2899                            gpui::rgb(0x80d090),
2900                            format!(
2901                                "Using {} · {}. Chat to generate sandboxed guest WASM apps. Output: {output_dir}",
2902                                active_provider.label(),
2903                                active_model
2904                            ),
2905                        )
2906                    };
2907
2908                    let phase = snapshot.as_ref().map(|s| s.phase);
2909                    let can_build = matches!(
2910                        phase,
2911                        Some(ForgePhase::StreamComplete)
2912                            | Some(ForgePhase::Error)
2913                            | Some(ForgePhase::BuildOk),
2914                    );
2915                    let can_run = matches!(phase, Some(ForgePhase::BuildOk));
2916                    let can_delete = snapshot
2917                        .as_ref()
2918                        .map(|s| !matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building))
2919                        .unwrap_or(false);
2920
2921                    let caret = if prompt_focused && caret_blink_on {
2922                        "\u{2588}"
2923                    } else {
2924                        ""
2925                    };
2926                    let prompt_display = if prompt_draft.is_empty() {
2927                        "Describe an app…".to_string()
2928                    } else {
2929                        prompt_draft.clone()
2930                    };
2931                    let prompt_color = if prompt_draft.is_empty() {
2932                        gpui::rgb(0x5a5a6a)
2933                    } else {
2934                        gpui::rgb(0xe0e0ff)
2935                    };
2936
2937                    let submit_enabled = forge_ready && !prompt_draft.trim().is_empty();
2938                    let submit_label = if self.tabs[active].forge_session_id.is_some() {
2939                        "Apply changes"
2940                    } else {
2941                        "Create app"
2942                    };
2943
2944                    let selected_id = self.tabs[active].forge_session_id;
2945
2946                    let list_panel = div()
2947                        .id("oxide_forge_list")
2948                        .w(px(260.0))
2949                        .h_full()
2950                        .flex()
2951                        .flex_col()
2952                        .min_h_0()
2953                        .border_r_1()
2954                        .border_color(gpui::rgb(0x2a2a32))
2955                        .pr_3()
2956                        .child(
2957                            div()
2958                                .flex()
2959                                .flex_row()
2960                                .items_center()
2961                                .justify_between()
2962                                .child(
2963                                    div()
2964                                        .text_sm()
2965                                        .font_weight(gpui::FontWeight::BOLD)
2966                                        .text_color(gpui::rgb(0xe8e8f4))
2967                                        .child("Creations"),
2968                                )
2969                                .child(
2970                                    div()
2971                                        .id("oxide_forge_new_btn")
2972                                        .px_2()
2973                                        .py(px(5.0))
2974                                        .rounded_sm()
2975                                        .bg(gpui::rgb(0x2a2a36))
2976                                        .text_xs()
2977                                        .text_color(gpui::rgb(0xc8c8d4))
2978                                        .cursor_pointer()
2979                                        .child("New")
2980                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2981                                            let tab = &mut this.tabs[this.active_tab];
2982                                            tab.forge_session_id = None;
2983                                            tab.forge_prompt.clear();
2984                                            cx.notify();
2985                                        })),
2986                                ),
2987                        )
2988                        .child(
2989                            div()
2990                                .mt_2()
2991                                .text_xs()
2992                                .text_color(gpui::rgb(0x7a7a90))
2993                                .child(format!(
2994                                    "{} app{}",
2995                                    creations.len(),
2996                                    if creations.len() == 1 { "" } else { "s" }
2997                                )),
2998                        )
2999                        .child(
3000                            div()
3001                                .id("oxide_forge_creation_scroll")
3002                                .mt_3()
3003                                .flex_1()
3004                                .min_h_0()
3005                                .overflow_scroll()
3006                                .children(creations.iter().enumerate().map(|(i, item)| {
3007                                    let id = item.id;
3008                                    let selected = Some(id) == selected_id;
3009                                    let title = if item.prompt.trim().is_empty() {
3010                                        item.slug.clone()
3011                                    } else {
3012                                        truncate_tab_title(&item.prompt)
3013                                    };
3014                                    let path = item.project_dir.display().to_string();
3015                                    div()
3016                                        .id(("oxide_forge_creation", i))
3017                                        .mb_2()
3018                                        .p_2()
3019                                        .rounded_md()
3020                                        .cursor_pointer()
3021                                        .bg(if selected {
3022                                            gpui::rgb(0x2f2944)
3023                                        } else {
3024                                            gpui::rgb(0x202028)
3025                                        })
3026                                        .border_1()
3027                                        .border_color(if selected {
3028                                            gpui::rgb(0x7a5ae0)
3029                                        } else {
3030                                            gpui::rgb(0x2e2e38)
3031                                        })
3032                                        .child(
3033                                            div()
3034                                                .flex()
3035                                                .flex_row()
3036                                                .items_center()
3037                                                .justify_between()
3038                                                .gap_2()
3039                                                .child(
3040                                                    div()
3041                                                        .flex_1()
3042                                                        .min_w_0()
3043                                                        .text_sm()
3044                                                        .text_color(gpui::rgb(0xe4e4f0))
3045                                                        .child(title),
3046                                                )
3047                                                .child(
3048                                                    div()
3049                                                        .text_xs()
3050                                                        .text_color(phase_color_for(item.phase))
3051                                                        .child(phase_label(item.phase)),
3052                                                ),
3053                                        )
3054                                        .child(
3055                                            div()
3056                                                .mt_1()
3057                                                .text_xs()
3058                                                .text_color(gpui::rgb(0x747488))
3059                                                .child(item.slug.clone()),
3060                                        )
3061                                        .child(
3062                                            div()
3063                                                .mt_1()
3064                                                .text_xs()
3065                                                .text_color(gpui::rgb(0x5f5f72))
3066                                                .child(path),
3067                                        )
3068                                        .on_click(cx.listener(
3069                                            move |this, _: &ClickEvent, _, cx| {
3070                                                this.tabs[this.active_tab].forge_session_id =
3071                                                    Some(id);
3072                                                this.tabs[this.active_tab].forge_prompt.clear();
3073                                                cx.notify();
3074                                            },
3075                                        ))
3076                                })),
3077                        );
3078
3079                    let mut chat_panel = div()
3080                        .id("oxide_forge_chat")
3081                        .flex_1()
3082                        .min_h_0()
3083                        .flex()
3084                        .flex_col()
3085                        .rounded_md()
3086                        .bg(gpui::rgb(0x181820))
3087                        .border_1()
3088                        .border_color(gpui::rgb(0x2a2a34));
3089
3090                    let mut code_panel = div()
3091                        .id("oxide_forge_code_panel")
3092                        .w(px(340.0))
3093                        .min_h_0()
3094                        .flex()
3095                        .flex_col()
3096                        .rounded_md()
3097                        .bg(gpui::rgb(0x15151c))
3098                        .border_1()
3099                        .border_color(gpui::rgb(0x2a2a34));
3100
3101                    if let Some(snap) = snapshot.clone() {
3102                        let phase_text = if snap.retries_used > 0 {
3103                            format!(
3104                                "{} (auto-fix {}/{})",
3105                                phase_label(snap.phase),
3106                                snap.retries_used,
3107                                snap.max_retries
3108                            )
3109                        } else {
3110                            phase_label(snap.phase).to_string()
3111                        };
3112                        let phase_color = phase_color_for(snap.phase);
3113                        let prompt_preview = truncate_tab_title(&snap.prompt);
3114                        let code_text = snap.code.clone();
3115                        let build_log = snap.build_log.clone();
3116                        let err = snap.error.clone();
3117                        let artifact = snap
3118                            .artifact_path
3119                            .as_ref()
3120                            .map(|p| p.display().to_string())
3121                            .unwrap_or_else(|| "No wasm artifact yet".to_string());
3122
3123                        chat_panel = chat_panel
3124                            .child(
3125                                div()
3126                                    .px_3()
3127                                    .py_2()
3128                                    .border_b_1()
3129                                    .border_color(gpui::rgb(0x2a2a34))
3130                                    .flex()
3131                                    .flex_row()
3132                                    .items_center()
3133                                    .justify_between()
3134                                    .child(
3135                                        div()
3136                                            .child(
3137                                                div()
3138                                                    .text_sm()
3139                                                    .font_weight(gpui::FontWeight::BOLD)
3140                                                    .text_color(gpui::rgb(0xe8e8f4))
3141                                                    .child(prompt_preview),
3142                                            )
3143                                            .child(
3144                                                div()
3145                                                    .mt_1()
3146                                                    .text_xs()
3147                                                    .text_color(gpui::rgb(0x7a7a90))
3148                                                    .child(format!(
3149                                                        "{} · {} · {}",
3150                                                        snap.slug,
3151                                                        snap.provider.label(),
3152                                                        snap.model
3153                                                    )),
3154                                            ),
3155                                    )
3156                                    .child(
3157                                        div()
3158                                            .px_2()
3159                                            .py(px(2.0))
3160                                            .rounded_sm()
3161                                            .bg(gpui::rgb(0x22222c))
3162                                            .text_xs()
3163                                            .text_color(phase_color)
3164                                            .child(phase_text),
3165                                    ),
3166                            )
3167                            .child(
3168                                div()
3169                                    .id("oxide_forge_messages")
3170                                    .flex_1()
3171                                    .min_h_0()
3172                                    .overflow_scroll()
3173                                    .p_3()
3174                                    .flex()
3175                                    .flex_col()
3176                                    .gap_2()
3177                                    .children(
3178                                        snap.messages
3179                                            .iter()
3180                                            .enumerate()
3181                                            .map(|(i, msg)| forge_chat_bubble(i, msg, snap.phase)),
3182                                    ),
3183                            );
3184
3185                        code_panel = code_panel
3186                            .child(
3187                                div()
3188                                    .px_3()
3189                                    .py_2()
3190                                    .border_b_1()
3191                                    .border_color(gpui::rgb(0x2a2a34))
3192                                    .text_xs()
3193                                    .font_weight(gpui::FontWeight::BOLD)
3194                                    .text_color(gpui::rgb(0xc0c0d8))
3195                                    .child("Generated code"),
3196                            )
3197                            .child(
3198                                div()
3199                                    .id("oxide_forge_code")
3200                                    .flex_1()
3201                                    .min_h_0()
3202                                    .overflow_scroll()
3203                                    .px_3()
3204                                    .py_2()
3205                                    .text_xs()
3206                                    .text_color(gpui::rgb(0xc0c0d8))
3207                                    .font(font("Menlo"))
3208                                    .child(if code_text.is_empty() {
3209                                        "(awaiting stream…)".to_string()
3210                                    } else {
3211                                        code_text
3212                                    }),
3213                            )
3214                            .child(
3215                                div()
3216                                    .id("oxide_forge_log")
3217                                    .max_h(px(120.0))
3218                                    .overflow_scroll()
3219                                    .px_3()
3220                                    .py_2()
3221                                    .border_t_1()
3222                                    .border_color(gpui::rgb(0x2a2a34))
3223                                    .text_xs()
3224                                    .text_color(gpui::rgb(0xf0c0a0))
3225                                    .font(font("Menlo"))
3226                                    .child(if let Some(e) = err {
3227                                        format!("error: {e}\n\n{build_log}")
3228                                    } else if build_log.is_empty() {
3229                                        artifact
3230                                    } else {
3231                                        build_log
3232                                    }),
3233                            )
3234                            .child(
3235                                div()
3236                                    .p_2()
3237                                    .flex()
3238                                    .flex_row()
3239                                    .gap_2()
3240                                    .child(
3241                                        div()
3242                                            .id("oxide_forge_build_btn")
3243                                            .flex_1()
3244                                            .px_2()
3245                                            .py_2()
3246                                            .rounded_md()
3247                                            .bg(if can_build {
3248                                                gpui::rgb(0x3a6a8a)
3249                                            } else {
3250                                                gpui::rgb(0x3a3a44)
3251                                            })
3252                                            .text_xs()
3253                                            .text_color(gpui::rgb(0xffffff))
3254                                            .cursor_pointer()
3255                                            .child("Build")
3256                                            .on_click(cx.listener(
3257                                                |this, _: &ClickEvent, _, cx| {
3258                                                    this.forge_build();
3259                                                    cx.notify();
3260                                                },
3261                                            )),
3262                                    )
3263                                    .child(
3264                                        div()
3265                                            .id("oxide_forge_run_btn")
3266                                            .flex_1()
3267                                            .px_2()
3268                                            .py_2()
3269                                            .rounded_md()
3270                                            .bg(if can_run {
3271                                                gpui::rgb(0x4a9a6a)
3272                                            } else {
3273                                                gpui::rgb(0x3a3a44)
3274                                            })
3275                                            .text_xs()
3276                                            .text_color(gpui::rgb(0xffffff))
3277                                            .cursor_pointer()
3278                                            .child("Run")
3279                                            .on_click(cx.listener(
3280                                                |this, _: &ClickEvent, _, cx| {
3281                                                    this.forge_run_in_new_tab();
3282                                                    cx.notify();
3283                                                },
3284                                            )),
3285                                    )
3286                                    .child(
3287                                        div()
3288                                            .id("oxide_forge_delete_btn")
3289                                            .px_2()
3290                                            .py_2()
3291                                            .rounded_md()
3292                                            .bg(if can_delete {
3293                                                gpui::rgb(0x8a3a3a)
3294                                            } else {
3295                                                gpui::rgb(0x3a3a44)
3296                                            })
3297                                            .text_xs()
3298                                            .text_color(gpui::rgb(0xffffff))
3299                                            .cursor_pointer()
3300                                            .child("Del")
3301                                            .on_click(cx.listener(
3302                                                |this, _: &ClickEvent, _, cx| {
3303                                                    this.forge_delete_current_creation();
3304                                                    cx.notify();
3305                                                },
3306                                            )),
3307                                    ),
3308                            );
3309                    } else {
3310                        chat_panel = chat_panel.child(
3311                            div()
3312                                .flex_1()
3313                                .flex()
3314                                .flex_col()
3315                                .items_center()
3316                                .justify_center()
3317                                .p_4()
3318                                .child(
3319                                    div()
3320                                        .text_sm()
3321                                        .text_color(gpui::rgb(0x9a9ab0))
3322                                        .child("Welcome to Oxide Forge"),
3323                                )
3324                                .child(
3325                                    div()
3326                                        .mt_2()
3327                                        .text_xs()
3328                                        .text_color(gpui::rgb(0x6a6a80))
3329                                        .child(
3330                                            "Describe an app below — like Cursor chat, Forge writes Rust, builds WASM, and runs it in a tab.",
3331                                        ),
3332                                ),
3333                        );
3334                        code_panel = code_panel.child(
3335                            div()
3336                                .flex_1()
3337                                .flex()
3338                                .items_center()
3339                                .justify_center()
3340                                .text_xs()
3341                                .text_color(gpui::rgb(0x6a6a80))
3342                                .child("Code appears here after generation."),
3343                        );
3344                    }
3345
3346                    let workspace_panel = div()
3347                        .flex_1()
3348                        .min_h_0()
3349                        .flex()
3350                        .flex_row()
3351                        .gap_3()
3352                        .child(list_panel)
3353                        .child(chat_panel)
3354                        .child(code_panel);
3355
3356                    content_col = content_col.child(
3357                        div()
3358                            .id("oxide_forge_page")
3359                            .flex_1()
3360                            .flex()
3361                            .flex_col()
3362                            .min_h_0()
3363                            .p_4()
3364                            .child(
3365                                div()
3366                                    .flex()
3367                                    .flex_row()
3368                                    .items_center()
3369                                    .justify_between()
3370                                    .gap_3()
3371                                    .child(
3372                                        div()
3373                                            .flex_1()
3374                                            .min_w_0()
3375                                            .child(
3376                                                div()
3377                                                    .text_lg()
3378                                                    .font_weight(gpui::FontWeight::BOLD)
3379                                                    .text_color(gpui::rgb(0xb478ff))
3380                                                    .child("Oxide Forge"),
3381                                            )
3382                                            .child(
3383                                                div()
3384                                                    .mt_1()
3385                                                    .text_xs()
3386                                                    .text_color(gpui::rgb(0x8a8aa0))
3387                                                    .child(status_hint.clone()),
3388                                            ),
3389                                    )
3390                                    .child(
3391                                        div()
3392                                            .flex()
3393                                            .flex_row()
3394                                            .gap_1()
3395                                            .children(ForgeProvider::ALL.iter().enumerate().map(
3396                                                |(i, provider)| {
3397                                                    let selected = *provider == active_provider;
3398                                                    let configured =
3399                                                        self.forge_config.provider(*provider)
3400                                                            .has_key();
3401                                                    let dot = if configured { "● " } else { "" };
3402                                                    div()
3403                                                        .id(("oxide_forge_provider", i))
3404                                                        .px_2()
3405                                                        .py_1()
3406                                                        .rounded_sm()
3407                                                        .bg(if selected {
3408                                                            gpui::rgb(0x4a3a7a)
3409                                                        } else {
3410                                                            gpui::rgb(0x2a2a36)
3411                                                        })
3412                                                        .text_xs()
3413                                                        .text_color(if selected {
3414                                                            gpui::rgb(0xffffff)
3415                                                        } else {
3416                                                            gpui::rgb(0xb0b0c0)
3417                                                        })
3418                                                        .cursor_pointer()
3419                                                        .child(format!(
3420                                                            "{dot}{}",
3421                                                            provider.label()
3422                                                        ))
3423                                                        .on_click(cx.listener(
3424                                                            move |this, _: &ClickEvent, _, cx| {
3425                                                                this.forge_select_provider(
3426                                                                    *provider,
3427                                                                );
3428                                                                cx.notify();
3429                                                            },
3430                                                        ))
3431                                                },
3432                                            )),
3433                                    )
3434                                    .child(
3435                                        div()
3436                                            .id("oxide_forge_settings_btn")
3437                                            .px_2()
3438                                            .py_1()
3439                                            .rounded_sm()
3440                                            .bg(if settings_open {
3441                                                gpui::rgb(0x3a4a5a)
3442                                            } else {
3443                                                gpui::rgb(0x2a2a36)
3444                                            })
3445                                            .text_xs()
3446                                            .text_color(gpui::rgb(0xd0d0dc))
3447                                            .cursor_pointer()
3448                                            .child("Settings")
3449                                            .on_click(cx.listener(
3450                                                |this, _: &ClickEvent, _, cx| {
3451                                                    this.forge_toggle_settings();
3452                                                    cx.notify();
3453                                                },
3454                                            )),
3455                                    )
3456                                    .child(
3457                                        div()
3458                                            .id("oxide_forge_choose_folder")
3459                                            .px_2()
3460                                            .py_1()
3461                                            .rounded_sm()
3462                                            .bg(gpui::rgb(0x2a2a36))
3463                                            .text_xs()
3464                                            .text_color(gpui::rgb(0xd0d0dc))
3465                                            .cursor_pointer()
3466                                            .child("Folder")
3467                                            .on_click(cx.listener(
3468                                                |this, _: &ClickEvent, _, cx| {
3469                                                    this.forge_pick_output_dir();
3470                                                    cx.notify();
3471                                                },
3472                                            )),
3473                                    )
3474                                    .child(
3475                                        div()
3476                                            .px_2()
3477                                            .py_1()
3478                                            .rounded_sm()
3479                                            .bg(gpui::rgb(0x22222c))
3480                                            .text_xs()
3481                                            .text_color(status_color)
3482                                            .child(status_word),
3483                                    ),
3484                            )
3485                            .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
3486                            .when(settings_open, |panel| {
3487                                panel.child(
3488                                    div()
3489                                        .id("oxide_forge_settings")
3490                                        .mt_3()
3491                                        .p_3()
3492                                        .rounded_md()
3493                                        .bg(gpui::rgb(0x1a1a22))
3494                                        .border_1()
3495                                        .border_color(gpui::rgb(0x33333f))
3496                                        .child(
3497                                            div()
3498                                                .text_sm()
3499                                                .font_weight(gpui::FontWeight::BOLD)
3500                                                .text_color(gpui::rgb(0xe0e0f0))
3501                                                .child("AI provider settings"),
3502                                        )
3503                                        .child(
3504                                            div()
3505                                                .mt_2()
3506                                                .flex()
3507                                                .flex_row()
3508                                                .gap_1()
3509                                                .children(
3510                                                    ForgeProvider::ALL.iter().enumerate().map(
3511                                                        |(i, provider)| {
3512                                                            let selected =
3513                                                                *provider == settings_provider;
3514                                                            let configured = self
3515                                                                .forge_config
3516                                                                .provider(*provider)
3517                                                                .has_key();
3518                                                            div()
3519                                                                .id(("oxide_forge_settings_tab", i))
3520                                                                .px_2()
3521                                                                .py_1()
3522                                                                .rounded_sm()
3523                                                                .bg(if selected {
3524                                                                    gpui::rgb(0x3a3a50)
3525                                                                } else {
3526                                                                    gpui::rgb(0x252530)
3527                                                                })
3528                                                                .text_xs()
3529                                                                .text_color(if configured {
3530                                                                    gpui::rgb(0x90d0a0)
3531                                                                } else {
3532                                                                    gpui::rgb(0x9090a8)
3533                                                                })
3534                                                                .cursor_pointer()
3535                                                                .child(provider.label())
3536                                                                .on_click(cx.listener(
3537                                                                    move |this,
3538                                                                          _: &ClickEvent,
3539                                                                          _,
3540                                                                          cx| {
3541                                                                        this.forge_settings_provider =
3542                                                                            *provider;
3543                                                                        this.forge_sync_settings_drafts();
3544                                                                        cx.notify();
3545                                                                    },
3546                                                                ))
3547                                                        },
3548                                                    ),
3549                                                ),
3550                                        )
3551                                        .child(
3552                                            div()
3553                                                .mt_2()
3554                                                .text_xs()
3555                                                .text_color(gpui::rgb(0x7a7a90))
3556                                                .child(format!(
3557                                                    "Keys are stored locally at {}",
3558                                                    ForgeUserConfig::config_path().display()
3559                                                )),
3560                                        )
3561                                        .child(
3562                                            div()
3563                                                .mt_2()
3564                                                .text_xs()
3565                                                .text_color(gpui::rgb(0x8a8aa0))
3566                                                .child("Model"),
3567                                        )
3568                                        .child(
3569                                            div()
3570                                                .id("oxide_forge_settings_model")
3571                                                .mt_1()
3572                                                .track_focus(&self.forge_settings_model_focus)
3573                                                .focusable()
3574                                                .px_3()
3575                                                .py_2()
3576                                                .rounded_md()
3577                                                .bg(gpui::rgb(0x121218))
3578                                                .border_1()
3579                                                .border_color(if settings_model_focused {
3580                                                    gpui::rgb(0x7a5ae0)
3581                                                } else {
3582                                                    gpui::rgb(0x33333f)
3583                                                })
3584                                                .text_sm()
3585                                                .text_color(gpui::rgb(0xe0e0ff))
3586                                                .child(if settings_model_focused && caret_blink_on
3587                                                {
3588                                                    format!("{settings_model_draft}\u{2588}")
3589                                                } else {
3590                                                    settings_model_draft.clone()
3591                                                })
3592                                                .on_click(cx.listener(
3593                                                    |this, _: &ClickEvent, window, cx| {
3594                                                        window.focus(
3595                                                            &this.forge_settings_model_focus,
3596                                                        );
3597                                                        cx.notify();
3598                                                    },
3599                                                )),
3600                                        )
3601                                        .child(
3602                                            div()
3603                                                .mt_2()
3604                                                .text_xs()
3605                                                .text_color(gpui::rgb(0x8a8aa0))
3606                                                .child("API key"),
3607                                        )
3608                                        .child(
3609                                            div()
3610                                                .mt_1()
3611                                                .flex()
3612                                                .flex_row()
3613                                                .gap_2()
3614                                                .child(
3615                                                    div()
3616                                                        .id("oxide_forge_settings_key")
3617                                                        .track_focus(&self.forge_settings_key_focus)
3618                                                        .focusable()
3619                                                        .flex_1()
3620                                                        .px_3()
3621                                                        .py_2()
3622                                                        .rounded_md()
3623                                                        .bg(gpui::rgb(0x121218))
3624                                                        .border_1()
3625                                                        .border_color(if settings_key_focused {
3626                                                            gpui::rgb(0x4ea39a)
3627                                                        } else {
3628                                                            gpui::rgb(0x33333f)
3629                                                        })
3630                                                        .text_sm()
3631                                                        .text_color(gpui::rgb(0xe0e0ff))
3632                                                        .child(
3633                                                            if settings_key_focused && caret_blink_on
3634                                                            {
3635                                                                format!("{settings_key_hint}\u{2588}")
3636                                                            } else {
3637                                                                settings_key_hint.clone()
3638                                                            },
3639                                                        )
3640                                                        .on_click(cx.listener(
3641                                                            |this, _: &ClickEvent, window, cx| {
3642                                                                window.focus(
3643                                                                    &this.forge_settings_key_focus,
3644                                                                );
3645                                                                cx.notify();
3646                                                            },
3647                                                        )),
3648                                                )
3649                                                .child(
3650                                                    div()
3651                                                        .id("oxide_forge_settings_save")
3652                                                        .px_3()
3653                                                        .py_2()
3654                                                        .rounded_md()
3655                                                        .bg(if settings_save_enabled {
3656                                                            gpui::rgb(0x2f6f68)
3657                                                        } else {
3658                                                            gpui::rgb(0x3a3a44)
3659                                                        })
3660                                                        .text_sm()
3661                                                        .text_color(gpui::rgb(0xffffff))
3662                                                        .cursor_pointer()
3663                                                        .child("Save")
3664                                                        .on_click(cx.listener(
3665                                                            |this, _: &ClickEvent, _, cx| {
3666                                                                this.forge_save_provider_settings();
3667                                                                cx.notify();
3668                                                            },
3669                                                        )),
3670                                                ),
3671                                        ),
3672                                )
3673                            })
3674                            .child(
3675                                div()
3676                                    .mt_3()
3677                                    .flex_1()
3678                                    .min_h_0()
3679                                    .flex()
3680                                    .flex_col()
3681                                    .child(workspace_panel)
3682                                    .child(
3683                                        div()
3684                                            .id("oxide_forge_prompt_row")
3685                                            .flex()
3686                                            .flex_row()
3687                                            .gap_2()
3688                                            .items_center()
3689                                            .child(
3690                                        div()
3691                                            .id("oxide_forge_prompt_input")
3692                                            .track_focus(&self.forge_focus)
3693                                            .focusable()
3694                                            .flex_1()
3695                                            .px_3()
3696                                            .py_2()
3697                                            .rounded_md()
3698                                            .bg(gpui::rgb(0x22222c))
3699                                            .border_1()
3700                                            .border_color(if prompt_focused {
3701                                                gpui::rgb(0x7a5ae0)
3702                                            } else {
3703                                                gpui::rgb(0x33333f)
3704                                            })
3705                                            .text_sm()
3706                                            .text_color(prompt_color)
3707                                            .child(format!("{prompt_display}{caret}"))
3708                                            .on_click(cx.listener(
3709                                                |this, _: &ClickEvent, window, cx| {
3710                                                    window.focus(&this.forge_focus);
3711                                                    cx.notify();
3712                                                },
3713                                            )),
3714                                    )
3715                                    .child(
3716                                        div()
3717                                            .id("oxide_forge_submit")
3718                                            .px_3()
3719                                            .py_2()
3720                                            .rounded_md()
3721                                            .bg(if submit_enabled {
3722                                                gpui::rgb(0x7a5ae0)
3723                                            } else {
3724                                                gpui::rgb(0x3a3a44)
3725                                            })
3726                                            .text_sm()
3727                                            .text_color(gpui::rgb(0xffffff))
3728                                            .cursor_pointer()
3729                                            .child(submit_label)
3730                                            .on_click(cx.listener(
3731                                                |this, _: &ClickEvent, _, cx| {
3732                                                    this.forge_submit();
3733                                                    cx.notify();
3734                                                },
3735                                            )),
3736                                    ),
3737                            )
3738                            .child(
3739                                div()
3740                                    .mt_1()
3741                                    .text_xs()
3742                                    .text_color(gpui::rgb(0x6a6a80))
3743                                    .child(
3744                                        "Enter to send · Shift+Enter for newline · Pick a provider above · Settings for API keys",
3745                                    ),
3746                            ),
3747                    ),
3748                    );
3749                }
3750            }
3751        } else {
3752            let text_input_focus_id = self.tabs[active].text_input_focus;
3753            let caret_blink_on = SystemTime::now()
3754                .duration_since(UNIX_EPOCH)
3755                .map(|d| (d.as_millis() / 530) % 2 == 0)
3756                .unwrap_or(true);
3757
3758            let canvas_area = div()
3759                .id("oxide_canvas_area")
3760                .flex_1()
3761                .flex()
3762                .flex_col()
3763                .min_h_0()
3764                .relative()
3765                .on_mouse_move(cx.listener({
3766                    let hyperlinks_hover = hyperlinks_hover.clone();
3767                    move |this, event: &gpui::MouseMoveEvent, _, cx| {
3768                        let tab = &mut this.tabs[this.active_tab];
3769                        let mut input = tab.host_state.input_state.lock().unwrap();
3770                        input.mouse_x = f32::from(event.position.x);
3771                        input.mouse_y = f32::from(event.position.y);
3772                        drop(input);
3773
3774                        if this.scroll_dragging {
3775                            let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
3776                            let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
3777                            if content_h > viewport_h && viewport_h > 0.0 {
3778                                let max_scroll_y = content_h - viewport_h;
3779                                let thumb_height =
3780                                    ((viewport_h / content_h) * viewport_h).max(20.0);
3781                                let max_thumb_top = viewport_h - thumb_height;
3782                                if max_thumb_top > 0.0 {
3783                                    let dy = f32::from(event.position.y) - this.scroll_drag_start_y;
3784                                    let d_scroll = dy * (max_scroll_y / max_thumb_top);
3785                                    let new_scroll_y = (this.scroll_drag_start_scroll_y + d_scroll)
3786                                        .clamp(0.0, max_scroll_y);
3787                                    *tab.host_state.scroll_y.lock().unwrap() = new_scroll_y;
3788                                }
3789                            }
3790                            cx.notify();
3791                        }
3792
3793                        let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3794                        let lx = f32::from(event.position.x) - ox;
3795                        let ly = f32::from(event.position.y) - oy;
3796                        let mut hovered = None;
3797                        for link in &hyperlinks_hover {
3798                            if lx >= link.x
3799                                && ly >= link.y
3800                                && lx <= link.x + link.w
3801                                && ly <= link.y + link.h
3802                            {
3803                                hovered = Some(link.url.clone());
3804                                break;
3805                            }
3806                        }
3807                        tab.hovered_link_url = hovered;
3808                    }
3809                }))
3810                .on_any_mouse_down(cx.listener(|this, event: &MouseDownEvent, _, _cx| {
3811                    let tab = &mut this.tabs[this.active_tab];
3812                    let mut input = tab.host_state.input_state.lock().unwrap();
3813                    let b = match event.button {
3814                        MouseButton::Left => 0,
3815                        MouseButton::Right => 1,
3816                        MouseButton::Middle => 2,
3817                        _ => return,
3818                    };
3819                    input.mouse_buttons_down[b] = true;
3820                }))
3821                .on_mouse_up(
3822                    MouseButton::Left,
3823                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3824                        this.scroll_dragging = false;
3825                        let tab = &mut this.tabs[this.active_tab];
3826                        let mut input = tab.host_state.input_state.lock().unwrap();
3827                        input.mouse_buttons_down[0] = false;
3828                        input.mouse_buttons_clicked[0] = true;
3829                    }),
3830                )
3831                .on_mouse_up(
3832                    MouseButton::Right,
3833                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3834                        let tab = &mut this.tabs[this.active_tab];
3835                        let mut input = tab.host_state.input_state.lock().unwrap();
3836                        input.mouse_buttons_down[1] = false;
3837                        input.mouse_buttons_clicked[1] = true;
3838                    }),
3839                )
3840                .on_mouse_up(
3841                    MouseButton::Middle,
3842                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3843                        let tab = &mut this.tabs[this.active_tab];
3844                        let mut input = tab.host_state.input_state.lock().unwrap();
3845                        input.mouse_buttons_down[2] = false;
3846                        input.mouse_buttons_clicked[2] = true;
3847                    }),
3848                )
3849                .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
3850                    if let Some(pos) = event.mouse_position() {
3851                        let tab = &mut this.tabs[this.active_tab];
3852                        let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3853                        let lx = f32::from(pos.x) - ox;
3854                        let ly = f32::from(pos.y) - oy;
3855                        if canvas_point_hits_widget(lx, ly, &widget_cmds_overlay) {
3856                            return;
3857                        }
3858                        let links = tab.host_state.hyperlinks.lock().unwrap().clone();
3859                        for link in links.iter().rev() {
3860                            if lx >= link.x
3861                                && ly >= link.y
3862                                && lx <= link.x + link.w
3863                                && ly <= link.y + link.h
3864                            {
3865                                tab.navigate_to(link.url.clone(), true, &this.download_manager);
3866                                this.show_downloads =
3867                                    this.download_manager.has_active() || this.show_downloads;
3868                                cx.notify();
3869                                return;
3870                            }
3871                        }
3872                        tab.text_input_focus = None;
3873                        this.canvas_focus.focus(window);
3874                    }
3875                }))
3876                .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
3877                    let tab = &mut this.tabs[this.active_tab];
3878                    let mut input = tab.host_state.input_state.lock().unwrap();
3879                    let (dx, dy);
3880                    match event.delta {
3881                        ScrollDelta::Pixels(p) => {
3882                            input.scroll_x += f32::from(p.x);
3883                            input.scroll_y += f32::from(p.y);
3884                            dx = f32::from(p.x);
3885                            dy = f32::from(p.y);
3886                        }
3887                        ScrollDelta::Lines(l) => {
3888                            input.scroll_x += l.x * 20.0;
3889                            input.scroll_y += l.y * 20.0;
3890                            dx = l.x * 20.0;
3891                            dy = l.y * 20.0;
3892                        }
3893                    }
3894                    drop(input);
3895
3896                    let viewport_w = tab.host_state.canvas.lock().unwrap().width as f32;
3897                    let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
3898                    let content_w = *tab.host_state.content_width.lock().unwrap() as f32;
3899                    let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
3900
3901                    let max_x = (content_w - viewport_w).max(0.0);
3902                    let max_y = (content_h - viewport_h).max(0.0);
3903
3904                    let mut sx = tab.host_state.scroll_x.lock().unwrap();
3905                    let mut sy = tab.host_state.scroll_y.lock().unwrap();
3906                    *sx = (*sx - dx).clamp(0.0, max_x);
3907                    *sy = (*sy - dy).clamp(0.0, max_y);
3908                    cx.notify();
3909                }))
3910                .on_drop(cx.listener(|this, paths: &gpui::ExternalPaths, _, _cx| {
3911                    let tab = &mut this.tabs[this.active_tab];
3912                    crate::events::enqueue_drop_files(&tab.host_state.events, paths.paths());
3913                }))
3914                .child({
3915                    let cmds = cmds.clone();
3916                    let textures = textures.clone();
3917                    let canvas_offset = canvas_offset.clone();
3918                    let canvas_state_for_dims = self.tabs[active].host_state.canvas.clone();
3919                    canvas(
3920                        move |bounds, _window, _cx| {
3921                            *canvas_offset.lock().unwrap() =
3922                                (f32::from(bounds.origin.x), f32::from(bounds.origin.y));
3923                            let mut cs = canvas_state_for_dims.lock().unwrap();
3924                            cs.width = f32::from(bounds.size.width) as u32;
3925                            cs.height = f32::from(bounds.size.height) as u32;
3926                        },
3927                        move |bounds, (), window, cx| {
3928                            if cmds.is_empty() {
3929                                let _ = window
3930                                    .text_system()
3931                                    .shape_line(
3932                                        "Oxide Browser".into(),
3933                                        px(28.0),
3934                                        &[TextRun {
3935                                            len: 13,
3936                                            font: font(".SystemUIFont"),
3937                                            color: gpui::hsla(0.75, 0.5, 0.7, 1.0),
3938                                            background_color: None,
3939                                            underline: None,
3940                                            strikethrough: None,
3941                                        }],
3942                                        None,
3943                                    )
3944                                    .paint(
3945                                        bounds.origin + point(px(24.0), px(24.0)),
3946                                        px(32.0),
3947                                        window,
3948                                        cx,
3949                                    );
3950                            } else {
3951                                paint_draw_commands(window, cx, bounds, &cmds, &textures);
3952                            }
3953                        },
3954                    )
3955                    .flex_1()
3956                });
3957
3958            let widget_states_snapshot = self.tabs[active]
3959                .host_state
3960                .widget_states
3961                .lock()
3962                .unwrap()
3963                .clone();
3964
3965            let canvas_with_widgets =
3966                widget_commands
3967                    .into_iter()
3968                    .fold(canvas_area, |el, cmd| match cmd {
3969                        WidgetCommand::Button {
3970                            id,
3971                            x,
3972                            y,
3973                            w,
3974                            h,
3975                            label,
3976                        } => el.child(
3977                            div()
3978                                .id(("oxide_btn", id as usize))
3979                                .absolute()
3980                                .left(px(x))
3981                                .top(px(y))
3982                                .w(px(w))
3983                                .h(px(h))
3984                                .flex()
3985                                .items_center()
3986                                .justify_center()
3987                                .rounded_md()
3988                                .bg(gpui::rgb(0x3a3a48))
3989                                .cursor_pointer()
3990                                .text_sm()
3991                                .text_color(gpui::rgb(0xe8e8f0))
3992                                .child(label)
3993                                .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
3994                                    this.tabs[this.active_tab]
3995                                        .host_state
3996                                        .widget_clicked
3997                                        .lock()
3998                                        .unwrap()
3999                                        .insert(id);
4000                                    cx.notify();
4001                                })),
4002                        ),
4003                        WidgetCommand::Checkbox { id, x, y, label } => {
4004                            let checked = widget_states_snapshot
4005                                .get(&id)
4006                                .and_then(|v| match v {
4007                                    WidgetValue::Bool(b) => Some(*b),
4008                                    _ => None,
4009                                })
4010                                .unwrap_or(false);
4011                            el.child(
4012                                div()
4013                                    .id(("oxide_cb", id as usize))
4014                                    .absolute()
4015                                    .left(px(x))
4016                                    .top(px(y))
4017                                    .w(px(220.0))
4018                                    .h(px(30.0))
4019                                    .flex()
4020                                    .flex_row()
4021                                    .items_center()
4022                                    .gap_2()
4023                                    .cursor_pointer()
4024                                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
4025                                        let mut states = this.tabs[this.active_tab]
4026                                            .host_state
4027                                            .widget_states
4028                                            .lock()
4029                                            .unwrap();
4030                                        let cur = states
4031                                            .get(&id)
4032                                            .and_then(|v| match v {
4033                                                WidgetValue::Bool(b) => Some(*b),
4034                                                _ => None,
4035                                            })
4036                                            .unwrap_or(false);
4037                                        states.insert(id, WidgetValue::Bool(!cur));
4038                                        cx.notify();
4039                                    }))
4040                                    .child(
4041                                        div()
4042                                            .text_sm()
4043                                            .text_color(gpui::rgb(0xa0a0aa))
4044                                            .child(if checked { "☑" } else { "☐" }),
4045                                    )
4046                                    .child(
4047                                        div()
4048                                            .text_sm()
4049                                            .text_color(gpui::rgb(0xd0d0dc))
4050                                            .child(label),
4051                                    ),
4052                            )
4053                        }
4054                        WidgetCommand::Slider {
4055                            id,
4056                            x,
4057                            y,
4058                            w,
4059                            min,
4060                            max,
4061                        } => {
4062                            let cur = widget_states_snapshot
4063                                .get(&id)
4064                                .and_then(|v| match v {
4065                                    WidgetValue::Float(f) => Some(*f),
4066                                    _ => None,
4067                                })
4068                                .unwrap_or(min);
4069                            let frac = if max > min {
4070                                ((cur - min) / (max - min)).clamp(0.0, 1.0)
4071                            } else {
4072                                0.0
4073                            };
4074                            let handle_size: f32 = 14.0;
4075                            let handle_left =
4076                                (frac * w - handle_size / 2.0).clamp(0.0, w - handle_size);
4077                            el.child(
4078                                div()
4079                                    .id(("oxide_sl", id as usize))
4080                                    .absolute()
4081                                    .left(px(x))
4082                                    .top(px(y))
4083                                    .w(px(w))
4084                                    .h(px(28.0))
4085                                    .flex()
4086                                    .items_center()
4087                                    .justify_end()
4088                                    .pr_2()
4089                                    .rounded_md()
4090                                    .bg(gpui::rgb(0x2a2a32))
4091                                    .on_click(cx.listener(
4092                                        move |this, event: &ClickEvent, _, cx| {
4093                                            if let Some(pos) = event.mouse_position() {
4094                                                let tab = &mut this.tabs[this.active_tab];
4095                                                let (ox, _) =
4096                                                    *tab.host_state.canvas_offset.lock().unwrap();
4097                                                let lx = f32::from(pos.x) - ox;
4098                                                let frac = ((lx - x) / w).clamp(0.0, 1.0);
4099                                                let v = min + frac * (max - min);
4100                                                tab.host_state
4101                                                    .widget_states
4102                                                    .lock()
4103                                                    .unwrap()
4104                                                    .insert(id, WidgetValue::Float(v));
4105                                                cx.notify();
4106                                            }
4107                                        },
4108                                    ))
4109                                    .child(
4110                                        div()
4111                                            .absolute()
4112                                            .left(px(0.0))
4113                                            .top(px(12.0))
4114                                            .w(px(frac * w))
4115                                            .h(px(4.0))
4116                                            .rounded_sm()
4117                                            .bg(gpui::rgb(0x5a5a8a)),
4118                                    )
4119                                    .child(
4120                                        div()
4121                                            .absolute()
4122                                            .left(px(handle_left))
4123                                            .top(px((28.0 - handle_size) / 2.0))
4124                                            .w(px(handle_size))
4125                                            .h(px(handle_size))
4126                                            .rounded_full()
4127                                            .bg(gpui::rgb(0xe4e4ec)),
4128                                    )
4129                                    .child(
4130                                        div()
4131                                            .text_xs()
4132                                            .text_color(gpui::rgb(0xb0b0c0))
4133                                            .child(format!("{cur:.1}")),
4134                                    ),
4135                            )
4136                        }
4137                        WidgetCommand::TextInput { id, x, y, w } => {
4138                            let value = widget_states_snapshot
4139                                .get(&id)
4140                                .and_then(|v| match v {
4141                                    WidgetValue::Text(t) => Some(t.clone()),
4142                                    _ => None,
4143                                })
4144                                .unwrap_or_default();
4145                            let show_caret = text_input_focus_id == Some(id) && caret_blink_on;
4146                            el.child(
4147                                div()
4148                                    .id(("oxide_ti", id as usize))
4149                                    .absolute()
4150                                    .left(px(x))
4151                                    .top(px(y))
4152                                    .w(px(w))
4153                                    .h(px(28.0))
4154                                    .px_2()
4155                                    .rounded_md()
4156                                    .bg(gpui::rgb(0x121218))
4157                                    .border_1()
4158                                    .border_color(if text_input_focus_id == Some(id) {
4159                                        gpui::rgb(0x6a6a8a)
4160                                    } else {
4161                                        gpui::rgb(0x3a3a48)
4162                                    })
4163                                    .cursor_pointer()
4164                                    .flex()
4165                                    .flex_row()
4166                                    .items_center()
4167                                    .justify_start()
4168                                    .gap_1()
4169                                    .min_w_0()
4170                                    .child(
4171                                        div()
4172                                            .flex_initial()
4173                                            .min_w_0()
4174                                            .overflow_hidden()
4175                                            .text_sm()
4176                                            .text_color(gpui::rgb(0xe4e4ec))
4177                                            .child(SharedString::from(value)),
4178                                    )
4179                                    .when(show_caret, |d| {
4180                                        d.child(
4181                                            div()
4182                                                .flex_shrink_0()
4183                                                .w(px(2.0))
4184                                                .h(px(16.0))
4185                                                .mt(px(1.0))
4186                                                .rounded_sm()
4187                                                .bg(gpui::rgb(0xe8e8f0)),
4188                                        )
4189                                    })
4190                                    .on_click(cx.listener(
4191                                        move |this, _: &ClickEvent, window, cx| {
4192                                            this.tabs[this.active_tab].text_input_focus = Some(id);
4193                                            this.canvas_focus.focus(window);
4194                                            cx.notify();
4195                                        },
4196                                    )),
4197                            )
4198                        }
4199                    });
4200
4201            let viewport_h = {
4202                let canvas_state = self.tabs[active].host_state.canvas.lock().unwrap();
4203                canvas_state.height as f32
4204            };
4205            let content_h = *self.tabs[active].host_state.content_height.lock().unwrap() as f32;
4206            let scroll_y = *self.tabs[active].host_state.scroll_y.lock().unwrap();
4207
4208            let canvas_with_widgets = if content_h > viewport_h && viewport_h > 0.0 {
4209                let max_scroll_y = content_h - viewport_h;
4210                let thumb_height = ((viewport_h / content_h) * viewport_h).max(20.0);
4211                let max_thumb_top = viewport_h - thumb_height;
4212                let thumb_top = (scroll_y / max_scroll_y) * max_thumb_top;
4213
4214                let thumb = div()
4215                    .id("oxide_scrollbar_thumb")
4216                    .absolute()
4217                    .top(px(thumb_top))
4218                    .left(px(0.0))
4219                    .w(px(8.0))
4220                    .h(px(thumb_height))
4221                    .rounded_full()
4222                    .bg(rgba8(0xff, 0xff, 0xff, 0x44))
4223                    .hover(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x66)))
4224                    .active(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x88)))
4225                    .on_mouse_down(
4226                        MouseButton::Left,
4227                        cx.listener(move |this, event: &MouseDownEvent, _, cx| {
4228                            this.scroll_dragging = true;
4229                            this.scroll_drag_start_y = f32::from(event.position.y);
4230                            this.scroll_drag_start_scroll_y = *this.tabs[this.active_tab]
4231                                .host_state
4232                                .scroll_y
4233                                .lock()
4234                                .unwrap();
4235                            cx.notify();
4236                        }),
4237                    );
4238
4239                let track = div()
4240                    .id("oxide_scrollbar_track")
4241                    .absolute()
4242                    .right(px(2.0))
4243                    .top(px(0.0))
4244                    .bottom(px(0.0))
4245                    .w(px(8.0))
4246                    .rounded_full()
4247                    .bg(rgba8(0x00, 0x00, 0x00, 0x11))
4248                    .child(thumb);
4249
4250                canvas_with_widgets.child(track)
4251            } else {
4252                canvas_with_widgets
4253            };
4254
4255            content_col = content_col.child(canvas_with_widgets);
4256        }
4257
4258        if let Some(tex) = pip_tex {
4259            content_col = content_col.child(
4260                div()
4261                    .id("oxide_pip")
4262                    .absolute()
4263                    .bottom(px(16.0))
4264                    .right(px(16.0))
4265                    .w(px(320.0))
4266                    .h(px(200.0))
4267                    .rounded_md()
4268                    .overflow_hidden()
4269                    .border_1()
4270                    .border_color(gpui::rgb(0x2a2a32))
4271                    .child(img(ImageSource::from(tex)).object_fit(gpui::ObjectFit::Contain)),
4272            );
4273        }
4274
4275        if show_console {
4276            let entries = self.tabs[active].host_state.console.lock().unwrap().clone();
4277            content_col = content_col.child(
4278                div()
4279                    .id("oxide_console")
4280                    .h(px(160.0))
4281                    .border_t_1()
4282                    .border_color(gpui::rgb(0x2a2a32))
4283                    .flex()
4284                    .flex_col()
4285                    .child(
4286                        div()
4287                            .flex()
4288                            .flex_row()
4289                            .items_center()
4290                            .justify_between()
4291                            .h(px(28.0))
4292                            .px_2()
4293                            .border_b_1()
4294                            .border_color(gpui::rgb(0x2a2a32))
4295                            .child(
4296                                div()
4297                                    .text_xs()
4298                                    .font_weight(gpui::FontWeight::SEMIBOLD)
4299                                    .text_color(gpui::rgb(0x9696a0))
4300                                    .child("Console"),
4301                            )
4302                            .child(
4303                                div()
4304                                    .id("oxide_console_close")
4305                                    .cursor_pointer()
4306                                    .w(px(20.0))
4307                                    .h(px(20.0))
4308                                    .flex()
4309                                    .items_center()
4310                                    .justify_center()
4311                                    .rounded_sm()
4312                                    .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4313                                    .text_xs()
4314                                    .text_color(gpui::rgb(0x9696a0))
4315                                    .child("✕")
4316                                    .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4317                                        this.tabs[this.active_tab].show_console = false;
4318                                        cx.notify();
4319                                    })),
4320                            ),
4321                    )
4322                    .child(
4323                        div()
4324                            .id("oxide_console_entries")
4325                            .flex_1()
4326                            .overflow_scroll()
4327                            .p_2()
4328                            .font_family("Monaco")
4329                            .text_xs()
4330                            .children(entries.into_iter().map(|e| {
4331                                let color = match e.level {
4332                                    ConsoleLevel::Log => gpui::rgb(0xc8c8c8),
4333                                    ConsoleLevel::Warn => gpui::rgb(0xf0c83c),
4334                                    ConsoleLevel::Error => gpui::rgb(0xf05050),
4335                                };
4336                                div()
4337                                    .flex()
4338                                    .flex_row()
4339                                    .gap_2()
4340                                    .child(
4341                                        div()
4342                                            .text_color(gpui::rgb(0x646464))
4343                                            .child(e.timestamp.clone()),
4344                                    )
4345                                    .child(div().text_color(color).child(e.message.clone()))
4346                            })),
4347                    ),
4348            );
4349        }
4350
4351        main_row = main_row.child(content_col);
4352
4353        root = root.child(main_row);
4354
4355        // Downloads panel
4356        {
4357            let downloads = self.download_manager.downloads();
4358            let list = downloads.lock().unwrap().clone();
4359            if self.show_downloads && !list.is_empty() {
4360                let panel_height = (list.len() as f32 * 56.0 + 32.0).min(240.0);
4361                root = root.child(
4362                    div()
4363                        .id("oxide_downloads_panel")
4364                        .h(px(panel_height))
4365                        .border_t_1()
4366                        .border_color(gpui::rgb(0x2a2a32))
4367                        .flex()
4368                        .flex_col()
4369                        .child(
4370                            div()
4371                                .flex()
4372                                .flex_row()
4373                                .items_center()
4374                                .justify_between()
4375                                .h(px(28.0))
4376                                .px_2()
4377                                .border_b_1()
4378                                .border_color(gpui::rgb(0x2a2a32))
4379                                .child(
4380                                    div()
4381                                        .text_xs()
4382                                        .font_weight(gpui::FontWeight::SEMIBOLD)
4383                                        .text_color(gpui::rgb(0x9696a0))
4384                                        .child("Downloads"),
4385                                )
4386                                .child(
4387                                    div()
4388                                        .id("oxide_downloads_close")
4389                                        .cursor_pointer()
4390                                        .w(px(20.0))
4391                                        .h(px(20.0))
4392                                        .flex()
4393                                        .items_center()
4394                                        .justify_center()
4395                                        .rounded_sm()
4396                                        .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4397                                        .text_xs()
4398                                        .text_color(gpui::rgb(0x9696a0))
4399                                        .child("✕")
4400                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4401                                            this.show_downloads = false;
4402                                            cx.notify();
4403                                        })),
4404                                ),
4405                        )
4406                        .child(
4407                            div()
4408                                .id("oxide_downloads_list")
4409                                .flex_1()
4410                                .overflow_y_scroll()
4411                                .children(list.iter().enumerate().map(|(idx, dl)| {
4412                                    let dl_id = dl.id;
4413                                    let filename = SharedString::from(dl.filename.clone());
4414                                    let (status_text, status_color) = match &dl.state {
4415                                        DownloadState::InProgress => {
4416                                            let downloaded = format_bytes(dl.bytes_downloaded);
4417                                            let total = dl
4418                                                .total_bytes
4419                                                .map(format_bytes)
4420                                                .unwrap_or_else(|| "?".to_string());
4421                                            let speed = format_bytes(dl.speed_bytes_per_sec as u64);
4422                                            let pct = dl
4423                                                .percent()
4424                                                .map(|p| format!("{p:.0}%"))
4425                                                .unwrap_or_default();
4426                                            (
4427                                                format!("{downloaded} / {total}  {speed}/s  {pct}"),
4428                                                gpui::rgb(0x50b0e0),
4429                                            )
4430                                        }
4431                                        DownloadState::Completed => {
4432                                            let total = format_bytes(dl.bytes_downloaded);
4433                                            (format!("Complete — {total}"), gpui::rgb(0x50e070))
4434                                        }
4435                                        DownloadState::Failed(msg) => {
4436                                            (format!("Failed: {msg}"), gpui::rgb(0xf05050))
4437                                        }
4438                                        DownloadState::Cancelled => {
4439                                            ("Cancelled".to_string(), gpui::rgb(0x9696a0))
4440                                        }
4441                                    };
4442
4443                                    let progress_fraction = match &dl.state {
4444                                        DownloadState::InProgress => {
4445                                            dl.percent().map(|p| (p / 100.0) as f32).unwrap_or(0.0)
4446                                        }
4447                                        DownloadState::Completed => 1.0,
4448                                        _ => 0.0,
4449                                    };
4450
4451                                    let is_active = dl.state == DownloadState::InProgress;
4452
4453                                    div()
4454                                        .id(("oxide_dl", idx))
4455                                        .flex()
4456                                        .flex_row()
4457                                        .items_center()
4458                                        .gap_2()
4459                                        .px_2()
4460                                        .py_1()
4461                                        .border_b_1()
4462                                        .border_color(gpui::rgb(0x24242c))
4463                                        .child(
4464                                            div()
4465                                                .flex_1()
4466                                                .min_w_0()
4467                                                .flex()
4468                                                .flex_col()
4469                                                .gap(px(2.0))
4470                                                .child(
4471                                                    div()
4472                                                        .text_sm()
4473                                                        .text_color(gpui::rgb(0xe4e4ec))
4474                                                        .overflow_hidden()
4475                                                        .child(filename),
4476                                                )
4477                                                .child(
4478                                                    div()
4479                                                        .text_xs()
4480                                                        .text_color(status_color)
4481                                                        .child(SharedString::from(status_text)),
4482                                                )
4483                                                .when(is_active, |d| {
4484                                                    d.child(
4485                                                        div()
4486                                                            .h(px(4.0))
4487                                                            .w_full()
4488                                                            .rounded_sm()
4489                                                            .bg(gpui::rgb(0x2a2a32))
4490                                                            .child(
4491                                                                div()
4492                                                                    .h_full()
4493                                                                    .rounded_sm()
4494                                                                    .bg(gpui::rgb(0x50b0e0))
4495                                                                    .w(gpui::relative(
4496                                                                        progress_fraction,
4497                                                                    )),
4498                                                            ),
4499                                                    )
4500                                                }),
4501                                        )
4502                                        .child(if is_active {
4503                                            div()
4504                                                .id(("oxide_dl_cancel", idx))
4505                                                .cursor_pointer()
4506                                                .flex_shrink_0()
4507                                                .px_2()
4508                                                .py(px(4.0))
4509                                                .rounded_sm()
4510                                                .text_xs()
4511                                                .text_color(gpui::rgb(0xf05050))
4512                                                .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4513                                                .child("Cancel")
4514                                                .on_click(cx.listener(
4515                                                    move |this, _: &ClickEvent, _, cx| {
4516                                                        this.download_manager.cancel(dl_id);
4517                                                        cx.notify();
4518                                                    },
4519                                                ))
4520                                        } else {
4521                                            div()
4522                                                .id(("oxide_dl_dismiss", idx))
4523                                                .cursor_pointer()
4524                                                .flex_shrink_0()
4525                                                .px_2()
4526                                                .py(px(4.0))
4527                                                .rounded_sm()
4528                                                .text_xs()
4529                                                .text_color(gpui::rgb(0x9696a0))
4530                                                .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4531                                                .child("Dismiss")
4532                                                .on_click(cx.listener(
4533                                                    move |this, _: &ClickEvent, _, cx| {
4534                                                        this.download_manager.dismiss(dl_id);
4535                                                        cx.notify();
4536                                                    },
4537                                                ))
4538                                        })
4539                                })),
4540                        ),
4541                );
4542            }
4543        }
4544
4545        if let Some(url) = self.tabs[active].hovered_link_url.clone() {
4546            root = root.child(
4547                div()
4548                    .id("oxide_link_status")
4549                    .h(px(18.0))
4550                    .border_t_1()
4551                    .border_color(gpui::rgb(0x2a2a32))
4552                    .px_2()
4553                    .font_family("Monaco")
4554                    .text_xs()
4555                    .text_color(gpui::rgb(0x8c8cb4))
4556                    .child(url),
4557            );
4558        }
4559
4560        if self.show_menu {
4561            root = root.child(
4562                div()
4563                    .id("oxide_menu_scrim")
4564                    .absolute()
4565                    .size_full()
4566                    .top_0()
4567                    .left_0()
4568                    .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4569                        this.show_menu = false;
4570                        cx.notify();
4571                    })),
4572            );
4573            root = root.child(
4574                div()
4575                    .id("oxide_menu_dropdown")
4576                    .absolute()
4577                    .top(px(88.0))
4578                    .right(px(8.0))
4579                    .w(px(180.0))
4580                    .rounded_md()
4581                    .bg(gpui::rgb(0x2c2c36))
4582                    .border_1()
4583                    .border_color(gpui::rgb(0x3a3a44))
4584                    .py_1()
4585                    .shadow_lg()
4586                    .child(
4587                        div()
4588                            .id("oxide_menu_new_tab")
4589                            .px_3()
4590                            .py(px(8.0))
4591                            .cursor_pointer()
4592                            .text_sm()
4593                            .text_color(gpui::rgb(0xdcdce6))
4594                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4595                            .rounded_sm()
4596                            .child("  New Tab")
4597                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4598                                let i = this.create_tab();
4599                                this.active_tab = i;
4600                                this.show_menu = false;
4601                                cx.notify();
4602                            })),
4603                    )
4604                    .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4605                    .child(
4606                        div()
4607                            .id("oxide_menu_bookmarks")
4608                            .px_3()
4609                            .py(px(8.0))
4610                            .cursor_pointer()
4611                            .text_sm()
4612                            .text_color(gpui::rgb(0xdcdce6))
4613                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4614                            .rounded_sm()
4615                            .child(if self.show_bookmarks {
4616                                "✓ Bookmarks"
4617                            } else {
4618                                "  Bookmarks"
4619                            })
4620                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4621                                this.show_bookmarks = !this.show_bookmarks;
4622                                this.show_menu = false;
4623                                cx.notify();
4624                            })),
4625                    )
4626                    .child(
4627                        div()
4628                            .id("oxide_menu_console")
4629                            .px_3()
4630                            .py(px(8.0))
4631                            .cursor_pointer()
4632                            .text_sm()
4633                            .text_color(gpui::rgb(0xdcdce6))
4634                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4635                            .rounded_sm()
4636                            .child(if show_console {
4637                                "✓ Console"
4638                            } else {
4639                                "  Console"
4640                            })
4641                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4642                                this.tabs[this.active_tab].show_console =
4643                                    !this.tabs[this.active_tab].show_console;
4644                                this.show_menu = false;
4645                                cx.notify();
4646                            })),
4647                    )
4648                    .child(
4649                        div()
4650                            .id("oxide_menu_downloads")
4651                            .px_3()
4652                            .py(px(8.0))
4653                            .cursor_pointer()
4654                            .text_sm()
4655                            .text_color(gpui::rgb(0xdcdce6))
4656                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4657                            .rounded_sm()
4658                            .child(if self.show_downloads {
4659                                "✓ Downloads"
4660                            } else {
4661                                "  Downloads"
4662                            })
4663                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4664                                this.show_downloads = !this.show_downloads;
4665                                this.show_menu = false;
4666                                cx.notify();
4667                            })),
4668                    )
4669                    .child(
4670                        div()
4671                            .id("oxide_menu_history")
4672                            .px_3()
4673                            .py(px(8.0))
4674                            .cursor_pointer()
4675                            .text_sm()
4676                            .text_color(gpui::rgb(0xdcdce6))
4677                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4678                            .rounded_sm()
4679                            .child("  History")
4680                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4681                                let i = this.create_tab();
4682                                this.active_tab = i;
4683                                this.tabs[i].navigate_to(
4684                                    "oxide://history".to_string(),
4685                                    true,
4686                                    &this.download_manager,
4687                                );
4688                                this.show_menu = false;
4689                                cx.notify();
4690                            })),
4691                    )
4692                    .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4693                    .child(
4694                        div()
4695                            .id("oxide_menu_about")
4696                            .px_3()
4697                            .py(px(8.0))
4698                            .cursor_pointer()
4699                            .text_sm()
4700                            .text_color(gpui::rgb(0xdcdce6))
4701                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4702                            .rounded_sm()
4703                            .child("  About Oxide")
4704                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4705                                let i = this.create_tab();
4706                                this.active_tab = i;
4707                                this.tabs[i].navigate_to(
4708                                    "oxide://about".to_string(),
4709                                    true,
4710                                    &this.download_manager,
4711                                );
4712                                this.show_menu = false;
4713                                cx.notify();
4714                            })),
4715                    ),
4716            );
4717        }
4718
4719        root
4720    }
4721}
4722
4723/// Render one chat bubble in the Forge conversation panel.
4724fn forge_chat_bubble(index: usize, msg: &ForgeChatMessage, phase: ForgePhase) -> impl IntoElement {
4725    let is_user = msg.role == ForgeMessageRole::User;
4726    let preview = if msg.content.len() > 4000 {
4727        format!("{}…", &msg.content[..4000])
4728    } else {
4729        msg.content.clone()
4730    };
4731    let streaming_tail = if !is_user && phase == ForgePhase::Streaming && msg.content.is_empty() {
4732        "▍"
4733    } else {
4734        ""
4735    };
4736    let (bg, fg, label) = if is_user {
4737        (gpui::rgb(0x3a2f5a), gpui::rgb(0xf0ecff), "You")
4738    } else {
4739        (gpui::rgb(0x242430), gpui::rgb(0xd8d8e8), "Forge")
4740    };
4741    div()
4742        .id(("oxide_forge_msg", index))
4743        .w_full()
4744        .flex()
4745        .flex_col()
4746        .items_start()
4747        .child(div().text_xs().text_color(gpui::rgb(0x7a7a90)).child(label))
4748        .child(
4749            div()
4750                .mt_1()
4751                .max_w(px(520.0))
4752                .px_3()
4753                .py_2()
4754                .rounded_md()
4755                .bg(bg)
4756                .text_sm()
4757                .text_color(fg)
4758                .font(if is_user {
4759                    font("Inter")
4760                } else {
4761                    font("Menlo")
4762                })
4763                .child(format!("{preview}{streaming_tail}")),
4764        )
4765}
4766
4767/// Human-readable label for a [`ForgePhase`].
4768fn phase_label(p: ForgePhase) -> &'static str {
4769    match p {
4770        ForgePhase::Idle => "idle",
4771        ForgePhase::Streaming => "streaming",
4772        ForgePhase::StreamComplete => "ready to build",
4773        ForgePhase::Building => "building",
4774        ForgePhase::BuildOk => "build ok",
4775        ForgePhase::Error => "error",
4776    }
4777}
4778
4779/// Themed color for a [`ForgePhase`] badge.
4780fn phase_color_for(p: ForgePhase) -> Rgba {
4781    match p {
4782        ForgePhase::Idle => gpui::rgb(0x8888a0),
4783        ForgePhase::Streaming => gpui::rgb(0xffc060),
4784        ForgePhase::StreamComplete => gpui::rgb(0x70b0ff),
4785        ForgePhase::Building => gpui::rgb(0xffa040),
4786        ForgePhase::BuildOk => gpui::rgb(0x80d090),
4787        ForgePhase::Error => gpui::rgb(0xf06070),
4788    }
4789}
4790
4791/// Guest widget bounds in canvas-local coordinates (must match overlay hit-test skip logic).
4792fn widget_bounds(cmd: &WidgetCommand) -> (f32, f32, f32, f32) {
4793    match cmd {
4794        WidgetCommand::Button { x, y, w, h, .. } => (*x, *y, *w, *h),
4795        WidgetCommand::Checkbox { x, y, .. } => (*x, *y, 220.0, 30.0),
4796        WidgetCommand::Slider { x, y, w, .. } => (*x, *y, *w, 28.0),
4797        WidgetCommand::TextInput { x, y, w, .. } => (*x, *y, *w, 28.0),
4798    }
4799}
4800
4801/// True if `(lx, ly)` lies inside any guest widget rect (canvas space).
4802fn canvas_point_hits_widget(lx: f32, ly: f32, cmds: &[WidgetCommand]) -> bool {
4803    for cmd in cmds {
4804        let (x, y, w, h) = widget_bounds(cmd);
4805        if lx >= x && ly >= y && lx <= x + w && ly <= y + h {
4806            return true;
4807        }
4808    }
4809    false
4810}
4811
4812fn truncate_tab_title(title: &str) -> String {
4813    let max_len = 30;
4814    if title.chars().count() > max_len {
4815        let t: String = title.chars().take(max_len).collect();
4816        format!("{t}\u{2026}")
4817    } else {
4818        title.to_string()
4819    }
4820}
4821
4822/// Typed character for URL bar and guest [`WidgetCommand::TextInput`] fields.
4823/// Uses `key_char` when set; otherwise mirrors [`Keystroke::with_simulated_ime`] for plain typing.
4824fn text_insert_from_keystroke(ks: &Keystroke) -> Option<String> {
4825    if ks.modifiers.control || ks.modifiers.platform || ks.modifiers.function || ks.modifiers.alt {
4826        return None;
4827    }
4828    if let Some(ref c) = ks.key_char {
4829        return Some(c.clone());
4830    }
4831    ks.clone().with_simulated_ime().key_char
4832}
4833
4834fn keystroke_to_oxide(k: &Keystroke) -> Option<u32> {
4835    let key = k.key.as_str();
4836    match key {
4837        "a" => Some(0),
4838        "b" => Some(1),
4839        "c" => Some(2),
4840        "d" => Some(3),
4841        "e" => Some(4),
4842        "f" => Some(5),
4843        "g" => Some(6),
4844        "h" => Some(7),
4845        "i" => Some(8),
4846        "j" => Some(9),
4847        "k" => Some(10),
4848        "l" => Some(11),
4849        "m" => Some(12),
4850        "n" => Some(13),
4851        "o" => Some(14),
4852        "p" => Some(15),
4853        "q" => Some(16),
4854        "r" => Some(17),
4855        "s" => Some(18),
4856        "t" => Some(19),
4857        "u" => Some(20),
4858        "v" => Some(21),
4859        "w" => Some(22),
4860        "x" => Some(23),
4861        "y" => Some(24),
4862        "z" => Some(25),
4863        "0" => Some(26),
4864        "1" => Some(27),
4865        "2" => Some(28),
4866        "3" => Some(29),
4867        "4" => Some(30),
4868        "5" => Some(31),
4869        "6" => Some(32),
4870        "7" => Some(33),
4871        "8" => Some(34),
4872        "9" => Some(35),
4873        "enter" => Some(36),
4874        "escape" => Some(37),
4875        "tab" => Some(38),
4876        "backspace" => Some(39),
4877        "delete" => Some(40),
4878        "space" => Some(41),
4879        "up" => Some(42),
4880        "down" => Some(43),
4881        "left" => Some(44),
4882        "right" => Some(45),
4883        "home" => Some(46),
4884        "end" => Some(47),
4885        "pageup" => Some(48),
4886        "pagedown" => Some(49),
4887        _ => None,
4888    }
4889}
4890
4891/// Returns `true` when `url` clearly points to a downloadable file rather
4892/// than a WASM module.  Heuristic: the URL path has a file extension and that
4893/// extension is *not* `.wasm`.  Bare directories and extensionless paths are
4894/// assumed to be WASM endpoints (they get `/index.wasm` appended by the
4895/// runtime).
4896fn is_downloadable_url(url: &str) -> bool {
4897    let trimmed = url.trim();
4898    if trimmed.is_empty() {
4899        return false;
4900    }
4901    if let Ok(parsed) = url::Url::parse(trimmed) {
4902        if !matches!(parsed.scheme(), "http" | "https") {
4903            return false;
4904        }
4905        let path = parsed.path();
4906        if path.ends_with('/') || path == "/" || path.is_empty() {
4907            return false;
4908        }
4909        if let Some(last_segment) = path.rsplit('/').next() {
4910            if let Some(dot) = last_segment.rfind('.') {
4911                let ext = &last_segment[dot + 1..];
4912                return !ext.eq_ignore_ascii_case("wasm");
4913            }
4914        }
4915    }
4916    false
4917}
4918
4919fn url_to_title(url: &str) -> String {
4920    if url == "(local)" {
4921        return "Local Module".to_string();
4922    }
4923    match url {
4924        "oxide://home" => return "Home".to_string(),
4925        "oxide://history" => return "History".to_string(),
4926        "oxide://bookmarks" => return "Bookmarks".to_string(),
4927        "oxide://about" => return "About Oxide".to_string(),
4928        "oxide://forge" => return "Forge".to_string(),
4929        _ => {}
4930    }
4931    if let Some(stripped) = url
4932        .strip_prefix("https://")
4933        .or_else(|| url.strip_prefix("http://"))
4934    {
4935        stripped.split('/').next().unwrap_or(stripped).to_string()
4936    } else if let Some(stripped) = url.strip_prefix("file://") {
4937        stripped
4938            .rsplit('/')
4939            .next()
4940            .unwrap_or("Local File")
4941            .to_string()
4942    } else {
4943        let max = 20;
4944        if url.chars().count() > max {
4945            let truncated: String = url.chars().take(max).collect();
4946            format!("{truncated}\u{2026}")
4947        } else {
4948            url.to_string()
4949        }
4950    }
4951}
4952
4953/// Start the Oxide desktop shell: GPUI event loop and one main window.
4954///
4955/// Call this after constructing a [`crate::runtime::BrowserHost`] and cloning its [`HostState`]
4956/// and status mutex,
4957/// as the `oxide` binary does.
4958/// This function does not return until the application exits.
4959pub fn run_browser(host_state: HostState, status: Arc<Mutex<PageStatus>>) -> anyhow::Result<()> {
4960    Application::new().run(move |cx: &mut gpui::App| {
4961        cx.on_window_closed(|cx| {
4962            if cx.windows().is_empty() {
4963                cx.quit();
4964            }
4965        })
4966        .detach();
4967
4968        let opts = WindowOptions {
4969            window_bounds: Some(WindowBounds::centered(size(px(1024.0), px(720.0)), cx)),
4970            titlebar: Some(TitlebarOptions {
4971                title: Some("Oxide Browser".into()),
4972                ..Default::default()
4973            }),
4974            window_min_size: Some(size(px(600.0), px(400.0))),
4975            kind: WindowKind::Normal,
4976            ..Default::default()
4977        };
4978        cx.open_window(opts, move |_, cx| {
4979            cx.new(|cx| OxideBrowserView::new(cx, host_state.clone(), status.clone()))
4980        })
4981        .expect("open window");
4982    });
4983    Ok(())
4984}