Vectrum
Sign In
604 lines · 27.0 KB · Rust
//! The strip hanging below the composer, like T3 Code's branch toolbar:
//! the machine the thread runs on, its workspace (the project's checkout, a
//! new worktree, or a previous one), and the branch, with a searchable
//! branch picker.

use std::path::PathBuf;

use gpui_kit::component::{
    Icon, IconName, h_flex,
    input::{Input, InputEvent, InputState},
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::{ChatEvent, ChatView, Picker};
use crate::git::{self, GitRef};
use crate::settings::{self, WorkspaceMode};
use crate::ui::composer;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Palette};
use crate::ui::pickers;
use crate::ui::workspace::{ProjectDirectory, ProjectSummary};
use crate::worktree::Worktree;

/// The most branches the picker shows at once.
const MAX_REFS: usize = 100;

impl ChatView {
    /// Whether the strip shows: on a new thread, or always when Settings →
    /// Appearance → Composer context says so.
    pub(super) fn strip_visible(&self, cx: &App) -> bool {
        let session = self.session.read(cx);
        let draft = session.items.is_empty();
        if !draft && !settings::get(|settings| settings.composer_context) {
            return false;
        }
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let Some(directory) = cx.try_global::<ProjectDirectory>() else { return false };
        let Some(project) = directory.find(&session.project, env.as_deref()) else { return false };
        project.git || environment_indicator(directory, project)
    }

    pub(super) fn render_strip(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let draft = session.items.is_empty();
        let Some(directory) = cx.try_global::<ProjectDirectory>() else { return div().into_any_element() };
        let Some(project) = directory.find(&session.project, env.as_deref()) else { return div().into_any_element() };
        let show_env = environment_indicator(directory, project);
        let machine = project.host.clone().unwrap_or_else(|| {
            if directory.hostname.is_empty() { "This device".into() } else { directory.hostname.clone() }
        });
        let remote_machine = project.host.is_some();
        let (git, scratch, branch) = (project.git, project.scratch, project.branch.clone());
        let machine_icon = || if remote_machine { Icon::new(Lucide::Server) } else { Icon::new(Lucide::Laptop) };

        let mut strip = composer::strip(p);
        if show_env {
            strip = strip.child(if draft {
                let button = composer::strip_control("strip-environment").icon(machine_icon().size(px(12.))).label(machine).caret();
                self.picker_anchored(Picker::Environment, button, Anchor::BottomLeft, cx, |this, cx| {
                    this.render_environment_menu(cx)
                })
                .into_any_element()
            } else {
                composer::strip_label("strip-environment", machine_icon(), machine.clone(), machine, p).into_any_element()
            });
        }
        if git && !scratch {
            if show_env {
                strip = strip.child(composer::strip_separator(p));
            }
            strip = strip.child(self.render_workspace_control(cx));
        }
        strip = strip.child(div().flex_1());
        if let Some(pull_request) = self.session.read(cx).pull_request.clone() {
            let color = match pull_request.state.as_str() {
                "OPEN" => p.green_ink,
                "MERGED" => p.tints[5],
                "CLOSED" => p.red,
                _ => p.ink3,
            };
            let url = pull_request.url.clone();
            let state = match pull_request.state.as_str() {
                "OPEN" => "Open",
                "MERGED" => "Merged",
                "CLOSED" => "Closed",
                _ => "Draft",
            };
            strip = strip.child(
                composer::strip_control("strip-pull-request")
                    .icon(Icon::new(Lucide::GitPullRequest).size(px(12.)).text_color(color))
                    .label(format!("#{}", pull_request.number))
                    .text_color(color)
                    .tooltip(format!("PR #{} - {state}", pull_request.number))
                    .on_click(move |_, _, _| crate::ui::workspace::open_url(&url)),
            );
        }
        if git {
            strip = strip.child(self.render_branch_control(branch, cx));
        }
        strip.into_any_element()
    }

