//! The window root, laid out like T3 Code: a searchable list of threads on
//! the left, the conversation on a raised sheet, and a project panel (editor,
//! branch, commit, changes) on the right. It also keeps threads on disk and
//! looks after them in the background: waking snoozed ones, continuing ones a
//! usage limit stopped, settling idle ones, and checking on the agents.
use std::{
collections::{HashMap, HashSet},
hash::{Hash as _, Hasher as _},
path::PathBuf,
process::{Command, Stdio},
rc::Rc,
time::{Duration, Instant, SystemTime},
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, WindowExt as _,
button::ButtonVariant,
h_flex,
input::{Input, InputEvent, InputState},
menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem},
notification::Notification,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::claude::PermissionMode;
use crate::clock;
use crate::git::{self, GitInfo};
use crate::models::{Catalog, ModelEntry, Provider, split_key};
use crate::session::{NewSession, Reasoning, Session, SessionStatus, from_millis, to_millis};
use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
use crate::store::{self, AppState, SavedProject};
use crate::ui::chat::{ChatEvent, ChatView, display_path};
use crate::ui::folder_browser::{self, FolderBrowser, FolderBrowserEvent};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers;
use crate::ui::settings::{SettingsEvent, SettingsView};
use crate::worktree::{self, WorktreePlan};
use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar};
mod remote;
use remote::RemoteState;
pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
const PANEL_WIDTH: f32 = 292.;
/// Below this window width the project panel stays out of the conversation's way.
const PANEL_MIN_WINDOW: f32 = 1120.;
/// The gap between the sheet and the window edge, and the sheet's rounding.
pub(crate) const SHEET_INSET: f32 = 8.;
pub(crate) const SHEET_RADIUS: f32 = 24.;
const TICK: Duration = Duration::from_secs(2);
const GIT_POLL: Duration = Duration::from_secs(4);
const HOUSEKEEPING: Duration = Duration::from_secs(15);
const PR_CHECK: Duration = Duration::from_secs(300);
/// How long a settle button stays armed for its confirming second click.
const SETTLE_ARMED: Duration = Duration::from_secs(3);
/// With balanced background activity, how long after the window was last
/// active background checks keep running.
const BALANCED_GRACE: Duration = Duration::from_secs(300);
const CONTINUE_AFTER_RESTART: &str =
"The app restarted while you were working, which interrupted your last turn. Continue where you left off.";
const CONTINUE_AFTER_LIMIT: &str = "The usage limit has reset. Continue where you left off.";
/// The projects, as the conversation's start screen needs them: to name its
/// project, offer the others, and show where and on which branch it starts.
#[derive(Default)]
pub struct ProjectDirectory {
pub hostname: SharedString,
/// In the order the settings sort projects.
pub projects: Vec<ProjectSummary>,
}
impl ProjectDirectory {
/// The project at `path` on a remote connection (`None` for this device).
pub fn find(&self, path: &std::path::Path, environment: Option<&str>) -> Option<&ProjectSummary> {
self.projects.iter().find(|project| project.path == path && project.environment.as_deref() == environment)
}
}
pub struct ProjectSummary {
pub name: SharedString,
pub path: PathBuf,
/// The remote connection it's on, by id; `None` on this device.
pub environment: Option<String>,
/// The device's name, for projects on other devices.
pub host: Option<SharedString>,
/// The folder threads "without a project" run in.
pub scratch: bool,
/// Whether it's a git repository, so threads can start in a worktree.
pub git: bool,
pub branch: Option<String>,
pub branches: Vec<String>,
}
impl Global for ProjectDirectory {}
/// What a new thread starts with: the agent, model and settings the user
/// picked last.
#[derive(Clone, Default)]
struct ThreadSettings {
instance: String,
provider: Provider,
model: Option<String>,
mode: PermissionMode,
reasoning: Reasoning,
fast_mode: bool,
}
struct Project {
path: PathBuf,
name: SharedString,
initials: SharedString,
/// Index into the palette's tints.
tint: usize,
added: SystemTime,
sessions: Vec<Entity<Session>>,
/// `None` until read, or when the folder isn't a git repository.
git: Option<GitInfo>,
/// The remote connection it's on, by id; `None` on this device.
environment: Option<String>,
}
/// A thread in the sidebar: its project's index and the session.
type ThreadEntry = (usize, Entity<Session>);
/// A settled thread, as the settings' archive lists it.
pub struct SettledThread {
pub session: Entity<Session>,
pub project: SharedString,
pub initials: SharedString,
pub tint: usize,
pub title: SharedString,
pub provider: Provider,
pub age: String,
}
/// A GUI program that can open a folder.
struct EditorApp {
name: &'static str,
program: PathBuf,
}
/// What a thread was doing when last seen, to notice when that changes.
#[derive(Clone, Copy, Default)]
struct Activity {
working: bool,
attention: bool,
}
pub struct Workspace {
projects: Vec<Project>,
chats: HashMap<EntityId, Entity<ChatView>>,
active: Option<Entity<Session>>,
active_project: usize,
search: Entity<InputState>,
sidebar_open: bool,
panel_open: bool,
settled_open: bool,
snoozed_open: bool,
working_open: bool,
/// Projects folded away in the grouped thread list.
collapsed_projects: HashSet<PathBuf>,
editors: Rc<Vec<EditorApp>>,
editor: usize,
hostname: SharedString,
settings_view: Entity<SettingsView>,
settings_open: bool,
/// The Add project browser, while it's open.
folder_browser: Option<(Entity<FolderBrowser>, Subscription)>,
activity: HashMap<EntityId, Activity>,
/// The thread whose settle button waits for its confirming click.
settle_armed: Option<(EntityId, Instant)>,
/// Threads changed since they were last saved.
dirty: HashSet<EntityId>,
/// `state.json` as last written.
saved_state: String,
/// The git state of the open thread's worktree.
worktree_git: HashMap<PathBuf, GitInfo>,
last_active: Instant,
last_git: Instant,
git_busy: bool,
last_housekeeping: Instant,
last_health: Instant,
last_pr_check: Option<Instant>,
last_repaint: Instant,
remote: RemoteState,
focus_handle: FocusHandle,
observers: HashMap<EntityId, Vec<Subscription>>,
_subscriptions: Vec<Subscription>,
_ticker: Task<()>,
_remote_flush: Task<()>,
}
impl Workspace {
/// Opens the window's workspace with the saved projects and threads.
/// `initial_project`, from the command line, starts a thread there.
pub fn new(initial_project: Option<PathBuf>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
let workspace = cx.weak_entity();
let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx));
let subscriptions = vec![
cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event {
SettingsEvent::Close => this.close_settings(window, cx),
}),
cx.on_app_quit(|this: &mut Self, cx| {
this.save_everything(cx);
for project in &this.projects {
for session in &project.sessions {
session.update(cx, |session, _| session.shutdown());
}
}
async {}
}),
// The System theme mode follows the desktop's light/dark setting.
cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)),
cx.observe_window_activation(window, |this, window, cx| {
if window.is_window_active() {
this.last_active = Instant::now();
// Catch up on what was paused while the window was away.
this.last_git = Instant::now() - GIT_POLL;
cx.notify();
}
}),
cx.subscribe(&search, |_, _, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
cx.notify();
}
}),
];
let ticker = cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(TICK).await;
if this.update_in(cx, |this, window, cx| this.tick(window, cx)).is_err() {
break;
}
}
});
let remote_flush = cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(remote::FLUSH).await;
if this.update(cx, |this, cx| this.flush_remote(cx)).is_err() {
break;
}
}
});
let editors = detect_editors();
let saved_editor = settings::get(|settings| settings.editor.clone());
let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
let now = Instant::now();
let mut workspace = Self {
projects: Vec::new(),
chats: HashMap::new(),
active: None,
active_project: 0,
search,
sidebar_open: true,
panel_open: true,
settled_open: false,
snoozed_open: false,
working_open: false,
collapsed_projects: HashSet::new(),
editors: Rc::new(editors),
editor,
hostname: hostname().into(),
settings_view,
settings_open: false,
folder_browser: None,
activity: HashMap::new(),
settle_armed: None,
dirty: HashSet::new(),
saved_state: String::new(),
worktree_git: HashMap::new(),
last_active: now,
last_git: now,
git_busy: false,
last_housekeeping: now,
last_health: now,
last_pr_check: None,
last_repaint: now,
remote: RemoteState::default(),
focus_handle: cx.focus_handle(),
observers: HashMap::new(),
_subscriptions: subscriptions,
_ticker: ticker,
_remote_flush: remote_flush,
};
let active = workspace.restore(window, cx);
match (initial_project, active) {
(Some(path), _) => workspace.add_project(path, window, cx),
(None, Some((ix, session))) => workspace.select(ix, session, window, cx),
(None, None) => {
let ix = match workspace.ordered_projects(cx).first() {
Some(&ix) => ix,
None => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
workspace.ensure_project(cwd.canonicalize().unwrap_or(cwd), None)
}
};
let settings = workspace.new_thread_settings(cx);
workspace.replace_thread(ix, settings, window, cx);
}
}
workspace.sync_remote(window, cx);
workspace
}
// ---- keeping threads on disk -------------------------------------------
/// Loads the saved projects and threads, and returns the thread that was
/// open.
fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<ThreadEntry> {
let state = store::load_state();
for project in &state.projects {
let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
let ix = self.ensure_project(project.path.clone(), name);
self.projects[ix].added = from_millis(project.added);
}
let continue_threads = settings::get(|settings| settings.continue_after_restart);
let mut active = None;
for saved in store::load_threads() {
let ix = self.ensure_project(saved.project.clone(), None);
let id = saved.id.clone();
let session = cx.new(|cx| Session::restore(saved, cx));
self.attach(ix, session.clone(), window, cx);
if state.active.as_deref() == Some(id.as_str()) {
active = Some((ix, session.clone()));
}
let interrupted = {
let state = session.read(cx);
state.interrupted && !state.settled
};
if continue_threads && interrupted {
session.update(cx, |session, cx| session.send_message(CONTINUE_AFTER_RESTART.into(), Vec::new(), cx));
}
}
self.saved_state = serde_json::to_string(&self.app_state(cx)).unwrap_or_default();
active
}
fn app_state(&self, cx: &App) -> AppState {
let scratch = scratch_dir();
AppState {
projects: self
.projects
.iter()
.filter(|project| project.environment.is_none() && Some(&project.path) != scratch.as_ref())
.map(|project| SavedProject {
path: project.path.clone(),
name: project.name.to_string(),
added: to_millis(project.added),
})
.collect(),
active: self.active.as_ref().map(|session| session.read(cx).id.clone()),
}
}
/// Writes the threads that changed, and the project list if it did, in
/// the background.
fn flush_saves(&mut self, cx: &mut Context<Self>) {
let state = self.app_state(cx);
let state_text = serde_json::to_string(&state).unwrap_or_default();
let state = (state_text != self.saved_state).then(|| {
self.saved_state = state_text;
state
});
let dirty = std::mem::take(&mut self.dirty);
let threads: Vec<_> = self
.projects
.iter()
.flat_map(|project| &project.sessions)
.filter(|session| dirty.contains(&session.entity_id()))
.map(|session| session.read(cx))
// A thread nothing was sent in isn't worth keeping, and another
// device keeps its own.
.filter(|session| !session.items.is_empty() && !session.is_remote())
.map(Session::save)
.collect();
if threads.is_empty() && state.is_none() {
return;
}
cx.background_executor()
.spawn(async move {
for thread in &threads {
store::save_thread(thread);
}
if let Some(state) = state {
store::save_state(&state);
}
})
.detach();
}
/// Writes every thread and the project list now, as the app quits.
fn save_everything(&mut self, cx: &App) {
for session in self.projects.iter().flat_map(|project| &project.sessions) {
let session = session.read(cx);
if !session.items.is_empty() && !session.is_remote() {
store::save_thread(&session.save());
}
}
store::save_state(&self.app_state(cx));
}
// ---- background work ----------------------------------------------------
fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if window.is_window_active() {
self.last_active = Instant::now();
}
self.flush_saves(cx);
let allowed = self.background_allowed(window);
if allowed && !self.git_busy && self.last_git.elapsed() >= GIT_POLL {
self.poll_git(cx);
}
if self.last_housekeeping.elapsed() >= HOUSEKEEPING {
self.last_housekeeping = Instant::now();
self.housekeeping(allowed, cx);
}
let health = settings::get(|settings| settings.health_check_secs);
if allowed && health > 0 && self.last_health.elapsed() >= Duration::from_secs(health) {
self.last_health = Instant::now();
Catalog::refresh_all(cx);
}
if let Some((_, armed)) = self.settle_armed
&& armed.elapsed() >= SETTLE_ARMED
{
self.settle_armed = None;
cx.notify();
}
// Repaint about once a minute so thread ages stay current.
if self.last_repaint.elapsed() >= Duration::from_secs(60) {
self.last_repaint = Instant::now();
cx.notify();
}
}
/// Whether background checks should run now, as Background activity says.
fn background_allowed(&self, window: &Window) -> bool {
match settings::get(|settings| settings.background_activity) {
BackgroundActivity::Always => true,
BackgroundActivity::Minimal => window.is_window_active(),
BackgroundActivity::Balanced => {
(window.is_window_active() || self.last_active.elapsed() < BALANCED_GRACE) && !power_saving()
}
}
}
/// Reads the git state of every project, and of the open thread's worktree.
fn poll_git(&mut self, cx: &mut Context<Self>) {
self.git_busy = true;
self.last_git = Instant::now();
let mut paths: Vec<PathBuf> =
self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.clone()).collect();
let worktree = self.active_worktree(cx);
paths.extend(worktree.clone());
let task = cx.background_executor().spawn(async move {
paths.into_iter().map(|path| (git::read_info(&path), path)).collect::<Vec<_>>()
});
cx.spawn(async move |this, cx| {
let infos = task.await;
let _ = this.update(cx, |this, cx| {
this.git_busy = false;
let mut changed = false;
for (info, path) in infos {
if Some(&path) == worktree.as_ref() {
let info = info.unwrap_or_default();
if this.worktree_git.get(&path) != Some(&info) {
this.worktree_git.insert(path, info);
changed = true;
}
} else if let Some(project) =
this.projects.iter_mut().find(|project| project.environment.is_none() && project.path == path)
&& project.git != info
{
project.git = info;
changed = true;
}
}
if changed {
cx.notify();
}
});
})
.detach();
}
/// Wakes snoozed threads, continues those a usage limit stopped, settles
/// idle ones, and checks worktree threads' pull requests.
fn housekeeping(&mut self, allowed: bool, cx: &mut Context<Self>) {
let now = SystemTime::now();
let (settle_inactive, inactive_days) =
settings::get(|settings| (settings.auto_settle_inactive, settings.inactive_days.max(1)));
let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400);
// Other devices look after their own threads.
let sessions: Vec<Entity<Session>> = self
.projects
.iter()
.filter(|project| project.environment.is_none())
.flat_map(|project| project.sessions.clone())
.collect();
for session in &sessions {
let (wake, resume, settle) = {
let state = session.read(cx);
let idle = !state.is_working() && !state.needs_attention() && !state.items.is_empty();
(
state.snoozed_until.is_some_and(|until| until <= now),
state.resume_at.is_some_and(|at| at <= now) && !state.is_working(),
settle_inactive
&& idle
&& !state.settled
&& !state.pinned
&& state.resume_at.is_none()
&& now.duration_since(state.updated).unwrap_or_default() >= inactive,
)
};
if wake {
session.update(cx, |session, cx| session.snooze(None, cx));
}
if resume {
session.update(cx, |session, cx| {
session.resume_at = None;
session.send_message(CONTINUE_AFTER_LIMIT.into(), Vec::new(), cx);
});
}
if settle {
session.update(cx, |session, cx| session.settle(cx));
}
}
if allowed && self.last_pr_check.is_none_or(|checked| checked.elapsed() >= PR_CHECK) {
self.last_pr_check = Some(Instant::now());
self.check_pull_requests(cx);
}
}
/// Looks up the pull requests of worktree threads' branches, and settles
/// threads whose pull request merged (when set to) or closed.
fn check_pull_requests(&mut self, cx: &mut Context<Self>) {
let threads: Vec<(Entity<Session>, PathBuf, String)> = self
.projects
.iter()
.filter(|project| project.environment.is_none())
.flat_map(|project| &project.sessions)
.filter_map(|session| {
let state = session.read(cx);
let worktree = state.worktree.as_ref().filter(|_| !state.settled)?;
Some((session.clone(), worktree.repo.clone(), worktree.branch.clone()))
})
.collect();
if threads.is_empty() {
return;
}
let lookups: Vec<(PathBuf, String)> = threads.iter().map(|(_, repo, branch)| (repo.clone(), branch.clone())).collect();
let task = cx.background_executor().spawn(async move {
lookups.into_iter().map(|(repo, branch)| worktree::pull_request(&repo, &branch)).collect::<Vec<_>>()
});
cx.spawn(async move |_, cx| {
let results = task.await;
let merged_settles = settings::get(|settings| settings.auto_settle_merged);
for ((session, _, _), pull_request) in threads.into_iter().zip(results) {
let Some(pull_request) = pull_request else { continue };
let _ = session.update(cx, |session, cx| {
let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
let settle = was_open
&& !session.is_working()
&& match pull_request.state.as_str() {
"MERGED" => merged_settles,
"CLOSED" => true,
_ => false,
};
if session.pull_request.as_ref() != Some(&pull_request) {
session.pull_request = Some(pull_request);
cx.notify();
}
if settle {
session.settle(cx);
}
});
}
})
.detach();
}
// ---- threads ------------------------------------------------------------
fn add_project(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.ensure_project(path, None);
let settings = self.current_settings(cx);
self.replace_thread(ix, settings, window, cx);
}
/// The settings of the open thread, which a new thread inherits.
fn current_settings(&self, cx: &App) -> ThreadSettings {
let mut settings = self
.active
.as_ref()
.map(|active| {
let active = active.read(cx);
ThreadSettings {
instance: active.instance.clone(),
provider: active.provider,
model: active.model.clone(),
mode: active.permission_mode,
reasoning: active.reasoning,
fast_mode: active.fast_mode,
}
})
.unwrap_or_default();
// Stay on an agent that's still on, preferring one of the same kind.
let catalog = Catalog::global(cx);
if catalog.instance(&settings.instance).is_none_or(|state| !state.config.enabled) {
let fallback = catalog.first_of(settings.provider).or_else(|| catalog.enabled().next());
match fallback {
Some(state) if state.kind() == settings.provider => settings.instance = state.id().to_string(),
Some(state) => {
settings = ThreadSettings {
instance: state.id().to_string(),
provider: state.kind(),
mode: settings.mode,
..ThreadSettings::default()
}
}
None if settings.instance.is_empty() => settings.instance = settings.provider.key().to_string(),
None => {}
}
}
settings
}
/// What a fresh thread starts with: the open thread's picks, overridden
/// by the defaults chosen in the settings.
fn new_thread_settings(&self, cx: &App) -> ThreadSettings {
let (model, reasoning, access) = settings::get(|settings| {
(settings.default_model.clone(), settings.default_reasoning.clone(), settings.default_access.clone())
});
let mut settings = self.current_settings(cx);
if let Some(key) = model {
match Catalog::global(cx).find_key(&key) {
Some(model) => settings = self.settings_for(model, cx),
// Claude's models are only known once Claude Code answered.
None => {
if let Some((instance, id)) = split_key(&key)
&& let Some(state) = Catalog::global(cx).instance(instance).filter(|state| state.config.enabled)
{
settings.instance = instance.to_string();
settings.provider = state.kind();
settings.model = Some(id.to_string());
}
}
}
}
if let Some(reasoning) = reasoning.as_deref().and_then(Reasoning::from_key) {
let catalog = Catalog::global(cx);
let model = catalog.find(&settings.instance, settings.model.as_deref());
settings.reasoning = match reasoning {
Reasoning::Level(effort) if model.is_none_or(|model| model.efforts.contains(&effort)) => reasoning,
Reasoning::Ultracode | Reasoning::Ultrathink if settings.provider == Provider::Claude => reasoning,
_ => Reasoning::Default,
};
}
if let Some(mode) = access.as_deref().and_then(PermissionMode::from_cli) {
settings.mode = mode;
}
settings
}
/// The worktree a new thread in this project starts in, as Settings →
/// New threads → Workspace says.
fn worktree_plan(&self, project_ix: usize, mode: WorkspaceMode) -> Option<WorktreePlan> {
let project = self.projects.get(project_ix)?;
let git = project.git.as_ref()?;
if mode != WorkspaceMode::Worktree || project.environment.is_some() || Some(&project.path) == scratch_dir().as_ref() {
return None;
}
let (from_origin, submodules) = settings::get(|settings| (settings.start_from_origin, settings.submodules));
Some(WorktreePlan { project: project.path.clone(), base: git.branch.clone(), from_origin, submodules })
}
fn new_thread(&mut self, project_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let settings = self.new_thread_settings(cx);
self.new_thread_with(project_ix, settings, window, cx);
}
fn new_thread_with(
&mut self,
project_ix: usize,
settings: ThreadSettings,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<ChatView>> {
let project = self.projects.get(project_ix)?;
let mode = settings::get(|settings| settings.workspace);
let session = match project.environment.clone() {
Some(env) => {
let worktree = mode == WorkspaceMode::Worktree && project.git.is_some();
let path = project.path.clone();
let session = self.new_remote_session(&env, path, settings, worktree, cx)?;
self.attach(project_ix, session.clone(), window, cx);
session
}
None => {
let plan = self.worktree_plan(project_ix, mode);
self.spawn_thread(project_ix, settings, plan, window, cx)
}
};
let chat = self.chats.get(&session.entity_id()).cloned();
self.select(project_ix, session, window, cx);
chat
}
/// Starts a local thread without opening it.
fn spawn_thread(
&mut self,
project_ix: usize,
settings: ThreadSettings,
worktree: Option<WorktreePlan>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<Session> {
let project = self.projects[project_ix].path.clone();
let session = cx.new(|cx| {
Session::new(
NewSession {
instance: settings.instance,
provider: settings.provider,
project,
model: settings.model,
permission_mode: settings.mode,
reasoning: settings.reasoning,
fast_mode: settings.fast_mode,
worktree,
},
cx,
)
});
self.attach(project_ix, session.clone(), window, cx);
session
}
/// Adds a thread to a project with a conversation view for it.
fn attach(
&mut self,
project_ix: usize,
session: Entity<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ChatView> {
let chat = cx.new(|cx| ChatView::new(session.clone(), window, cx));
self.observers.insert(
session.entity_id(),
vec![
cx.observe_in(&session, window, Self::on_session_changed),
cx.subscribe_in(&chat, window, Self::on_chat_event),
],
);
self.chats.insert(session.entity_id(), chat.clone());
self.projects[project_ix].sessions.push(session);
chat
}
/// Starts a thread that replaces the open one if nothing was sent in it
/// yet, carrying over what was typed.
fn replace_thread(
&mut self,
project_ix: usize,
settings: ThreadSettings,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous = self.active.clone().filter(|session| session.read(cx).is_blank());
let previous_chat = previous.as_ref().and_then(|session| self.chats.get(&session.entity_id()).cloned());
let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) else { return };
if let (Some(previous), Some(previous_chat)) = (previous, previous_chat) {
chat.update(cx, |chat, cx| chat.take_draft_from(&previous_chat, window, cx));
if let Some(ix) = self.project_of(&previous) {
self.remove_thread(ix, &previous, cx);
}
}
}
/// Repaints, marks the thread for saving, and tells the user when a
/// thread finishes, fails or starts waiting for them: on the desktop
/// while the window is in the background, in a toast while it isn't.
fn on_session_changed(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
cx.notify();
self.dirty.insert(session.entity_id());
self.remote_thread_changed(&session, cx);
let state = session.read(cx);
let now = Activity { working: state.is_working(), attention: state.needs_attention() };
let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
if state.settled {
return;
}
let failed = state.ended_with_error();
let event = if now.attention && !before.attention {
"Waiting for you"
} else if before.working && !now.working {
if failed { "Failed" } else { "Finished" }
} else {
return;
};
let title = state.title.to_string();
let (notify, sound, in_app) =
settings::get(|settings| (settings.notify, settings.notify_sound, settings.in_app_notifications));
if window.is_window_active() {
let is_open = self.active.as_ref() == Some(&session) && !self.settings_open;
if in_app && !is_open {
let note = if failed && event == "Failed" { Notification::error(event) } else { Notification::info(event) };
window.push_notification(note.title(title), cx);
}
} else if notify {
desktop_notification(&title, event, sound);
}
}
fn project_of(&self, session: &Entity<Session>) -> Option<usize> {
self.projects
.iter()
.position(|project| project.sessions.iter().any(|candidate| candidate == session))
}
fn on_chat_event(
&mut self,
chat: &Entity<ChatView>,
event: &ChatEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let session = chat.read(cx).session().clone();
let Some(project_ix) = self.project_of(&session) else { return };
match event {
ChatEvent::NewThreadWithModel(model) => {
let settings = self.settings_for(model, cx);
self.replace_thread(project_ix, settings, window, cx);
}
ChatEvent::SwitchProject(path, environment) => {
if let Some(target) =
self.projects.iter().position(|project| &project.path == path && &project.environment == environment)
{
let settings = self.current_settings(cx);
self.replace_thread(target, settings, window, cx);
}
}
ChatEvent::AddProject => self.prompt_for_project(window, cx),
ChatEvent::StartWithoutProject => {
let Some(path) = scratch_dir() else { return };
let target = self.ensure_project(path, Some("No project"));
let settings = self.current_settings(cx);
self.replace_thread(target, settings, window, cx);
}
ChatEvent::SwitchBranch(branch) => {
// A thread bound for a worktree starts from the branch; others
// switch the project's checkout.
let planned = session.read(cx).pending_worktree.is_some();
if planned {
let branch = branch.clone();
session.update(cx, |session, cx| {
if let Some(plan) = session.pending_worktree.as_mut() {
plan.base = Some(branch);
}
cx.notify();
});
} else {
self.switch_branch(branch.clone(), window, cx);
}
}
ChatEvent::SetWorkspace(mode) => {
let project = &self.projects[project_ix];
let plan = match &project.environment {
// The other device makes the worktree; this only says so.
Some(_) => (*mode == WorkspaceMode::Worktree && project.git.is_some()).then(|| WorktreePlan {
project: project.path.clone(),
base: None,
from_origin: false,
submodules: settings::Submodules::Skip,
}),
None => self.worktree_plan(project_ix, *mode),
};
session.update(cx, |session, cx| session.set_worktree_plan(plan, cx));
}
}
}
/// The settings for a thread on `model`, keeping the user's other picks
/// where the provider supports them.
fn settings_for(&self, model: &ModelEntry, cx: &App) -> ThreadSettings {
let current = self.current_settings(cx);
let reasoning = match current.reasoning {
Reasoning::Level(effort) if model.efforts.contains(&effort) => current.reasoning,
Reasoning::Ultracode | Reasoning::Ultrathink if model.provider == Provider::Claude => current.reasoning,
_ => Reasoning::Default,
};
ThreadSettings {
instance: model.instance.clone(),
provider: model.provider,
model: Some(model.id.clone()),
mode: current.mode,
reasoning,
fast_mode: current.fast_mode && model.fast_mode,
}
}
/// The index of this device's project at `path`, adding it if needed.
fn ensure_project(&mut self, path: PathBuf, name: Option<&str>) -> usize {
if let Some(ix) = self.projects.iter().position(|project| project.environment.is_none() && project.path == path) {
return ix;
}
let name = name.map(str::to_string).unwrap_or_else(|| {
path.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string())
});
let git = git::read_info(&path);
self.projects.push(Project {
path,
initials: initials(&name).into(),
tint: tint_for(&name),
name: name.into(),
added: SystemTime::now(),
sessions: Vec::new(),
git,
environment: None,
});
self.projects.len() - 1
}
/// Project indices in the order Settings → Organization → Project order says.
fn ordered_projects(&self, cx: &App) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.projects.len()).collect();
match settings::get(|settings| settings.project_order) {
ProjectOrder::Name => order.sort_by_key(|&ix| self.projects[ix].name.to_lowercase()),
ProjectOrder::Added => order.sort_by_key(|&ix| std::cmp::Reverse(self.projects[ix].added)),
ProjectOrder::LastMessage => order.sort_by_key(|&ix| {
let project = &self.projects[ix];
let last = project
.sessions
.iter()
.map(|session| session.read(cx))
.filter(|session| !session.items.is_empty())
.map(|session| session.last_message)
.max();
std::cmp::Reverse(last.unwrap_or(project.added))
}),
}
order
}
fn select(
&mut self,
project_ix: usize,
session: Entity<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_project = project_ix;
session.update(cx, |session, _| session.subscribe_remote());
if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
self.active = Some(session);
self.last_git = Instant::now() - GIT_POLL;
cx.notify();
}
fn settle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
self.settle_armed = None;
session.update(cx, |session, cx| session.settle(cx));
cx.notify();
}
/// The settle button on a thread card: settles at once, or arms itself
/// for a confirming second click.
fn press_settle(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
let id = session.entity_id();
let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
if settings::get(|settings| settings.confirm_settle) && !armed {
self.settle_armed = Some((id, Instant::now()));
cx.notify();
} else {
self.settle_thread(session, cx);
}
}
/// Deletes a thread, asking first when Delete confirmation is on.
fn request_delete(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if !settings::get(|settings| settings.confirm_delete) {
self.forget_thread(session, window, cx);
return;
}
let title = session.read(cx).title.clone();
let workspace = cx.weak_entity();
window.open_alert_dialog(cx, move |alert, _, _| {
let workspace = workspace.clone();
let session = session.clone();
alert
.title("Delete thread?")
.description(format!("\u{201c}{title}\u{201d} and its chat history will be deleted."))
.ok_text("Delete")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true)
.on_ok(move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.forget_thread(session.clone(), window, cx));
true
})
});
}
/// Pins or unpins a thread, asking before unpinning when set to.
fn toggle_pin(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let pinned = session.read(cx).pinned;
if !pinned || !settings::get(|settings| settings.confirm_unpin) {
session.update(cx, |session, cx| session.set_pinned(!pinned, cx));
return;
}
let title = session.read(cx).title.clone();
window.open_alert_dialog(cx, move |alert, _, _| {
let session = session.clone();
alert
.title("Unpin thread?")
.description(format!("\u{201c}{title}\u{201d} moves back among the other threads."))
.ok_text("Unpin")
.show_cancel(true)
.on_ok(move |_, _, cx| {
session.update(cx, |session, cx| session.set_pinned(false, cx));
true
})
});
}
fn rename_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let title = session.read(cx).title.to_string();
let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
input.update(cx, |input, cx| input.focus(window, cx));
let rename = {
let input = input.clone();
let session = session.clone();
move |window: &mut Window, cx: &mut App| {
let title = input.read(cx).value().to_string();
session.update(cx, |session, cx| session.rename(title, cx));
window.close_dialog(cx);
}
};
let on_enter = rename.clone();
let subscription = cx.subscribe_in(&input, window, move |_, _, event: &InputEvent, window, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
on_enter(window, cx);
}
});
let rename = Rc::new(rename);
window.open_dialog(cx, move |dialog, _, cx| {
let p = metal::palette(cx);
let rename = rename.clone();
dialog
.title("Rename thread")
.w(px(420.))
.child(
h_flex()
.h(px(36.))
.px(px(11.))
.rounded(px(12.))
.well(p)
.text_size(px(13.5))
.child(div().flex_1().child(Input::new(&input).appearance(false))),
)
.footer(
h_flex()
.w_full()
.justify_end()
.gap(px(8.))
.child(
MetalButton::new("rename-cancel")
.ghost()
.compact()
.label("Cancel")
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
MetalButton::new("rename-save")
.primary()
.compact()
.label("Rename")
.on_click(move |_, window, cx| rename(window, cx)),
),
)
});
// The subscription lives as long as the dialog might.
self._subscriptions.push(subscription);
}
fn delete_thread(
&mut self,
project_ix: usize,
session: Entity<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let (id, worktree, remote) = {
let state = session.read(cx);
(state.id.clone(), state.worktree.clone(), state.is_remote())
};
self.remove_thread(project_ix, &session, cx);
if remote {
session.read(cx).forget_remote();
} else {
cx.background_executor()
.spawn(async move {
store::delete_thread(&id);
if let Some(worktree) = worktree {
let _ = worktree::remove(&worktree);
}
})
.detach();
}
let entity_id = session.entity_id();
if self.active.as_ref().is_some_and(|active| active.entity_id() == entity_id) {
self.active = None;
if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
self.select(ix, next, window, cx);
}
}
cx.notify();
}
/// Stops a thread's process and forgets it.
fn remove_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
session.update(cx, |session, _| session.shutdown());
let id = session.entity_id();
self.chats.remove(&id);
self.observers.remove(&id);
self.activity.remove(&id);
self.dirty.remove(&id);
if let Some(project) = self.projects.get_mut(project_ix) {
project.sessions.retain(|candidate| candidate.entity_id() != id);
}
cx.notify();
}
/// Opens the Add project browser over the window, in the folder the
/// settings name.
fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let saved = settings::get(|settings| settings.add_project_dir.clone());
let start = folder_browser::start_dir(Some(saved.as_str()));
let browser = cx.new(|cx| FolderBrowser::new(start, window, cx));
let subscription = cx.subscribe_in(&browser, window, |this, _, event: &FolderBrowserEvent, window, cx| {
this.close_folder_browser(window, cx);
match event {
FolderBrowserEvent::Picked(path) => this.add_project(path.clone(), window, cx),
FolderBrowserEvent::Cancelled => {}
FolderBrowserEvent::SystemDialog => this.prompt_with_system_dialog(window, cx),
}
});
browser.focus_handle(cx).focus(window, cx);
self.folder_browser = Some((browser, subscription));
cx.notify();
}
fn close_folder_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.folder_browser = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
fn prompt_with_system_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let paths = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Open project".into()),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = paths.await else { return };
let Some(path) = paths.into_iter().next() else { return };
let _ = this.update_in(cx, |this, window, cx| this.add_project(path, window, cx));
})
.detach();
}
/// Every thread matching the search, most recently active first, split
/// into open and settled.
fn threads(&self, cx: &App) -> (Vec<ThreadEntry>, Vec<ThreadEntry>) {
let query = self.search.read(cx).value().trim().to_lowercase();
let mut threads: Vec<_> = self
.projects
.iter()
.enumerate()
.flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
.filter(|(ix, session)| {
query.is_empty()
|| session.read(cx).title.to_lowercase().contains(&query)
|| self.projects[*ix].name.to_lowercase().contains(&query)
})
.collect();
threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
threads.into_iter().partition(|(_, session)| !session.read(cx).settled)
}
fn active_project(&self) -> Option<&Project> {
self.projects.get(self.active_project)
}
/// The open thread's worktree root, if it runs in one.
fn active_worktree(&self, cx: &App) -> Option<PathBuf> {
self.active.as_ref().and_then(|session| session.read(cx).worktree.as_ref().map(|worktree| worktree.root.clone()))
}
/// Where the open thread runs: its worktree, or the project folder.
fn active_path(&self, cx: &App) -> Option<PathBuf> {
self.active_worktree(cx).or_else(|| self.active_project().map(|project| project.path.clone()))
}
fn open_in_editor(&mut self, editor: usize, window: &mut Window, cx: &mut Context<Self>) {
let (Some(app), Some(path)) = (self.editors.get(editor), self.active_path(cx)) else {
return;
};
let result = Command::new(&app.program)
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match result {
Ok(mut child) => {
// Reap it so it doesn't linger as a zombie.
std::thread::spawn(move || child.wait());
self.set_editor(editor, cx);
}
Err(err) => window.push_notification(
Notification::error(format!("Couldn't start {}: {err}", app.name)),
cx,
),
}
cx.notify();
}
fn switch_branch(&mut self, branch: String, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = self.active_project().map(|project| project.path.clone()) else { return };
let task = cx.background_executor().spawn(async move {
let output = git::git(&path, &["switch", &branch]);
let info = git::read_info(&path);
(path, output, info)
});
cx.spawn_in(window, async move |this, cx| {
let (path, output, info) = task.await;
let _ = this.update_in(cx, |this, window, cx| {
if let Err(err) = output {
window.push_notification(Notification::error(format!("git switch failed: {err}")), cx);
}
if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) {
project.git = info;
}
cx.notify();
});
})
.detach();
}
/// Hands a git chore to the agent in the open thread.
fn ask_agent(&mut self, prompt: &str, cx: &mut Context<Self>) {
if let Some(session) = &self.active {
session.update(cx, |session, cx| session.send_message(prompt.to_string(), Vec::new(), cx));
}
}
// ---- what the settings view reads and changes ----------------------
fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settings_open = true;
self.settings_view.update(cx, |view, cx| view.opened(window, cx));
cx.notify();
}
fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settings_open = false;
if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
cx.notify();
}
/// Lets every open conversation pick up changed settings.
pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.sync_remote(window, cx);
for chat in self.chats.values() {
chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
}
cx.notify();
}
pub fn editor_names(&self) -> Vec<&'static str> {
self.editors.iter().map(|app| app.name).collect()
}
pub fn editor(&self) -> usize {
self.editor
}
pub fn set_editor(&mut self, editor: usize, cx: &mut Context<Self>) {
let Some(app) = self.editors.get(editor) else { return };
self.editor = editor;
let name = app.name.to_string();
settings::update(|settings| settings.editor = Some(name));
cx.notify();
}
/// Every settled thread, most recently active first.
pub fn settled_threads(&self, cx: &App) -> Vec<SettledThread> {
let mut threads: Vec<_> = self
.projects
.iter()
.flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
.filter(|(_, session)| session.read(cx).settled)
.collect();
threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
threads
.into_iter()
.map(|(project, session)| {
let state = session.read(cx);
SettledThread {
session: session.clone(),
project: project.name.clone(),
initials: project.initials.clone(),
tint: project.tint,
title: state.title.clone(),
provider: state.provider,
age: relative_time(state.updated),
}
})
.collect()
}
/// Brings a settled thread back and opens it.
pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let Some(ix) = self.project_of(&session) else { return };
session.update(cx, |session, cx| session.unsettle(cx));
self.select(ix, session, window, cx);
self.close_settings(window, cx);
}
pub fn forget_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if let Some(ix) = self.project_of(&session) {
self.delete_thread(ix, session, window, cx);
}
}
// ---- sidebar --------------------------------------------------------
fn render_sidebar_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
h_flex()
.flex_none()
.h(px(52.))
.pl(px(10.))
.pr(px(12.))
.gap(px(8.))
.child(
MetalButton::new("hide-sidebar")
.ghost()
.mini()
.icon(Lucide::PanelLeftClose)
.tooltip("Hide sidebar (Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_open = false;
cx.notify();
})),
)
.child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
.child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
.child(metal::tag("Code", p))
}
fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let projects: Vec<(usize, SharedString)> = self
.ordered_projects(cx)
.into_iter()
.map(|ix| {
let project = &self.projects[ix];
let name = match &project.environment {
Some(env) => format!("{} · {}", project.name, self.environment_name(env)).into(),
None => project.name.clone(),
};
(ix, name)
})
.collect();
let workspace = cx.entity().downgrade();
h_flex()
.flex_none()
.px(px(12.))
.gap(px(4.))
.child(
h_flex()
.flex_1()
.min_w_0()
.h(px(36.))
.pl(px(11.))
.pr(px(8.))
.gap(px(8.))
.rounded(px(14.))
.well(p)
.text_size(px(13.))
.text_color(p.ink)
.child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink))
.child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))),
)
.child(
MetalButton::new("project-menu")
.ghost()
.mini()
.icon(Lucide::FolderPlus)
.dropdown_menu(move |mut menu, _, _| {
for (ix, name) in projects.clone() {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new(format!("New thread in {name}")).on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
},
));
}
let workspace = workspace.clone();
menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.prompt_for_project(window, cx));
},
))
}),
)
.child(
MetalButton::new("new-thread")
.ghost()
.mini()
.icon(Lucide::SquarePen)
.tooltip("New thread (Ctrl+N)")
.on_click(cx.listener(|this, _, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
})),
)
}
/// The right-click menu of a thread card.
fn thread_menu(
workspace: WeakEntity<Self>,
session: Entity<Session>,
menu: PopupMenu,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let state = session.read(cx);
let (pinned, settled, snoozed) = (state.pinned, state.settled, state.is_snoozed());
let action = |run: fn(&mut Workspace, Entity<Session>, &mut Window, &mut Context<Workspace>)| {
let workspace = workspace.clone();
let session = session.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx));
}
};
let snooze_session = session.clone();
let mut menu = menu
.min_w(px(200.))
.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin to top" })
.icon(Icon::new(if pinned { Lucide::PinOff } else { Lucide::Pin }))
.on_click(action(|this, session, window, cx| this.toggle_pin(session, window, cx))),
)
.item(
PopupMenuItem::new("Rename…")
.icon(Icon::new(Lucide::Pencil))
.on_click(action(|this, session, window, cx| this.rename_thread(session, window, cx))),
);
if !settled {
menu = menu.submenu_with_icon(Some(Icon::new(Lucide::AlarmClock)), "Snooze", window, cx, move |mut menu, _, _| {
let snooze = |until: fn() -> SystemTime| {
let session = snooze_session.clone();
move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
session.update(cx, |session, cx| session.snooze(Some(until()), cx));
}
};
menu = menu
.min_w(px(220.))
.item(PopupMenuItem::new("For an hour").on_click(snooze(|| SystemTime::now() + Duration::from_secs(3_600))))
.item(PopupMenuItem::new("For three hours").on_click(snooze(|| SystemTime::now() + Duration::from_secs(10_800))))
.item(PopupMenuItem::new(format!("Until tomorrow, {}", clock::clock(clock::at_hour(1, 9)))).on_click(snooze(|| clock::at_hour(1, 9))))
.item(PopupMenuItem::new(format!("Until next week, {}", clock::clock(clock::at_hour(7, 9)))).on_click(snooze(|| clock::at_hour(7, 9))));
menu
});
if snoozed {
let session = session.clone();
menu = menu.item(PopupMenuItem::new("Wake up now").icon(Icon::new(Lucide::AlarmClockOff)).on_click(
move |_, _, cx| session.update(cx, |session, cx| session.snooze(None, cx)),
));
}
}
menu = menu.separator();
menu = if settled {
menu.item(
PopupMenuItem::new("Restore")
.icon(Icon::new(Lucide::ArchiveRestore))
.on_click(action(|this, session, window, cx| this.restore_thread(session, window, cx))),
)
} else {
menu.item(
PopupMenuItem::new("Settle")
.icon(Icon::new(Lucide::Archive))
.on_click(action(|this, session, _, cx| this.settle_thread(session, cx))),
)
};
menu.item(
PopupMenuItem::new("Delete…")
.icon(Icon::new(Lucide::Trash))
.on_click(action(|this, session, window, cx| this.request_delete(session, window, cx))),
)
}
fn render_thread_card(
&self,
project_ix: usize,
session: &Entity<Session>,
cx: &mut Context<Self>,
) -> AnyElement {
let p = metal::palette(cx);
let project = &self.projects[project_ix];
let state = session.read(cx);
let id = session.entity_id();
let selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
let (led, pulse) = if state.settled {
(Led::Off, false)
} else if state.needs_attention() {
(Led::Waiting, true)
} else if state.limited_until().is_some() {
(Led::Waiting, false)
} else {
match state.status {
SessionStatus::Working => (Led::Live, true),
SessionStatus::Idle => (Led::Live, false),
SessionStatus::Starting => (Led::Off, false),
SessionStatus::Exited => (Led::Failed, false),
}
};
let branch = match &state.worktree {
Some(worktree) => Some(worktree.branch.clone()),
None => project.git.as_ref().and_then(|git| git.branch.clone()),
};
let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some();
let host = project.environment.as_deref().map(|env| self.environment_name(env));
let settled = state.settled;
let pinned = state.pinned;
let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed());
let group = SharedString::from(format!("thread-{id}"));
let select_session = session.clone();
let action_session = session.clone();
let pin_session = session.clone();
let compact = settings::get(|settings| settings.compact_threads);
let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
let settle_or_delete = if settled {
MetalButton::new(("delete-thread", id.as_u64() as usize))
.ghost()
.mini()
.icon(IconName::Close)
.tooltip("Delete thread")
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.request_delete(action_session.clone(), window, cx)
}))
} else if armed {
MetalButton::new(("settle-thread", id.as_u64() as usize))
.danger()
.mini()
.icon(IconName::Check)
.tooltip("Click again to settle")
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.press_settle(action_session.clone(), cx)
}))
} else {
MetalButton::new(("settle-thread", id.as_u64() as usize))
.ghost()
.mini()
.icon(Lucide::Archive)
.tooltip("Settle: stop the agent and file the thread away")
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.press_settle(action_session.clone(), cx)
}))
};
let pin_button = MetalButton::new(("pin-thread", id.as_u64() as usize))
.ghost()
.mini()
.icon(if pinned { Lucide::PinOff } else { Lucide::Pin })
.tooltip(if pinned { "Unpin" } else { "Pin to top" })
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.toggle_pin(pin_session.clone(), window, cx)
}));
let actions = h_flex()
.gap(px(1.))
.when(!armed, |this| this.invisible().group_hover(group.clone(), |this| this.visible()))
.when(!settled, |this| this.child(pin_button))
.child(settle_or_delete);
let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p);
let title = h_flex()
.min_w_0()
.gap(px(5.))
.when(pinned, |this| this.child(Icon::new(Lucide::Pin).size(px(11.)).text_color(p.ink3).flex_none()))
.child(
div()
.min_w_0()
.truncate()
.text_size(px(13.5))
.font_weight(FontWeight::SEMIBOLD)
.text_color(if selected || !settled { p.ink } else { p.ink2 })
.child(state.title.clone()),
);
let age = div()
.flex_none()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(match snoozed_until {
Some(until) => clock::when(until),
None => relative_time(state.updated),
});
let workspace = cx.weak_entity();
let menu_session = session.clone();
let card = v_flex()
.id(("thread", id.as_u64() as usize))
.group(group)
.px(px(12.))
.py(px(if compact { 5. } else { 9. }))
.gap(px(4.))
.rounded(px(if compact { 12. } else { 14. }))
.cursor_pointer()
.map(|this| {
if selected {
this.raised(p)
} else {
this.hover(|style| style.bg(p.row_hover))
}
})
.on_click(cx.listener(move |this, _, window, cx| {
this.select(project_ix, select_session.clone(), window, cx)
}));
let card = if compact {
card.child(
h_flex()
.h(px(22.))
.gap(px(7.))
.child(metal::badge(project.initials.clone(), project.tint, p))
.child(title.flex_1().text_size(px(13.)).font_weight(FontWeight::MEDIUM))
.child(actions)
.child(age)
.child(led),
)
} else {
card.child(
h_flex()
.gap(px(7.))
.child(metal::badge(project.initials.clone(), project.tint, p))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink3)
.child(project.name.clone()),
)
.when_some(host, |this, host| this.child(metal::tag(host, p)))
.when(snoozed_until.is_some(), |this| {
this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3))
})
.child(age),
)
.child(title)
.child(
h_flex()
.h(px(18.))
.gap(px(6.))
.text_color(p.ink3)
.when_some(branch, |this, branch| {
this.child(Icon::new(if in_worktree { Lucide::FolderGit2 } else { Lucide::GitBranch }).size(px(11.)))
.child(
div()
.min_w_0()
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.child(branch),
)
})
.child(div().flex_1())
.child(pickers::provider_icon(state.provider, px(11.), p))
.child(actions)
.child(led),
)
};
card.context_menu(move |menu, window, cx| {
Self::thread_menu(workspace.clone(), menu_session.clone(), menu, window, cx)
})
.into_any_element()
}
/// A foldable section header in the thread list: `Settled (3) ——— ›`.
fn render_fold(
&self,
id: &'static str,
label: String,
open: bool,
toggle: fn(&mut Self),
cx: &mut Context<Self>,
) -> AnyElement {
let p = metal::palette(cx);
h_flex()
.id(id)
.mt(px(10.))
.h(px(30.))
.px(px(12.))
.gap(px(8.))
.rounded(px(10.))
.cursor_pointer()
.text_size(px(12.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink3)
.hover(|style| style.bg(p.row_hover).text_color(p.ink))
.on_click(cx.listener(move |this, _, _, cx| {
toggle(this);
cx.notify();
}))
.child(label)
.child(div().flex_1().h(px(1.)).bg(p.rule))
.child(Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(12.)))
.into_any_element()
}
/// A project's header in the grouped thread list.
fn render_project_header(&self, ix: usize, count: usize, cx: &mut Context<Self>) -> AnyElement {
let p = metal::palette(cx);
let project = &self.projects[ix];
let collapsed = self.collapsed_projects.contains(&project.path);
let path = project.path.clone();
h_flex()
.id(("project-group", ix))
.group(SharedString::from(format!("project-group-{ix}")))
.mt(px(8.))
.h(px(30.))
.pl(px(10.))
.pr(px(4.))
.gap(px(7.))
.rounded(px(10.))
.cursor_pointer()
.hover(|style| style.bg(p.row_hover))
.on_click(cx.listener(move |this, _, _, cx| {
if !this.collapsed_projects.remove(&path) {
this.collapsed_projects.insert(path.clone());
}
cx.notify();
}))
.child(Icon::new(if collapsed { IconName::ChevronRight } else { IconName::ChevronDown }).size(px(12.)).text_color(p.ink3))
.child(metal::badge(project.initials.clone(), project.tint, p))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.5))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink2)
.child(project.name.clone()),
)
.child(div().font_family(metal::MONO).text_size(px(10.5)).text_color(p.ink3).child(count.to_string()))
.child(
div().invisible().group_hover(SharedString::from(format!("project-group-{ix}")), |this| this.visible()).child(
MetalButton::new(("project-new-thread", ix))
.ghost()
.mini()
.icon(Lucide::SquarePen)
.tooltip(format!("New thread in {}", project.name))
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.new_thread(ix, window, cx);
})),
),
)
.into_any_element()
}
fn render_thread_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let (open, settled) = self.threads(cx);
let searching = !self.search.read(cx).value().trim().is_empty();
let (group, working_section) = settings::get(|settings| (settings.group_by_project, settings.working_section));
// Pinned threads stay on top; snoozed ones wait below; working ones
// fold away (when set to) until they need the user.
let mut pinned = Vec::new();
let mut snoozed = Vec::new();
let mut working = Vec::new();
let mut rest = Vec::new();
for entry in open {
let state = entry.1.read(cx);
if state.is_snoozed() {
snoozed.push(entry);
} else if state.pinned {
pinned.push(entry);
} else if working_section && state.is_working() && !state.needs_attention() {
working.push(entry);
} else {
rest.push(entry);
}
}
let is_empty = pinned.is_empty() && snoozed.is_empty() && working.is_empty() && rest.is_empty() && settled.is_empty();
let mut children: Vec<AnyElement> = Vec::new();
if !pinned.is_empty() {
children.push(
div()
.px(px(12.))
.pt(px(2.))
.pb(px(4.))
.child(metal::engraved(format!("Pinned · {}", pinned.len()), p))
.into_any_element(),
);
children.extend(pinned.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
if !rest.is_empty() || !working.is_empty() {
children.push(div().px(px(12.)).py(px(6.)).child(metal::rule(p)).into_any_element());
}
}
if !working.is_empty() {
let open = self.working_open || searching;
children.push(self.render_fold(
"working-toggle",
format!("Working ({})", working.len()),
open,
|this| this.working_open = !this.working_open,
cx,
));
if open {
children.extend(working.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
}
}
if group {
for ix in self.ordered_projects(cx) {
let threads: Vec<_> = rest.iter().filter(|(project, _)| *project == ix).collect();
if threads.is_empty() {
continue;
}
children.push(self.render_project_header(ix, threads.len(), cx));
if searching || !self.collapsed_projects.contains(&self.projects[ix].path) {
children.extend(threads.into_iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
}
}
} else {
children.extend(rest.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
}
for (id, label, threads, open, toggle) in [
(
"snoozed-toggle",
"Snoozed",
snoozed,
self.snoozed_open || searching,
(|this: &mut Self| this.snoozed_open = !this.snoozed_open) as fn(&mut Self),
),
(
"settled-toggle",
"Settled",
settled,
self.settled_open || searching,
|this: &mut Self| this.settled_open = !this.settled_open,
),
] {
if threads.is_empty() {
continue;
}
children.push(self.render_fold(id, format!("{label} ({})", threads.len()), open, toggle, cx));
if open {
children.extend(threads.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
}
}
v_flex()
.id("threads")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.px(px(8.))
.pt(px(12.))
.pb(px(12.))
.gap(px(3.))
.when(is_empty, |this| {
this.child(
div()
.px(px(12.))
.py(px(8.))
.text_size(px(12.5))
.text_color(p.ink3)
.child(if searching { "No threads match." } else { "No threads yet." }),
)
})
.children(children)
}
fn render_sidebar_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let catalog = Catalog::global(cx);
let claude = catalog.first_of(Provider::Claude);
let account = claude.and_then(|state| state.account.clone());
let checking = claude.is_some_and(|state| state.checking || state.checked.is_none());
let workspace = cx.entity().downgrade();
h_flex()
.flex_none()
.h(px(48.))
.px(px(12.))
.gap(px(8.))
.child(
MetalButton::new("settings")
.ghost()
.compact()
.icon(IconName::Settings2)
.label("Settings")
.tooltip("Settings (Ctrl+,)")
.on_click(move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_settings(window, cx));
}),
)
.child(div().flex_1())
.child(
div()
.min_w_0()
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(account.clone().unwrap_or_else(|| if checking { "Connecting…" } else { "Not signed in" }.into())),
)
.child(metal::led("account-led", if account.is_some() { Led::Live } else { Led::Off }, false, p))
}
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(px(SIDEBAR_WIDTH))
.h_full()
.flex_none()
.child(self.render_sidebar_header(cx))
.child(self.render_sidebar_toolbar(cx))
.child(self.render_thread_list(cx))
.child(self.render_sidebar_footer(cx))
}
fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let project = self.active_project();
let title = self.active.as_ref().map(|session| session.read(cx).title.clone());
v_flex()
.flex_none()
.child(
h_flex()
.h(px(52.))
.pl(px(if self.sidebar_open { 20. } else { 12. }))
.pr(px(12.))
.gap(px(10.))
.when(!self.sidebar_open, |this| {
this.child(
MetalButton::new("show-sidebar")
.ghost()
.mini()
.icon(Lucide::PanelLeftOpen)
.tooltip("Show sidebar (Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_open = true;
cx.notify();
})),
)
})
.when_some(project, |this, project| {
this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
div()
.flex_none()
.text_size(px(14.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink2)
.child(project.name.clone()),
)
})
.when_some(title, |this, title| {
this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(14.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink)
.child(title),
)
})
.child(div().flex_1())
.child(
MetalButton::new("toggle-panel")
.ghost()
.mini()
.icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
.tooltip("Project panel (Ctrl+Shift+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.panel_open = !this.panel_open;
cx.notify();
})),
),
)
.child(metal::rule(p))
}
/// One row of the project panel: an icon, a label and an optional trailer.
fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
h_flex()
.h(px(36.))
.px(px(10.))
.gap(px(10.))
.rounded(px(12.))
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(icon.into().size(px(15.)).text_color(p.ink2))
.child(div().flex_1().min_w_0().truncate().child(label.into()))
}
fn render_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let Some(project) = self.active_project() else {
return div().into_any_element();
};
let active = self.active.as_ref().map(|session| session.read(cx));
let worktree = active.and_then(|session| session.worktree.clone());
let pull_request = active.and_then(|session| session.pull_request.clone());
let git = match &worktree {
Some(worktree) => self.worktree_git.get(&worktree.root).cloned().or_else(|| {
Some(GitInfo { branch: Some(worktree.branch.clone()), ..GitInfo::default() })
}),
None => project.git.clone(),
};
let path = worktree.as_ref().map(|worktree| worktree.root.clone()).unwrap_or_else(|| project.path.clone());
let host = project.environment.as_deref().map(|env| self.environment_name(env));
let remote = host.is_some();
let has_thread = self.active.is_some();
let cost = active
.map(|session| session.total_cost)
.filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs));
let workspace = cx.entity().downgrade();
let editors = self.editors.clone();
let editor = self.editors.get(self.editor);
let hover = |row: Stateful<Div>| row.cursor_pointer().hover(|style| style.bg(p.row_hover));
let editor_row = match editor {
Some(app) => h_flex()
.gap(px(2.))
.child(
hover(
Self::panel_row(Lucide::Code, format!("Open in {}", app.name), p)
.flex_1()
.id("open-editor"),
)
.on_click(cx.listener(|this, _, window, cx| this.open_in_editor(this.editor, window, cx))),
)
.child(
MetalButton::new("editor-menu")
.ghost()
.mini()
.icon(IconName::ChevronDown)
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
for (ix, app) in editors.iter().enumerate() {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new(app.name).on_click(move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_in_editor(ix, window, cx));
}));
}
menu
}),
)
.into_any_element(),
None => Self::panel_row(Lucide::Code, "No editor found", p)
.text_color(p.ink3)
.into_any_element(),
};
let git_rows: AnyElement = match git {
Some(git) => {
let branches = git.branches.clone();
let current = git.branch.clone();
let workspace = cx.entity().downgrade();
let branch_label = git.branch.clone().unwrap_or_else(|| "Detached HEAD".into());
let has_changes = git.added + git.removed > 0;
let branch_row = if worktree.is_some() {
Self::panel_row(Lucide::FolderGit2, branch_label, p).child(metal::tag("Worktree", p)).into_any_element()
} else if remote {
Self::panel_row(Lucide::GitBranch, branch_label, p).into_any_element()
} else {
MetalButton::new("branch-menu")
.ghost()
.w_full()
.h(px(36.))
.px(px(10.))
.rounded(px(12.))
.justify_start()
.gap(px(10.))
.text_size(px(13.5))
.text_color(p.ink)
.icon(Lucide::GitBranch)
.label(branch_label)
.stretch()
.caret()
.dropdown_menu(move |mut menu, _, _| {
menu = menu.min_w(px(220.)).label("Switch branch");
for branch in branches.clone() {
let workspace = workspace.clone();
let checked = current.as_deref() == Some(branch.as_str());
menu = menu.item(PopupMenuItem::new(branch.clone()).checked(checked).on_click(
move |_, window, cx| {
let branch = branch.clone();
let _ = workspace
.update(cx, |this, cx| this.switch_branch(branch, window, cx));
},
));
}
menu
})
.into_any_element()
};
v_flex()
.gap(px(2.))
.child(branch_row)
.child(self.render_commit_row(has_thread && has_changes, cx))
.child(
Self::panel_row(Lucide::FileDiff, "Changes", p)
.child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.green_ink)
.child(format!("+{}", git.added)),
)
.child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.red)
.child(format!("−{}", git.removed)),
),
)
.when_some(pull_request, |this, pull_request| {
let url = pull_request.url.clone();
let state = match pull_request.state.as_str() {
"MERGED" => "Merged",
"CLOSED" => "Closed",
_ => "Open",
};
this.child(
hover(
Self::panel_row(Lucide::GitPullRequest, format!("Pull request #{}", pull_request.number), p)
.id("pull-request"),
)
.on_click(move |_, _, _| open_url(&url))
.child(metal::tag(state, p)),
)
})
.into_any_element()
}
None => Self::panel_row(Lucide::GitBranch, "Not a git repository", p)
.text_color(p.ink3)
.into_any_element(),
};
v_flex()
.w(px(PANEL_WIDTH))
.flex_none()
.p(px(12.))
.pl(px(0.))
.child(
v_flex()
.p(px(6.))
.gap(px(2.))
.rounded(px(18.))
.raised(p)
.child(match host {
Some(host) => Self::panel_row(Lucide::Server, host, p).child(metal::tag("Remote", p)),
None => Self::panel_row(Lucide::Laptop, self.hostname.clone(), p).child(metal::tag("Local", p)),
})
.child(
div()
.px(px(10.))
.pb(px(4.))
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(display_path(&path)),
)
.when(!remote, |this| this.child(editor_row))
.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
.child(git_rows)
.when_some(cost, |this, cost| {
this.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))).child(
Self::panel_row(IconName::ChartPie, "Thread cost", p).child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.ink2)
.child(format!("${cost:.4}")),
),
)
}),
)
.into_any_element()
}
fn render_commit_row(&self, enabled: bool, cx: &mut Context<Self>) -> impl IntoElement {
const COMMIT: &str = "Commit all current changes in this repository with a concise, descriptive commit message.";
const COMMIT_AND_PUSH: &str = "Commit all current changes in this repository with a concise, descriptive commit message, then push the branch to its remote.";
const PULL_REQUEST: &str = "Commit all current changes in this repository with a concise, descriptive commit message, push the branch, and open a pull request for it with `gh pr create`.";
let p = metal::palette(cx);
let workspace = cx.entity().downgrade();
h_flex()
.gap(px(2.))
.child(
Self::panel_row(Lucide::GitCommitHorizontal, "Commit", p)
.flex_1()
.id("commit")
.map(|this| {
if enabled {
this.cursor_pointer()
.hover(|style| style.bg(p.row_hover))
.on_click(cx.listener(|this, _, _, cx| this.ask_agent(COMMIT, cx)))
} else {
this.opacity(0.45)
}
})
.tooltip(|window, cx| {
gpui_kit::component::tooltip::Tooltip::new("Ask the agent to commit the changes")
.build(window, cx)
}),
)
.child(
MetalButton::new("commit-menu")
.ghost()
.mini()
.icon(IconName::ChevronDown)
.disabled(!enabled)
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
let ask = |prompt: &'static str| {
let workspace = workspace.clone();
move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
let _ = workspace.update(cx, |this, cx| this.ask_agent(prompt, cx));
}
};
menu.min_w(px(240.))
.item(PopupMenuItem::new("Ask the agent to commit").on_click(ask(COMMIT)))
.item(PopupMenuItem::new("Ask the agent to commit and push").on_click(ask(COMMIT_AND_PUSH)))
.item(PopupMenuItem::new("Ask the agent to open a pull request").on_click(ask(PULL_REQUEST)))
}),
)
}
fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
v_flex()
.size_full()
.items_center()
.justify_center()
.gap(px(14.))
.child(metal::engraved("No thread open", p))
.child(
div()
.text_size(px(22.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink)
.child("Pick a thread, or start a new one."),
)
.child(
MetalButton::new("empty-new-thread")
.primary()
.icon(Lucide::SquarePen)
.label("New thread")
.on_click(cx.listener(|this, _, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
})),
)
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let chat = self
.active
.as_ref()
.and_then(|session| self.chats.get(&session.entity_id()))
.cloned();
let title = match &self.active {
Some(session) => format!("{} — Vectrum Code", session.read(cx).title),
None => "Vectrum Code".to_string(),
};
window.set_window_title(&title);
// The conversation's start screen reads the projects from here.
let scratch = scratch_dir();
let directory = ProjectDirectory {
hostname: self.hostname.clone(),
projects: self
.ordered_projects(cx)
.into_iter()
.map(|ix| &self.projects[ix])
.map(|project| ProjectSummary {
name: project.name.clone(),
path: project.path.clone(),
environment: project.environment.clone(),
host: project.environment.as_deref().map(|env| self.environment_name(env).into()),
scratch: Some(&project.path) == scratch.as_ref(),
git: project.git.is_some(),
branch: project.git.as_ref().and_then(|git| git.branch.clone()),
branches: project.git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(),
})
.collect(),
};
cx.set_global(directory);
let p = metal::palette(cx);
let panel_visible = self.panel_open
&& self.active_project().is_some()
&& window.viewport_size().width >= px(PANEL_MIN_WINDOW);
h_flex()
.key_context("Workspace")
.track_focus(&self.focus_handle)
.size_full()
.bg(p.page)
.text_color(p.ink)
.font_family(cx.theme().font_family.clone())
.on_action(cx.listener(|this, _: &NewThread, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
}))
.on_action(cx.listener(|this, _: &OpenProject, window, cx| {
this.prompt_for_project(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| {
this.sidebar_open = !this.sidebar_open;
cx.notify();
}))
.on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| {
this.panel_open = !this.panel_open;
cx.notify();
}))
.on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
this.settings_open = false;
this.sidebar_open = true;
this.search.update(cx, |search, cx| search.focus(window, cx));
cx.notify();
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
if this.settings_open {
this.close_settings(window, cx);
} else {
this.open_settings(window, cx);
}
}))
.when(self.settings_open, |this| this.child(self.settings_view.clone()))
.when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx)))
.when(!self.settings_open, |this| this.child(
div()
.flex_1()
.min_w_0()
.h_full()
.py(px(SHEET_INSET))
.pr(px(SHEET_INSET))
.when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET)))
.child(
v_flex()
.size_full()
.rounded(px(SHEET_RADIUS))
.plate(p)
.child(self.render_top_bar(panel_visible, cx))
.child(
h_flex()
.flex_1()
.min_h_0()
.items_start()
.child(div().flex_1().min_w_0().h_full().map(|this| match chat {
Some(chat) => this.child(chat),
None => this.child(self.render_empty_sheet(cx)),
}))
.when(panel_visible, |this| this.child(self.render_panel(cx))),
),
),
))
// The Add project browser floats over everything; a click beside it cancels.
.when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| {
this.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
.occlude()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| this.close_folder_browser(window, cx)),
)
.child(browser),
)
})
}
}
/// Two letters for a project's badge: the first letters of its first two
/// words (`vectrum.git` → `VG`), or of its name (`T3-2` → `T3`).
fn initials(name: &str) -> String {
let words: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|word| !word.is_empty()).collect();
let letters: String = match words.as_slice() {
[first, second, ..] if second.starts_with(|c: char| c.is_alphabetic()) => {
first.chars().take(1).chain(second.chars().take(1)).collect()
}
[first, ..] => first.chars().take(2).collect(),
[] => "?".into(),
};
letters.to_uppercase()
}
fn tint_for(name: &str) -> usize {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut hasher);
hasher.finish() as usize
}
fn relative_time(time: SystemTime) -> String {
let secs = time.elapsed().unwrap_or_default().as_secs();
match secs {
0..60 => "now".into(),
60..3_600 => format!("{}m", secs / 60),
3_600..86_400 => format!("{}h", secs / 3_600),
_ => format!("{}d", secs / 86_400),
}
}
fn hostname() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.or_else(|_| std::fs::read_to_string("/etc/hostname"))
.map(|name| name.trim().to_string())
.ok()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "This computer".into())
}
/// Whether the machine is saving power: the power-saver platform profile.
fn power_saving() -> bool {
std::fs::read_to_string("/sys/firmware/acpi/platform_profile")
.is_ok_and(|profile| matches!(profile.trim(), "low-power" | "quiet"))
}
pub(crate) fn find_program(names: &[&str]) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
.find(|path| path.is_file())
}
fn detect_editors() -> Vec<EditorApp> {
const CANDIDATES: &[(&str, &[&str])] = &[
("Zed", &["zeditor", "zed"]),
("VS Code", &["code"]),
("Cursor", &["cursor"]),
("VSCodium", &["codium"]),
("Sublime Text", &["subl"]),
("Kate", &["kate"]),
("File manager", &["xdg-open"]),
];
CANDIDATES
.iter()
.filter_map(|(name, programs)| Some(EditorApp { name, program: find_program(programs)? }))
.collect()
}
/// Runs a program in the background and forgets it, reaping it when it exits.
fn spawn_detached(command: &mut Command) {
let spawned = command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
if let Ok(mut child) = spawned {
std::thread::spawn(move || child.wait());
}
}
/// Opens a link in the default browser.
pub(crate) fn open_url(url: &str) {
if let Some(program) = find_program(&["xdg-open"]) {
spawn_detached(Command::new(program).arg(url));
}
}
/// Posts a desktop notification through `notify-send`, if it's installed,
/// with the desktop's "complete" sound when `sound` is on.
fn desktop_notification(title: &str, body: &str, sound: bool) {
if let Some(program) = find_program(&["notify-send"]) {
spawn_detached(Command::new(program).args(["--app-name=Vectrum Code", title, body]));
}
if sound {
play_sound();
}
}
/// Plays the freedesktop "complete" sound with whichever player is around.
fn play_sound() {
const SOUND: &str = "/usr/share/sounds/freedesktop/stereo/complete.oga";
if let Some(program) = find_program(&["canberra-gtk-play"]) {
spawn_detached(Command::new(program).args(["-i", "complete", "-d", "Vectrum Code"]));
} else if let Some(program) = find_program(&["pw-play", "paplay"])
&& std::path::Path::new(SOUND).exists()
{
spawn_detached(Command::new(program).arg(SOUND));
}
}
/// Where threads started without a project run.
pub(crate) fn scratch_dir() -> Option<PathBuf> {
let dir = settings::data_dir()?.join("scratch");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
#[cfg(test)]
mod tests {
use super::initials;
#[::core::prelude::v1::test]
fn project_initials() {
assert_eq!(initials("T3-2"), "T3");
assert_eq!(initials("vectrum.git"), "VG");
assert_eq!(initials("my-pi-config"), "MP");
assert_eq!(initials("codey"), "CO");
}
}