//! 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();
});
}))
}