    /// Where the thread runs: a select on a new thread, a label after.
    fn render_workspace_control(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let locked = !session.items.is_empty() || session.is_remote() && session.remote.as_ref().is_some_and(|r| r.thread.is_some());
        if locked {
            let (icon, label) = if session.worktree.is_some() {
                (Icon::new(Lucide::FolderGit), "Worktree")
            } else if session.preparing || session.pending_worktree.is_some() {
                (Icon::new(Lucide::FolderGit2), "New worktree")
            } else {
                (Icon::new(IconName::Folder), "Local checkout")
            };
            return composer::strip_label("strip-workspace", icon, label, label, p).into_any_element();
        }
        let new_worktree = session.pending_worktree.is_some();
        let existing = session.worktree.is_some();
        let (icon, label, tooltip) = if new_worktree {
            (Icon::new(Lucide::FolderGit2), "New worktree", "New worktree")
        } else if existing {
            (Icon::new(Lucide::FolderGit), "Current worktree", "Current worktree")
        } else {
            (Icon::new(IconName::Folder), "Current checkout", "Current checkout")
        };
        let button = composer::strip_control("strip-workspace").icon(icon.size(px(12.))).label(label).caret().tooltip(tooltip);
        self.picker_anchored(Picker::Workspace, button, Anchor::BottomLeft, cx, |this, cx| this.render_workspace_menu(cx))
            .into_any_element()
    }

