Vectrum
Sign In
267 lines · 12.0 KB · Rust
//! The chat header, as T3 Code lays it out: the project as a "New thread in"
//! breadcrumb, then the thread title, which opens the thread action menu
//! (double-click renames), and the panel toggles on the right.

use std::time::Duration;

use gpui_kit::component::{Icon, IconName, Selectable as _, h_flex, input::Input, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::Workspace;
use crate::Dismiss;
use crate::ui::browser::BrowserWatch;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};

/// How long a title click waits for a second click before opening the menu.
const DOUBLE_CLICK: Duration = Duration::from_millis(500);

/// The sidebar toggle: in the sidebar's chrome row while it's open, at the
/// header's start while it's closed.
pub(super) fn sidebar_toggle(open: bool, cx: &mut Context<Workspace>) -> MetalButton {
    MetalButton::new(if open { "hide-sidebar" } else { "show-sidebar" })
        .ghost()
        .mini()
        .icon(if open { Lucide::PanelLeftClose } else { Lucide::PanelLeft })
        .tooltip("Toggle main sidebar (Ctrl+B)")
        .on_click(cx.listener(|this, _, _, cx| {
            this.sidebar_open = !this.sidebar_open;
            cx.notify();
        }))
}

impl Workspace {
    /// Opens the thread action menu under the title.
    fn open_title_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(session) = self.active.clone().filter(|session| !session.read(cx).is_blank()) else { return };
        let bounds = self.title_bounds.get();
        let position = point(bounds.origin.x, bounds.origin.y + bounds.size.height + px(4.));
        let workspace = cx.weak_entity();
        self.open_menu(position, window, cx, move |menu, window, cx| {
            Workspace::thread_action_menu(workspace, session, false, menu, window, cx)
        });
    }

    /// A right click anywhere in the header: the thread menu at the pointer,
    /// or just Project settings for a thread nothing was sent in yet.
    fn open_header_menu(&mut self, position: Point<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
        let Some(session) = self.active.clone() else { return };
        let workspace = cx.weak_entity();
        if session.read(cx).is_blank() {
            let Some(project) = self.project_of(&session) else { return };
            self.open_menu(position, window, cx, move |menu, _, _| {
                menu.item(
                    gpui_kit::component::menu::PopupMenuItem::new("Project settings")
                        .icon(Icon::new(IconName::Settings))
                        .on_click(move |_, window, cx| {
                            let _ = workspace.update(cx, |this, cx| this.open_project_settings(project, window, cx));
                        }),
                )
            });
            return;
        }
        self.open_menu(position, window, cx, move |menu, window, cx| {
            Workspace::thread_action_menu(workspace, session, false, menu, window, cx)
        });
    }

    /// A click on the title: a double click renames; a single one opens the
    /// menu once it's clear no second click follows.
    fn click_title(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
        let modified = event.modifiers().modified();
        if event.click_count() >= 2 && !modified {
            self.title_click = Task::ready(());
            if let Some(session) = self.active.clone() {
                self.overlay_menu = None;
                self.start_rename(session, false, window, cx);
            }
            return;
        }
        if event.is_keyboard() {
            self.open_title_menu(window, cx);
            return;
        }
        self.title_click = cx.spawn_in(window, async move |this, cx| {
            cx.background_executor().timer(DOUBLE_CLICK).await;
            let _ = this.update_in(cx, |this, window, cx| this.open_title_menu(window, cx));
        });
    }

    pub(super) fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let project = self.active_project().filter(|_| self.active.is_some());
        let project_ix = self.active_project;
        let active = self.active.as_ref().map(|session| session.read(cx));
        let title = active.map(|session| session.title.clone());
        let draft = active.is_none_or(|session| session.is_blank());
        let renaming = self.renaming.as_ref().filter(|rename| !rename.sidebar && self.active.as_ref() == Some(&rename.session));
        let bounds = self.title_bounds.clone();

        let breadcrumb = project.filter(|project| Some(&project.path) != super::scratch_dir().as_ref()).map(|project| {
            let tip = SharedString::from(format!("New thread in {}", project.name));
            h_flex()
                .id("project-breadcrumb")
                .flex_none()
                .gap(px(8.))
                .cursor_pointer()
                .text_size(px(14.))
                .text_color(p.ink3)
                .hover(|style| style.text_color(p.ink))
                .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
                .on_click(cx.listener(move |this, _, window, cx| this.new_thread(project_ix, window, cx)))
                .child(metal::badge(project.initials.clone(), project.tint, p))
                .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone()))
        });

        let title_element = title.map(|title| -> AnyElement {
            if let Some(rename) = renaming {
                return div()
                    .key_context("InlineRename")
                    .on_action(cx.listener(|this, _: &Dismiss, _, cx| this.cancel_rename(cx)))
                    .flex_1()
                    .min_w_0()
                    .max_w(px(480.))
                    .h(px(28.))
                    .px(px(8.))
                    .flex()
                    .items_center()
                    .rounded(px(8.))
                    .well(p)
                    .text_size(px(14.))
                    .font_weight(FontWeight::MEDIUM)
                    .child(Input::new(&rename.input).appearance(false))
                    .into_any_element();
            }
            if draft {
                return div()
                    .min_w_0()
                    .truncate()
                    .text_size(px(14.))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(p.ink)
                    .child(title)
                    .into_any_element();
            }
            let tip = title.clone();
            h_flex()
                .id("thread-title")
                .group("thread-title")
                .relative()
                .min_w_0()
                .gap(px(4.))
                .px(px(6.))
                .h(px(28.))
                .rounded(px(8.))
                .cursor_pointer()
                .text_size(px(14.))
                .font_weight(FontWeight::MEDIUM)
                .text_color(p.ink)
                .hover(|style| style.bg(p.row_hover))
                .when(self.overlay_menu.is_none(), |this| {
                    this.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
                })
                .on_click(cx.listener(|this, event: &ClickEvent, window, cx| this.click_title(event, window, cx)))
                .child(div().min_w_0().truncate().child(title))
                .child(
                    div()
                        .id("thread-title-chevron")
                        .invisible()
                        .group_hover("thread-title", |style| style.visible())
                        .on_click(cx.listener(|this, _, window, cx| {
                            cx.stop_propagation();
                            this.title_click = Task::ready(());
                            this.open_title_menu(window, cx);
                        }))
                        .child(Icon::new(IconName::ChevronDown).size(px(14.)).text_color(p.ink3)),
                )
                .child(
                    canvas(move |element_bounds, _, _| bounds.set(element_bounds), |_, _, _, _| {})
                        .absolute()
                        .inset_0(),
                )
                .into_any_element()
        });

        v_flex()
            .flex_none()
            .child(
                h_flex()
                    .id("chat-header")
                    .h(px(52.))
                    .pl(px(if self.sidebar_open { 20. } else { 12. }))
                    .pr(px(12.))
                    .gap(px(8.))
                    .on_mouse_down(
                        MouseButton::Right,
                        cx.listener(|this, event: &MouseDownEvent, window, cx| {
                            this.open_header_menu(event.position, window, cx)
                        }),
                    )
                    .when(!self.sidebar_open, |this| this.child(sidebar_toggle(false, cx)))
                    .when_some(breadcrumb, |this, breadcrumb| {
                        this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/"))
                    })
                    .when_some(title_element, |this, title| this.child(title))
                    .child(div().flex_1())
                    .child(self.render_layout_controls(panel_visible, cx)),
            )
            .child(metal::rule(p))
    }

    /// T3's panel toggles: thread details (our project panel), the terminal
    /// drawer, and the right panel (the browser).
    fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let live = !self.browser_open
            && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
        h_flex()
            .flex_none()
            .gap(px(4.))
            .child(
                MetalButton::new("toggle-thread-details")
                    .ghost()
                    .mini()
                    .icon(Lucide::SquareMenu)
                    .selected(panel_visible)
                    .tooltip("Toggle thread details (Ctrl+Shift+B)")
                    .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))),
            )
            .child(if self.terminal_available(cx) {
                MetalButton::new("toggle-terminal-drawer")
                    .ghost()
                    .mini()
                    .icon(IconName::PanelBottom)
                    .selected(self.terminal_open())
                    .tooltip("Toggle terminal drawer (Ctrl+J)")
                    .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx)))
                    .into_any_element()
            } else {
                // A thread on another device has no folder here to start in.
                div()
                    .id("terminal-drawer-unavailable")
                    .tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx))
                    .child(MetalButton::new("toggle-terminal-drawer").ghost().mini().icon(IconName::PanelBottom).disabled(true))
                    .into_any_element()
            })
            .child(
                div()
                    .relative()
                    .child(
                        MetalButton::new("toggle-right-panel")
                            .ghost()
                            .mini()
                            .icon(IconName::PanelRight)
                            .selected(self.browser_open)
                            .tooltip("Toggle right panel (Ctrl+Alt+B)")
                            .on_click(cx.listener(|this, _, _, cx| {
                                this.browser_open = !this.browser_open;
                                cx.notify();
                            })),
                    )
                    .when(live, |this| {
                        this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p)))
                    }),
            )
    }
}