//! 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::() 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) -> 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::() 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) -> 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) -> 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::() .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| { 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) -> 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::() 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, 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, cx: &mut Context) -> 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) { 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) { 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, refs: Vec, loading: bool, highlighted: usize, base_mode: bool, from_origin: bool, remote: bool, _subscription: Subscription, _load: Task<()>, } impl EventEmitter for BranchPicker {} impl BranchPicker { pub fn new(window: &mut Window, cx: &mut Context) -> 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.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 { let query = self.query(cx).to_lowercase(); let sanitized = query.split_whitespace().collect::>().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 { 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) { 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) -> 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) -> 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(); }); })) }