    fn render_workspace_menu(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let previous = cx
            .try_global::<ProjectDirectory>()
            .and_then(|directory| directory.find(&session.project, env.as_deref()))
            .and_then(|project| project.previous_worktree.clone())
            .filter(|worktree| session.worktree.as_ref() != Some(worktree));
        let new_worktree = session.pending_worktree.is_some();
        let existing = session.worktree.is_some();
        let local_label = if existing { "Current worktree" } else { "Current checkout" };
        let local_icon = if existing { Icon::new(Lucide::FolderGit) } else { Icon::new(IconName::Folder) };
        let chat = cx.entity().downgrade();
        let pick = move |mode: WorkspaceMode, previous: Option<Worktree>| {
            let chat = chat.clone();
            move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                let _ = chat.update(cx, |this, cx| {
                    this.open_picker = None;
                    match &previous {
                        Some(worktree) => {
                            cx.emit(ChatEvent::SetWorkspace(WorkspaceMode::Local));
                            let worktree = worktree.clone();
                            this.session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx));
                        }
                        None => {
                            if mode == WorkspaceMode::Worktree {
                                this.session.update(cx, |session, cx| session.use_worktree(None, cx));
                            }
                            cx.emit(ChatEvent::SetWorkspace(mode));
                        }
                    }
                    this.focus_composer(window, cx);
                    cx.notify();
                });
            }
        };
        pickers::panel(p)
            .when(previous.is_some(), |this| this.w(px(336.)))
            .child(composer::menu_label("Workspace", p))
            .child(
                composer::menu_item("workspace-local", !new_worktree, true, p)
                    .on_click(pick(WorkspaceMode::Local, None))
                    .child(local_icon.size(px(12.)).text_color(p.ink3))
                    .child(local_label),
            )
            .child(
                composer::menu_item("workspace-worktree", new_worktree, true, p)
                    .on_click(pick(WorkspaceMode::Worktree, None))
                    .child(Icon::new(Lucide::FolderGit2).size(px(12.)).text_color(p.ink3))
                    .child("New worktree"),
            )
            .when_some(previous, |this, worktree| {
                let branch = worktree.branch.clone();
                this.child(
                    composer::menu_item("workspace-previous", false, true, p)
                        .items_start()
                        .on_click(pick(WorkspaceMode::Local, Some(worktree)))
                        .child(div().pt(px(3.)).child(Icon::new(Lucide::RotateCcwClock).size(px(12.)).text_color(p.ink3)))
                        .child(
                            v_flex()
                                .min_w_0()
                                .child("Previous worktree")
                                .child(div().text_size(px(12.)).text_color(p.ink3).truncate().child(branch)),
                        ),
                )
            })
            .into_any_element()
    }

    /// The machines threads can run on, for a new thread.
    fn render_environment_menu(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let current = session.remote.as_ref().map(|remote| remote.env.clone());
        let Some(directory) = cx.try_global::<ProjectDirectory>() else { return div().into_any_element() };
        let name = directory.find(&session.project, current.as_deref()).map(|project| project.name.clone());
        let mut environments: Vec<(Option<String>, SharedString)> =
            vec![(None, if directory.hostname.is_empty() { "This device".into() } else { directory.hostname.clone() })];
        for project in &directory.projects {
            if let (Some(env), Some(host)) = (&project.environment, &project.host)
                && !environments.iter().any(|(known, _)| known.as_deref() == Some(env.as_str()))
            {
                environments.push((Some(env.clone()), host.clone()));
            }
        }
        let mut menu = pickers::panel(p).child(composer::menu_label("Run on", p));
        for (ix, (env, label)) in environments.into_iter().enumerate() {
            // The same project on that machine, or its first one.
            let target = directory
                .projects
                .iter()
                .filter(|project| project.environment == env && !project.scratch)
                .find(|project| Some(&project.name) == name.as_ref())
                .or_else(|| directory.projects.iter().find(|project| project.environment == env && !project.scratch))
                .map(|project| project.path.clone());
            let chat = cx.entity().downgrade();
            let icon = if env.is_some() { Icon::new(Lucide::Server) } else { Icon::new(Lucide::Laptop) };
            let selected = env == current;
            let env_for_click = env.clone();
            menu = menu.child(
                composer::menu_item(("environment", ix), selected, target.is_some(), p)
                    .when_some(target, |this, path| {
                        this.on_click(move |_, _, cx| {
                            let (path, env) = (path.clone(), env_for_click.clone());
                            let _ = chat.update(cx, |this, cx| {
                                this.open_picker = None;
                                cx.emit(ChatEvent::SwitchProject(path, env));
                            });
                        })
                    })
                    .child(icon.size(px(12.)).text_color(p.ink3))
                    .child(label),
            );
        }
        menu.into_any_element()
    }

    /// The branch the thread is on, or (bound for a new worktree) the one
    /// it starts from.
    fn render_branch_control(&self, project_branch: Option<String>, cx: &mut Context<Self>) -> AnyElement {
        let session = self.session.read(cx);
        let label = match (&session.pending_worktree, &session.worktree) {
            (Some(plan), _) => {
                let base = plan.base.clone().or_else(|| project_branch.clone());
                match base {
                    Some(base) if plan.from_origin && !base.starts_with("origin/") => format!("From origin/{base}"),
                    Some(base) => format!("From {base}"),
                    None => "Select ref".to_string(),
                }
            }
            (None, Some(worktree)) => worktree.branch.clone(),
            (None, None) => project_branch.clone().unwrap_or_else(|| "Select ref".into()),
        };
        let button = composer::strip_control("strip-branch").icon(Icon::new(Lucide::GitBranch).size(px(12.))).label(label).caret();
        let picker = self.branch_picker.clone();
        self.picker_anchored(Picker::Branch, button, Anchor::BottomRight, cx, move |_, _| picker.clone().into_any_element())
            .into_any_element()
    }

    /// Opens the branch picker on the thread's folder.
    pub(super) fn open_branch_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let session = self.session.read(cx);
        let cwd = session.cwd.clone();
        let base_mode = session.pending_worktree.is_some();
        let from_origin = session.pending_worktree.as_ref().is_some_and(|plan| plan.from_origin);
        let remote = session.is_remote();
        self.branch_picker.update(cx, |picker, cx| picker.open(cwd, base_mode, from_origin, remote, window, cx));
    }

    pub(super) fn on_branch_event(&mut self, event: &BranchPickerEvent, window: &mut Window, cx: &mut Context<Self>) {
        match event {
            BranchPickerEvent::Picked(git_ref) => {
                self.open_picker = None;
                let session = self.session.read(cx);
                if session.pending_worktree.is_some() {
                    let base = git_ref.name.clone();
                    self.session.update(cx, |session, cx| session.set_worktree_base(base, cx));
                } else if let Some(folder) = git_ref.worktree.clone().filter(|_| session.items.is_empty() && !session.is_remote()) {
                    // Checked out in another worktree: run the new thread there.
                    let project = session.project.clone();
                    let branch = git_ref.name.clone();
                    let task = cx.background_executor().spawn(async move {
                        let common = git::git(&project, &["rev-parse", "--path-format=absolute", "--git-common-dir"]).ok()?;
                        let repo = PathBuf::from(common.trim()).parent()?.to_path_buf();
                        Some(Worktree { root: folder, branch, repo })
                    });
                    let session = self.session.clone();
                    cx.spawn(async move |_, cx| {
                        if let Some(worktree) = task.await {
                            let _ = session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx));
                        }
                    })
                    .detach();
                } else {
                    cx.emit(ChatEvent::SwitchBranch { branch: git_ref.name.clone(), create: false });
                }
            }
            BranchPickerEvent::Create(name) => {
                self.open_picker = None;
                cx.emit(ChatEvent::SwitchBranch { branch: name.clone(), create: true });
            }
            BranchPickerEvent::FromOrigin(on) => {
                let on = *on;
                self.session.update(cx, |session, cx| session.set_worktree_from_origin(on, cx));
                return;
            }
            BranchPickerEvent::Dismiss => self.open_picker = None,
        }
        self.focus_composer(window, cx);
        cx.notify();
    }
}

/// Whether the strip names the machine: when threads can run on more than
/// one, or this one runs on another device.
fn environment_indicator(directory: &ProjectDirectory, project: &ProjectSummary) -> bool {
    project.host.is_some() || directory.projects.iter().any(|project| project.host.is_some())
}

pub enum BranchPickerEvent {
    Picked(GitRef),
    Create(String),
    FromOrigin(bool),
    Dismiss,
}

/// The branch picker: a search field over the repository's branches, a
/// way to create one, and (for a new worktree) whether to start from
/// origin's copy of the branch.
pub struct BranchPicker {
    search: Entity<InputState>,
    refs: Vec<GitRef>,
    loading: bool,
    highlighted: usize,
    base_mode: bool,
    from_origin: bool,
    remote: bool,
    _subscription: Subscription,
    _load: Task<()>,
}

impl EventEmitter<BranchPickerEvent> for BranchPicker {}

impl BranchPicker {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search refs..."));
        let subscription = cx.subscribe(&search, |this: &mut Self, _, event: &InputEvent, cx| {
            if matches!(event, InputEvent::Change) {
                this.highlighted = 0;
                cx.notify();
            }
        });
        Self {
            search,
            refs: Vec::new(),
            loading: false,
            highlighted: 0,
            base_mode: false,
            from_origin: false,
            remote: false,
            _subscription: subscription,
            _load: Task::ready(()),
        }
    }

    fn open(&mut self, cwd: PathBuf, base_mode: bool, from_origin: bool, remote: bool, window: &mut Window, cx: &mut Context<Self>) {
        self.base_mode = base_mode;
        self.from_origin = from_origin;
        self.remote = remote;
        self.highlighted = 0;
        self.loading = !remote;
        self.search.update(cx, |search, cx| {
            search.set_value("", window, cx);
            search.focus(window, cx);
        });
        if remote {
            self.refs.clear();
            return;
        }
        let task = cx.background_executor().spawn(async move { git::list_refs(&cwd) });
        self._load = cx.spawn(async move |this, cx| {
            let refs = task.await;
            let _ = this.update(cx, |this, cx| {
                this.refs = refs;
                this.loading = false;
                cx.notify();
            });
        });
        cx.notify();
    }

    fn query(&self, cx: &App) -> String {
        self.search.read(cx).value().trim().to_string()
    }

    /// The branches matching the search: a substring of the name, or of its
    /// branch-safe form.
    fn matches(&self, cx: &App) -> Vec<GitRef> {
        let query = self.query(cx).to_lowercase();
        let sanitized = query.split_whitespace().collect::<Vec<_>>().join("-");
        self.refs
            .iter()
            .filter(|git_ref| {
                let name = git_ref.name.to_lowercase();
                query.is_empty() || name.contains(&query) || name.contains(&sanitized)
            })
            .take(MAX_REFS)
            .cloned()
            .collect()
    }

    /// The branch "Create new ref" would make, when the search names one
    /// that doesn't exist yet.
    fn creatable(&self, cx: &App) -> Option<String> {
        let query = self.query(cx);
        if query.is_empty() || self.base_mode || self.remote {
            return None;
        }
        let name = crate::worktree::sanitize_fragment(&query);
        (!self.refs.iter().any(|git_ref| git_ref.name == name)).then_some(name)
    }

    fn choose(&mut self, cx: &mut Context<Self>) {
        let matches = self.matches(cx);
        if let Some(git_ref) = matches.get(self.highlighted) {
            cx.emit(BranchPickerEvent::Picked(git_ref.clone()));
        } else if let Some(name) = self.creatable(cx) {
            cx.emit(BranchPickerEvent::Create(name));
        }
    }
}

impl Render for BranchPicker {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let matches = self.matches(cx);
        let creatable = self.creatable(cx);
        let rows = matches.len() + usize::from(creatable.is_some());
        let highlighted = self.highlighted.min(rows.saturating_sub(1));
        let total = self.refs.len();
        let shown = matches.len();
        v_flex()
            .w(px(320.))
            .max_h(px(368.))
            .rounded(px(14.))
            .bg(p.menu_bg)
            .shadow(p.raise.clone())
            .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| match event.keystroke.key.as_str() {
                "down" if rows > 0 => {
                    this.highlighted = (highlighted + 1) % rows;
                    cx.stop_propagation();
                    cx.notify();
                }
                "up" if rows > 0 => {
                    this.highlighted = (highlighted + rows - 1) % rows;
                    cx.stop_propagation();
                    cx.notify();
                }
                "enter" => {
                    this.highlighted = highlighted;
                    cx.stop_propagation();
                    this.choose(cx);
                }
                "escape" => {
                    cx.stop_propagation();
                    cx.emit(BranchPickerEvent::Dismiss);
                }
                _ => {}
            }))
            .child(
                div().px(px(12.)).pt(px(10.)).child(
                    h_flex()
                        .pb(px(6.))
                        .gap(px(4.))
                        .border_b_1()
                        .border_color(p.rule)
                        .text_size(px(14.))
                        .child(Icon::new(IconName::Search).size(px(16.)).text_color(p.ink3.opacity(0.55)))
                        .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))),
                ),
            )
            .child(
                v_flex()
                    .id("branch-list")
                    .max_h(px(224.))
                    .overflow_y_scroll()
                    .pl(px(4.))
                    .pr(px(4.))
                    .pt(px(8.))
                    .pb(px(4.))
                    .children(matches.iter().enumerate().map(|(ix, git_ref)| {
                        let pick = git_ref.clone();
                        h_flex()
                            .id(("branch-ref", ix))
                            .w_full()
                            .min_h(px(28.))
                            .px(px(8.))
                            .gap(px(8.))
                            .justify_between()
                            .rounded(px(8.))
                            .cursor_pointer()
                            .text_size(px(13.5))
                            .text_color(p.ink)
                            .when(ix == highlighted, |this| this.bg(p.menu_row_hover))
                            .hover(|style| style.bg(p.menu_row_hover))
                            .on_click(cx.listener(move |_, _, _, cx| cx.emit(BranchPickerEvent::Picked(pick.clone()))))
                            .child(div().flex_1().min_w_0().truncate().child(git_ref.name.clone()))
                            .when_some(git_ref.badge(), |this, badge| {
                                this.child(div().flex_none().text_size(px(10.)).text_color(p.ink3.opacity(0.6)).child(badge))
                            })
                    }))
                    .when_some(creatable, |this, name| {
                        let ix = matches.len();
                        let create = name.clone();
                        this.child(
                            h_flex()
                                .id("branch-create")
                                .w_full()
                                .min_h(px(28.))
                                .px(px(8.))
                                .rounded(px(8.))
                                .cursor_pointer()
                                .text_size(px(13.5))
                                .text_color(p.ink)
                                .when(ix == highlighted, |this| this.bg(p.menu_row_hover))
                                .hover(|style| style.bg(p.menu_row_hover))
                                .on_click(cx.listener(move |_, _, _, cx| cx.emit(BranchPickerEvent::Create(create.clone()))))
                                .child(div().truncate().child(format!("Create new ref \"{name}\""))),
                        )
                    })
                    .when(rows == 0 && !self.loading, |this| {
                        this.child(div().p(px(8.)).text_center().text_size(px(14.)).text_color(p.ink3).child(if self.remote {
                            "Branches of threads on another device are picked there."
                        } else {
                            "No refs found."
                        }))
                    }),
            )
            .when(self.base_mode, |this| this.child(render_origin_row(self.from_origin, p, cx)))
            .child(
                div()
                    .px(px(12.))
                    .py(px(8.))
                    .text_size(px(12.))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(p.ink3)
                    .child(if self.loading {
                        "Loading refs...".to_string()
                    } else {
                        format!("Showing {shown} of {total} refs")
                    }),
            )
    }
}

/// "Start from origin", with its switch, for a new worktree's base branch.
fn render_origin_row(on: bool, p: &Palette, cx: &mut Context<BranchPicker>) -> impl IntoElement {
    let picker = cx.entity().downgrade();
    h_flex()
        .id("branch-origin")
        .justify_between()
        .gap(px(12.))
        .px(px(12.))
        .py(px(8.))
        .border_t_1()
        .border_color(p.rule.opacity(0.6))
        .text_size(px(12.))
        .tooltip(|window, cx| {
            gpui_kit::component::tooltip::Tooltip::new(
                "Creates the worktree from the latest matching branch on origin instead of your local branch.",
            )
            .build(window, cx)
        })
        .child(
            h_flex()
                .gap(px(6.))
                .min_w_0()
                .child(Icon::new(IconName::RefreshCw).size(px(12.)).text_color(p.ink3))
                .child(div().truncate().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Start from origin")),
        )
        .child(metal::toggle("branch-origin-toggle", on, p, move |on, _, cx| {
            let _ = picker.update(cx, |picker, cx| {
                picker.from_origin = on;
                cx.emit(BranchPickerEvent::FromOrigin(on));
                cx.notify();
            });
        }))
}